1mod cli;
2mod commands;
3mod community_detection;
4mod conflict_matrix;
5mod context_pack;
6mod output;
7mod rewrite;
8mod search_budget;
9mod semantic_edit;
10mod session_review_budget;
11mod token_savings;
12mod workflow;
13
14pub(crate) use community_detection::{
15 CommunityDetectionReport, annotate_community_members_with_context,
16 community_tagpath_cache_part, community_tagpath_cache_part_for_loaded,
17 detect_communities_cached, file_communities_from_callers, graph_effectiveness_blocked,
18 graph_effectiveness_ready, resolve_tagpath_handle_for_callee_edge,
19 update_community_annotation_diagnostics,
20};
21#[allow(unused_imports)]
22pub(crate) use conflict_matrix::{
23 ConflictMatrixCandidate, ConflictMatrixGraphPreparedInputs, ConflictMatrixPreparedInputs,
24 ConflictMatrixReport, ConflictMatrixSemanticRef, ConflictMatrixSharedPreparationSummary,
25 ConflictMatrixWorkerFeedback, ConflictMatrixWorkerPromptPacket, build_conflict_matrix_report,
26 build_conflict_matrix_report_from_prepared_graph, cmd_conflict_matrix,
27 collect_conflict_matrix_evidence_packets, conflict_matrix_candidate_from_evidence,
28 conflict_matrix_graph_index, conflict_matrix_semantic_ref,
29 conflict_matrix_shared_preparation_summary, conflict_matrix_source_handle,
30 conflict_matrix_target_scoped_graph_snapshot, conflict_matrix_worker_feedback,
31 conflict_risk_label, extract_conflict_target_refs, hash_bytes_hex, is_planner_config_path,
32 normalize_conflict_target, prepare_conflict_matrix_graph_orchestration,
33 prepare_conflict_matrix_inputs, resolve_conflict_matrix_targets, sorted_intersection,
34 sorted_set,
35};
36#[allow(unused_imports)]
37pub(crate) use context_pack::{
38 ContextPackReport, ContextPackSummaryRefPreview, build_context_pack_diff_preview,
39 build_context_pack_log_preview, build_context_pack_report,
40 build_context_pack_report_with_profile, build_context_pack_test_preview,
41 context_pack_status_reminders, exploration_ref_id, materialize_context_pack_exploration_packet,
42 print_context_pack_human,
43};
44pub use rewrite::rewrite_command;
45pub(crate) use rewrite::{
46 apply_rewrite_output_format, execute_rewritten_command, no_rewrite_message,
47};
48#[cfg(test)]
49use search_budget::{SearchBudgetReport, search_facet_filters_summary};
50pub(crate) use search_budget::{
51 SearchBudgetReportInput, apply_search_facet_filters, build_search_budget_follow_up,
52 build_search_budget_report, print_search_budget_human,
53};
54pub(crate) use semantic_edit::{
55 AstSpanPreview, EditBatch, EditResult, EditStatus, MarkdownEmbeddedSymbol,
56 MarkdownSpanMetadata, MetricDigestOptions, SemanticEditVerifyOptions,
57 apply_edit_plan_atomically, build_edit_plan, cmd_edit_intents,
58};
59#[allow(unused_imports)]
60pub(crate) use session_review_budget::{
61 SessionReviewBudgetFailurePreview, SessionReviewBudgetReport,
62 SessionReviewNextContextBudgetReport, SessionReviewNextTokenAction,
63 build_session_review_budget_report, build_session_review_next_context_budget_report,
64 print_session_review_budget_human, print_session_review_next_context_budget_human,
65};
66
67#[cfg(test)]
68use rewrite::{
69 OutputCap, apply_output_cap, effective_rewrite_run_command, resolve_digest_context_path,
70 rewrite_output_cap,
71};
72#[cfg(test)]
73use std::io::{BufRead as _, BufReader};
74#[cfg(test)]
75use token_savings::{
76 TokenSavingsFamily, TokenSavingsFixture, TokenSavingsFixtureCase,
77 TokenSavingsMarkdownProjectionInput, TokenSavingsMarkdownProjectionInputs,
78 TokenSavingsRawSymbol, TokenSavingsSourceReadInput, TokenSavingsSourceReadInputs,
79 build_token_savings_report,
80};
81
82use anyhow::{Context, Result, bail};
83use clap::Parser;
84use cli::{Cli, Commands, DispatchTraceFormat, GraphDbQuery, SemanticRelatedKind, SourceReadStyle};
85#[cfg(test)]
86use cli::{GraphDbBackend, TraverseFormat};
87use commands::digests::{
88 cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
89 cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
90};
91#[cfg(test)]
92use commands::graph::cmd_explain;
93use commands::graph::{
94 cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
95};
96#[cfg(test)]
97use commands::index_search::cmd_search;
98use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
99use commands::infra::{
100 StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
101 cmd_rewrite, cmd_route, cmd_sql, cmd_status,
102};
103use commands::memory::cmd_memory;
104use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
105use commands::summarize::cmd_summarize;
106use flate2::{Compression, read::GzDecoder, write::GzEncoder};
107#[cfg(test)]
108use output::ResponseBudgetPreset;
109use output::tagpath::{
110 TagpathAnnotationDiagnostic, TagpathSearchOpts, annotate_communities_with_tagpath,
111 annotate_hits_with_tagpath, annotate_path_nodes_with_tagpath,
112 annotate_stored_edges_with_tagpath, annotate_stored_symbols_with_tagpath,
113};
114use output::{
115 OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric, ToolEnvelopeSummary,
116 TranscriptArtifactRef,
117};
118use rusqlite::{Connection, OptionalExtension};
119use serde::{Deserialize, Serialize};
120use sift::{SearchInput, SearchOptions, Sift};
121#[cfg(test)]
122use std::cell::RefCell;
123use std::cmp::Ordering;
124use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
125use std::env;
126use std::fs;
127use std::io::{Read as _, Write as _};
128use std::path::{Path, PathBuf};
129use std::process::{Command, Stdio};
130use std::sync::{Mutex, OnceLock};
131use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
132use substrate::{
133 ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
134 GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
135 GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
136 SqliteGraphStore, SqliteProjectionRefresh, TerseGraphEdge as SubstrateTerseGraphEdge,
137 TerseGraphNode as SubstrateTerseGraphNode,
138};
139use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
140#[cfg(test)]
141use tsift_agent_doc::session_cost;
142#[cfg(test)]
143use tsift_agent_doc::session_review;
144use tsift_cache::cycle_packet_cache;
145use tsift_core::{
146 NeighborhoodScoring, RankedNeighborhoodOptions, SemanticSeededNeighborhoodOptions,
147};
148use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
149use tsift_graph as graph;
150use tsift_index::{config, index, init, multiplicity, walk};
151use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
152#[cfg(test)]
153use tsift_memory::MemoryEvent;
154use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
155use tsift_resolution as resolution;
156use tsift_search::{impact, sift};
157use tsift_sqlite as substrate;
158use tsift_status::status;
159use tsift_summarize::summarize;
160#[cfg(feature = "backend-surrealdb")]
161use tsift_surrealdb::SurrealdbGraphStore;
162use tsift_tokensave::TokensaveDb;
163
164#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
165pub(crate) enum GraphDbExperimentalBackend {
166 DuckdbDuckpgq,
167 Falkordb,
168 Ladybug,
169 Kuzu,
170 Surrealdb,
171}
172
173#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
174pub(crate) struct SearchFacetFilters {
175 #[serde(skip_serializing_if = "Vec::is_empty", default)]
176 pub(crate) languages: Vec<String>,
177 #[serde(skip_serializing_if = "Vec::is_empty", default)]
178 pub(crate) kinds: Vec<String>,
179 #[serde(skip_serializing_if = "Vec::is_empty", default)]
180 pub(crate) node_kinds: Vec<String>,
181 #[serde(skip_serializing_if = "Vec::is_empty", default)]
182 pub(crate) sections: Vec<String>,
183 #[serde(skip_serializing_if = "Vec::is_empty", default)]
184 pub(crate) parents: Vec<String>,
185 #[serde(skip_serializing_if = "Vec::is_empty", default)]
186 pub(crate) children: Vec<String>,
187 #[serde(skip_serializing_if = "Vec::is_empty", default)]
188 pub(crate) fence_languages: Vec<String>,
189 #[serde(skip_serializing_if = "Vec::is_empty", default)]
190 pub(crate) list_depths: Vec<usize>,
191 #[serde(skip_serializing_if = "Vec::is_empty", default)]
192 pub(crate) heading_levels: Vec<usize>,
193}
194
195impl SearchFacetFilters {
196 pub(crate) fn is_empty(&self) -> bool {
197 self.languages.is_empty()
198 && self.kinds.is_empty()
199 && self.node_kinds.is_empty()
200 && self.sections.is_empty()
201 && self.parents.is_empty()
202 && self.children.is_empty()
203 && self.fence_languages.is_empty()
204 && self.list_depths.is_empty()
205 && self.heading_levels.is_empty()
206 }
207
208 fn needs_ast_context(&self) -> bool {
209 !self.sections.is_empty()
210 || !self.parents.is_empty()
211 || !self.children.is_empty()
212 || !self.fence_languages.is_empty()
213 || !self.list_depths.is_empty()
214 || !self.heading_levels.is_empty()
215 }
216}
217
218#[derive(Serialize)]
219struct GraphDbBackendPromotionGate {
220 status: String,
221 native_adapter_required: bool,
222 required_checks: Vec<String>,
223}
224
225impl GraphDbExperimentalBackend {
226 fn name(self) -> &'static str {
227 match self {
228 Self::DuckdbDuckpgq => "duckdb-duckpgq",
229 Self::Falkordb => "falkordb",
230 Self::Ladybug => "ladybug",
231 Self::Kuzu => "kuzu",
232 Self::Surrealdb => "surrealdb",
233 }
234 }
235
236 fn adapter_label(self) -> &'static str {
237 match self {
238 Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
239 Self::Falkordb => "FalkorDB read-only prototype",
240 Self::Ladybug => "Ladybug read-only prototype",
241 Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
242 Self::Surrealdb => "SurrealDB read-only prototype",
243 }
244 }
245
246 fn projection_load(self) -> &'static str {
247 match self {
248 Self::Falkordb => {
249 "provider-neutral rows loaded into a FalkorDB-shaped read snapshot for parity and timing only; production FalkorDB storage remains behind backend-eval until a real adapter passes the full-projection gate"
250 }
251 Self::Kuzu => {
252 "provider-neutral rows loaded into a Kuzu-compatible in-process read snapshot for parity and performance gates; production Vela-Engineering/kuzu storage remains behind a future optional adapter"
253 }
254 Self::Surrealdb => {
255 "provider-neutral rows loaded into a SurrealDB-compatible read snapshot for parity and timing only; production SurrealDB storage remains behind backend-eval until a real optional adapter passes the full-projection gate"
256 }
257 _ => {
258 "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
259 }
260 }
261 }
262
263 fn lock_behavior(self) -> &'static str {
264 match self {
265 Self::Falkordb => {
266 "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
267 }
268 Self::Kuzu => {
269 "read-only Kuzu prototype snapshot; no SQLite writer lock is taken during benchmarks, and production Vela-Engineering/kuzu promotion must prove concurrent writer semantics before replacing SQLite"
270 }
271 Self::Surrealdb => {
272 "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
273 }
274 _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
275 }
276 }
277
278 fn install_portability(self) -> &'static str {
279 match self {
280 Self::Falkordb => {
281 "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
282 }
283 Self::Kuzu => {
284 "prototype is dependency-free in this binary; production Vela-Engineering/kuzu integration must stay optional so cargo build/install works without a native Kuzu toolchain"
285 }
286 Self::Surrealdb => {
287 "prototype is dependency-free in this binary; production SurrealDB integration must stay optional so cargo build/install works without pulling SurrealDB into the default build"
288 }
289 _ => {
290 "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
291 }
292 }
293 }
294
295 fn prototype_hold_reason(self) -> Option<&'static str> {
296 match self {
297 Self::DuckdbDuckpgq => Some(
298 "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
299 ),
300 Self::Falkordb => Some(
301 "FalkorDB remains behind backend-eval until a production adapter beats SQLite on full_projection conflict-matrix, evidence, dispatch-trace, path tiers, install portability, and lock behavior",
302 ),
303 Self::Ladybug => Some(
304 "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
305 ),
306 Self::Kuzu => Some(
307 "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
308 ),
309 Self::Surrealdb => Some(
310 "SurrealDB remains behind backend-eval until a feature-gated optional adapter proves provider-neutral projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
311 ),
312 }
313 }
314
315 fn promotion_gate(self) -> GraphDbBackendPromotionGate {
316 match self {
317 Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
318 status: "hold_native_adapter_required".to_string(),
319 native_adapter_required: true,
320 required_checks: vec![
321 "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
322 .to_string(),
323 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
324 .to_string(),
325 "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
326 "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
327 .to_string(),
328 ],
329 },
330 Self::Falkordb => GraphDbBackendPromotionGate {
331 status: "hold_native_adapter_required".to_string(),
332 native_adapter_required: true,
333 required_checks: vec![
334 "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
335 .to_string(),
336 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
337 .to_string(),
338 "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
339 .to_string(),
340 "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
341 .to_string(),
342 ],
343 },
344 Self::Ladybug => GraphDbBackendPromotionGate {
345 status: "hold_native_adapter_required".to_string(),
346 native_adapter_required: true,
347 required_checks: vec![
348 "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
349 .to_string(),
350 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
351 .to_string(),
352 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
353 .to_string(),
354 "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
355 .to_string(),
356 ],
357 },
358 Self::Kuzu => GraphDbBackendPromotionGate {
359 status: "hold_native_adapter_required".to_string(),
360 native_adapter_required: true,
361 required_checks: vec![
362 "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
363 .to_string(),
364 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
365 .to_string(),
366 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
367 .to_string(),
368 "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
369 .to_string(),
370 ],
371 },
372 Self::Surrealdb => GraphDbBackendPromotionGate {
373 status: "hold_native_adapter_required".to_string(),
374 native_adapter_required: true,
375 required_checks: vec![
376 "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
377 .to_string(),
378 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
379 .to_string(),
380 "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
381 .to_string(),
382 "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
383 .to_string(),
384 ],
385 },
386 }
387 }
388
389 fn parse(raw: &str) -> Result<Self> {
390 match raw {
391 "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
392 "falkordb" | "falkor" => Ok(Self::Falkordb),
393 "ladybug" => Ok(Self::Ladybug),
394 "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
395 "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
396 _ => {
397 bail!(
398 "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
399 )
400 }
401 }
402 }
403}
404
405pub fn run() -> Result<()> {
406 let cli = Cli::parse();
407 let compact = cli.compact;
408 let pretty = cli.pretty;
409 let terse = cli.terse || cli.ultra_terse;
410 let ultra_terse = cli.ultra_terse;
411 let absolute = cli.absolute;
412 let tabular = cli.tabular;
413 let schema = cli.schema;
414 let envelope = cli.envelope;
415 match cli.command {
416 Some(Commands::Search {
417 query,
418 path,
419 limit,
420 strategy,
421 exact,
422 scope,
423 federated,
424 lang,
425 kind,
426 node_kind,
427 section,
428 parent,
429 child,
430 fence_language,
431 list_depth,
432 heading_level,
433 json,
434 autoindex,
435 no_autoindex,
436 timeout,
437 max_items,
438 max_bytes,
439 budget,
440 no_tagpath,
441 tagpath_strict,
442 }) => cmd_search_with_budget(
443 query,
444 path,
445 limit,
446 if exact {
447 Some("exact".to_string())
448 } else {
449 strategy
450 },
451 scope,
452 federated,
453 json || terse || schema || envelope,
454 autoindex || !no_autoindex,
455 timeout,
456 compact,
457 pretty,
458 terse,
459 ultra_terse,
460 absolute,
461 tabular,
462 schema,
463 envelope,
464 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
465 TagpathSearchOpts {
466 no_tagpath,
467 strict: tagpath_strict,
468 },
469 SearchFacetFilters {
470 languages: lang,
471 kinds: kind,
472 node_kinds: node_kind,
473 sections: section,
474 parents: parent,
475 children: child,
476 fence_languages: fence_language,
477 list_depths: list_depth,
478 heading_levels: heading_level,
479 },
480 ),
481 Some(Commands::SearchWorker {
482 path,
483 cache_dir,
484 query,
485 limit,
486 strategy,
487 output,
488 }) => cmd_search_worker(&path, &cache_dir, &query, limit, &strategy, &output),
489 Some(Commands::DigestRunner {
490 kind,
491 path,
492 runner,
493 shell_command,
494 json,
495 }) => cmd_digest_runner(
496 &kind,
497 &path,
498 runner.as_deref(),
499 &shell_command,
500 OutputFormat {
501 json_output: json || terse || schema || envelope,
502 compact,
503 pretty,
504 terse,
505 ultra_terse,
506 schema,
507 envelope,
508 },
509 ),
510 Some(Commands::Edit { dry_run, file }) => {
511 cmd_edit(dry_run, file, compact, pretty, terse, schema)
512 }
513 Some(Commands::EditIntents {
514 path,
515 scope,
516 file,
517 json,
518 apply,
519 verify,
520 verify_command,
521 max_items,
522 max_bytes,
523 budget,
524 }) => cmd_edit_intents(
525 &path,
526 scope.as_deref(),
527 file,
528 apply,
529 SemanticEditVerifyOptions {
530 enabled: verify,
531 command: verify_command.as_deref(),
532 },
533 OutputFormat {
534 json_output: json || terse || schema || envelope,
535 compact,
536 pretty,
537 terse,
538 ultra_terse,
539 schema,
540 envelope,
541 },
542 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
543 ),
544 Some(Commands::Index {
545 path,
546 rebuild,
547 check,
548 exit_code,
549 prune,
550 quiet,
551 workspace,
552 submodule,
553 json,
554 }) => cmd_index(
555 &path,
556 rebuild,
557 check,
558 exit_code,
559 prune,
560 quiet,
561 workspace,
562 submodule.as_deref(),
563 json || terse || schema || envelope,
564 compact,
565 pretty,
566 terse,
567 absolute,
568 schema,
569 ),
570 Some(Commands::Rewrite { command, run }) => cmd_rewrite(
571 &command,
572 run,
573 OutputFormat {
574 json_output: terse || schema || envelope,
575 compact,
576 pretty,
577 terse,
578 ultra_terse,
579 schema,
580 envelope,
581 },
582 ),
583 Some(Commands::Route { task, id }) => cmd_route(&task, id),
584 Some(Commands::Memory { command }) => {
585 let json = command.json_output();
586 cmd_memory(
587 command,
588 OutputFormat {
589 json_output: json || terse || schema || envelope,
590 compact,
591 pretty,
592 terse,
593 ultra_terse,
594 schema,
595 envelope,
596 },
597 )
598 }
599 Some(Commands::Finding { command }) => match command {
600 cli::FindingCommand::Add {
601 path,
602 kind,
603 title,
604 body,
605 about,
606 confidence,
607 status,
608 relates,
609 scope,
610 json,
611 } => commands::finding::cmd_finding_add(
612 &path,
613 &kind,
614 &title,
615 &body,
616 &about,
617 confidence,
618 &status,
619 relates.as_deref(),
620 scope.as_deref(),
621 json || terse || schema || envelope,
622 pretty,
623 ),
624 cli::FindingCommand::List {
625 path,
626 about,
627 kind,
628 status,
629 include_stale,
630 scope,
631 json,
632 } => commands::finding::cmd_finding_list(
633 &path,
634 about.as_deref(),
635 kind.as_deref(),
636 status.as_deref(),
637 include_stale,
638 scope.as_deref(),
639 json || terse || schema || envelope,
640 pretty,
641 ),
642 cli::FindingCommand::Harvest { path, scope, json } => {
643 commands::finding::cmd_finding_harvest(
644 &path,
645 scope.as_deref(),
646 json || terse || schema || envelope,
647 pretty,
648 )
649 }
650 cli::FindingCommand::Promote { id, path, json } => {
651 commands::finding::cmd_finding_promote(
652 &path,
653 &id,
654 json || terse || schema || envelope,
655 pretty,
656 )
657 }
658 },
659 Some(Commands::Graph {
660 symbol,
661 path,
662 callers,
663 callees,
664 scope,
665 limit,
666 json,
667 no_tagpath,
668 tagpath_strict,
669 }) => cmd_graph(
670 &symbol,
671 &path,
672 callers,
673 callees,
674 scope.as_deref(),
675 limit,
676 json || terse || schema || envelope,
677 compact,
678 pretty,
679 terse,
680 absolute,
681 tabular,
682 schema,
683 TagpathSearchOpts {
684 no_tagpath,
685 strict: tagpath_strict,
686 },
687 ),
688 Some(Commands::Sql {
689 db,
690 query,
691 table,
692 json,
693 }) => cmd_sql(
694 &db,
695 query,
696 table,
697 json || terse || schema || envelope,
698 compact,
699 pretty,
700 terse,
701 schema,
702 ),
703 Some(Commands::Communities {
704 path,
705 scope,
706 min_size,
707 limit,
708 json,
709 no_tagpath,
710 tagpath_strict,
711 }) => cmd_communities(
712 &path,
713 scope.as_deref(),
714 min_size,
715 limit,
716 json || terse || schema || envelope,
717 compact,
718 pretty,
719 terse,
720 tabular,
721 schema,
722 TagpathSearchOpts {
723 no_tagpath,
724 strict: tagpath_strict,
725 },
726 ),
727 Some(Commands::Analyze {
728 path,
729 scope,
730 entry_points,
731 limit,
732 json,
733 }) => cmd_analyze(
734 &path,
735 scope.as_deref(),
736 &entry_points,
737 limit,
738 OutputFormat {
739 json_output: json || terse || schema || envelope,
740 compact,
741 pretty,
742 terse,
743 ultra_terse,
744 schema,
745 envelope,
746 },
747 ),
748 Some(Commands::Path {
749 from,
750 to,
751 path,
752 scope,
753 json,
754 no_tagpath,
755 tagpath_strict,
756 }) => cmd_path(
757 &from,
758 &to,
759 &path,
760 scope.as_deref(),
761 json || terse || schema || envelope,
762 compact,
763 pretty,
764 terse,
765 schema,
766 TagpathSearchOpts {
767 no_tagpath,
768 strict: tagpath_strict,
769 },
770 ),
771 Some(Commands::Explain {
772 symbol,
773 path,
774 scope,
775 limit,
776 json,
777 max_items,
778 max_bytes,
779 budget,
780 no_tagpath,
781 tagpath_strict,
782 }) => cmd_explain_with_budget(
783 &symbol,
784 &path,
785 scope.as_deref(),
786 limit,
787 json || terse || schema || envelope,
788 compact,
789 pretty,
790 terse,
791 ultra_terse,
792 absolute,
793 tabular,
794 schema,
795 envelope,
796 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
797 TagpathSearchOpts {
798 no_tagpath,
799 strict: tagpath_strict,
800 },
801 ),
802 Some(Commands::Traverse {
803 node,
804 to,
805 path,
806 scope,
807 depth,
808 limit,
809 format,
810 convex_snapshot,
811 }) => cmd_traverse(
812 node.as_deref(),
813 to.as_deref(),
814 &path,
815 scope.as_deref(),
816 depth,
817 limit,
818 format,
819 pretty,
820 terse,
821 schema,
822 convex_snapshot.as_deref(),
823 ),
824 Some(Commands::ConvexSync {
825 path,
826 scope,
827 snapshot,
828 chunk_size,
829 remote_snapshot,
830 apply,
831 endpoint,
832 auth_token_env,
833 json,
834 }) => cmd_convex_sync(
835 ConvexSyncOptions {
836 path: &path,
837 scope: scope.as_deref(),
838 snapshot: snapshot.as_deref(),
839 chunk_size,
840 remote_snapshot,
841 apply,
842 endpoint: endpoint.as_deref(),
843 auth_token_env: &auth_token_env,
844 },
845 OutputFormat {
846 json_output: json || terse || schema || envelope,
847 compact,
848 pretty,
849 terse,
850 ultra_terse,
851 schema,
852 envelope,
853 },
854 ),
855 Some(Commands::GraphDb {
856 path,
857 scope,
858 backend,
859 convex_snapshot,
860 json,
861 query,
862 }) => cmd_graph_db(
863 &path,
864 scope.as_deref(),
865 backend,
866 convex_snapshot.as_deref(),
867 query,
868 OutputFormat {
869 json_output: json || terse || schema || envelope,
870 compact,
871 pretty,
872 terse,
873 ultra_terse,
874 schema,
875 envelope,
876 },
877 ),
878 Some(Commands::SourceRead {
879 file,
880 path,
881 style,
882 start,
883 lines,
884 end,
885 scope,
886 json,
887 max_items,
888 max_bytes,
889 budget,
890 }) => cmd_source_read(
891 &file,
892 &path,
893 style,
894 start,
895 lines,
896 end,
897 scope.as_deref(),
898 OutputFormat {
899 json_output: json || terse || schema || envelope,
900 compact,
901 pretty,
902 terse,
903 ultra_terse,
904 schema,
905 envelope,
906 },
907 absolute,
908 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
909 ),
910 Some(Commands::MarkdownAst {
911 file,
912 path,
913 node,
914 json,
915 max_items,
916 max_bytes,
917 budget,
918 }) => cmd_markdown_ast(
919 &file,
920 &path,
921 node.as_deref(),
922 OutputFormat {
923 json_output: json || terse || schema || envelope,
924 compact,
925 pretty,
926 terse,
927 ultra_terse,
928 schema,
929 envelope,
930 },
931 absolute,
932 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
933 ),
934 Some(Commands::SymbolRead {
935 symbol,
936 file,
937 path,
938 scope,
939 json,
940 max_items,
941 max_bytes,
942 budget,
943 }) => cmd_symbol_read(
944 &symbol,
945 file.as_deref(),
946 &path,
947 scope.as_deref(),
948 OutputFormat {
949 json_output: json || terse || schema || envelope,
950 compact,
951 pretty,
952 terse,
953 ultra_terse,
954 schema,
955 envelope,
956 },
957 absolute,
958 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
959 ),
960 Some(Commands::Audit {
961 skills_dir,
962 manifest,
963 usage,
964 cleanup,
965 report,
966 json,
967 }) => cmd_audit(
968 &skills_dir,
969 manifest,
970 usage,
971 cleanup,
972 report,
973 json || terse || schema || envelope,
974 compact,
975 pretty,
976 terse,
977 schema,
978 ),
979 Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
980 &path,
981 scope.as_deref(),
982 json || terse || schema || envelope,
983 pretty,
984 terse,
985 schema,
986 ),
987 Some(Commands::Init {
988 path,
989 codex,
990 opencode,
991 workspace,
992 }) => cmd_init(&path, codex, opencode, workspace),
993 Some(Commands::Lint {
994 file,
995 index,
996 entities_from,
997 json,
998 }) => cmd_lint(
999 &file,
1000 index,
1001 entities_from,
1002 json || terse || schema || envelope,
1003 compact,
1004 pretty,
1005 terse,
1006 schema,
1007 ),
1008 Some(Commands::Summarize {
1009 symbol,
1010 file,
1011 extract,
1012 diff,
1013 stats,
1014 path,
1015 json,
1016 }) => cmd_summarize(
1017 symbol,
1018 file,
1019 extract,
1020 diff,
1021 stats,
1022 &path,
1023 json || terse || schema || envelope,
1024 compact,
1025 pretty,
1026 terse,
1027 schema,
1028 ),
1029 Some(Commands::Semantic {
1030 query,
1031 path,
1032 scope,
1033 limit,
1034 kind,
1035 json,
1036 }) => cmd_semantic_related(
1037 &query,
1038 &path,
1039 scope.as_deref(),
1040 limit,
1041 kind,
1042 json || terse || schema || envelope,
1043 compact,
1044 pretty,
1045 terse,
1046 schema,
1047 ),
1048 Some(Commands::DiffDigest {
1049 path,
1050 cached,
1051 revision,
1052 max_parsed_files,
1053 json,
1054 }) => cmd_diff_digest(
1055 &path,
1056 cached,
1057 revision.as_deref(),
1058 max_parsed_files,
1059 OutputFormat {
1060 json_output: json || terse || schema || envelope,
1061 compact,
1062 pretty,
1063 terse,
1064 ultra_terse,
1065 schema,
1066 envelope,
1067 },
1068 ),
1069 Some(Commands::Impact {
1070 path,
1071 cached,
1072 revision,
1073 scope,
1074 limit,
1075 json,
1076 }) => cmd_impact(
1077 &path,
1078 cached,
1079 revision.as_deref(),
1080 scope.as_deref(),
1081 limit,
1082 OutputFormat {
1083 json_output: json || terse || schema || envelope,
1084 compact,
1085 pretty,
1086 terse,
1087 ultra_terse,
1088 schema,
1089 envelope,
1090 },
1091 ),
1092 Some(Commands::TestDigest {
1093 path,
1094 input,
1095 runner,
1096 json,
1097 }) => cmd_test_digest(
1098 &path,
1099 input.as_deref(),
1100 runner.as_deref(),
1101 OutputFormat {
1102 json_output: json || terse || schema || envelope,
1103 compact,
1104 pretty,
1105 terse,
1106 ultra_terse,
1107 schema,
1108 envelope,
1109 },
1110 ),
1111 Some(Commands::LogDigest { path, input, json }) => cmd_log_digest(
1112 &path,
1113 input.as_deref(),
1114 OutputFormat {
1115 json_output: json || terse || schema || envelope,
1116 compact,
1117 pretty,
1118 terse,
1119 ultra_terse,
1120 schema,
1121 envelope,
1122 },
1123 ),
1124 Some(Commands::ContextPack {
1125 path,
1126 test_input,
1127 runner,
1128 log_input,
1129 json,
1130 max_items,
1131 max_bytes,
1132 budget,
1133 convex_snapshot,
1134 }) => cmd_context_pack(
1135 &path,
1136 test_input.as_deref(),
1137 runner.as_deref(),
1138 log_input.as_deref(),
1139 OutputFormat {
1140 json_output: json || terse || schema || envelope,
1141 compact,
1142 pretty,
1143 terse,
1144 ultra_terse,
1145 schema,
1146 envelope,
1147 },
1148 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1149 convex_snapshot.as_deref(),
1150 ),
1151 Some(Commands::ConflictMatrix {
1152 targets,
1153 path,
1154 scope,
1155 depth,
1156 limit,
1157 impact_limit,
1158 json,
1159 }) => cmd_conflict_matrix(
1160 &path,
1161 scope.as_deref(),
1162 &targets,
1163 depth,
1164 limit,
1165 impact_limit,
1166 OutputFormat {
1167 json_output: json || terse || schema || envelope,
1168 compact,
1169 pretty,
1170 terse,
1171 ultra_terse,
1172 schema,
1173 envelope,
1174 },
1175 ),
1176 Some(Commands::DispatchTrace {
1177 targets,
1178 path,
1179 scope,
1180 depth,
1181 limit,
1182 impact_limit,
1183 format,
1184 json,
1185 }) => cmd_dispatch_trace(
1186 DispatchTraceOptions {
1187 path: &path,
1188 scope: scope.as_deref(),
1189 raw_targets: &targets,
1190 depth,
1191 limit,
1192 impact_limit,
1193 trace_format: if json {
1194 DispatchTraceFormat::Json
1195 } else {
1196 format
1197 },
1198 },
1199 OutputFormat {
1200 json_output: json || terse || schema || envelope,
1201 compact,
1202 pretty,
1203 terse,
1204 ultra_terse,
1205 schema,
1206 envelope,
1207 },
1208 ),
1209 Some(Commands::DependencyDag {
1210 targets,
1211 path,
1212 scope,
1213 depth,
1214 limit,
1215 json,
1216 }) => cmd_dependency_dag(
1217 &path,
1218 scope.as_deref(),
1219 &targets,
1220 depth,
1221 limit,
1222 OutputFormat {
1223 json_output: json || terse || schema || envelope,
1224 compact,
1225 pretty,
1226 terse,
1227 ultra_terse,
1228 schema,
1229 envelope,
1230 },
1231 ),
1232 Some(Commands::TokenSavings {
1233 fixture,
1234 fail_under,
1235 json,
1236 }) => token_savings::cmd_token_savings(
1237 &fixture,
1238 fail_under,
1239 OutputFormat {
1240 json_output: json || terse || schema || envelope,
1241 compact,
1242 pretty,
1243 terse,
1244 ultra_terse,
1245 schema,
1246 envelope,
1247 },
1248 ),
1249 Some(Commands::MetricDigest {
1250 input,
1251 baseline,
1252 metrics,
1253 lower_is_better,
1254 higher_is_better,
1255 history,
1256 top,
1257 json,
1258 }) => cmd_metric_digest(
1259 MetricDigestOptions {
1260 input_path: input.as_deref(),
1261 baseline_path: baseline.as_deref(),
1262 metrics: &metrics,
1263 lower_is_better: &lower_is_better,
1264 higher_is_better: &higher_is_better,
1265 history,
1266 top,
1267 },
1268 OutputFormat {
1269 json_output: json || terse || schema || envelope,
1270 compact,
1271 pretty,
1272 terse,
1273 ultra_terse,
1274 schema,
1275 envelope,
1276 },
1277 ),
1278 Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1279 &fixture,
1280 OutputFormat {
1281 json_output: json || terse || schema || envelope,
1282 compact,
1283 pretty,
1284 terse,
1285 ultra_terse,
1286 schema,
1287 envelope,
1288 },
1289 ),
1290 Some(Commands::TokenGate { command }) => {
1291 cmd_token_gate(
1292 command,
1293 OutputFormat {
1294 json_output: true,
1295 compact,
1296 pretty,
1297 terse,
1298 ultra_terse,
1299 schema,
1300 envelope,
1301 },
1302 )?;
1303 Ok(())
1304 }
1305 Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1306 &topic,
1307 OutputFormat {
1308 json_output: json || terse || schema || envelope,
1309 compact,
1310 pretty,
1311 terse,
1312 ultra_terse,
1313 schema,
1314 envelope,
1315 },
1316 ),
1317 Some(Commands::SessionDigest {
1318 path,
1319 input,
1320 source,
1321 json,
1322 }) => cmd_session_digest(
1323 &path,
1324 input.as_deref(),
1325 source.as_deref(),
1326 OutputFormat {
1327 json_output: json || terse || schema || envelope,
1328 compact,
1329 pretty,
1330 terse,
1331 ultra_terse,
1332 schema,
1333 envelope,
1334 },
1335 ),
1336 Some(Commands::SessionCost {
1337 input,
1338 fixture,
1339 fail_under,
1340 source,
1341 json,
1342 }) => cmd_session_cost(
1343 input.as_deref(),
1344 fixture.as_deref(),
1345 fail_under,
1346 source.as_deref(),
1347 OutputFormat {
1348 json_output: json || terse || schema || envelope,
1349 compact,
1350 pretty,
1351 terse,
1352 ultra_terse,
1353 schema,
1354 envelope,
1355 },
1356 ),
1357 Some(Commands::SessionReview {
1358 path,
1359 next_context,
1360 json,
1361 max_items,
1362 max_bytes,
1363 budget,
1364 }) => cmd_session_review_with_budget(
1365 &path,
1366 next_context,
1367 OutputFormat {
1368 json_output: json || terse || schema || envelope,
1369 compact,
1370 pretty,
1371 terse,
1372 ultra_terse,
1373 schema,
1374 envelope,
1375 },
1376 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1377 ),
1378 Some(Commands::Status {
1379 path,
1380 fix,
1381 no_fix,
1382 json,
1383 }) => cmd_status(
1384 &path,
1385 StatusCommandOptions {
1386 fix,
1387 no_fix,
1388 json_output: json || terse || schema || envelope,
1389 compact,
1390 pretty,
1391 terse,
1392 schema,
1393 },
1394 ),
1395 Some(Commands::Locks { path, scope, json }) => cmd_locks(
1396 &path,
1397 scope.as_deref(),
1398 json || terse || schema || envelope,
1399 compact,
1400 pretty,
1401 terse,
1402 schema,
1403 ),
1404 None => {
1405 println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1406 println!("Run `tsift --help` for usage.");
1407 Ok(())
1408 }
1409 }
1410}
1411
1412pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1415 let lower = task.to_lowercase();
1416 for signal in &[
1418 "architect",
1419 "architecture",
1420 "design",
1421 "plan",
1422 "strateg",
1423 "analy",
1424 "review",
1425 "evaluate",
1426 "assess",
1427 ] {
1428 if lower.contains(signal) {
1429 return ("opus", "claude-opus-4-6");
1430 }
1431 }
1432 for signal in &[
1434 "edit",
1435 "write",
1436 "fix",
1437 "change",
1438 "update",
1439 "create",
1440 "add ",
1441 "remove",
1442 "delete",
1443 "modify",
1444 "refactor",
1445 "implement",
1446 "build",
1447 ] {
1448 if lower.contains(signal) {
1449 return ("sonnet", "claude-sonnet-4-6");
1450 }
1451 }
1452 ("haiku", "claude-haiku-4-5-20251001")
1454}
1455
1456#[cfg(test)]
1457fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
1458 to_json_schema(val, pretty, terse, false, false)
1459}
1460
1461pub(crate) fn inject_tagpath_stale_into_json(
1468 value: &mut serde_json::Value,
1469 stale: bool,
1470 reason: Option<&str>,
1471) {
1472 if !stale {
1473 return;
1474 }
1475 if let Some(obj) = value.as_object_mut() {
1476 obj.insert(
1477 "tagpath_index_stale".to_string(),
1478 serde_json::Value::Bool(true),
1479 );
1480 if let Some(reason) = reason {
1481 obj.insert(
1482 "tagpath_stale_reason".to_string(),
1483 serde_json::Value::String(reason.to_string()),
1484 );
1485 }
1486 }
1487}
1488
1489pub(crate) fn to_json_schema<T: serde::Serialize>(
1490 val: &T,
1491 pretty: bool,
1492 terse: bool,
1493 ultra_terse: bool,
1494 schema: bool,
1495) -> anyhow::Result<String> {
1496 if terse || schema {
1497 let value = serde_json::to_value(val)?;
1498 let mut transformed = if terse { terse_transform(value) } else { value };
1499 if ultra_terse {
1500 transformed = ultra_terse_transform(transformed);
1501 transformed = edge_index_transform(transformed);
1502 }
1503 if schema {
1504 transformed = schema_transform(transformed);
1505 }
1506 if terse {
1507 let terse_schema = terse_schema_for(&transformed);
1508 let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
1509 if pretty {
1510 Ok(serde_json::to_string_pretty(&wrapped)?)
1511 } else {
1512 Ok(serde_json::to_string(&wrapped)?)
1513 }
1514 } else if pretty {
1515 Ok(serde_json::to_string_pretty(&transformed)?)
1516 } else {
1517 Ok(serde_json::to_string(&transformed)?)
1518 }
1519 } else if pretty {
1520 Ok(serde_json::to_string_pretty(val)?)
1521 } else {
1522 Ok(serde_json::to_string(val)?)
1523 }
1524}
1525
1526pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
1527 ToolEnvelopeMetric {
1528 label: label.to_string(),
1529 value: value.to_string(),
1530 }
1531}
1532
1533pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
1534 let mut seen = HashSet::new();
1535 let mut deduped = Vec::new();
1536 for value in values {
1537 if seen.insert(value.clone()) {
1538 deduped.push(value);
1539 }
1540 }
1541 deduped
1542}
1543
1544pub(crate) fn print_json_or_envelope<T: Serialize>(
1545 report: &T,
1546 format: &OutputFormat,
1547 tool: &str,
1548 view: &str,
1549 summary: ToolEnvelopeSummary,
1550 truncated: bool,
1551 follow_up: Vec<String>,
1552) -> Result<()> {
1553 if format.envelope {
1554 let schema = format.schema || tool == "source-read";
1555 let envelope = ToolEnvelope {
1556 tool,
1557 view,
1558 summary,
1559 truncated,
1560 follow_up: dedupe_preserve_order(follow_up),
1561 report,
1562 };
1563 println!(
1564 "{}",
1565 to_json_schema(
1566 &envelope,
1567 format.pretty,
1568 format.terse,
1569 format.ultra_terse,
1570 schema
1571 )?
1572 );
1573 } else {
1574 println!(
1575 "{}",
1576 to_json_schema(
1577 report,
1578 format.pretty,
1579 format.terse,
1580 format.ultra_terse,
1581 format.schema
1582 )?
1583 );
1584 }
1585 Ok(())
1586}
1587
1588pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
1589 bytes.div_ceil(4)
1590}
1591
1592fn cmd_token_gate(command: cli::TokenGateCommand, format: OutputFormat) -> Result<()> {
1593 match command {
1594 cli::TokenGateCommand::Sample {
1595 surface,
1596 path,
1597 scope,
1598 target,
1599 depth,
1600 sample_index,
1601 json: _,
1602 } => cmd_token_gate_sample(
1603 &surface,
1604 &path,
1605 scope.as_deref(),
1606 target.as_deref(),
1607 depth,
1608 sample_index,
1609 ),
1610 cli::TokenGateCommand::Evaluate {
1611 history,
1612 allowed_regression_percent,
1613 json: _,
1614 } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
1615 }
1616}
1617
1618fn cmd_token_gate_sample(
1619 surface: &str,
1620 path: &Path,
1621 scope: Option<&str>,
1622 target: Option<&str>,
1623 depth: usize,
1624 sample_index: usize,
1625) -> Result<()> {
1626 if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
1627 bail!(
1628 "unknown surface `{}`; expected one of: {}",
1629 surface,
1630 token_gate::TOKEN_GATE_SURFACES.join(", ")
1631 );
1632 }
1633
1634 let path_str = path.to_string_lossy().to_string();
1635 let tsift_bin = std::env::current_exe()?;
1636
1637 let args: Vec<String> = match surface {
1638 "context_pack" => vec!["context-pack".to_string(), "--json".to_string(), path_str],
1639 "session_review_next_context" => vec![
1640 "session-review".to_string(),
1641 "--json".to_string(),
1642 "--next-context".to_string(),
1643 path_str,
1644 ],
1645 "graph_db_evidence" => {
1646 let tgt = target.unwrap_or("default").to_string();
1647 vec![
1648 "graph-db".to_string(),
1649 "--json".to_string(),
1650 "--path".to_string(),
1651 path_str,
1652 "evidence".to_string(),
1653 tgt,
1654 "--depth".to_string(),
1655 depth.to_string(),
1656 ]
1657 }
1658 "conflict_matrix" => {
1659 let tgt = target.unwrap_or("default").to_string();
1660 let mut a = vec![
1661 "conflict-matrix".to_string(),
1662 "--json".to_string(),
1663 "--path".to_string(),
1664 path_str,
1665 "--depth".to_string(),
1666 depth.to_string(),
1667 ];
1668 if let Some(s) = scope {
1669 a.push("--scope".to_string());
1670 a.push(s.to_string());
1671 }
1672 a.push(tgt);
1673 a
1674 }
1675 "dispatch_trace" => {
1676 let tgt = target.unwrap_or("default").to_string();
1677 vec![
1678 "dispatch-trace".to_string(),
1679 "--json".to_string(),
1680 "--path".to_string(),
1681 path_str,
1682 tgt,
1683 ]
1684 }
1685 _ => bail!("unhandled surface: {}", surface),
1686 };
1687
1688 let start = Instant::now();
1689 let child = Command::new(&tsift_bin)
1690 .args(&args)
1691 .stdout(Stdio::piped())
1692 .stderr(Stdio::piped())
1693 .env("TSIFT_QUIET", "1")
1694 .spawn();
1695 let output = match child {
1696 Ok(c) => c.wait_with_output()?,
1697 Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
1698 };
1699 let runtime_micros = start.elapsed().as_micros() as f64;
1700
1701 let stdout = String::from_utf8_lossy(&output.stdout);
1702 let envelope_bytes = stdout.trim().len() as f64;
1703 let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
1704
1705 let cache_hit_rate_percent = 0.0;
1706 let raw_read_avoidance = 0.0;
1707 let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
1708
1709 let timestamp = iso_timestamp_now();
1710 let id = format!(
1711 "{surface}-baseline-{}-sample-{sample_index}",
1712 ×tamp[..10]
1713 );
1714 let label = format!(
1715 "token-gate baseline {surface} sample {sample_index} for {}",
1716 path.display()
1717 );
1718
1719 let mut metrics = BTreeMap::new();
1720 metrics.insert("prompt_tokens".to_string(), prompt_tokens);
1721 metrics.insert("envelope_bytes".to_string(), envelope_bytes);
1722 metrics.insert("runtime_micros".to_string(), runtime_micros);
1723 metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
1724 metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
1725 metrics.insert("useful_hit_density".to_string(), useful_hit_density);
1726
1727 let sample = token_gate::TokenGateSample {
1728 label,
1729 id,
1730 timestamp: Some(timestamp),
1731 surface: surface.to_string(),
1732 metrics,
1733 };
1734
1735 println!("{}", serde_json::to_string_pretty(&sample)?);
1736 Ok(())
1737}
1738
1739fn cmd_token_gate_evaluate(
1740 history_path: Option<&Path>,
1741 allowed_regression_percent: f64,
1742 format: &OutputFormat,
1743) -> Result<()> {
1744 let history_path = history_path.map(PathBuf::from).unwrap_or_else(|| {
1745 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1746 p.push("../../fixtures/token-gate-history.json");
1747 p
1748 });
1749
1750 let raw = std::fs::read_to_string(&history_path).with_context(|| {
1751 format!(
1752 "failed to read token gate history: {}",
1753 history_path.display()
1754 )
1755 })?;
1756 let samples = token_gate::parse_token_history(&raw)?;
1757 let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
1758
1759 if format.json_output {
1760 println!(
1761 "{}",
1762 to_json_schema(&report, format.pretty, format.terse, false, format.schema)?
1763 );
1764 } else {
1765 println!("Token Gate Report");
1766 println!(" min_samples: {}", report.min_samples);
1767 println!(
1768 " allowed_regression: {:.1}%",
1769 report.allowed_regression_percent
1770 );
1771 println!(" decision: {:?}", report.decision);
1772 for eval in &report.surface_evaluations {
1773 println!(
1774 " {} ({} samples): {:?}",
1775 eval.display_name, eval.sample_count, eval.verdict
1776 );
1777 for me in &eval.metric_evaluations {
1778 println!(" {} ({:?}): {}", me.metric, me.direction, me.diagnostic);
1779 }
1780 }
1781 for d in &report.diagnostics {
1782 println!(" ! {}", d);
1783 }
1784 }
1785 Ok(())
1786}
1787
1788fn iso_timestamp_now() -> String {
1789 let dur = SystemTime::now()
1790 .duration_since(UNIX_EPOCH)
1791 .unwrap_or_default();
1792 let total_secs = dur.as_secs();
1793 let days_since_epoch = total_secs / 86400;
1794 let (year, month, day) = days_to_ymd(days_since_epoch);
1795 let time_of_day = total_secs % 86400;
1796 let hour = (time_of_day / 3600) as u8;
1797 let minute = ((time_of_day % 3600) / 60) as u8;
1798 let second = (time_of_day % 60) as u8;
1799 format!(
1800 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1801 year, month, day, hour, minute, second
1802 )
1803}
1804
1805fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
1806 let mut year = 1970u64;
1807 loop {
1808 let days_in_year = if is_leap(year) { 366 } else { 365 };
1809 if days < days_in_year {
1810 break;
1811 }
1812 days -= days_in_year;
1813 year += 1;
1814 }
1815 let leap = is_leap(year);
1816 let month_days: [u8; 12] = if leap {
1817 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1818 } else {
1819 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1820 };
1821 let mut month: u8 = 1;
1822 for &md in &month_days {
1823 if days < md as u64 {
1824 break;
1825 }
1826 days -= md as u64;
1827 month += 1;
1828 }
1829 let day = days as u8 + 1;
1830 (year, month, day)
1831}
1832
1833fn is_leap(year: u64) -> bool {
1834 year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
1835}
1836
1837fn persist_transcript_artifact(
1838 root: &Path,
1839 prefix: &str,
1840 suffix: &str,
1841 key: &str,
1842 body: &str,
1843 expand: String,
1844) -> Result<TranscriptArtifactRef> {
1845 let handle = stable_handle(prefix, key);
1846 let artifacts_dir = root.join(".tsift/artifacts");
1847 fs::create_dir_all(&artifacts_dir).with_context(|| {
1848 format!(
1849 "creating transcript artifacts dir: {}",
1850 artifacts_dir.display()
1851 )
1852 })?;
1853 let file_name = format!("{handle}.{suffix}");
1854 let artifact_path = artifacts_dir.join(file_name);
1855 fs::write(&artifact_path, body)
1856 .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
1857 let rel_path = relativize_pathbuf(&artifact_path, root);
1858 Ok(TranscriptArtifactRef {
1859 handle,
1860 path: rel_path.display().to_string(),
1861 bytes: body.len(),
1862 lines: body.lines().count(),
1863 expand,
1864 })
1865}
1866
1867fn terse_key(key: &str) -> &str {
1868 match key {
1869 "name" => "n",
1870 "kind" => "k",
1871 "file" => "f",
1872 "line" => "l",
1873 "path" => "p",
1874 "from" => "fr",
1875 "type" => "ty",
1876 "text" => "tx",
1877 "new" => "nw",
1878 "run" => "r",
1879 "use" => "u",
1880 "score" => "sc",
1881 "language" => "la",
1882 "status" => "st",
1883 "state" => "stt",
1884 "error" => "err",
1885 "errors" => "ers",
1886 "hops" => "hp",
1887 "tags" => "tg",
1888 "model" => "ml",
1889 "skill" => "sk",
1890 "count" => "ct",
1891 "total" => "tot",
1892 "column" => "col",
1893 "description" => "dsc",
1894 "end_line" => "el",
1895 "signature" => "sig",
1896 "parent_module" => "pm",
1897 "visibility" => "vis",
1898 "match_type" => "mt",
1899 "caller_file" => "cf",
1900 "caller_name" => "cn",
1901 "caller_line" => "cl",
1902 "callee_name" => "en",
1903 "call_site_line" => "csl",
1904 "members" => "m",
1905 "refs" => "refs",
1906 "role" => "rl",
1907 "peer" => "pr",
1908 "modularity" => "q",
1909 "modularity_contribution" => "mc",
1910 "iterations" => "it",
1911 "node_count" => "nc",
1912 "edge_count" => "ec",
1913 "community_count" => "cc",
1914 "communities" => "cms",
1915 "community" => "cm",
1916 "community_diagnostics" => "cd",
1917 "cache_hit" => "cah",
1918 "tagpath_state" => "tps",
1919 "tagpath_stale_reason" => "tsr",
1920 "annotated_community_count" => "acc",
1921 "annotated_member_count" => "amc",
1922 "ambiguous_member_count" => "ambc",
1923 "ambiguous_members" => "amb",
1924 "candidate_count" => "cand",
1925 "tagpath_candidate_count" => "tcand",
1926 "evidence" => "ev",
1927 "chosen_file" => "chf",
1928 "symbol" => "s",
1929 "symbols" => "sy",
1930 "definitions" => "df",
1931 "callers" => "crs",
1932 "callees" => "ces",
1933 "total_tracked" => "tt",
1934 "modified" => "md",
1935 "deleted" => "dl",
1936 "unchanged" => "uc",
1937 "changes" => "ch",
1938 "prune_stats" => "ps",
1939 "hits" => "h",
1940 "rank" => "rk",
1941 "snippet" => "sn",
1942 "confidence" => "co",
1943 "index" => "ix",
1944 "summaries" => "sms",
1945 "recommendations" => "rec",
1946 "total_files" => "tf",
1947 "stale_files" => "sf",
1948 "last_indexed_secs_ago" => "age",
1949 "cached_files" => "caf",
1950 "total_indexed_files" => "tif",
1951 "coverage_pct" => "cov",
1952 "symbol_name" => "syn",
1953 "file_path" => "fp",
1954 "content_hash" => "hsh",
1955 "summary" => "sum",
1956 "tool" => "tl",
1957 "view" => "vw",
1958 "truncated" => "tr",
1959 "follow_up" => "fu",
1960 "report" => "rp",
1961 "metrics" => "ms",
1962 "label" => "lb",
1963 "value" => "v",
1964 "command" => "cmd",
1965 "exit_code" => "xc",
1966 "success" => "ok",
1967 "artifact" => "art",
1968 "digest" => "dg",
1969 "bytes" => "bt",
1970 "lines" => "lns",
1971 "expand" => "xp",
1972 "entities" => "ent",
1973 "relationships" => "rel",
1974 "concept_labels" => "cls",
1975 "extracted_at" => "at",
1976 "tokens_input" => "ti",
1977 "tokens_output" => "tout",
1978 "total_summaries" => "ts",
1979 "stale_count" => "stc",
1980 "total_tokens_input" => "tti",
1981 "total_tokens_output" => "tto",
1982 "estimated_tokens_saved" => "ets",
1983 "files_processed" => "fps",
1984 "symbols_extracted" => "se",
1985 "skills_dir" => "sd",
1986 "healthy" => "ok",
1987 "broken" => "brk",
1988 "skills" => "sks",
1989 "manifest_diffs" => "mdf",
1990 "similar_pairs" => "sim",
1991 "usage" => "usg",
1992 "cleanup" => "cln",
1993 "has_skill_md" => "hsm",
1994 "is_symlink" => "isl",
1995 "issues" => "iss",
1996 "invocation_count" => "inv",
1997 "reasons" => "rsn",
1998 "token_estimate" => "te",
1999 "skill_a" => "sa",
2000 "skill_b" => "sb",
2001 "desc_a" => "da",
2002 "desc_b" => "db",
2003 "annotations" => "ann",
2004 "entity" => "ety",
2005 "suggestion" => "sug",
2006 "columns" => "cols",
2007 "row_count" => "rc",
2008 "notnull" => "nn",
2009 "default_value" => "dv",
2010 "replace_all" => "ra",
2011 other => other,
2012 }
2013}
2014
2015fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2016 match val {
2017 serde_json::Value::Object(map) => {
2018 let mut new_map = serde_json::Map::new();
2019 for (k, v) in map {
2020 new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2021 }
2022 serde_json::Value::Object(new_map)
2023 }
2024 serde_json::Value::Array(arr) => {
2025 serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2026 }
2027 other => other,
2028 }
2029}
2030
2031fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2032 match val {
2033 serde_json::Value::Object(mut map) => {
2034 let is_graph_node =
2035 map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2036 let is_graph_edge =
2037 map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2038 if is_graph_node || is_graph_edge {
2039 map.remove("properties");
2040 map.remove("provenance");
2041 map.remove("freshness");
2042 }
2043 if is_graph_edge && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2044 *s = abbreviate_edge_kind(s).to_string();
2045 }
2046 let is_coverage = map.contains_key("mode")
2047 && (map.contains_key("total_sector_count")
2048 || map.contains_key("dirty_sector_count"));
2049 if is_coverage {
2050 map.remove("active_rebuild");
2051 map.remove("completed_dirty_sector_count");
2052 map.remove("mounted_sector_count");
2053 map.remove("rebuilding_sector_count");
2054 map.remove("resumed_sector_count");
2055 map.remove("reused_sector_count");
2056 }
2057 if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2058 *s = truncate_for_ultra_terse(s, 80);
2059 }
2060 if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2061 *s = truncate_for_ultra_terse(s, 80);
2062 }
2063 let new_map: serde_json::Map<String, serde_json::Value> = map
2064 .into_iter()
2065 .map(|(k, v)| (k, ultra_terse_transform(v)))
2066 .collect();
2067 serde_json::Value::Object(new_map)
2068 }
2069 serde_json::Value::Array(arr) => {
2070 serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2071 }
2072 other => other,
2073 }
2074}
2075
2076fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2077 match val {
2078 serde_json::Value::Object(mut map) => {
2079 let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2080 nodes.as_array().map(|arr| {
2081 arr.iter()
2082 .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2083 .collect()
2084 })
2085 });
2086 if let Some(ref ids) = node_ids {
2087 let id_map: std::collections::HashMap<&str, usize> = ids
2088 .iter()
2089 .enumerate()
2090 .map(|(i, id)| (id.as_str(), i))
2091 .collect();
2092 if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2093 for edge in edges.iter_mut() {
2094 if let serde_json::Value::Object(edge_map) = edge {
2095 if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id")
2096 {
2097 if let Some(&idx) = id_map.get(fid.as_str()) {
2098 edge_map.insert(
2099 "from".to_string(),
2100 serde_json::Value::Number(idx.into()),
2101 );
2102 } else {
2103 edge_map.insert(
2104 "from_id".to_string(),
2105 serde_json::Value::String(fid),
2106 );
2107 }
2108 }
2109 if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2110 if let Some(&idx) = id_map.get(tid.as_str()) {
2111 edge_map.insert(
2112 "to".to_string(),
2113 serde_json::Value::Number(idx.into()),
2114 );
2115 } else {
2116 edge_map.insert(
2117 "to_id".to_string(),
2118 serde_json::Value::String(tid),
2119 );
2120 }
2121 }
2122 }
2123 }
2124 }
2125 }
2126 let new_map: serde_json::Map<String, serde_json::Value> = map
2127 .into_iter()
2128 .map(|(k, v)| (k, edge_index_transform(v)))
2129 .collect();
2130 serde_json::Value::Object(new_map)
2131 }
2132 serde_json::Value::Array(arr) => {
2133 serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2134 }
2135 other => other,
2136 }
2137}
2138
2139fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2140 if s.len() <= max_len {
2141 s.to_string()
2142 } else {
2143 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2144 format!("{truncated}...")
2145 }
2146}
2147
2148fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2149 let mut keys = HashSet::new();
2150 collect_terse_keys(val, &mut keys);
2151 let mut schema = serde_json::Map::new();
2152 for (long, short) in TERSE_PAIRS {
2153 if keys.contains(*short) {
2154 schema.insert(
2155 short.to_string(),
2156 serde_json::Value::String(long.to_string()),
2157 );
2158 }
2159 }
2160 serde_json::Value::Object(schema)
2161}
2162
2163fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2164 match val {
2165 serde_json::Value::Object(map) => {
2166 for (k, v) in map {
2167 keys.insert(k.clone());
2168 collect_terse_keys(v, keys);
2169 }
2170 }
2171 serde_json::Value::Array(arr) => {
2172 for v in arr {
2173 collect_terse_keys(v, keys);
2174 }
2175 }
2176 _ => {}
2177 }
2178}
2179
2180fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2181 match val {
2182 serde_json::Value::Array(arr) if arr.len() >= 2 => {
2183 if let Some(cols) = homogeneous_keys(&arr) {
2184 let rows: Vec<serde_json::Value> = arr
2185 .into_iter()
2186 .map(|item| {
2187 if let serde_json::Value::Object(map) = item {
2188 let vals: Vec<serde_json::Value> = cols
2189 .iter()
2190 .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2191 .collect();
2192 serde_json::Value::Array(vals)
2193 } else {
2194 item
2195 }
2196 })
2197 .collect();
2198 let col_vals: Vec<serde_json::Value> =
2199 cols.into_iter().map(serde_json::Value::String).collect();
2200 serde_json::json!({"_c": col_vals, "_r": rows})
2201 } else {
2202 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2203 }
2204 }
2205 serde_json::Value::Array(arr) => {
2206 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2207 }
2208 serde_json::Value::Object(map) => {
2209 let new_map: serde_json::Map<String, serde_json::Value> = map
2210 .into_iter()
2211 .map(|(k, v)| (k, schema_transform(v)))
2212 .collect();
2213 serde_json::Value::Object(new_map)
2214 }
2215 other => other,
2216 }
2217}
2218
2219fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2220 let first = arr.first()?.as_object()?;
2221 let keys: Vec<String> = first.keys().cloned().collect();
2222 for item in &arr[1..] {
2223 let obj = item.as_object()?;
2224 if obj.len() != keys.len() {
2225 return None;
2226 }
2227 for k in &keys {
2228 if !obj.contains_key(k) {
2229 return None;
2230 }
2231 }
2232 }
2233 Some(keys)
2234}
2235
2236const TERSE_PAIRS: &[(&str, &str)] = &[
2237 ("name", "n"),
2238 ("kind", "k"),
2239 ("file", "f"),
2240 ("line", "l"),
2241 ("path", "p"),
2242 ("from", "fr"),
2243 ("type", "ty"),
2244 ("text", "tx"),
2245 ("new", "nw"),
2246 ("run", "r"),
2247 ("use", "u"),
2248 ("score", "sc"),
2249 ("language", "la"),
2250 ("status", "st"),
2251 ("state", "stt"),
2252 ("error", "err"),
2253 ("errors", "ers"),
2254 ("hops", "hp"),
2255 ("tags", "tg"),
2256 ("model", "ml"),
2257 ("skill", "sk"),
2258 ("count", "ct"),
2259 ("total", "tot"),
2260 ("column", "col"),
2261 ("description", "dsc"),
2262 ("end_line", "el"),
2263 ("signature", "sig"),
2264 ("parent_module", "pm"),
2265 ("visibility", "vis"),
2266 ("match_type", "mt"),
2267 ("caller_file", "cf"),
2268 ("caller_name", "cn"),
2269 ("caller_line", "cl"),
2270 ("callee_name", "en"),
2271 ("call_site_line", "csl"),
2272 ("members", "m"),
2273 ("refs", "refs"),
2274 ("role", "rl"),
2275 ("peer", "pr"),
2276 ("modularity", "q"),
2277 ("modularity_contribution", "mc"),
2278 ("iterations", "it"),
2279 ("node_count", "nc"),
2280 ("edge_count", "ec"),
2281 ("community_count", "cc"),
2282 ("communities", "cms"),
2283 ("community", "cm"),
2284 ("community_diagnostics", "cd"),
2285 ("cache_hit", "cah"),
2286 ("tagpath_state", "tps"),
2287 ("tagpath_stale_reason", "tsr"),
2288 ("annotated_community_count", "acc"),
2289 ("annotated_member_count", "amc"),
2290 ("ambiguous_member_count", "ambc"),
2291 ("ambiguous_members", "amb"),
2292 ("candidate_count", "cand"),
2293 ("tagpath_candidate_count", "tcand"),
2294 ("evidence", "ev"),
2295 ("chosen_file", "chf"),
2296 ("symbol", "s"),
2297 ("symbols", "sy"),
2298 ("definitions", "df"),
2299 ("callers", "crs"),
2300 ("callees", "ces"),
2301 ("total_tracked", "tt"),
2302 ("modified", "md"),
2303 ("deleted", "dl"),
2304 ("unchanged", "uc"),
2305 ("changes", "ch"),
2306 ("prune_stats", "ps"),
2307 ("hits", "h"),
2308 ("rank", "rk"),
2309 ("snippet", "sn"),
2310 ("confidence", "co"),
2311 ("index", "ix"),
2312 ("summaries", "sms"),
2313 ("recommendations", "rec"),
2314 ("total_files", "tf"),
2315 ("stale_files", "sf"),
2316 ("last_indexed_secs_ago", "age"),
2317 ("cached_files", "caf"),
2318 ("total_indexed_files", "tif"),
2319 ("coverage_pct", "cov"),
2320 ("symbol_name", "syn"),
2321 ("file_path", "fp"),
2322 ("content_hash", "hsh"),
2323 ("summary", "sum"),
2324 ("tool", "tl"),
2325 ("view", "vw"),
2326 ("truncated", "tr"),
2327 ("follow_up", "fu"),
2328 ("report", "rp"),
2329 ("metrics", "ms"),
2330 ("label", "lb"),
2331 ("value", "v"),
2332 ("command", "cmd"),
2333 ("exit_code", "xc"),
2334 ("success", "ok"),
2335 ("artifact", "art"),
2336 ("digest", "dg"),
2337 ("bytes", "bt"),
2338 ("lines", "lns"),
2339 ("expand", "xp"),
2340 ("entities", "ent"),
2341 ("relationships", "rel"),
2342 ("concept_labels", "cls"),
2343 ("extracted_at", "at"),
2344 ("tokens_input", "ti"),
2345 ("tokens_output", "tout"),
2346 ("total_summaries", "ts"),
2347 ("stale_count", "stc"),
2348 ("total_tokens_input", "tti"),
2349 ("total_tokens_output", "tto"),
2350 ("estimated_tokens_saved", "ets"),
2351 ("files_processed", "fps"),
2352 ("symbols_extracted", "se"),
2353 ("skills_dir", "sd"),
2354 ("healthy", "ok"),
2355 ("broken", "brk"),
2356 ("skills", "sks"),
2357 ("manifest_diffs", "mdf"),
2358 ("similar_pairs", "sim"),
2359 ("usage", "usg"),
2360 ("cleanup", "cln"),
2361 ("has_skill_md", "hsm"),
2362 ("is_symlink", "isl"),
2363 ("issues", "iss"),
2364 ("invocation_count", "inv"),
2365 ("reasons", "rsn"),
2366 ("token_estimate", "te"),
2367 ("skill_a", "sa"),
2368 ("skill_b", "sb"),
2369 ("desc_a", "da"),
2370 ("desc_b", "db"),
2371 ("annotations", "ann"),
2372 ("entity", "ety"),
2373 ("suggestion", "sug"),
2374 ("columns", "cols"),
2375 ("row_count", "rc"),
2376 ("notnull", "nn"),
2377 ("default_value", "dv"),
2378 ("replace_all", "ra"),
2379];
2380
2381pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2382 let root_str = root.to_string_lossy();
2383 let prefix = format!("{}/", root_str.trim_end_matches('/'));
2384 path.strip_prefix(&prefix).unwrap_or(path).to_string()
2385}
2386
2387fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2388 let canonical = path
2389 .canonicalize()
2390 .with_context(|| format!("canonicalizing {}", path.display()))?;
2391 let start = if canonical.is_dir() {
2392 canonical.clone()
2393 } else {
2394 canonical
2395 .parent()
2396 .map(Path::to_path_buf)
2397 .unwrap_or_else(|| canonical.clone())
2398 };
2399
2400 for ancestor in start.ancestors() {
2401 if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2402 return Ok(ancestor.to_path_buf());
2403 }
2404 }
2405
2406 Ok(start)
2407}
2408
2409pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2410 path.strip_prefix(root)
2411 .map(|p| p.to_path_buf())
2412 .unwrap_or_else(|_| path.to_path_buf())
2413}
2414
2415pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
2416 for edge in edges {
2417 edge.caller_file = relativize(&edge.caller_file, root);
2418 }
2419}
2420
2421pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
2422 for sym in symbols {
2423 sym.file = relativize(&sym.file, root);
2424 }
2425}
2426
2427pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
2428 for hit in hits {
2429 hit.file = relativize(&hit.file, root);
2430 }
2431}
2432
2433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2436pub enum EdgeSide {
2437 Caller,
2438 Callee,
2439}
2440
2441const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
2442
2443pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
2444 let root_str = root.to_string_lossy();
2445 let prefix = format!("{}/", root_str.trim_end_matches('/'));
2446 relativize_json_inner(val, &prefix);
2447}
2448
2449fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
2450 match val {
2451 serde_json::Value::Array(arr) => {
2452 for v in arr {
2453 relativize_json_inner(v, prefix);
2454 }
2455 }
2456 serde_json::Value::Object(map) => {
2457 for (k, v) in map.iter_mut() {
2458 if JSON_PATH_KEYS.contains(&k.as_str())
2459 && let serde_json::Value::String(s) = v
2460 && let Some(rest) = s.strip_prefix(prefix)
2461 {
2462 *s = rest.to_string();
2463 }
2464 relativize_json_inner(v, prefix);
2465 }
2466 }
2467 _ => {}
2468 }
2469}
2470
2471pub(crate) fn format_score(score: f64, compact: bool) -> String {
2472 if compact {
2473 format!("{score:.2}")
2474 } else {
2475 format!("{score:.4}")
2476 }
2477}
2478
2479pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
2480 let trimmed = input.trim();
2481 let count = trimmed.chars().count();
2482 if count <= max_chars {
2483 return trimmed.to_string();
2484 }
2485 let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
2486 format!("{prefix}...")
2487}
2488
2489pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
2490 snippet
2491 .lines()
2492 .find(|line| !line.trim().is_empty())
2493 .map(|line| truncate_for_compact(line, 100))
2494}
2495
2496pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
2497 let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
2498 if names.len() <= limit {
2499 return names.join(", ");
2500 }
2501 format!(
2502 "{} (+{} more)",
2503 names[..limit].join(", "),
2504 names.len() - limit
2505 )
2506}
2507
2508pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
2509 let mut hasher = blake3::Hasher::new();
2510 hasher.update(prefix.as_bytes());
2511 hasher.update(&[0]);
2512 hasher.update(key.as_bytes());
2513 let hex = hasher.finalize().to_hex();
2514 format!("{prefix}-{}", &hex[..10])
2515}
2516
2517#[derive(Clone, Debug, PartialEq, Eq)]
2518struct CanonicalTagFamily {
2519 canonical: String,
2520 tag_alias: String,
2521}
2522
2523fn canonical_family_from_tagpath_family(
2524 family: tagpath_family::TagFamily,
2525) -> Option<CanonicalTagFamily> {
2526 let tag_alias = if family.dimensions.is_empty() {
2527 family.tags.join("/")
2528 } else {
2529 family
2530 .dimensions
2531 .iter()
2532 .filter(|dimension| !dimension.tags.is_empty())
2533 .map(|dimension| dimension.tags.join("."))
2534 .collect::<Vec<_>>()
2535 .join("/")
2536 };
2537
2538 if tag_alias.is_empty() {
2539 None
2540 } else {
2541 Some(CanonicalTagFamily {
2542 canonical: family.canonical,
2543 tag_alias,
2544 })
2545 }
2546}
2547
2548fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
2549 let trimmed = name.trim();
2550 if trimmed.is_empty() {
2551 return None;
2552 }
2553
2554 canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
2555}
2556
2557fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
2558 let canonical = tags
2559 .split(',')
2560 .map(str::trim)
2561 .filter(|tag| !tag.is_empty())
2562 .collect::<Vec<_>>()
2563 .join("_");
2564 if canonical.is_empty() {
2565 None
2566 } else {
2567 canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
2568 }
2569}
2570
2571pub(crate) fn canonical_tag_family_from_symbol(
2572 name: &str,
2573 tags: Option<&str>,
2574) -> Option<CanonicalTagFamily> {
2575 tags.and_then(canonical_tag_family_from_tags)
2576 .or_else(|| canonical_tag_family_from_name(name))
2577}
2578
2579fn tag_alias_from_name(name: &str) -> Option<String> {
2580 canonical_tag_family_from_name(name).map(|family| family.tag_alias)
2581}
2582
2583fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
2584 canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
2585}
2586
2587pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
2588 let query = tag_alias
2589 .split(['/', '.'])
2590 .map(str::trim)
2591 .filter(|part| !part.is_empty())
2592 .collect::<Vec<_>>()
2593 .join(" ");
2594 if query.is_empty() { None } else { Some(query) }
2595}
2596
2597#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2598struct CompactOntologyRefPreview {
2599 handle: String,
2600 tag: String,
2601 path: String,
2602 #[serde(skip_serializing_if = "Option::is_none")]
2603 title: Option<String>,
2604 #[serde(skip_serializing_if = "Option::is_none")]
2605 domain: Option<String>,
2606}
2607
2608#[derive(Clone, Debug)]
2609struct TagOntologyPreviewContext {
2610 project_root: PathBuf,
2611 tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
2612}
2613
2614#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2615struct CompactSymbolRefPreview {
2616 handle: String,
2617 name: String,
2618 #[serde(skip_serializing_if = "Option::is_none")]
2619 tag_alias: Option<String>,
2620 #[serde(skip_serializing_if = "Vec::is_empty", default)]
2621 ontology_refs: Vec<CompactOntologyRefPreview>,
2622}
2623
2624fn build_compact_symbol_ref(
2625 prefix: &str,
2626 key: &str,
2627 name: &str,
2628 tags: Option<&str>,
2629 max_bytes: usize,
2630) -> CompactSymbolRefPreview {
2631 build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
2632}
2633
2634fn build_compact_symbol_ref_with_ontology(
2635 prefix: &str,
2636 key: &str,
2637 name: &str,
2638 tags: Option<&str>,
2639 max_bytes: usize,
2640 ontology: Option<&TagOntologyPreviewContext>,
2641) -> CompactSymbolRefPreview {
2642 let tag_alias = tag_alias_from_tags(name, tags);
2643 let ontology_refs = tag_alias
2644 .as_deref()
2645 .map(|alias| ontology_refs_for_alias(ontology, alias))
2646 .unwrap_or_default();
2647 CompactSymbolRefPreview {
2648 handle: stable_handle(prefix, key),
2649 name: truncate_for_budget(name, max_bytes),
2650 tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
2651 ontology_refs,
2652 }
2653}
2654
2655fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
2656 let report = tagpath_ontology::load_project(root).ok()?;
2657 if report.tags.is_empty() {
2658 return None;
2659 }
2660 Some(TagOntologyPreviewContext {
2661 project_root: report.project_path,
2662 tags: report
2663 .tags
2664 .into_iter()
2665 .map(|tag| (tag.tag.clone(), tag))
2666 .collect(),
2667 })
2668}
2669
2670fn ontology_refs_for_alias(
2671 ontology: Option<&TagOntologyPreviewContext>,
2672 alias: &str,
2673) -> Vec<CompactOntologyRefPreview> {
2674 let Some(ontology) = ontology else {
2675 return Vec::new();
2676 };
2677 let mut seen = BTreeSet::new();
2678 alias
2679 .split('/')
2680 .flat_map(|part| part.split('.'))
2681 .map(str::trim)
2682 .filter(|tag| !tag.is_empty())
2683 .filter_map(|tag| {
2684 let key = tag.to_ascii_lowercase();
2685 if !seen.insert(key.clone()) {
2686 return None;
2687 }
2688 let ontology_tag = ontology.tags.get(&key)?;
2689 let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
2690 Some(CompactOntologyRefPreview {
2691 handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
2692 tag: ontology_tag.tag.clone(),
2693 path,
2694 title: ontology_tag.title.clone(),
2695 domain: ontology_tag.domain.clone(),
2696 })
2697 })
2698 .collect()
2699}
2700
2701fn relativize_ontology_path(path: &Path, root: &Path) -> String {
2702 path.strip_prefix(root)
2703 .unwrap_or(path)
2704 .to_string_lossy()
2705 .replace('\\', "/")
2706}
2707
2708fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
2709 match tag_alias {
2710 Some(alias) => format!("{handle} {name} tag:{alias}"),
2711 None => format!("{handle} {name}"),
2712 }
2713}
2714
2715fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
2716 match summary.tag_alias.as_deref() {
2717 Some(alias) => format!(
2718 "{} {} tag:{} expand:{}",
2719 summary.handle, summary.symbol, alias, summary.expand
2720 ),
2721 None => format!(
2722 "{} {} expand:{}",
2723 summary.handle, summary.symbol, summary.expand
2724 ),
2725 }
2726}
2727
2728fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
2729 match symbol.tag_alias.as_deref() {
2730 Some(alias) => format!("{}@{}", symbol.handle, alias),
2731 None => format!("{}@{}", symbol.handle, symbol.name),
2732 }
2733}
2734
2735pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
2736 let trimmed = input.trim();
2737 if trimmed.len() <= max_bytes {
2738 return trimmed.to_string();
2739 }
2740 if max_bytes <= 3 {
2741 return ".".repeat(max_bytes);
2742 }
2743
2744 let mut end = 0usize;
2745 for (idx, ch) in trimmed.char_indices() {
2746 let next = idx + ch.len_utf8();
2747 if next > max_bytes.saturating_sub(3) {
2748 break;
2749 }
2750 end = next;
2751 }
2752
2753 if end == 0 {
2754 "...".to_string()
2755 } else {
2756 format!("{}...", &trimmed[..end])
2757 }
2758}
2759
2760struct TokenCappedPreview {
2761 preview: Vec<SourceLinePreview>,
2762 capped_end: usize,
2763 was_capped: bool,
2764}
2765
2766fn build_token_capped_preview(
2767 all_lines: &[&str],
2768 start: usize,
2769 end: usize,
2770 max_bytes: usize,
2771 token_cap: usize,
2772) -> TokenCappedPreview {
2773 let mut preview = Vec::new();
2774 let mut accumulated_tokens = 0usize;
2775 let mut capped_end = end;
2776 let mut was_capped = false;
2777
2778 for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
2779 let truncated = truncate_for_budget(line, max_bytes);
2780 let line_tokens = estimated_tokens_from_bytes(truncated.len());
2781 if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
2782 capped_end = start + idx - 1;
2783 was_capped = true;
2784 break;
2785 }
2786 accumulated_tokens += line_tokens;
2787 preview.push(SourceLinePreview {
2788 line: start + idx,
2789 text: truncated,
2790 });
2791 }
2792
2793 TokenCappedPreview {
2794 preview,
2795 capped_end,
2796 was_capped,
2797 }
2798}
2799
2800pub(crate) fn abbreviate_kind(kind: &str) -> &str {
2801 match kind {
2802 "function" => "fn",
2803 "method" => "meth",
2804 "module" | "mod" => "mod",
2805 "struct" => "struct",
2806 "trait" => "trait",
2807 "impl" => "impl",
2808 "class" => "cls",
2809 "interface" => "iface",
2810 "type_alias" => "type",
2811 "data_class" => "data_cls",
2812 "sealed_class" => "sealed_cls",
2813 "enum_class" => "enum_cls",
2814 "companion_object" => "comp_obj",
2815 "object" => "obj",
2816 "heading" => "h",
2817 "code_block" => "code",
2818 "alias" => "alias",
2819 other => other,
2820 }
2821}
2822
2823pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
2824 match kind {
2825 "calls" => "c",
2826 "defines" => "d",
2827 "contains" => "ct",
2828 "imports" => "i",
2829 "mentions" => "m",
2830 "mentions_concept" => "mc",
2831 "mentions_entity" => "me",
2832 "semantic_relation" => "sr",
2833 "belongs_to" => "bt",
2834 "scopes_context" => "sctx",
2835 "scopes_source" => "ssrc",
2836 "requests_context" => "rctx",
2837 "explains_result" => "er",
2838 "tagged_concept" => "tc",
2839 "tagged_entity" => "te",
2840 "related_concept" => "relc",
2841 "handled_by" => "hb",
2842 "defines_route" => "dr",
2843 "handles_route" => "hr",
2844 "targets" => "tgt",
2845 "has_vector_handle" => "hv",
2846 "parent" => "p",
2847 "child" => "ch",
2848 "uses" => "u",
2849 "projects_source" => "psrc",
2850 "records_memory_source" => "rms",
2851 "records_memory_event" => "rme",
2852 "has_ast_span" => "ha",
2853 "represents_symbol" => "rs",
2854 "contains_embedded_symbol" => "ces",
2855 "embedded_in_fence" => "ef",
2856 "contains_markdown_block" => "cmb",
2857 "contains_embedded_code" => "cec",
2858 "enclosing_module" => "em",
2859 "enclosing_section" => "es",
2860 "previous_sibling" => "psib",
2861 "next_sibling" => "nsib",
2862 "explicit_depends_on" => "edo",
2863 "worker_result_follow_up" => "wrf",
2864 "shared_resource" => "shr",
2865 "community_member" => "cm",
2866 other => other,
2867 }
2868}
2869
2870pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
2871 match mt {
2872 "exact_name" => "exact",
2873 "all_tags" => "all_tags",
2874 "partial_tags" => "partial",
2875 other => other,
2876 }
2877}
2878
2879pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
2880 path.iter()
2881 .map(|n| n.name.as_str())
2882 .collect::<Vec<_>>()
2883 .join(" -> ")
2884}
2885
2886const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
2887
2888struct SearchHitGroup {
2889 path: String,
2890 first_rank: usize,
2891 top_score: f64,
2892 confidence: String,
2893 hits: usize,
2894 samples: Vec<String>,
2895}
2896
2897fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
2898 let snippet = compact_snippet(&hit.snippet)?;
2899 Some(match hit.location.as_deref() {
2900 Some(location) => format!("{location}: {snippet}"),
2901 None => snippet,
2902 })
2903}
2904
2905pub(crate) fn group_search_hits(
2906 hits: &[sift::SearchHit],
2907 root: &Path,
2908 absolute: bool,
2909) -> Vec<SearchHitGroup> {
2910 let mut positions = BTreeMap::new();
2911 let mut groups = Vec::new();
2912 for hit in hits {
2913 let path = if absolute {
2914 hit.path.clone()
2915 } else {
2916 relativize(&hit.path, root)
2917 };
2918 let entry = positions.entry(path.clone()).or_insert_with(|| {
2919 groups.push(SearchHitGroup {
2920 path: path.clone(),
2921 first_rank: hit.rank,
2922 top_score: hit.score,
2923 confidence: format!("{:?}", hit.confidence),
2924 hits: 0,
2925 samples: Vec::new(),
2926 });
2927 groups.len() - 1
2928 });
2929 let group = &mut groups[*entry];
2930 group.hits += 1;
2931 if hit.rank < group.first_rank {
2932 group.first_rank = hit.rank;
2933 }
2934 if hit.score > group.top_score {
2935 group.top_score = hit.score;
2936 }
2937 if let Some(sample) = format_search_sample(hit)
2938 && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
2939 && !group.samples.contains(&sample)
2940 {
2941 group.samples.push(sample);
2942 }
2943 }
2944 groups.sort_by_key(|group| group.first_rank);
2945 groups
2946}
2947
2948pub(crate) fn should_collapse_search_hits(
2949 hits: &[sift::SearchHit],
2950 root: &Path,
2951 absolute: bool,
2952) -> bool {
2953 let groups = group_search_hits(hits, root, absolute);
2954 let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
2955 max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
2956}
2957
2958pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
2959 let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
2960 for edge in edges {
2961 let key = edge.caller_file.as_str();
2962 let name = if use_callers {
2963 edge.caller_name.as_str()
2964 } else {
2965 edge.callee_name.as_str()
2966 };
2967 let names = grouped.entry(key).or_default();
2968 if !names.contains(&name) {
2969 names.push(name);
2970 }
2971 }
2972
2973 grouped
2974 .into_iter()
2975 .map(|(file, names)| format!(" {} ({}): {}", file, names.len(), names.join(", ")))
2976 .collect()
2977}
2978
2979pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
2980 let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
2981 for edge in edges {
2982 *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
2983 }
2984 let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
2985 max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
2986}
2987
2988fn resolve_query_index_target(
2989 root: &Path,
2990 path_hint: &Path,
2991 scope: Option<&str>,
2992) -> Result<SearchIndexTarget> {
2993 let cfg = config::Config::load(root)?;
2994 if let Some(scope_name) = scope {
2995 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
2996 return Ok(SearchIndexTarget {
2997 label: format!("submodule `{}` index", scope.id),
2998 db_path: cfg.db_path_for(root, &scope.id),
2999 source_root: scope.source_root.clone(),
3000 scope_name: Some(scope.id.clone()),
3001 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3002 });
3003 }
3004 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3005 return Ok(cargo_package_index_target(root, package));
3006 }
3007 config::Config::resolve_submodule(root, scope_name)?;
3008 }
3009
3010 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3011 return Ok(SearchIndexTarget {
3012 label: format!("submodule `{}` index", scope.id),
3013 db_path: cfg.db_path_for(root, &scope.id),
3014 source_root: scope.source_root.clone(),
3015 scope_name: Some(scope.id.clone()),
3016 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3017 });
3018 }
3019
3020 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3021 return Ok(cargo_package_index_target(root, package));
3022 }
3023
3024 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3025 return Ok(SearchIndexTarget {
3026 label: format!("submodule `{}` index", scope.id),
3027 db_path: cfg.db_path_for(root, &scope.id),
3028 source_root: scope.source_root.clone(),
3029 scope_name: Some(scope.id.clone()),
3030 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3031 });
3032 }
3033
3034 let db_path = root.join(".tsift/index.db");
3035 if db_path.exists() {
3036 return Ok(SearchIndexTarget {
3037 label: "index".to_string(),
3038 db_path,
3039 source_root: root.to_path_buf(),
3040 scope_name: None,
3041 reindex_cmd: format!("tsift index {}", root.display()),
3042 });
3043 }
3044
3045 let scopes = config::Config::submodule_dirs(root)?;
3046 if scopes.is_empty() {
3047 return Ok(SearchIndexTarget {
3048 label: "index".to_string(),
3049 db_path,
3050 source_root: root.to_path_buf(),
3051 scope_name: None,
3052 reindex_cmd: format!("tsift index {}", root.display()),
3053 });
3054 }
3055
3056 let available_scopes = scopes
3057 .iter()
3058 .map(|scope| scope.id.as_str())
3059 .collect::<Vec<_>>()
3060 .join(", ");
3061 let indexed_scopes = scopes
3062 .iter()
3063 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3064 .map(|scope| scope.id.as_str())
3065 .collect::<Vec<_>>();
3066 let indexed_label = if indexed_scopes.is_empty() {
3067 "none".to_string()
3068 } else {
3069 indexed_scopes.join(", ")
3070 };
3071
3072 bail!(
3073 "workspace root {} has no shared root index at {}. Read-only graph queries require `--scope <scope>` when the workspace is indexed into `.tsift/indexes/*/index.db`. Available scopes: {}. Indexed scopes: {}.",
3074 root.display(),
3075 db_path.display(),
3076 available_scopes,
3077 indexed_label
3078 );
3079}
3080
3081pub(crate) fn resolve_query_db_path(
3082 root: &Path,
3083 path_hint: &Path,
3084 scope: Option<&str>,
3085) -> Result<PathBuf> {
3086 Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3087}
3088
3089fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3090 let state = inspect_search_index(target)?;
3091 let Some(reason) = index_reason_for_state(state) else {
3092 return Ok(());
3093 };
3094
3095 match apply_search_index_update(root, target) {
3096 Ok(_) => {
3097 index::inspect_scope_invalidate_all();
3098 Ok(())
3099 }
3100 Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3101 eprintln!(
3102 "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3103 Continuing with the current read-only index snapshot; graph results may lag. \
3104 Retry `{}` after the active writer finishes for fresh graph results.",
3105 index_reason_detail(target, reason),
3106 target.reindex_cmd
3107 );
3108 Ok(())
3109 }
3110 Err(err) => Err(err),
3111 }
3112}
3113
3114pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3115 let root = lint::resolve_project_root_or_canonical_path(path)?;
3116 let target = resolve_query_index_target(&root, path, scope)?;
3117 ensure_query_index_current(&root, &target)?;
3118 let db_path = target.db_path;
3119 if !db_path.exists() {
3120 bail!(
3121 "no index found at {}. Run `tsift index` first.",
3122 db_path.display()
3123 );
3124 }
3125 index::IndexDb::open_read_only_resilient(&db_path)
3126}
3127
3128pub(crate) fn query_tagpath_root(
3129 root: &std::path::Path,
3130 path_hint: &std::path::Path,
3131 scope: Option<&str>,
3132) -> Result<PathBuf> {
3133 if let Some(scope_name) = scope {
3134 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3135 return Ok(scope.source_root);
3136 }
3137 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3138 return Ok(package.package_root);
3139 }
3140 config::Config::resolve_submodule(root, scope_name)?;
3141 }
3142 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3143 return Ok(scope.source_root);
3144 }
3145 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3146 return Ok(package.package_root);
3147 }
3148 Ok(root.to_path_buf())
3149}
3150
3151#[derive(Clone, Debug, Serialize, PartialEq)]
3152struct TraversalNode {
3153 handle: String,
3154 kind: String,
3155 label: String,
3156 #[serde(skip_serializing_if = "Option::is_none")]
3157 ref_id: Option<String>,
3158 #[serde(skip_serializing_if = "Option::is_none")]
3159 path: Option<String>,
3160 #[serde(skip_serializing_if = "Option::is_none")]
3161 line: Option<i64>,
3162 #[serde(skip_serializing_if = "Option::is_none")]
3163 detail: Option<String>,
3164 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3165 properties: BTreeMap<String, String>,
3166 expand: String,
3167}
3168
3169#[derive(Clone, Debug, Serialize, PartialEq)]
3170struct TraversalEdge {
3171 from: String,
3172 to: String,
3173 relation: String,
3174 #[serde(skip_serializing_if = "Option::is_none")]
3175 label: Option<String>,
3176 weight: usize,
3177}
3178
3179#[derive(Clone, Debug, Default)]
3180struct TraversalGraphBuild {
3181 nodes: BTreeMap<String, TraversalNode>,
3182 edges: Vec<TraversalEdge>,
3183 edge_keys: BTreeSet<(String, String, String)>,
3184 warnings: Vec<String>,
3185}
3186
3187pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3188const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3189const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3190const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3191const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3192 "context-pack-graph-orchestration-v1";
3193const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3194const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3195const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3196const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3197const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3198const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3199const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3200
3201#[derive(Debug, Serialize, PartialEq)]
3202struct TraversalTotals {
3203 nodes: usize,
3204 edges: usize,
3205}
3206
3207#[derive(Debug, Serialize, PartialEq)]
3208struct TraversalPathReport {
3209 from: TraversalNode,
3210 to: TraversalNode,
3211 hops: usize,
3212 nodes: Vec<TraversalNode>,
3213 edges: Vec<TraversalEdge>,
3214}
3215
3216#[derive(Debug, Serialize, PartialEq)]
3217struct TraversalRecommendation {
3218 handle: String,
3219 kind: String,
3220 label: String,
3221 reason: String,
3222 score: usize,
3223 expand: String,
3224}
3225
3226#[derive(Debug, Serialize, PartialEq)]
3227struct TraversalReport {
3228 root: String,
3229 #[serde(skip_serializing_if = "Option::is_none")]
3230 scope: Option<String>,
3231 mode: String,
3232 totals: TraversalTotals,
3233 #[serde(skip_serializing_if = "Option::is_none")]
3234 query: Option<String>,
3235 #[serde(skip_serializing_if = "Option::is_none")]
3236 target: Option<String>,
3237 nodes: Vec<TraversalNode>,
3238 edges: Vec<TraversalEdge>,
3239 #[serde(skip_serializing_if = "Option::is_none")]
3240 shortest_path: Option<TraversalPathReport>,
3241 recommendations: Vec<TraversalRecommendation>,
3242 exploration: ExplorationPacket,
3243 truncated: bool,
3244 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3245 warnings: Vec<String>,
3246}
3247
3248#[derive(Debug, Serialize, PartialEq)]
3249struct SemanticRelatedReport {
3250 root: String,
3251 #[serde(skip_serializing_if = "Option::is_none")]
3252 scope: Option<String>,
3253 query: String,
3254 embedding_model: String,
3255 count: usize,
3256 items: Vec<SemanticRelatedItem>,
3257 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3258 warnings: Vec<String>,
3259}
3260
3261#[derive(Clone, Debug, Serialize, PartialEq)]
3262struct SemanticRelatedItem {
3263 handle: String,
3264 kind: String,
3265 label: String,
3266 score: f64,
3267 #[serde(skip_serializing_if = "Option::is_none")]
3268 file_path: Option<String>,
3269 #[serde(skip_serializing_if = "Option::is_none")]
3270 source_symbol: Option<String>,
3271 #[serde(skip_serializing_if = "Option::is_none")]
3272 detail: Option<String>,
3273 expand: String,
3274}
3275
3276#[derive(Clone)]
3277struct TraversalSymbolIndexEntry {
3278 handle: String,
3279 node: TraversalNode,
3280 tokens: BTreeSet<String>,
3281}
3282
3283#[derive(Clone)]
3284struct TraversalFileIndexEntry {
3285 handle: String,
3286 node: TraversalNode,
3287 tokens: BTreeSet<String>,
3288}
3289
3290#[derive(Clone)]
3291struct TraversalRouteIndexEntry {
3292 handle: String,
3293 node: TraversalNode,
3294 tokens: BTreeSet<String>,
3295}
3296
3297#[derive(Clone)]
3298struct TraversalAstSpanIndexEntry {
3299 handle: String,
3300 symbol_handle: String,
3301 file_handle: Option<String>,
3302 file: String,
3303 name: String,
3304 kind: String,
3305 language: String,
3306 node_kind: String,
3307 start_byte: usize,
3308 end_byte: usize,
3309 parent_module: Option<String>,
3310 markdown: Option<MarkdownSpanMetadata>,
3311}
3312
3313#[derive(Clone)]
3314struct TraversalMultiplicityIndexEntry {
3315 handle: String,
3316 node: TraversalNode,
3317 tokens: BTreeSet<String>,
3318}
3319
3320struct TraversalCodeLookup<'a> {
3321 symbols: &'a [TraversalSymbolIndexEntry],
3322 files: &'a [TraversalFileIndexEntry],
3323 routes: &'a [TraversalRouteIndexEntry],
3324 multiplicities: &'a [TraversalMultiplicityIndexEntry],
3325 symbol_index: HashMap<String, Vec<usize>>,
3326 file_index: HashMap<String, Vec<usize>>,
3327 route_index: HashMap<String, Vec<usize>>,
3328 multiplicity_index: HashMap<String, Vec<usize>>,
3329 file_path_index: HashMap<String, String>,
3330}
3331
3332#[derive(Clone, Debug, Serialize, PartialEq)]
3333struct ExplorationBudget {
3334 project_size: String,
3335 max_source_windows: usize,
3336 lines_per_window: usize,
3337 relationship_limit: usize,
3338}
3339
3340#[derive(Clone, Debug, Serialize, PartialEq)]
3341struct ExplorationRelation {
3342 from: String,
3343 relation: String,
3344 to: String,
3345 #[serde(skip_serializing_if = "Option::is_none")]
3346 label: Option<String>,
3347}
3348
3349#[derive(Clone, Debug, Serialize, PartialEq)]
3350struct ExplorationSourceWindow {
3351 handle: String,
3352 file: String,
3353 start: usize,
3354 end: usize,
3355 reason: String,
3356 expand: String,
3357}
3358
3359#[derive(Clone, Debug, Serialize, PartialEq)]
3360struct ExplorationWorkerContext {
3361 handle: String,
3362 target: String,
3363 summary: String,
3364 expand: String,
3365}
3366
3367#[derive(Clone, Debug, Serialize, PartialEq)]
3368struct ExplorationPacket {
3369 budget: ExplorationBudget,
3370 relationship_map: Vec<ExplorationRelation>,
3371 source_windows: Vec<ExplorationSourceWindow>,
3372 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3373 worker_context: Vec<ExplorationWorkerContext>,
3374 no_reread_guidance: String,
3375}
3376
3377impl TraversalGraphBuild {
3378 fn add_node(&mut self, node: TraversalNode) {
3379 self.nodes.entry(node.handle.clone()).or_insert(node);
3380 }
3381
3382 fn add_edge(
3383 &mut self,
3384 from: &str,
3385 to: &str,
3386 relation: &str,
3387 label: Option<String>,
3388 weight: usize,
3389 ) {
3390 if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3391 return;
3392 }
3393 let key = (from.to_string(), to.to_string(), relation.to_string());
3394 if self.edge_keys.insert(key) {
3395 self.edges.push(TraversalEdge {
3396 from: from.to_string(),
3397 to: to.to_string(),
3398 relation: relation.to_string(),
3399 label,
3400 weight,
3401 });
3402 }
3403 }
3404}
3405
3406pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3407 match scope {
3408 Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3409 None => root.join(".tsift/graph.db"),
3410 }
3411}
3412
3413fn graph_projection_meta_id(scope: Option<&str>) -> String {
3414 format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3415}
3416
3417pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
3418 let bytes = serde_json::to_vec(value)?;
3419 Ok(blake3::hash(&bytes).to_hex().to_string())
3420}
3421
3422fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
3423 let mut hashable = node.clone();
3424 hashable.freshness = None;
3425 node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3426 Ok(node)
3427}
3428
3429fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
3430 let mut hashable = edge.clone();
3431 hashable.freshness = None;
3432 edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3433 Ok(edge)
3434}
3435
3436const SEMANTIC_EMBEDDING_DIM: usize = 32;
3437const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
3438
3439fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
3440 match kind {
3441 SemanticRelatedKind::Concept => "concept",
3442 SemanticRelatedKind::Entity => "entity",
3443 SemanticRelatedKind::All => "all",
3444 }
3445}
3446
3447fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
3448 format!(
3449 "tsift semantic {} --path {} --kind {} --limit 10",
3450 shell_quote(query),
3451 shell_quote(root.to_string_lossy().as_ref()),
3452 semantic_related_kind_name(kind)
3453 )
3454}
3455
3456fn semantic_embedding(input: &str) -> Vec<f64> {
3457 let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
3458 let mut tokens = traversal_tokens(input);
3459 if tokens.is_empty() {
3460 let trimmed = input.trim().to_ascii_lowercase();
3461 if !trimmed.is_empty() {
3462 tokens.insert(trimmed);
3463 }
3464 }
3465
3466 for token in tokens {
3467 let hash = blake3::hash(token.as_bytes());
3468 let bytes = hash.as_bytes();
3469 let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
3470 let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
3471 vector[idx] += sign;
3472 }
3473
3474 let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
3475 if norm > 0.0 {
3476 for value in &mut vector {
3477 *value /= norm;
3478 }
3479 }
3480 vector
3481}
3482
3483fn semantic_embedding_property(input: &str) -> String {
3484 semantic_embedding(input)
3485 .iter()
3486 .map(|value| format!("{value:.6}"))
3487 .collect::<Vec<_>>()
3488 .join(",")
3489}
3490
3491fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
3492 let parsed = value
3493 .split(',')
3494 .map(str::trim)
3495 .map(str::parse::<f64>)
3496 .collect::<std::result::Result<Vec<_>, _>>()
3497 .ok()?;
3498 (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
3499}
3500
3501fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
3502 if left.len() != right.len() {
3503 return 0.0;
3504 }
3505 left.iter()
3506 .zip(right.iter())
3507 .map(|(left, right)| left * right)
3508 .sum::<f64>()
3509}
3510
3511fn semantic_entity_handle(name: &str, kind: &str) -> String {
3512 stable_handle(
3513 "gent",
3514 &format!(
3515 "entity:{}:{}",
3516 kind.trim().to_ascii_lowercase(),
3517 name.trim().to_ascii_lowercase()
3518 ),
3519 )
3520}
3521
3522fn semantic_concept_handle(label: &str) -> String {
3523 stable_handle(
3524 "gcon",
3525 &format!("concept:{}", label.trim().to_ascii_lowercase()),
3526 )
3527}
3528
3529fn summary_source_handles(
3530 summary: &summarize::Summary,
3531 file_node_by_path: &BTreeMap<String, String>,
3532 symbol_node_by_file_label: &BTreeMap<(String, String), String>,
3533) -> Vec<String> {
3534 let mut handles = Vec::new();
3535 if let Some(handle) = file_node_by_path.get(&summary.file_path) {
3536 handles.push(handle.clone());
3537 }
3538 if let Some(handle) =
3539 symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
3540 && !handles.iter().any(|existing| existing == handle)
3541 {
3542 handles.push(handle.clone());
3543 }
3544 handles
3545}
3546
3547fn semantic_entity_node(
3548 root: &Path,
3549 summary: &summarize::Summary,
3550 name: &str,
3551 kind: &str,
3552 description: &str,
3553 provenance: &GraphProvenance,
3554) -> SubstrateGraphNode {
3555 let handle = semantic_entity_handle(name, kind);
3556 let detail = if description.trim().is_empty() {
3557 format!("{kind} entity from cached summaries")
3558 } else {
3559 format!("{kind}: {description}")
3560 };
3561 SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
3562 .with_property("handle", handle)
3563 .with_property("ref_id", name.to_string())
3564 .with_property("detail", detail)
3565 .with_property("entity_kind", kind.to_string())
3566 .with_property("description", description.to_string())
3567 .with_property("source_file", summary.file_path.clone())
3568 .with_property("source_symbol", summary.symbol_name.clone())
3569 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3570 .with_property(
3571 "embedding",
3572 semantic_embedding_property(&format!("{name} {kind} {description}")),
3573 )
3574 .with_property(
3575 "expand",
3576 semantic_related_command(root, name, SemanticRelatedKind::Entity),
3577 )
3578 .with_provenance(provenance.clone())
3579}
3580
3581fn semantic_concept_node(
3582 root: &Path,
3583 summary: &summarize::Summary,
3584 label: &str,
3585 provenance: &GraphProvenance,
3586) -> SubstrateGraphNode {
3587 let handle = semantic_concept_handle(label);
3588 SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
3589 .with_property("handle", handle)
3590 .with_property("ref_id", label.to_string())
3591 .with_property("detail", "concept label from cached summaries".to_string())
3592 .with_property("source_file", summary.file_path.clone())
3593 .with_property("source_symbol", summary.symbol_name.clone())
3594 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3595 .with_property("embedding", semantic_embedding_property(label))
3596 .with_property(
3597 "expand",
3598 semantic_related_command(root, label, SemanticRelatedKind::Concept),
3599 )
3600 .with_provenance(provenance.clone())
3601}
3602
3603fn insert_semantic_edge(
3604 edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
3605 edge: SubstrateGraphEdge,
3606) {
3607 edge_map
3608 .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
3609 .or_insert(edge);
3610}
3611
3612fn append_summary_semantic_projection_rows(
3613 root: &Path,
3614 graph: &TraversalGraphBuild,
3615 provenance: &GraphProvenance,
3616 nodes: &mut Vec<SubstrateGraphNode>,
3617 edges: &mut Vec<SubstrateGraphEdge>,
3618) -> Result<()> {
3619 let summaries_db = root.join(".tsift/summaries.db");
3620 if !summaries_db.exists() {
3621 return Ok(());
3622 }
3623
3624 let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
3625 let summaries = summary_db.all()?;
3626 if summaries.is_empty() {
3627 return Ok(());
3628 }
3629
3630 let file_node_by_path = graph
3631 .nodes
3632 .values()
3633 .filter(|node| node.kind == "file")
3634 .filter_map(|node| {
3635 node.path
3636 .as_ref()
3637 .map(|path| (path.clone(), node.handle.clone()))
3638 })
3639 .collect::<BTreeMap<_, _>>();
3640 let symbol_node_by_file_label = graph
3641 .nodes
3642 .values()
3643 .filter(|node| node.kind == "symbol")
3644 .filter_map(|node| {
3645 Some((
3646 (node.path.clone()?, node.label.clone()),
3647 node.handle.clone(),
3648 ))
3649 })
3650 .collect::<BTreeMap<_, _>>();
3651
3652 let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
3653 let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
3654
3655 for summary in &summaries {
3656 let source_handles =
3657 summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
3658 let mut entity_ids_by_name = BTreeMap::<String, String>::new();
3659
3660 if let Some(entities) = &summary.entities {
3661 for entity in entities {
3662 let node = semantic_entity_node(
3663 root,
3664 summary,
3665 &entity.name,
3666 &entity.kind,
3667 &entity.description,
3668 provenance,
3669 );
3670 let entity_id = node.id.clone();
3671 entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
3672 semantic_nodes.entry(entity_id.clone()).or_insert(node);
3673
3674 for source_handle in &source_handles {
3675 insert_semantic_edge(
3676 &mut semantic_edges,
3677 SubstrateGraphEdge::new(
3678 source_handle.clone(),
3679 entity_id.clone(),
3680 "mentions_entity",
3681 )
3682 .with_property("label", format!("summary entity: {}", entity.name))
3683 .with_property("source_file", summary.file_path.clone())
3684 .with_provenance(provenance.clone()),
3685 );
3686 }
3687 }
3688 }
3689
3690 let mut concept_ids = Vec::new();
3691 if let Some(labels) = &summary.concept_labels {
3692 for label in labels
3693 .iter()
3694 .map(|label| label.trim())
3695 .filter(|label| !label.is_empty())
3696 {
3697 let node = semantic_concept_node(root, summary, label, provenance);
3698 let concept_id = node.id.clone();
3699 semantic_nodes.entry(concept_id.clone()).or_insert(node);
3700 concept_ids.push(concept_id.clone());
3701
3702 for source_handle in &source_handles {
3703 insert_semantic_edge(
3704 &mut semantic_edges,
3705 SubstrateGraphEdge::new(
3706 source_handle.clone(),
3707 concept_id.clone(),
3708 "mentions_concept",
3709 )
3710 .with_property("label", format!("summary concept: {label}"))
3711 .with_property("source_file", summary.file_path.clone())
3712 .with_provenance(provenance.clone()),
3713 );
3714 }
3715 }
3716 }
3717
3718 for entity_id in entity_ids_by_name.values() {
3719 for concept_id in &concept_ids {
3720 insert_semantic_edge(
3721 &mut semantic_edges,
3722 SubstrateGraphEdge::new(
3723 entity_id.clone(),
3724 concept_id.clone(),
3725 "tagged_concept",
3726 )
3727 .with_property("label", "entity concept label".to_string())
3728 .with_property("source_file", summary.file_path.clone())
3729 .with_provenance(provenance.clone()),
3730 );
3731 }
3732 }
3733
3734 for idx in 0..concept_ids.len() {
3735 for next_idx in (idx + 1)..concept_ids.len() {
3736 insert_semantic_edge(
3737 &mut semantic_edges,
3738 SubstrateGraphEdge::new(
3739 concept_ids[idx].clone(),
3740 concept_ids[next_idx].clone(),
3741 "related_concept",
3742 )
3743 .with_property("label", format!("co-occurs in {}", summary.symbol_name))
3744 .with_property("source_file", summary.file_path.clone())
3745 .with_provenance(provenance.clone()),
3746 );
3747 }
3748 }
3749
3750 if let Some(relationships) = &summary.relationships {
3751 for relationship in relationships {
3752 let from_id = entity_ids_by_name
3753 .get(&relationship.from.to_ascii_lowercase())
3754 .cloned()
3755 .unwrap_or_else(|| {
3756 let node = semantic_entity_node(
3757 root,
3758 summary,
3759 &relationship.from,
3760 "unknown",
3761 "",
3762 provenance,
3763 );
3764 let id = node.id.clone();
3765 semantic_nodes.entry(id.clone()).or_insert(node);
3766 id
3767 });
3768 let to_id = entity_ids_by_name
3769 .get(&relationship.to.to_ascii_lowercase())
3770 .cloned()
3771 .unwrap_or_else(|| {
3772 let node = semantic_entity_node(
3773 root,
3774 summary,
3775 &relationship.to,
3776 "unknown",
3777 "",
3778 provenance,
3779 );
3780 let id = node.id.clone();
3781 semantic_nodes.entry(id.clone()).or_insert(node);
3782 id
3783 });
3784 insert_semantic_edge(
3785 &mut semantic_edges,
3786 SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
3787 .with_property("relationship_kind", relationship.kind.clone())
3788 .with_property("label", relationship.kind.clone())
3789 .with_property("source_file", summary.file_path.clone())
3790 .with_property("source_symbol", summary.symbol_name.clone())
3791 .with_provenance(provenance.clone()),
3792 );
3793 }
3794 }
3795 }
3796
3797 for node in semantic_nodes.into_values() {
3798 nodes.push(node_with_content_freshness(node)?);
3799 }
3800 for edge in semantic_edges.into_values() {
3801 edges.push(edge_with_content_freshness(edge)?);
3802 }
3803
3804 Ok(())
3805}
3806
3807fn projection_content_hash(
3808 nodes: &[SubstrateGraphNode],
3809 edges: &[SubstrateGraphEdge],
3810) -> Result<String> {
3811 #[derive(Serialize)]
3812 struct Payload<'a> {
3813 version: &'static str,
3814 nodes: &'a [SubstrateGraphNode],
3815 edges: &'a [SubstrateGraphEdge],
3816 }
3817
3818 content_hash(&Payload {
3819 version: GRAPH_PROJECTION_VERSION,
3820 nodes,
3821 edges,
3822 })
3823}
3824
3825pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
3826 projection
3827 .nodes
3828 .iter()
3829 .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
3830 .and_then(|node| node.properties.get("content_hash").cloned())
3831}
3832
3833fn traversal_projection_from_graph(
3834 root: &Path,
3835 scope: Option<&str>,
3836 graph: &TraversalGraphBuild,
3837) -> Result<GraphProjection> {
3838 let provenance = GraphProvenance::new(
3839 "tsift.traverse",
3840 format!("{}:{}", root.display(), scope.unwrap_or("root")),
3841 );
3842 let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
3843 for node in graph.nodes.values() {
3844 let mut projected =
3845 SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
3846 .with_property("handle", node.handle.clone())
3847 .with_property("expand", node.expand.clone())
3848 .with_provenance(provenance.clone());
3849 if let Some(ref_id) = &node.ref_id {
3850 projected = projected.with_property("ref_id", ref_id.clone());
3851 }
3852 if let Some(path) = &node.path {
3853 projected = projected.with_property("path", path.clone());
3854 }
3855 if let Some(line) = node.line {
3856 projected = projected.with_property("line", line.to_string());
3857 }
3858 if let Some(detail) = &node.detail {
3859 projected = projected.with_property("detail", detail.clone());
3860 }
3861 for (key, value) in &node.properties {
3862 projected = projected.with_property(key.clone(), value.clone());
3863 }
3864 nodes.push(node_with_content_freshness(projected)?);
3865 }
3866
3867 let mut edges = Vec::with_capacity(graph.edges.len());
3868 for edge in &graph.edges {
3869 let mut projected =
3870 SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
3871 .with_property("weight", edge.weight.to_string())
3872 .with_provenance(provenance.clone());
3873 if let Some(label) = &edge.label {
3874 projected = projected.with_property("label", label.clone());
3875 }
3876 edges.push(edge_with_content_freshness(projected)?);
3877 }
3878
3879 append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3880 append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3881 append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
3882
3883 let projection_hash = projection_content_hash(&nodes, &edges)?;
3884 let meta = SubstrateGraphNode::new(
3885 graph_projection_meta_id(scope),
3886 GRAPH_PROJECTION_META_KIND,
3887 "tsift traversal projection",
3888 )
3889 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
3890 .with_property("content_hash", projection_hash.clone())
3891 .with_property("root", root.to_string_lossy().to_string())
3892 .with_property("scope", scope.unwrap_or("root"))
3893 .with_property("node_count", graph.nodes.len().to_string())
3894 .with_property("edge_count", graph.edges.len().to_string())
3895 .with_provenance(provenance)
3896 .with_freshness(GraphFreshness::content_hash(projection_hash));
3897 nodes.push(meta);
3898
3899 Ok(GraphProjection { nodes, edges })
3900}
3901
3902#[allow(clippy::too_many_arguments)]
3903fn ensure_traversal_source_handle(
3904 root: &Path,
3905 provenance: &GraphProvenance,
3906 file_node_by_path: &BTreeMap<String, String>,
3907 node: &TraversalNode,
3908 budget: &ExplorationBudget,
3909 source_handle_by_node: &mut BTreeMap<String, String>,
3910 seen_windows: &mut BTreeMap<(String, usize, usize), String>,
3911 nodes: &mut Vec<SubstrateGraphNode>,
3912 edges: &mut Vec<SubstrateGraphEdge>,
3913) -> Result<Option<String>> {
3914 if let Some(handle) = source_handle_by_node.get(&node.handle) {
3915 return Ok(Some(handle.clone()));
3916 }
3917 let Some(window) = exploration_source_window_for_node(root, node, budget) else {
3918 return Ok(None);
3919 };
3920 let window_key = (window.file.clone(), window.start, window.end);
3921 let handle = if let Some(handle) = seen_windows.get(&window_key) {
3922 handle.clone()
3923 } else {
3924 let label = format!("{}:{}-{}", window.file, window.start, window.end);
3925 let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
3926 .with_property("handle", window.handle.clone())
3927 .with_property("file", window.file.clone())
3928 .with_property("start", window.start.to_string())
3929 .with_property("end", window.end.to_string())
3930 .with_property("reason", window.reason.clone())
3931 .with_property("expand", window.expand.clone())
3932 .with_provenance(provenance.clone());
3933 nodes.push(node_with_content_freshness(projected)?);
3934
3935 if let Some(file_handle) = file_node_by_path.get(&window.file) {
3936 let edge = SubstrateGraphEdge::new(
3937 window.handle.clone(),
3938 file_handle.clone(),
3939 "expands_source",
3940 )
3941 .with_property("label", window.reason.clone())
3942 .with_provenance(provenance.clone());
3943 edges.push(edge_with_content_freshness(edge)?);
3944 }
3945 if node.kind != "file" {
3946 let edge = SubstrateGraphEdge::new(
3947 window.handle.clone(),
3948 node.handle.clone(),
3949 "anchors_source",
3950 )
3951 .with_property("label", window.reason.clone())
3952 .with_provenance(provenance.clone());
3953 edges.push(edge_with_content_freshness(edge)?);
3954 }
3955 seen_windows.insert(window_key, window.handle.clone());
3956 window.handle
3957 };
3958 source_handle_by_node.insert(node.handle.clone(), handle.clone());
3959 Ok(Some(handle))
3960}
3961
3962fn push_traversal_backlog_target_handles<'a>(
3963 backlog: &TraversalNode,
3964 edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
3965 node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
3966 max_handles: usize,
3967 seen_target_nodes: &mut BTreeSet<String>,
3968 target_node_handles: &mut Vec<String>,
3969) {
3970 for edge in edges_by_from
3971 .get(backlog.handle.as_str())
3972 .into_iter()
3973 .flatten()
3974 .filter(|edge| edge.relation == "mentions")
3975 {
3976 let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
3977 continue;
3978 };
3979 if !matches!(
3980 target_node.kind.as_str(),
3981 "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
3982 ) {
3983 continue;
3984 }
3985 if target_node
3986 .path
3987 .as_deref()
3988 .zip(backlog.path.as_deref())
3989 .is_some_and(|(target_path, backlog_path)| {
3990 target_path == backlog_path && target_path.ends_with(".md")
3991 })
3992 {
3993 continue;
3994 }
3995 if seen_target_nodes.insert(target_node.handle.clone()) {
3996 target_node_handles.push(target_node.handle.clone());
3997 }
3998 if target_node_handles.len() >= max_handles {
3999 break;
4000 }
4001 }
4002}
4003
4004fn append_traversal_context_projection_rows(
4005 root: &Path,
4006 graph: &TraversalGraphBuild,
4007 provenance: &GraphProvenance,
4008 nodes: &mut Vec<SubstrateGraphNode>,
4009 edges: &mut Vec<SubstrateGraphEdge>,
4010) -> Result<()> {
4011 let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4012 let file_node_by_path = graph
4013 .nodes
4014 .values()
4015 .filter(|node| node.kind == "file")
4016 .filter_map(|node| {
4017 node.path
4018 .as_ref()
4019 .map(|path| (path.clone(), node.handle.clone()))
4020 })
4021 .collect::<BTreeMap<_, _>>();
4022
4023 let node_by_handle = graph
4024 .nodes
4025 .values()
4026 .map(|node| (node.handle.as_str(), node))
4027 .collect::<BTreeMap<_, _>>();
4028 let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4029 for edge in &graph.edges {
4030 edges_by_from
4031 .entry(edge.from.as_str())
4032 .or_default()
4033 .push(edge);
4034 }
4035 for rows in edges_by_from.values_mut() {
4036 rows.sort_by(|left, right| {
4037 right
4038 .weight
4039 .cmp(&left.weight)
4040 .then(left.relation.cmp(&right.relation))
4041 .then(left.to.cmp(&right.to))
4042 });
4043 }
4044
4045 let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4046 let mut source_handle_by_node = BTreeMap::<String, String>::new();
4047
4048 let mut code_context_count = 0usize;
4049 let code_context_limit = budget.relationship_limit.min(8);
4050 for node in graph.nodes.values() {
4051 if !matches!(
4052 node.kind.as_str(),
4053 "backlog" | "job_packet" | "worker_result"
4054 ) {
4055 continue;
4056 }
4057 let mut target_node_handles = Vec::new();
4058 let mut fallback_target_handles = Vec::new();
4059 let mut seen_target_nodes = BTreeSet::new();
4060 if node.kind == "backlog" || node.kind == "worker_result" {
4061 push_traversal_backlog_target_handles(
4062 node,
4063 &edges_by_from,
4064 &node_by_handle,
4065 budget.max_source_windows,
4066 &mut seen_target_nodes,
4067 &mut target_node_handles,
4068 );
4069 fallback_target_handles.push(node.handle.clone());
4070 } else {
4071 for edge in edges_by_from
4072 .get(node.handle.as_str())
4073 .into_iter()
4074 .flatten()
4075 .filter(|edge| edge.relation == "targets")
4076 {
4077 let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4078 continue;
4079 };
4080 fallback_target_handles.push(backlog.handle.clone());
4081 push_traversal_backlog_target_handles(
4082 backlog,
4083 &edges_by_from,
4084 &node_by_handle,
4085 budget.max_source_windows,
4086 &mut seen_target_nodes,
4087 &mut target_node_handles,
4088 );
4089 if target_node_handles.len() >= budget.max_source_windows {
4090 break;
4091 }
4092 }
4093 if fallback_target_handles.is_empty() {
4094 continue;
4095 }
4096 }
4097 let code_context = !target_node_handles.is_empty();
4098 if target_node_handles.is_empty() {
4099 target_node_handles = dedupe_preserve_order(fallback_target_handles);
4100 } else if code_context_count >= code_context_limit {
4101 continue;
4102 }
4103
4104 let mut worker_source_handles = Vec::new();
4105 let mut seen_worker_handles = BTreeSet::new();
4106 for target_handle in target_node_handles {
4107 if worker_source_handles.len() >= budget.max_source_windows {
4108 break;
4109 }
4110 let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4111 continue;
4112 };
4113 let Some(handle) = ensure_traversal_source_handle(
4114 root,
4115 provenance,
4116 &file_node_by_path,
4117 target_node,
4118 &budget,
4119 &mut source_handle_by_node,
4120 &mut seen_windows,
4121 nodes,
4122 edges,
4123 )?
4124 else {
4125 continue;
4126 };
4127 if seen_worker_handles.insert(handle.clone()) {
4128 worker_source_handles.push(handle);
4129 }
4130 }
4131 if worker_source_handles.is_empty() {
4132 continue;
4133 }
4134 let target = node
4135 .path
4136 .clone()
4137 .unwrap_or_else(|| root.to_string_lossy().to_string());
4138 let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4139 let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4140 let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4141 .with_property("handle", handle.clone())
4142 .with_property("target", target.clone())
4143 .with_property("summary", summary)
4144 .with_property(
4145 "source_handle_count",
4146 worker_source_handles.len().to_string(),
4147 )
4148 .with_property(
4149 "expand",
4150 format!(
4151 "tsift --envelope context-pack {} --budget normal",
4152 shell_quote(&target)
4153 ),
4154 )
4155 .with_provenance(provenance.clone());
4156 nodes.push(node_with_content_freshness(projected)?);
4157
4158 let request_edge =
4159 SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4160 .with_property("label", "bounded worker context".to_string())
4161 .with_provenance(provenance.clone());
4162 edges.push(edge_with_content_freshness(request_edge)?);
4163
4164 for source_handle in &worker_source_handles {
4165 let scope_edge =
4166 SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4167 .with_property("label", "bounded worker source window".to_string())
4168 .with_provenance(provenance.clone());
4169 edges.push(edge_with_content_freshness(scope_edge)?);
4170 }
4171 if code_context {
4172 code_context_count += 1;
4173 }
4174 }
4175
4176 Ok(())
4177}
4178
4179fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4180 let handle = node
4181 .properties
4182 .get("handle")
4183 .cloned()
4184 .unwrap_or_else(|| node.id.clone());
4185 TraversalNode {
4186 expand: node
4187 .properties
4188 .get("expand")
4189 .cloned()
4190 .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4191 handle,
4192 kind: node.kind,
4193 label: node.label,
4194 ref_id: node.properties.get("ref_id").cloned(),
4195 path: node.properties.get("path").cloned(),
4196 line: node
4197 .properties
4198 .get("line")
4199 .and_then(|value| value.parse::<i64>().ok()),
4200 detail: node.properties.get("detail").cloned(),
4201 properties: node.properties,
4202 }
4203}
4204
4205fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4206 let mut graph = TraversalGraphBuild::default();
4207 for node in store.all_nodes()? {
4208 if node.kind == GRAPH_PROJECTION_META_KIND {
4209 continue;
4210 }
4211 graph.add_node(traversal_node_from_graph_node(root, node));
4212 }
4213 for edge in store.all_edges()? {
4214 graph.add_edge(
4215 &edge.from_id,
4216 &edge.to_id,
4217 &edge.kind,
4218 edge.properties.get("label").cloned(),
4219 edge.properties
4220 .get("weight")
4221 .and_then(|value| value.parse::<usize>().ok())
4222 .unwrap_or(1),
4223 );
4224 }
4225 Ok(graph)
4226}
4227
4228pub(crate) fn convex_rows_from_graph_store(
4229 store: &impl GraphStore,
4230) -> Result<ConvexProjectionRows> {
4231 Ok(GraphProjection {
4232 nodes: store.all_nodes()?,
4233 edges: store.all_edges()?,
4234 }
4235 .to_convex_rows())
4236}
4237
4238#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4239struct ConvexRequiredIndex {
4240 table: String,
4241 name: String,
4242 fields: Vec<String>,
4243}
4244
4245#[derive(Clone, Debug, Serialize, PartialEq)]
4246struct ConvexSyncChunk {
4247 operation: String,
4248 chunk: usize,
4249 count: usize,
4250 keys: Vec<String>,
4251 max_attempts: usize,
4252 retry_policy: String,
4253}
4254
4255#[derive(Clone, Debug, Serialize, PartialEq)]
4256struct ConvexTransportSummary {
4257 endpoint_env: String,
4258 endpoint_configured: bool,
4259 auth_token_env: String,
4260 auth_configured: bool,
4261 remote_snapshot: bool,
4262 applied_chunks: usize,
4263}
4264
4265#[derive(Clone, Debug, Serialize, PartialEq)]
4266struct ConvexTransportReceipt {
4267 operation: String,
4268 chunk: usize,
4269 attempt: usize,
4270 status: String,
4271 message: Option<String>,
4272}
4273
4274#[derive(Serialize)]
4275#[serde(rename_all = "camelCase")]
4276struct ConvexTransportRequest<'a> {
4277 operation: &'a str,
4278 chunk: usize,
4279 projection_version: &'a str,
4280 projection_hash: Option<&'a str>,
4281 #[serde(skip_serializing_if = "Option::is_none")]
4282 projection_meta_id: Option<&'a str>,
4283 node_rows: Vec<ConvexNodeRow>,
4284 edge_rows: Vec<ConvexEdgeRow>,
4285 keys: Vec<String>,
4286 #[serde(skip_serializing_if = "Option::is_none")]
4287 cursor: Option<String>,
4288 #[serde(skip_serializing_if = "Option::is_none")]
4289 limit: Option<usize>,
4290}
4291
4292#[derive(Deserialize)]
4293#[serde(rename_all = "camelCase")]
4294struct ConvexTransportResponse {
4295 status: Option<String>,
4296 message: Option<String>,
4297 rows: Option<ConvexProjectionRows>,
4298 #[serde(default)]
4299 meta: Option<ConvexSnapshotMeta>,
4300 #[serde(default)]
4301 page: Option<ConvexSnapshotPage>,
4302}
4303
4304#[derive(Deserialize, Debug, Clone)]
4305#[serde(rename_all = "camelCase")]
4306struct ConvexSnapshotMeta {
4307 #[serde(default)]
4311 #[allow(dead_code)]
4312 indexes: Vec<ConvexRequiredIndex>,
4313 #[serde(default)]
4314 #[allow(dead_code)]
4315 node_count: Option<usize>,
4316 #[serde(default)]
4317 #[allow(dead_code)]
4318 edge_count: Option<usize>,
4319 #[serde(default)]
4320 projection_hash: Option<String>,
4321 #[serde(default)]
4322 #[allow(dead_code)]
4323 page_size: Option<usize>,
4324}
4325
4326#[derive(Deserialize, Debug, Clone)]
4331#[serde(rename_all = "camelCase")]
4332struct ConvexSnapshotPage {
4333 rows: Vec<serde_json::Value>,
4334 #[serde(default)]
4335 next_cursor: Option<String>,
4336}
4337
4338#[derive(Clone, Debug, Serialize, PartialEq)]
4339struct ConvexProjectionFreshness {
4340 status: String,
4341 fail_closed: bool,
4342 local_hash: Option<String>,
4343 snapshot_hash: Option<String>,
4344 missing_nodes: Vec<String>,
4345 stale_nodes: Vec<String>,
4346 missing_edges: Vec<String>,
4347 stale_edges: Vec<String>,
4348 diagnostics: Vec<String>,
4349}
4350
4351const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4352
4353impl ConvexProjectionFreshness {
4354 fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4355 Self {
4356 status: "current".to_string(),
4357 fail_closed: false,
4358 local_hash,
4359 snapshot_hash,
4360 missing_nodes: Vec::new(),
4361 stale_nodes: Vec::new(),
4362 missing_edges: Vec::new(),
4363 stale_edges: Vec::new(),
4364 diagnostics: Vec::new(),
4365 }
4366 }
4367}
4368
4369#[derive(Clone, Debug, Serialize, PartialEq)]
4370struct ConvexSyncReport {
4371 root: String,
4372 #[serde(skip_serializing_if = "Option::is_none")]
4373 scope: Option<String>,
4374 graph_db: String,
4375 dry_run: bool,
4376 projection_version: String,
4377 projection_hash: Option<String>,
4378 required_indexes: Vec<ConvexRequiredIndex>,
4379 node_upserts: Vec<ConvexNodeRow>,
4380 edge_upserts: Vec<ConvexEdgeRow>,
4381 node_tombstones: Vec<String>,
4382 edge_tombstones: Vec<String>,
4383 chunks: Vec<ConvexSyncChunk>,
4384 freshness: ConvexProjectionFreshness,
4385 transport: Option<ConvexTransportSummary>,
4386 receipts: Vec<ConvexTransportReceipt>,
4387 diagnostics: Vec<String>,
4388 warnings: Vec<String>,
4389}
4390
4391fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4392 vec![
4393 ConvexRequiredIndex {
4394 table: "nodes".to_string(),
4395 name: "by_external_id".to_string(),
4396 fields: vec!["externalId".to_string()],
4397 },
4398 ConvexRequiredIndex {
4399 table: "nodes".to_string(),
4400 name: "by_kind".to_string(),
4401 fields: vec!["kind".to_string()],
4402 },
4403 ConvexRequiredIndex {
4404 table: "edges".to_string(),
4405 name: "by_edge_key".to_string(),
4406 fields: vec!["edgeKey".to_string()],
4407 },
4408 ConvexRequiredIndex {
4409 table: "edges".to_string(),
4410 name: "by_from_kind".to_string(),
4411 fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4412 },
4413 ConvexRequiredIndex {
4414 table: "edges".to_string(),
4415 name: "by_to_kind".to_string(),
4416 fields: vec!["toExternalId".to_string(), "kind".to_string()],
4417 },
4418 ]
4419}
4420
4421pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
4422 let content = fs::read_to_string(path)
4423 .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
4424 serde_json::from_str(&content)
4425 .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
4426}
4427
4428fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
4429 let mut diagnostics = Vec::new();
4430 let mut node_counts = BTreeMap::<&str, usize>::new();
4431 for row in &rows.nodes {
4432 *node_counts.entry(row.external_id.as_str()).or_default() += 1;
4433 }
4434 for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
4435 diagnostics.push(format!(
4436 "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
4437 ));
4438 }
4439
4440 let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
4441 let mut edge_counts = BTreeMap::<&str, usize>::new();
4442 for edge in &rows.edges {
4443 *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
4444 if !node_ids.contains(edge.from_external_id.as_str()) {
4445 diagnostics.push(format!(
4446 "Convex snapshot edge {} references missing from node {}",
4447 edge.edge_key, edge.from_external_id
4448 ));
4449 }
4450 if !node_ids.contains(edge.to_external_id.as_str()) {
4451 diagnostics.push(format!(
4452 "Convex snapshot edge {} references missing to node {}",
4453 edge.edge_key, edge.to_external_id
4454 ));
4455 }
4456 let expected_key =
4457 ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
4458 if edge.edge_key != expected_key {
4459 diagnostics.push(format!(
4460 "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
4461 edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
4462 ));
4463 }
4464 }
4465 for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
4466 diagnostics.push(format!(
4467 "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
4468 ));
4469 }
4470 diagnostics
4471}
4472
4473pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
4474 let diagnostics = convex_projection_row_diagnostics(rows);
4475 if diagnostics.is_empty() {
4476 Ok(())
4477 } else {
4478 bail!("{}", diagnostics.join("; "))
4479 }
4480}
4481
4482pub(crate) struct ConvexHttpTransport {
4483 endpoint: String,
4484 auth_token_env: String,
4485 auth_token: Option<String>,
4486}
4487
4488impl ConvexHttpTransport {
4489 fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
4490 let endpoint = endpoint
4491 .map(str::to_string)
4492 .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
4493 .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
4494 let auth_token = env::var(auth_token_env)
4495 .ok()
4496 .filter(|value| !value.trim().is_empty());
4497 Ok(Self {
4498 endpoint,
4499 auth_token_env: auth_token_env.to_string(),
4500 auth_token,
4501 })
4502 }
4503
4504 fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
4505 ConvexTransportSummary {
4506 endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
4507 endpoint_configured: true,
4508 auth_token_env: self.auth_token_env.clone(),
4509 auth_configured: self.auth_token.is_some(),
4510 remote_snapshot,
4511 applied_chunks,
4512 }
4513 }
4514
4515 fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
4516 let mut builder = ureq::post(&self.endpoint);
4517 if let Some(token) = &self.auth_token {
4518 builder = builder.header("Authorization", &format!("Bearer {token}"));
4519 }
4520 builder
4521 .send_json(request)
4522 .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
4523 .body_mut()
4524 .read_json::<ConvexTransportResponse>()
4525 .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
4526 }
4527
4528 fn fetch_snapshot(
4539 &self,
4540 projection_version: &str,
4541 scope: Option<&str>,
4542 local_hash: Option<&str>,
4543 local_rows: Option<&ConvexProjectionRows>,
4544 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4545 match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
4546 Ok(rows) => Ok(rows),
4547 Err(err) => {
4548 let msg = format!("{err:#}");
4553 let is_unknown_op = msg.contains("unknown operation")
4554 || msg.contains("snapshot_meta")
4555 || msg.contains("404");
4556 if !is_unknown_op {
4557 return Err(err);
4558 }
4559 self.fetch_snapshot_legacy(projection_version)
4560 .map(|rows| (rows, Vec::new()))
4561 }
4562 }
4563 }
4564
4565 fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
4566 let response = self.post(&ConvexTransportRequest {
4567 operation: "snapshot",
4568 chunk: 0,
4569 projection_version,
4570 projection_hash: None,
4571 projection_meta_id: None,
4572 node_rows: Vec::new(),
4573 edge_rows: Vec::new(),
4574 keys: Vec::new(),
4575 cursor: None,
4576 limit: None,
4577 })?;
4578 response
4579 .rows
4580 .context("Convex snapshot response did not include rows")
4581 }
4582
4583 fn fetch_snapshot_paginated(
4584 &self,
4585 projection_version: &str,
4586 scope: Option<&str>,
4587 local_hash: Option<&str>,
4588 local_rows: Option<&ConvexProjectionRows>,
4589 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4590 let projection_meta_id = graph_projection_meta_id(scope);
4591 let meta_response = self.post(&ConvexTransportRequest {
4592 operation: "snapshot_meta",
4593 chunk: 0,
4594 projection_version,
4595 projection_hash: None,
4596 projection_meta_id: Some(&projection_meta_id),
4597 node_rows: Vec::new(),
4598 edge_rows: Vec::new(),
4599 keys: Vec::new(),
4600 cursor: None,
4601 limit: None,
4602 })?;
4603 if matches!(meta_response.status.as_deref(), Some("error")) {
4604 anyhow::bail!(
4605 "Convex snapshot_meta returned error: {}",
4606 meta_response.message.unwrap_or_default()
4607 );
4608 }
4609 let meta = meta_response
4610 .meta
4611 .context("Convex snapshot_meta response did not include meta")?;
4612 if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
4613 (meta.projection_hash.as_deref(), local_hash, local_rows)
4614 && remote_hash == local_hash
4615 {
4616 return Ok((
4617 local_rows.clone(),
4618 vec![
4619 "remote projection hash matched local graph; skipped full row-page snapshot diff"
4620 .to_string(),
4621 ],
4622 ));
4623 }
4624
4625 let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
4626 let mut node_cursor: Option<String> = None;
4627 loop {
4628 let response = self.post(&ConvexTransportRequest {
4629 operation: "snapshot_nodes_page",
4630 chunk: 0,
4631 projection_version,
4632 projection_hash: None,
4633 projection_meta_id: None,
4634 node_rows: Vec::new(),
4635 edge_rows: Vec::new(),
4636 keys: Vec::new(),
4637 cursor: node_cursor.clone(),
4638 limit: None,
4639 })?;
4640 let page = response
4641 .page
4642 .context("Convex snapshot_nodes_page response did not include page")?;
4643 for raw in page.rows {
4644 let row: ConvexNodeRow =
4645 serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
4646 nodes.push(row);
4647 }
4648 match page.next_cursor {
4649 Some(next) => node_cursor = Some(next),
4650 None => break,
4651 }
4652 }
4653
4654 let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
4655 let mut edge_cursor: Option<String> = None;
4656 loop {
4657 let response = self.post(&ConvexTransportRequest {
4658 operation: "snapshot_edges_page",
4659 chunk: 0,
4660 projection_version,
4661 projection_hash: None,
4662 projection_meta_id: None,
4663 node_rows: Vec::new(),
4664 edge_rows: Vec::new(),
4665 keys: Vec::new(),
4666 cursor: edge_cursor.clone(),
4667 limit: None,
4668 })?;
4669 let page = response
4670 .page
4671 .context("Convex snapshot_edges_page response did not include page")?;
4672 for raw in page.rows {
4673 let row: ConvexEdgeRow =
4674 serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
4675 edges.push(row);
4676 }
4677 match page.next_cursor {
4678 Some(next) => edge_cursor = Some(next),
4679 None => break,
4680 }
4681 }
4682
4683 Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
4684 }
4685
4686 fn apply_chunk(
4687 &self,
4688 report: &ConvexSyncReport,
4689 chunk: &ConvexSyncChunk,
4690 ) -> Result<ConvexTransportReceipt> {
4691 let node_rows = if chunk.operation == "upsert_nodes" {
4692 report
4693 .node_upserts
4694 .iter()
4695 .filter(|row| chunk.keys.contains(&row.external_id))
4696 .cloned()
4697 .collect()
4698 } else {
4699 Vec::new()
4700 };
4701 let edge_rows = if chunk.operation == "upsert_edges" {
4702 report
4703 .edge_upserts
4704 .iter()
4705 .filter(|row| chunk.keys.contains(&row.edge_key))
4706 .cloned()
4707 .collect()
4708 } else {
4709 Vec::new()
4710 };
4711 let request = ConvexTransportRequest {
4712 operation: &chunk.operation,
4713 chunk: chunk.chunk,
4714 projection_version: &report.projection_version,
4715 projection_hash: report.projection_hash.as_deref(),
4716 projection_meta_id: None,
4717 node_rows,
4718 edge_rows,
4719 keys: chunk.keys.clone(),
4720 cursor: None,
4721 limit: None,
4722 };
4723 let mut last_error = None;
4724 for attempt in 1..=chunk.max_attempts {
4725 match self.post(&request) {
4726 Ok(response) => {
4727 return Ok(ConvexTransportReceipt {
4728 operation: chunk.operation.clone(),
4729 chunk: chunk.chunk,
4730 attempt,
4731 status: response.status.unwrap_or_else(|| "ok".to_string()),
4732 message: response.message,
4733 });
4734 }
4735 Err(err) => {
4736 last_error = Some(err);
4737 if attempt < chunk.max_attempts {
4738 std::thread::sleep(Duration::from_millis(100 * attempt as u64));
4739 }
4740 }
4741 }
4742 }
4743 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
4744 .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
4745 }
4746}
4747
4748fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
4749 let meta_id = graph_projection_meta_id(scope);
4750 rows.nodes
4751 .iter()
4752 .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
4753 .and_then(|row| row.properties.get("content_hash").cloned())
4754}
4755
4756fn convex_projection_freshness(
4757 local: &ConvexProjectionRows,
4758 snapshot: Option<&ConvexProjectionRows>,
4759 scope: Option<&str>,
4760) -> ConvexProjectionFreshness {
4761 let local_hash = convex_projection_hash(local, scope);
4762 let Some(snapshot) = snapshot else {
4763 return ConvexProjectionFreshness {
4764 status: "unchecked".to_string(),
4765 fail_closed: false,
4766 local_hash,
4767 snapshot_hash: None,
4768 missing_nodes: Vec::new(),
4769 stale_nodes: Vec::new(),
4770 missing_edges: Vec::new(),
4771 stale_edges: Vec::new(),
4772 diagnostics: vec![
4773 "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
4774 ],
4775 };
4776 };
4777
4778 let snapshot_hash = convex_projection_hash(snapshot, scope);
4779 let snapshot_nodes = snapshot
4780 .nodes
4781 .iter()
4782 .map(|row| (row.external_id.as_str(), row))
4783 .collect::<BTreeMap<_, _>>();
4784 let snapshot_edges = snapshot
4785 .edges
4786 .iter()
4787 .map(|row| (row.edge_key.as_str(), row))
4788 .collect::<BTreeMap<_, _>>();
4789
4790 let mut missing_nodes = Vec::new();
4791 let mut stale_nodes = Vec::new();
4792 for row in &local.nodes {
4793 match snapshot_nodes.get(row.external_id.as_str()) {
4794 Some(snapshot_row) if *snapshot_row == row => {}
4795 Some(_) => stale_nodes.push(row.external_id.clone()),
4796 None => missing_nodes.push(row.external_id.clone()),
4797 }
4798 }
4799
4800 let mut missing_edges = Vec::new();
4801 let mut stale_edges = Vec::new();
4802 for row in &local.edges {
4803 match snapshot_edges.get(row.edge_key.as_str()) {
4804 Some(snapshot_row) if *snapshot_row == row => {}
4805 Some(_) => stale_edges.push(row.edge_key.clone()),
4806 None => missing_edges.push(row.edge_key.clone()),
4807 }
4808 }
4809
4810 let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
4811 let rows_current = missing_nodes.is_empty()
4812 && stale_nodes.is_empty()
4813 && missing_edges.is_empty()
4814 && stale_edges.is_empty();
4815 if hash_current && rows_current {
4816 return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
4817 }
4818
4819 let mut diagnostics = Vec::new();
4820 if local_hash != snapshot_hash {
4821 diagnostics.push(format!(
4822 "projection hash mismatch: local={} snapshot={}",
4823 local_hash.as_deref().unwrap_or("missing"),
4824 snapshot_hash.as_deref().unwrap_or("missing")
4825 ));
4826 }
4827 if !missing_nodes.is_empty() || !missing_edges.is_empty() {
4828 diagnostics.push(format!(
4829 "Convex snapshot is missing {} node(s) and {} edge(s)",
4830 missing_nodes.len(),
4831 missing_edges.len()
4832 ));
4833 }
4834 if !stale_nodes.is_empty() || !stale_edges.is_empty() {
4835 diagnostics.push(format!(
4836 "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
4837 stale_nodes.len(),
4838 stale_edges.len()
4839 ));
4840 }
4841
4842 ConvexProjectionFreshness {
4843 status: "stale".to_string(),
4844 fail_closed: true,
4845 local_hash,
4846 snapshot_hash,
4847 missing_nodes,
4848 stale_nodes,
4849 missing_edges,
4850 stale_edges,
4851 diagnostics,
4852 }
4853}
4854
4855pub(crate) fn verify_convex_projection_snapshot(
4856 root: &Path,
4857 scope: Option<&str>,
4858 snapshot_path: &Path,
4859) -> Result<()> {
4860 let graph_db = graph_substrate_db_path(root, scope);
4861 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4862 let local = convex_rows_from_graph_store(&store)?;
4863 let snapshot = load_convex_projection_rows(snapshot_path)?;
4864 validate_convex_projection_rows(&snapshot)?;
4865 let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
4866 if freshness.fail_closed {
4867 bail!(
4868 "Convex graph projection is not current for {}: {}",
4869 root.display(),
4870 freshness.diagnostics.join("; ")
4871 );
4872 }
4873 Ok(())
4874}
4875
4876fn convex_rows_diff(
4877 local: &ConvexProjectionRows,
4878 snapshot: Option<&ConvexProjectionRows>,
4879) -> (
4880 Vec<ConvexNodeRow>,
4881 Vec<ConvexEdgeRow>,
4882 Vec<String>,
4883 Vec<String>,
4884) {
4885 let Some(snapshot) = snapshot else {
4886 return (
4887 local.nodes.clone(),
4888 local.edges.clone(),
4889 Vec::new(),
4890 Vec::new(),
4891 );
4892 };
4893 let local_nodes = local
4894 .nodes
4895 .iter()
4896 .map(|row| (row.external_id.as_str(), row))
4897 .collect::<BTreeMap<_, _>>();
4898 let local_edges = local
4899 .edges
4900 .iter()
4901 .map(|row| (row.edge_key.as_str(), row))
4902 .collect::<BTreeMap<_, _>>();
4903 let snapshot_nodes = snapshot
4904 .nodes
4905 .iter()
4906 .map(|row| (row.external_id.as_str(), row))
4907 .collect::<BTreeMap<_, _>>();
4908 let snapshot_edges = snapshot
4909 .edges
4910 .iter()
4911 .map(|row| (row.edge_key.as_str(), row))
4912 .collect::<BTreeMap<_, _>>();
4913
4914 let node_upserts = local
4915 .nodes
4916 .iter()
4917 .filter(|row| {
4918 snapshot_nodes
4919 .get(row.external_id.as_str())
4920 .is_none_or(|snapshot_row| *snapshot_row != *row)
4921 })
4922 .cloned()
4923 .collect::<Vec<_>>();
4924 let edge_upserts = local
4925 .edges
4926 .iter()
4927 .filter(|row| {
4928 snapshot_edges
4929 .get(row.edge_key.as_str())
4930 .is_none_or(|snapshot_row| *snapshot_row != *row)
4931 })
4932 .cloned()
4933 .collect::<Vec<_>>();
4934 let node_tombstones = snapshot
4935 .nodes
4936 .iter()
4937 .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
4938 .map(|row| row.external_id.clone())
4939 .collect::<Vec<_>>();
4940 let edge_tombstones = snapshot
4941 .edges
4942 .iter()
4943 .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
4944 .map(|row| row.edge_key.clone())
4945 .collect::<Vec<_>>();
4946
4947 (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
4948}
4949
4950fn push_sync_chunks(
4951 chunks: &mut Vec<ConvexSyncChunk>,
4952 operation: &str,
4953 keys: Vec<String>,
4954 size: usize,
4955) {
4956 if keys.is_empty() {
4957 return;
4958 }
4959 for (idx, chunk) in keys.chunks(size).enumerate() {
4960 chunks.push(ConvexSyncChunk {
4961 operation: operation.to_string(),
4962 chunk: idx + 1,
4963 count: chunk.len(),
4964 keys: chunk.to_vec(),
4965 max_attempts: 3,
4966 retry_policy:
4967 "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
4968 .to_string(),
4969 });
4970 }
4971}
4972
4973pub(crate) fn build_convex_sync_report_with_snapshot(
4974 path: &Path,
4975 scope: Option<&str>,
4976 snapshot: Option<ConvexProjectionRows>,
4977 chunk_size: usize,
4978 dry_run: bool,
4979) -> Result<ConvexSyncReport> {
4980 if chunk_size == 0 {
4981 bail!("--chunk-size must be greater than zero");
4982 }
4983 let root = lint::resolve_project_root_or_canonical_path(path)?;
4984 let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
4985 let graph_db = graph_substrate_db_path(&root, scope);
4986 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4987 let local = convex_rows_from_graph_store(&store)?;
4988 let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
4989 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
4990 convex_rows_diff(&local, snapshot.as_ref());
4991
4992 let mut chunks = Vec::new();
4993 push_sync_chunks(
4994 &mut chunks,
4995 "delete_edges",
4996 edge_tombstones.clone(),
4997 chunk_size,
4998 );
4999 push_sync_chunks(
5000 &mut chunks,
5001 "upsert_nodes",
5002 node_upserts
5003 .iter()
5004 .map(|row| row.external_id.clone())
5005 .collect(),
5006 chunk_size,
5007 );
5008 push_sync_chunks(
5009 &mut chunks,
5010 "upsert_edges",
5011 edge_upserts
5012 .iter()
5013 .map(|row| row.edge_key.clone())
5014 .collect(),
5015 chunk_size,
5016 );
5017 push_sync_chunks(
5018 &mut chunks,
5019 "delete_nodes",
5020 node_tombstones.clone(),
5021 chunk_size,
5022 );
5023
5024 let mut diagnostics = vec![
5025 "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5026 .to_string(),
5027 ];
5028 if dry_run {
5029 diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5030 }
5031 if freshness.fail_closed {
5032 diagnostics.push(
5033 "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5034 .to_string(),
5035 );
5036 }
5037
5038 Ok(ConvexSyncReport {
5039 root: root.to_string_lossy().to_string(),
5040 scope: scope.map(str::to_string),
5041 graph_db: graph_db.to_string_lossy().to_string(),
5042 dry_run,
5043 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5044 projection_hash: convex_projection_hash(&local, scope),
5045 required_indexes: convex_required_indexes(),
5046 node_upserts,
5047 edge_upserts,
5048 node_tombstones,
5049 edge_tombstones,
5050 chunks,
5051 freshness,
5052 transport: None,
5053 receipts: Vec::new(),
5054 diagnostics,
5055 warnings: graph.warnings,
5056 })
5057}
5058
5059#[cfg(test)]
5060fn build_convex_sync_report(
5061 path: &Path,
5062 scope: Option<&str>,
5063 snapshot_path: Option<&Path>,
5064 chunk_size: usize,
5065) -> Result<ConvexSyncReport> {
5066 let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5067 build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5068}
5069
5070pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5071 if compact {
5072 println!(
5073 "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5074 report.node_upserts.len(),
5075 report.node_tombstones.len(),
5076 report.edge_upserts.len(),
5077 report.edge_tombstones.len(),
5078 report.chunks.len(),
5079 report.freshness.status
5080 );
5081 return;
5082 }
5083
5084 println!(
5085 "Convex graph sync {}",
5086 if report.dry_run { "dry-run" } else { "apply" }
5087 );
5088 println!("root: {}", report.root);
5089 println!("graph_db: {}", report.graph_db);
5090 println!(
5091 "upserts: {} node(s), {} edge(s)",
5092 report.node_upserts.len(),
5093 report.edge_upserts.len()
5094 );
5095 println!(
5096 "tombstones: {} node(s), {} edge(s)",
5097 report.node_tombstones.len(),
5098 report.edge_tombstones.len()
5099 );
5100 println!("chunks: {}", report.chunks.len());
5101 println!("freshness: {}", report.freshness.status);
5102 if let Some(transport) = &report.transport {
5103 println!(
5104 "transport: endpoint_env={} auth_env={} applied_chunks={}",
5105 transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5106 );
5107 }
5108 for receipt in &report.receipts {
5109 println!(
5110 "receipt: {} chunk {} attempt {} {}",
5111 receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5112 );
5113 }
5114 for diagnostic in report
5115 .diagnostics
5116 .iter()
5117 .chain(report.freshness.diagnostics.iter())
5118 {
5119 println!("- {}", diagnostic);
5120 }
5121}
5122
5123pub(crate) struct ConvexSyncOptions<'a> {
5124 path: &'a Path,
5125 scope: Option<&'a str>,
5126 snapshot: Option<&'a Path>,
5127 chunk_size: usize,
5128 remote_snapshot: bool,
5129 apply: bool,
5130 endpoint: Option<&'a str>,
5131 auth_token_env: &'a str,
5132}
5133
5134#[derive(Serialize)]
5135struct GraphDbSchemaField {
5136 name: &'static str,
5137 value_type: &'static str,
5138 description: &'static str,
5139}
5140
5141#[derive(Serialize)]
5142struct GraphDbSchemaOperation {
5143 command: &'static str,
5144 description: &'static str,
5145}
5146
5147#[derive(Serialize)]
5148struct GraphDbSchemaContract {
5149 name: &'static str,
5150 version: &'static str,
5151 description: &'static str,
5152}
5153
5154#[derive(Serialize)]
5155struct GraphDbSchema {
5156 contract_versions: Vec<GraphDbSchemaContract>,
5157 node_fields: Vec<GraphDbSchemaField>,
5158 edge_fields: Vec<GraphDbSchemaField>,
5159 operations: Vec<GraphDbSchemaOperation>,
5160}
5161
5162#[derive(Clone, Serialize, Deserialize)]
5163struct GraphDbFreshnessReport {
5164 status: String,
5165 fail_closed: bool,
5166 projection_version: Option<String>,
5167 content_hash: Option<String>,
5168 source_watermark: Option<String>,
5169 diagnostics: Vec<String>,
5170}
5171
5172#[derive(Clone, Debug, Serialize)]
5173pub(crate) struct GraphEffectivenessReadiness {
5174 pub(crate) status: String,
5175 pub(crate) fail_closed: bool,
5176 pub(crate) reason: String,
5177 pub(crate) diagnostics: Vec<String>,
5178 pub(crate) next_commands: Vec<String>,
5179}
5180
5181#[derive(Clone, Debug, Serialize, PartialEq)]
5182struct GraphDbPropertyFilter {
5183 key: String,
5184 value: String,
5185}
5186
5187#[derive(Clone, Debug, Default)]
5188struct GraphDbQueryOptions {
5189 cursor: Option<String>,
5190 limit: Option<usize>,
5191 property_filters: Vec<GraphDbPropertyFilter>,
5192}
5193
5194#[derive(Clone, Debug, Serialize, PartialEq)]
5195struct GraphDbPageReport {
5196 #[serde(skip_serializing_if = "Option::is_none")]
5197 cursor: Option<String>,
5198 #[serde(skip_serializing_if = "Option::is_none")]
5199 limit: Option<usize>,
5200 #[serde(skip_serializing_if = "Option::is_none")]
5201 next_cursor: Option<String>,
5202 returned_nodes: usize,
5203 returned_edges: usize,
5204 truncated: bool,
5205 property_filters: Vec<GraphDbPropertyFilter>,
5206 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5207 diagnostics: Vec<String>,
5208}
5209
5210type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5211
5212#[derive(Clone, Debug, Serialize)]
5213struct CommunityTruncationSummary {
5214 total_communities: usize,
5215 fully_kept: usize,
5216 partially_pruned: usize,
5217 fully_pruned: usize,
5218 pruned_community_kinds: Vec<String>,
5219 pruned_community_top_labels: Vec<String>,
5220}
5221
5222#[derive(Clone, Debug, Serialize)]
5223struct GraphDbRankedNeighborhoodComparison {
5224 traversal_nodes: usize,
5225 traversal_edges: usize,
5226 pruned_count: usize,
5227 total_discovered: usize,
5228 latency_micros: u128,
5229 overlap_with_unranked_pct: f64,
5230 useful_hit_density_ranked: f64,
5231 useful_hit_density_unranked: f64,
5232 duplicate_name_count_ranked: usize,
5233 duplicate_name_count_unranked: usize,
5234 handle_coverage_ranked_pct: f64,
5235 handle_coverage_unranked_pct: f64,
5236 #[serde(skip_serializing_if = "Option::is_none")]
5237 community_truncation_summary: Option<CommunityTruncationSummary>,
5238 diagnostics: Vec<String>,
5239}
5240
5241#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5242struct GraphDbDroppedByBudget {
5243 item: String,
5244 kind: String,
5245 dropped: usize,
5246 reason: String,
5247}
5248
5249#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5250struct GraphDbOutputBudgetReport {
5251 max_tokens: usize,
5252 estimated_tokens: usize,
5253 selected_nodes: usize,
5254 selected_edges: usize,
5255 candidate_nodes: usize,
5256 candidate_edges: usize,
5257 dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5258 diagnostics: Vec<String>,
5259}
5260
5261#[derive(Clone, Debug, Serialize, PartialEq)]
5262struct GraphDbKnowledgeRetrieval {
5263 mode: String,
5264 query: String,
5265 seed_kind: String,
5266 seed_limit: usize,
5267 seed_count: usize,
5268 depth: usize,
5269 limit: usize,
5270 node_count: usize,
5271 edge_count: usize,
5272 truncated: bool,
5273 traversal: String,
5274 freshness_boundary: String,
5275 privacy_boundary: String,
5276 diagnostics: Vec<String>,
5277}
5278
5279struct GraphDbSemanticSeededSubgraph {
5280 nodes: Vec<SubstrateGraphNode>,
5281 edges: Vec<SubstrateGraphEdge>,
5282 truncated: bool,
5283 diagnostics: Vec<String>,
5284}
5285
5286type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5287
5288#[derive(Serialize)]
5289struct GraphDbReport {
5290 root: String,
5291 #[serde(skip_serializing_if = "Option::is_none")]
5292 scope: Option<String>,
5293 backend: String,
5294 query: String,
5295 freshness: GraphDbFreshnessReport,
5296 #[serde(skip_serializing_if = "Option::is_none")]
5297 readiness: Option<GraphEffectivenessReadiness>,
5298 #[serde(skip_serializing_if = "Option::is_none")]
5299 schema: Option<GraphDbSchema>,
5300 #[serde(skip_serializing_if = "Option::is_none")]
5301 node: Option<SubstrateTerseGraphNode>,
5302 #[serde(skip_serializing_if = "Option::is_none")]
5303 edge: Option<SubstrateTerseGraphEdge>,
5304 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5305 nodes: Vec<SubstrateTerseGraphNode>,
5306 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5307 edges: Vec<SubstrateTerseGraphEdge>,
5308 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5309 ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5310 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5311 semantic_related: Vec<SemanticRelatedItem>,
5312 #[serde(skip_serializing_if = "Option::is_none")]
5313 neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5314 #[serde(skip_serializing_if = "Option::is_none")]
5315 ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5316 #[serde(skip_serializing_if = "Option::is_none")]
5317 knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5318 #[serde(skip_serializing_if = "Option::is_none")]
5319 output_budget: Option<GraphDbOutputBudgetReport>,
5320 #[serde(skip_serializing_if = "Option::is_none")]
5321 path: Option<substrate::GraphPath>,
5322 #[serde(skip_serializing_if = "Option::is_none")]
5323 page: Option<GraphDbPageReport>,
5324 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5325 warnings: Vec<String>,
5326}
5327
5328struct ExperimentalReadOnlyGraphStore {
5329 backend: GraphDbExperimentalBackend,
5330 nodes: BTreeMap<String, SubstrateGraphNode>,
5331 edges: BTreeMap<String, SubstrateGraphEdge>,
5332 node_ids_by_kind: BTreeMap<String, Vec<String>>,
5333 outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5334}
5335
5336impl ExperimentalReadOnlyGraphStore {
5337 fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5338 validate_convex_projection_rows(rows)?;
5339 let nodes = rows
5340 .nodes
5341 .iter()
5342 .map(|row| {
5343 let node = SubstrateGraphNode {
5344 id: row.external_id.clone(),
5345 kind: row.kind.clone(),
5346 label: row.label.clone(),
5347 properties: row.properties.clone(),
5348 provenance: row.provenance.clone(),
5349 freshness: row.freshness.clone(),
5350 };
5351 (node.id.clone(), node)
5352 })
5353 .collect::<BTreeMap<_, _>>();
5354 let edges = rows
5355 .edges
5356 .iter()
5357 .map(|row| {
5358 let edge = SubstrateGraphEdge {
5359 id: row.edge_key.clone(),
5360 from_id: row.from_external_id.clone(),
5361 to_id: row.to_external_id.clone(),
5362 kind: row.kind.clone(),
5363 properties: row.properties.clone(),
5364 provenance: row.provenance.clone(),
5365 freshness: row.freshness.clone(),
5366 };
5367 (graph_db_edge_key(&edge), edge)
5368 })
5369 .collect::<BTreeMap<_, _>>();
5370 let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5371 for node in nodes.values() {
5372 node_ids_by_kind
5373 .entry(node.kind.clone())
5374 .or_default()
5375 .push(node.id.clone());
5376 }
5377 for ids in node_ids_by_kind.values_mut() {
5378 ids.sort();
5379 }
5380 let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5381 for edge in edges.values() {
5382 outgoing_edge_keys_by_from
5383 .entry(edge.from_id.clone())
5384 .or_default()
5385 .push(graph_db_edge_key(edge));
5386 }
5387 for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5388 edge_keys.sort_by(|left_key, right_key| {
5389 let left = &edges[left_key];
5390 let right = &edges[right_key];
5391 left.to_id
5392 .cmp(&right.to_id)
5393 .then(left.kind.cmp(&right.kind))
5394 .then(left_key.cmp(right_key))
5395 });
5396 }
5397 Ok(Self {
5398 backend,
5399 nodes,
5400 edges,
5401 node_ids_by_kind,
5402 outgoing_edge_keys_by_from,
5403 })
5404 }
5405}
5406
5407impl GraphStore for ExperimentalReadOnlyGraphStore {
5408 fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5409 bail!("{} backend-eval adapter is read-only", self.backend.name())
5410 }
5411
5412 fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5413 bail!("{} backend-eval adapter is read-only", self.backend.name())
5414 }
5415
5416 fn delete_node(&self, _id: &str) -> Result<usize> {
5417 bail!("{} backend-eval adapter is read-only", self.backend.name())
5418 }
5419
5420 fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
5421 bail!("{} backend-eval adapter is read-only", self.backend.name())
5422 }
5423
5424 fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
5425 Ok(self.nodes.get(id).cloned())
5426 }
5427
5428 fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
5429 Ok(self.nodes.values().cloned().collect())
5430 }
5431
5432 fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
5433 let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
5434 edges.sort_by(|left, right| {
5435 left.from_id
5436 .cmp(&right.from_id)
5437 .then(left.kind.cmp(&right.kind))
5438 .then(left.to_id.cmp(&right.to_id))
5439 });
5440 Ok(edges)
5441 }
5442
5443 fn graph_counts(&self) -> Result<(usize, usize)> {
5444 Ok((self.nodes.len(), self.edges.len()))
5445 }
5446
5447 fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
5448 let mut edges = self
5449 .edges
5450 .values()
5451 .filter(|edge| edge.from_id != edge.to_id)
5452 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5453 .cloned()
5454 .collect::<Vec<_>>();
5455 edges.sort_by(|left, right| {
5456 left.from_id
5457 .cmp(&right.from_id)
5458 .then(left.kind.cmp(&right.kind))
5459 .then(left.to_id.cmp(&right.to_id))
5460 });
5461 Ok(edges.into_iter().next())
5462 }
5463
5464 fn sample_edge_with_property(
5465 &self,
5466 ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
5467 Ok(self
5468 .edges
5469 .values()
5470 .filter(|edge| edge.from_id != edge.to_id)
5471 .filter_map(|edge| {
5472 edge.properties.iter().next().map(|(key, value)| {
5473 (
5474 edge,
5475 GraphPropertyFilter {
5476 key: key.clone(),
5477 value: value.clone(),
5478 },
5479 )
5480 })
5481 })
5482 .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
5483 left_filter
5484 .key
5485 .cmp(&right_filter.key)
5486 .then(left_filter.value.cmp(&right_filter.value))
5487 .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
5488 })
5489 .map(|(edge, filter)| (edge.clone(), filter)))
5490 }
5491
5492 fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
5493 Ok(self
5494 .node_ids_by_kind
5495 .get(kind)
5496 .into_iter()
5497 .flatten()
5498 .filter_map(|id| self.nodes.get(id).cloned())
5499 .collect())
5500 }
5501
5502 fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
5503 Ok(self
5504 .outgoing_edge_keys_by_from
5505 .get(from_id)
5506 .into_iter()
5507 .flatten()
5508 .filter_map(|key| self.edges.get(key))
5509 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5510 .cloned()
5511 .collect())
5512 }
5513
5514 fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
5515 Ok(self
5516 .edges
5517 .values()
5518 .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
5519 .cloned()
5520 .collect())
5521 }
5522
5523 fn shortest_path(
5524 &self,
5525 from_id: &str,
5526 to_id: &str,
5527 kind: Option<&str>,
5528 ) -> Result<Option<substrate::GraphPath>> {
5529 if from_id == to_id {
5530 return Ok(Some(substrate::GraphPath {
5531 nodes: vec![from_id.to_string()],
5532 hops: 0,
5533 }));
5534 }
5535
5536 let mut queue = VecDeque::new();
5537 let mut parent = BTreeMap::<String, String>::new();
5538 parent.insert(from_id.to_string(), String::new());
5539 queue.push_back(from_id.to_string());
5540
5541 while let Some(current) = queue.pop_front() {
5542 for edge in self.outgoing_edges(¤t, kind)? {
5543 if parent.contains_key(&edge.to_id) {
5544 continue;
5545 }
5546 parent.insert(edge.to_id.clone(), current.clone());
5547 if edge.to_id == to_id {
5548 let mut nodes = vec![to_id.to_string()];
5549 let mut cursor = to_id;
5550 while let Some(previous) = parent.get(cursor) {
5551 if previous.is_empty() {
5552 break;
5553 }
5554 nodes.push(previous.clone());
5555 cursor = previous;
5556 }
5557 nodes.reverse();
5558 return Ok(Some(substrate::GraphPath {
5559 hops: nodes.len().saturating_sub(1),
5560 nodes,
5561 }));
5562 }
5563 queue.push_back(edge.to_id);
5564 }
5565 }
5566
5567 Ok(None)
5568 }
5569
5570 fn reachable_nodes_by_kinds(
5571 &self,
5572 from_id: &str,
5573 kinds: &[&str],
5574 depth: usize,
5575 limit: usize,
5576 ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
5577 let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
5578 let mut rows = requested
5579 .iter()
5580 .map(|kind| {
5581 (
5582 (*kind).to_string(),
5583 BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
5584 )
5585 })
5586 .collect::<BTreeMap<_, _>>();
5587 if requested.is_empty() {
5588 return Ok(BTreeMap::new());
5589 }
5590
5591 let mut seen = BTreeSet::from([from_id.to_string()]);
5592 let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
5593 while let Some((current, path)) = queue.pop_front() {
5594 let current_depth = path.len().saturating_sub(1);
5595 if current_depth >= depth {
5596 continue;
5597 }
5598 for edge in self.outgoing_edges(¤t, None)? {
5599 if !seen.insert(edge.to_id.clone()) {
5600 continue;
5601 }
5602 let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
5603 continue;
5604 };
5605 let mut next_path = path.clone();
5606 next_path.push(edge.to_id.clone());
5607 let graph_path = substrate::GraphPath {
5608 hops: next_path.len().saturating_sub(1),
5609 nodes: next_path.clone(),
5610 };
5611 if requested.contains(node.kind.as_str()) {
5612 rows.entry(node.kind.clone())
5613 .or_default()
5614 .entry(node.id.clone())
5615 .or_insert((node.clone(), graph_path));
5616 }
5617 queue.push_back((edge.to_id, next_path));
5618 }
5619 }
5620
5621 Ok(rows
5622 .into_iter()
5623 .map(|(kind, values)| {
5624 let mut values = values.into_values().collect::<Vec<_>>();
5625 values.sort_by(|(left_node, left_path), (right_node, right_path)| {
5626 left_path
5627 .hops
5628 .cmp(&right_path.hops)
5629 .then(left_node.label.cmp(&right_node.label))
5630 .then(left_node.id.cmp(&right_node.id))
5631 });
5632 if limit > 0 && values.len() > limit {
5633 values.truncate(limit);
5634 }
5635 (kind, values)
5636 })
5637 .collect())
5638 }
5639}
5640
5641pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
5642pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
5643pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
5644const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
5645pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
5646const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
5647const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
5648const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
5649const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
5650
5651#[derive(Clone, Serialize, Deserialize)]
5652pub(crate) struct GraphDbBackendEvalPhaseTiming {
5653 name: String,
5654 duration_micros: u128,
5655 detail: String,
5656}
5657
5658#[derive(Serialize, Deserialize)]
5659struct GraphDbBackendEvalFullProjectionCache {
5660 version: String,
5661 key: String,
5662 source_watermark: String,
5663 projection: GraphProjection,
5664 warnings: Vec<String>,
5665}
5666
5667#[derive(Clone, Default)]
5668struct GraphDbBackendEvalFullProjectionCacheStats {
5669 hit: bool,
5670 disk_bytes: u64,
5671 json_bytes: u64,
5672 pruned_files: usize,
5673 pruned_bytes: u64,
5674}
5675
5676#[derive(Serialize)]
5677struct GraphDbBackendEvalRawSourceWatermarkRow {
5678 path: String,
5679 bytes: u64,
5680 content_hash: String,
5681}
5682
5683#[derive(Clone)]
5684struct GraphDbBackendEvalFullProjectionSourceWatermark {
5685 value: String,
5686 detail: String,
5687}
5688
5689#[derive(Serialize)]
5690pub(crate) struct GraphDbBackendEvalConfig {
5691 high_degree_nodes: usize,
5692 high_degree_fanout: usize,
5693 deep_chain_nodes: usize,
5694 deep_chain_fanout: usize,
5695 depth: usize,
5696 limit: usize,
5697 impact_limit: usize,
5698 path_max_hops: usize,
5699 path_direct_hop_budget: usize,
5700 path_deep_chain_hop_budget: usize,
5701 path_extended_hop_budgets: Vec<usize>,
5702 path_hop_policy: String,
5703 path_probe_strategy: String,
5704 path_query_plan_checks: Vec<String>,
5705 full_projection_enabled: bool,
5706 full_projection_profile: String,
5707 normalization_row_unit: usize,
5708}
5709
5710#[derive(Clone)]
5711struct GraphDbBackendEvalSignature {
5712 operation: String,
5713 value: serde_json::Value,
5714}
5715
5716#[derive(Serialize)]
5717struct GraphDbBackendEvalOperation {
5718 name: String,
5719 supported: bool,
5720 status: String,
5721 duration_micros: u128,
5722 #[serde(skip_serializing_if = "Option::is_none")]
5723 rows: Option<usize>,
5724 #[serde(skip_serializing_if = "Option::is_none")]
5725 error: Option<String>,
5726}
5727
5728#[derive(Serialize)]
5729struct GraphDbBackendEvalParity {
5730 matches_sqlite: bool,
5731 diagnostics: Vec<String>,
5732}
5733
5734#[derive(Serialize)]
5735struct GraphDbBackendEvalBackendReport {
5736 backend: String,
5737 adapter: String,
5738 read_only: bool,
5739 projection_load: String,
5740 operations: Vec<GraphDbBackendEvalOperation>,
5741 total_micros: u128,
5742 parity: GraphDbBackendEvalParity,
5743 lock_behavior: String,
5744 install_portability: String,
5745}
5746
5747#[derive(Serialize)]
5748struct GraphDbBackendEvalDataset {
5749 name: String,
5750 target_count: usize,
5751 nodes: usize,
5752 edges: usize,
5753 backends: Vec<GraphDbBackendEvalBackendReport>,
5754}
5755
5756#[derive(Serialize)]
5757struct GraphDbBackendPromotionDecision {
5758 backend: String,
5759 decision: String,
5760 reasons: Vec<String>,
5761 gate: GraphDbBackendPromotionGate,
5762}
5763
5764#[derive(Serialize)]
5765struct GraphDbBackendEvalPerformanceGate {
5766 baseline_fixture: String,
5767 ci_profile: String,
5768 opt_in_real_profile: String,
5769 full_projection_cache_hit_gate: String,
5770 allowed_regression_percent: f64,
5771 minimum_sample_runs: usize,
5772 normalized_metric_unit: String,
5773 required_metrics: Vec<String>,
5774 digest_command: String,
5775 repeated_sample_command: String,
5776 hop_cap_promotion: GraphDbHopCapPromotionGate,
5777 backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
5778}
5779
5780#[derive(Serialize)]
5781struct GraphDbHopCapPromotionGate {
5782 status: String,
5783 current_default_hops: usize,
5784 candidate_hop_tiers: Vec<usize>,
5785 required_backend: String,
5786 required_workloads: Vec<String>,
5787 required_metrics: Vec<String>,
5788 allowed_regression_percent: f64,
5789 minimum_sample_runs: usize,
5790 decision_rule: String,
5791}
5792
5793#[derive(Serialize)]
5794struct GraphDbBackendAdapterSpikeGate {
5795 status: String,
5796 candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
5797 required_workloads: Vec<String>,
5798 required_checks: Vec<String>,
5799 decision_rule: String,
5800 evidence_plan: String,
5801}
5802
5803#[derive(Serialize)]
5804struct GraphDbBackendAdapterSpikeCandidate {
5805 backend: String,
5806 adapter_label: String,
5807 projection_load: String,
5808 lock_behavior: String,
5809 install_portability: String,
5810}
5811
5812#[derive(Serialize)]
5813pub(crate) struct GraphDbBackendEvalReport {
5814 root: String,
5815 #[serde(skip_serializing_if = "Option::is_none")]
5816 scope: Option<String>,
5817 label: String,
5818 baseline_backend: String,
5819 candidates: Vec<String>,
5820 targets: Vec<String>,
5821 config: GraphDbBackendEvalConfig,
5822 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5823 datasets: Vec<GraphDbBackendEvalDataset>,
5824 promotion: Vec<GraphDbBackendPromotionDecision>,
5825 performance_gate: GraphDbBackendEvalPerformanceGate,
5826 metrics: BTreeMap<String, f64>,
5827 metric_digest_command: String,
5828 warnings: Vec<String>,
5829}
5830
5831#[derive(Clone, Debug, Serialize)]
5832struct GraphDbDoctorCheck {
5833 name: String,
5834 status: String,
5835 fail_closed: bool,
5836 diagnostics: Vec<String>,
5837 repair_commands: Vec<String>,
5838}
5839
5840#[derive(Serialize)]
5841pub(crate) struct GraphDbDoctorReport {
5842 root: String,
5843 #[serde(skip_serializing_if = "Option::is_none")]
5844 scope: Option<String>,
5845 backend: String,
5846 graph_db: String,
5847 #[serde(skip_serializing_if = "Option::is_none")]
5848 convex_snapshot: Option<String>,
5849 status: String,
5850 fail_closed: bool,
5851 checks: Vec<GraphDbDoctorCheck>,
5852 repair_commands: Vec<String>,
5853 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5854 required_indexes: Vec<ConvexRequiredIndex>,
5855}
5856
5857#[derive(Serialize)]
5858struct GraphDbDriftSummary {
5859 node_upserts: usize,
5860 edge_upserts: usize,
5861 node_tombstones: usize,
5862 edge_tombstones: usize,
5863 stale_nodes: usize,
5864 stale_edges: usize,
5865 stale_projection_metadata: usize,
5866 duplicate_failures: usize,
5867 orphan_failures: usize,
5868 missing_required_indexes: usize,
5869}
5870
5871#[derive(Serialize)]
5872struct GraphDbDriftReport {
5873 root: String,
5874 #[serde(skip_serializing_if = "Option::is_none")]
5875 scope: Option<String>,
5876 graph_db: String,
5877 convex_snapshot: String,
5878 status: String,
5879 graph_reads_allowed: bool,
5880 projection_version: String,
5881 local_hash: Option<String>,
5882 snapshot_hash: Option<String>,
5883 summary: GraphDbDriftSummary,
5884 node_upserts: Vec<String>,
5885 edge_upserts: Vec<String>,
5886 node_tombstones: Vec<String>,
5887 edge_tombstones: Vec<String>,
5888 stale_nodes: Vec<String>,
5889 stale_edges: Vec<String>,
5890 diagnostics: Vec<String>,
5891 next_commands: Vec<String>,
5892 required_indexes: Vec<ConvexRequiredIndex>,
5893 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5894 warnings: Vec<String>,
5895}
5896
5897#[derive(Clone, Serialize)]
5898struct GraphDbTombstoneCounts {
5899 nodes: usize,
5900 edges: usize,
5901 total: usize,
5902}
5903
5904#[derive(Clone, Serialize)]
5905struct GraphDbOperatorCounts {
5906 nodes: usize,
5907 edges: usize,
5908 tombstones: GraphDbTombstoneCounts,
5909 #[serde(skip_serializing_if = "Option::is_none")]
5910 file_size_bytes: Option<u64>,
5911 #[serde(skip_serializing_if = "Option::is_none")]
5912 freelist_bytes: Option<u64>,
5913}
5914
5915#[derive(Clone, Serialize)]
5916struct GraphDbCompactionPolicy {
5917 status: String,
5918 tombstone_scan_rows: usize,
5919 live_rows: usize,
5920 file_size_bytes: Option<u64>,
5921 freelist_bytes: Option<u64>,
5922 safe_to_prune_tombstones: bool,
5923 requires_convex_reconciliation: bool,
5924 recommendations: Vec<String>,
5925 proof: Vec<String>,
5926}
5927
5928#[derive(Serialize)]
5929pub(crate) struct GraphDbRefreshSummary {
5930 scope: String,
5931 projection_version: String,
5932 mode: String,
5933 #[serde(skip_serializing_if = "Option::is_none")]
5934 source_watermark: Option<String>,
5935 tombstoned_nodes: usize,
5936 tombstoned_edges: usize,
5937 upserted_nodes: usize,
5938 upserted_edges: usize,
5939 unchanged_nodes: usize,
5940 unchanged_edges: usize,
5941 upserted_properties: usize,
5942 unchanged_properties: usize,
5943 deleted_properties: usize,
5944 deleted_nodes: usize,
5945 deleted_edges: usize,
5946 pruned_tombstones: usize,
5947 #[serde(skip_serializing_if = "Option::is_none")]
5948 file_size_bytes_before: Option<u64>,
5949 #[serde(skip_serializing_if = "Option::is_none")]
5950 file_size_bytes_after: Option<u64>,
5951 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5952 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5953}
5954
5955#[derive(Serialize)]
5956struct GraphDbOperatorReport {
5957 root: String,
5958 #[serde(skip_serializing_if = "Option::is_none")]
5959 scope: Option<String>,
5960 graph_db: String,
5961 operation: String,
5962 status: String,
5963 materialized: bool,
5964 freshness: GraphDbFreshnessReport,
5965 readiness: GraphEffectivenessReadiness,
5966 counts: GraphDbOperatorCounts,
5967 #[serde(skip_serializing_if = "Option::is_none")]
5968 refresh: Option<GraphDbRefreshSummary>,
5969 compaction: GraphDbCompactionPolicy,
5970 #[serde(skip_serializing_if = "Option::is_none")]
5971 recovery: Option<index::ReadOnlyRecovery>,
5972 next_commands: Vec<String>,
5973 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5974 warnings: Vec<String>,
5975}
5976
5977#[derive(Serialize)]
5978pub(crate) struct GraphDbCompactionReport {
5979 root: String,
5980 #[serde(skip_serializing_if = "Option::is_none")]
5981 scope: Option<String>,
5982 graph_db: String,
5983 applied: bool,
5984 pruned_tombstones: usize,
5985 counts_before: GraphDbOperatorCounts,
5986 counts_after: GraphDbOperatorCounts,
5987 compaction_before: GraphDbCompactionPolicy,
5988 compaction_after: GraphDbCompactionPolicy,
5989 reclaimed_bytes: i64,
5990 next_commands: Vec<String>,
5991 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5992 warnings: Vec<String>,
5993}
5994
5995#[derive(Clone, Serialize, Deserialize)]
5996struct GraphDbEvidencePath {
5997 to: String,
5998 kind: String,
5999 label: String,
6000 #[serde(skip_serializing_if = "Option::is_none")]
6001 path: Option<substrate::GraphPath>,
6002 #[serde(skip_serializing_if = "Option::is_none")]
6003 expand: Option<String>,
6004}
6005
6006#[derive(Clone, Serialize, Deserialize)]
6007struct GraphDbFixtureCoverage {
6008 test: String,
6009 fixture: String,
6010 assertions: Vec<String>,
6011}
6012
6013#[derive(Clone, Serialize, Deserialize)]
6014struct GraphDbEvidenceReport {
6015 root: String,
6016 #[serde(skip_serializing_if = "Option::is_none")]
6017 scope: Option<String>,
6018 backend: String,
6019 contract_version: String,
6020 target: String,
6021 packet_id: String,
6022 #[serde(skip_serializing_if = "Option::is_none")]
6023 projection_hash: Option<String>,
6024 freshness: GraphDbFreshnessReport,
6025 target_node: SubstrateTerseGraphNode,
6026 worker_context: Vec<SubstrateTerseGraphNode>,
6027 source_handles: Vec<SubstrateTerseGraphNode>,
6028 worker_results: Vec<SubstrateTerseGraphNode>,
6029 semantic_related: Vec<SubstrateTerseGraphNode>,
6030 shortest_paths: Vec<GraphDbEvidencePath>,
6031 #[serde(skip_serializing_if = "Option::is_none")]
6032 output_budget: Option<GraphDbOutputBudgetReport>,
6033 #[serde(default)]
6034 truncated: bool,
6035 #[serde(skip_serializing_if = "Option::is_none")]
6036 next_cursor: Option<String>,
6037 next_commands: Vec<String>,
6038 replay_commands: Vec<String>,
6039 repair_commands: Vec<String>,
6040 fixture_coverage: GraphDbFixtureCoverage,
6041 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6042 warnings: Vec<String>,
6043}
6044
6045pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6046 root: &'a Path,
6047 scope: Option<&'a str>,
6048 backend: &'a str,
6049 target: &'a str,
6050 depth: usize,
6051 limit: usize,
6052 cursor: Option<&'a str>,
6053 store: &'a S,
6054 freshness: GraphDbFreshnessReport,
6055 warnings: Vec<String>,
6056}
6057
6058impl GraphDbDoctorReport {
6059 fn new(
6060 root: &Path,
6061 scope: Option<&str>,
6062 backend: &str,
6063 graph_db: &Path,
6064 convex_snapshot: Option<&Path>,
6065 ) -> Self {
6066 Self {
6067 root: root.to_string_lossy().to_string(),
6068 scope: scope.map(str::to_string),
6069 backend: backend.to_string(),
6070 graph_db: graph_db.to_string_lossy().to_string(),
6071 convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6072 status: "ok".to_string(),
6073 fail_closed: false,
6074 checks: Vec::new(),
6075 repair_commands: Vec::new(),
6076 required_indexes: Vec::new(),
6077 }
6078 }
6079
6080 fn push_check(&mut self, check: GraphDbDoctorCheck) {
6081 self.checks.push(check);
6082 }
6083
6084 fn finalize(&mut self) {
6085 self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6086 self.status = if self.fail_closed {
6087 "fail_closed"
6088 } else {
6089 "ok"
6090 }
6091 .to_string();
6092 let mut commands = BTreeSet::new();
6093 for check in &self.checks {
6094 commands.extend(check.repair_commands.iter().cloned());
6095 }
6096 self.repair_commands = commands.into_iter().collect();
6097 }
6098
6099 fn summary(&self) -> String {
6100 self.checks
6101 .iter()
6102 .filter(|check| check.fail_closed)
6103 .flat_map(|check| check.diagnostics.iter())
6104 .take(3)
6105 .cloned()
6106 .collect::<Vec<_>>()
6107 .join("; ")
6108 }
6109}
6110
6111fn graph_db_doctor_check(
6112 name: impl Into<String>,
6113 diagnostics: Vec<String>,
6114 repair_commands: Vec<String>,
6115) -> GraphDbDoctorCheck {
6116 let fail_closed = !diagnostics.is_empty();
6117 GraphDbDoctorCheck {
6118 name: name.into(),
6119 status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6120 fail_closed,
6121 diagnostics,
6122 repair_commands: if fail_closed {
6123 repair_commands
6124 } else {
6125 Vec::new()
6126 },
6127 }
6128}
6129
6130pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6131 scope
6132 .map(|scope| format!(" --scope {}", shell_quote(scope)))
6133 .unwrap_or_default()
6134}
6135
6136fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6137 format!(
6138 "tsift graph-db --path {}{} refresh --json",
6139 shell_quote(root.to_string_lossy().as_ref()),
6140 graph_db_scope_arg(scope)
6141 )
6142}
6143
6144fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6145 graph_db_refresh_command(root, scope)
6146}
6147
6148fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6149 let backup = format!("{}.bak", graph_db.to_string_lossy());
6150 format!(
6151 "mv {} {} && {}",
6152 shell_quote(graph_db.to_string_lossy().as_ref()),
6153 shell_quote(&backup),
6154 graph_db_rebuild_command(root, scope)
6155 )
6156}
6157
6158fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6159 format!(
6160 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6161 shell_quote(root.to_string_lossy().as_ref()),
6162 graph_db_scope_arg(scope)
6163 )
6164}
6165
6166fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6167 substrate::open_graph_read_only_connection_resilient(graph_db)
6168}
6169
6170fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6171 conn.query_row(
6172 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6173 [table],
6174 |row| row.get::<_, bool>(0),
6175 )
6176 .map_err(Into::into)
6177}
6178
6179fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6180 let sql = match table {
6181 "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6182 "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6183 "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6184 other => bail!("unsupported graph count table {other}"),
6185 };
6186 conn.query_row(sql, [], |row| row.get::<_, usize>(0))
6187 .map_err(Into::into)
6188}
6189
6190fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6191 if !sqlite_table_exists(conn, "graph_tombstones")? {
6192 return Ok(GraphDbTombstoneCounts {
6193 nodes: 0,
6194 edges: 0,
6195 total: 0,
6196 });
6197 }
6198 let mut stmt =
6199 conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6200 let mut rows = stmt.query([])?;
6201 let mut nodes = 0usize;
6202 let mut edges = 0usize;
6203 while let Some(row) = rows.next()? {
6204 let row_kind: String = row.get(0)?;
6205 let count: usize = row.get(1)?;
6206 match row_kind.as_str() {
6207 "node" => nodes = count,
6208 "edge" => edges = count,
6209 _ => {}
6210 }
6211 }
6212 Ok(GraphDbTombstoneCounts {
6213 nodes,
6214 edges,
6215 total: nodes + edges,
6216 })
6217}
6218
6219fn sqlite_graph_counts_from_cache(
6220 conn: &Connection,
6221 scope: &str,
6222) -> Result<Option<GraphDbOperatorCounts>> {
6223 if !sqlite_table_exists(conn, "graph_operator_stats")? {
6224 return Ok(None);
6225 }
6226 let row = conn
6227 .query_row(
6228 r#"
6229 SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6230 FROM graph_operator_stats
6231 WHERE scope = ?1
6232 "#,
6233 [scope],
6234 |row| {
6235 Ok((
6236 row.get::<_, usize>(0)?,
6237 row.get::<_, usize>(1)?,
6238 row.get::<_, usize>(2)?,
6239 row.get::<_, usize>(3)?,
6240 row.get::<_, Option<i64>>(4)?,
6241 row.get::<_, Option<i64>>(5)?,
6242 ))
6243 },
6244 )
6245 .optional()?;
6246 Ok(row.map(
6247 |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6248 GraphDbOperatorCounts {
6249 nodes,
6250 edges,
6251 tombstones: GraphDbTombstoneCounts {
6252 nodes: tombstone_nodes,
6253 edges: tombstone_edges,
6254 total: tombstone_nodes + tombstone_edges,
6255 },
6256 file_size_bytes: file_size_bytes
6257 .and_then(|value| u64::try_from(value).ok())
6258 .or_else(|| sqlite_database_size_bytes(conn).ok()),
6259 freelist_bytes: freelist_bytes
6260 .and_then(|value| u64::try_from(value).ok())
6261 .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6262 }
6263 },
6264 ))
6265}
6266
6267fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6268 if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6269 return Ok(counts);
6270 }
6271 let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6272 sqlite_known_table_count(conn, "graph_nodes")?
6273 } else {
6274 0
6275 };
6276 let edges = if sqlite_table_exists(conn, "graph_edges")? {
6277 sqlite_known_table_count(conn, "graph_edges")?
6278 } else {
6279 0
6280 };
6281 Ok(GraphDbOperatorCounts {
6282 nodes,
6283 edges,
6284 tombstones: sqlite_tombstone_counts(conn)?,
6285 file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6286 freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6287 })
6288}
6289
6290fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6291 if !sqlite_table_exists(conn, "graph_nodes")? {
6292 return Ok(0);
6293 }
6294 let count: i64 = conn.query_row(
6295 "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6296 [],
6297 |row| row.get(0),
6298 )?;
6299 Ok(count as usize)
6300}
6301
6302pub(crate) fn graph_db_compaction_policy(
6303 root: &Path,
6304 scope: Option<&str>,
6305 counts: &GraphDbOperatorCounts,
6306 prune_confirmed: bool,
6307) -> GraphDbCompactionPolicy {
6308 let live_rows = counts.nodes + counts.edges;
6309 let tombstone_scan_rows = counts.tombstones.total;
6310 let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6311 let freelist_heavy = counts
6312 .file_size_bytes
6313 .zip(counts.freelist_bytes)
6314 .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6315 let status = if tombstone_heavy || freelist_heavy {
6316 "recommended"
6317 } else {
6318 "not_needed"
6319 }
6320 .to_string();
6321 let mut recommendations = vec![
6322 convex_refresh_command(root, scope),
6323 graph_db_refresh_command(root, scope),
6324 format!(
6325 "tsift graph-db --path {}{} compact --apply --json",
6326 shell_quote(root.to_string_lossy().as_ref()),
6327 graph_db_scope_arg(scope)
6328 ),
6329 ];
6330 if prune_confirmed {
6331 recommendations.push(format!(
6332 "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6333 shell_quote(root.to_string_lossy().as_ref()),
6334 graph_db_scope_arg(scope)
6335 ));
6336 }
6337 let proof = vec![
6338 format!("{live_rows} live graph row(s)"),
6339 format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6340 format!(
6341 "graph.db file_size={} byte(s), freelist={} byte(s)",
6342 counts.file_size_bytes.unwrap_or(0),
6343 counts.freelist_bytes.unwrap_or(0)
6344 ),
6345 ];
6346 GraphDbCompactionPolicy {
6347 status,
6348 tombstone_scan_rows,
6349 live_rows,
6350 file_size_bytes: counts.file_size_bytes,
6351 freelist_bytes: counts.freelist_bytes,
6352 safe_to_prune_tombstones: prune_confirmed,
6353 requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6354 recommendations,
6355 proof,
6356 }
6357}
6358
6359fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6360 let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
6361 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6362 Ok(page_count.saturating_mul(page_size))
6363}
6364
6365fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6366 let freelist_count: u64 = conn.query_row("PRAGMA freelist_count", [], |row| row.get(0))?;
6367 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6368 Ok(freelist_count.saturating_mul(page_size))
6369}
6370
6371fn sqlite_graph_tombstone_retention_diagnostics(
6372 conn: &Connection,
6373 scope: &str,
6374) -> Result<Vec<String>> {
6375 if !sqlite_table_exists(conn, "graph_tombstones")? {
6376 return Ok(Vec::new());
6377 }
6378 let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6379 let counts = match cached.clone() {
6380 Some(counts) => counts,
6381 None => sqlite_graph_counts(conn, scope)?,
6382 };
6383 let live_rows = counts.nodes + counts.edges;
6384 let file_size = counts.file_size_bytes.unwrap_or(0);
6385 let freelist = counts.freelist_bytes.unwrap_or(0);
6386 let stale_live_tombstones = if cached.is_some() {
6387 0
6388 } else {
6389 let mut live_keys = BTreeSet::new();
6390 if sqlite_table_exists(conn, "graph_nodes")? {
6391 let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6392 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6393 live_keys.insert(format!("node:{}", row?));
6394 }
6395 }
6396 if sqlite_table_exists(conn, "graph_edges")? {
6397 let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6398 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6399 live_keys.insert(format!("edge:{}", row?));
6400 }
6401 }
6402 let mut stale_live_tombstones = 0usize;
6403 let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6404 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6405 if live_keys.contains(&row?) {
6406 stale_live_tombstones += 1;
6407 }
6408 }
6409 stale_live_tombstones
6410 };
6411
6412 let mut diagnostics = Vec::new();
6413 if stale_live_tombstones > 0 {
6414 diagnostics.push(format!(
6415 "{stale_live_tombstones} tombstone(s) reference rows that are live again; the next graph-db refresh prunes those stale tombstones before inserting new deletion markers"
6416 ));
6417 }
6418 if counts.tombstones.total > live_rows.max(1) {
6419 let source = if cached.is_some() {
6420 "cached refresh stats"
6421 } else {
6422 "live row scan"
6423 };
6424 diagnostics.push(format!(
6425 "tombstone retention exceeds live graph rows: {} tombstone(s) vs {} live row(s) from {}; graph.db file_size={} byte(s), freelist={} byte(s), status/doctor tombstone scans inspect {} extra row(s). Run convex-sync against the remote snapshot before rebuild/compaction if a remote consumer may still need deletion reconciliation.",
6426 counts.tombstones.total,
6427 live_rows,
6428 source,
6429 file_size,
6430 freelist,
6431 counts.tombstones.total
6432 ));
6433 }
6434 Ok(diagnostics)
6435}
6436
6437fn sqlite_graph_freshness_from_conn(
6438 conn: &Connection,
6439 scope: &str,
6440) -> Result<GraphDbFreshnessReport> {
6441 if !sqlite_table_exists(conn, "graph_projection_versions")? {
6442 return Ok(GraphDbFreshnessReport {
6443 status: "missing".to_string(),
6444 fail_closed: true,
6445 projection_version: None,
6446 content_hash: None,
6447 source_watermark: None,
6448 diagnostics: vec![
6449 "graph projection metadata table is missing; refresh graph.db before trusting reads"
6450 .to_string(),
6451 ],
6452 });
6453 }
6454 let version = conn
6455 .query_row(
6456 r#"
6457 SELECT projection_version, content_hash, source_watermark
6458 FROM graph_projection_versions
6459 WHERE scope = ?1
6460 "#,
6461 [scope],
6462 |row| {
6463 Ok((
6464 row.get::<_, String>(0)?,
6465 row.get::<_, Option<String>>(1)?,
6466 row.get::<_, Option<String>>(2)?,
6467 ))
6468 },
6469 )
6470 .optional()?;
6471 let Some((projection_version, content_hash, source_watermark)) = version else {
6472 return Ok(GraphDbFreshnessReport {
6473 status: "missing".to_string(),
6474 fail_closed: true,
6475 projection_version: None,
6476 content_hash: None,
6477 source_watermark: None,
6478 diagnostics: vec![
6479 "graph projection metadata is missing; refresh graph.db before trusting reads"
6480 .to_string(),
6481 ],
6482 });
6483 };
6484
6485 let mut diagnostics = Vec::new();
6486 if projection_version != GRAPH_PROJECTION_VERSION {
6487 diagnostics.push(format!(
6488 "projection version mismatch: expected {} got {}",
6489 GRAPH_PROJECTION_VERSION, projection_version
6490 ));
6491 }
6492 if content_hash.is_none() {
6493 diagnostics.push("projection content hash is missing".to_string());
6494 }
6495 let fail_closed = !diagnostics.is_empty();
6496 Ok(GraphDbFreshnessReport {
6497 status: if fail_closed { "stale" } else { "current" }.to_string(),
6498 fail_closed,
6499 projection_version: Some(projection_version),
6500 content_hash,
6501 source_watermark,
6502 diagnostics,
6503 })
6504}
6505
6506fn graph_db_operator_next_commands(
6507 root: &Path,
6508 scope: Option<&str>,
6509 include_refresh: bool,
6510) -> Vec<String> {
6511 let mut commands = Vec::new();
6512 if include_refresh {
6513 commands.push(graph_db_refresh_command(root, scope));
6514 }
6515 commands.push(format!(
6516 "tsift graph-db --path {}{} doctor --json",
6517 shell_quote(root.to_string_lossy().as_ref()),
6518 graph_db_scope_arg(scope)
6519 ));
6520 commands.push(format!(
6521 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
6522 shell_quote(root.to_string_lossy().as_ref()),
6523 graph_db_scope_arg(scope)
6524 ));
6525 commands.push(format!(
6526 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6527 shell_quote(root.to_string_lossy().as_ref()),
6528 graph_db_scope_arg(scope)
6529 ));
6530 commands
6531}
6532
6533pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
6534 match recovery {
6535 index::ReadOnlyRecovery::SnapshotFallback => {
6536 "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
6537 }
6538 index::ReadOnlyRecovery::SnapshotFallbackWal => {
6539 "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
6540 }
6541 }
6542}
6543
6544fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
6545 let mut stmt = conn.prepare(sql)?;
6546 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6547 let mut values = BTreeSet::new();
6548 for row in rows {
6549 values.insert(row?);
6550 }
6551 Ok(values)
6552}
6553
6554fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
6555 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
6556 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
6557 let mut columns = BTreeSet::new();
6558 for row in rows {
6559 columns.insert(row?);
6560 }
6561 Ok(columns)
6562}
6563
6564fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6565 let mut diagnostics = Vec::new();
6566 let user_version: i64 =
6567 conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
6568 if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
6569 diagnostics.push(format!(
6570 "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6571 ));
6572 } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
6573 diagnostics.push(format!(
6574 "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6575 ));
6576 }
6577
6578 let tables = sqlite_string_set(
6579 conn,
6580 "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
6581 )?;
6582 let required_tables = [
6583 (
6584 "graph_nodes",
6585 vec![
6586 "id",
6587 "kind",
6588 "label",
6589 "properties_json",
6590 "provenance_json",
6591 "freshness_json",
6592 "row_hash",
6593 "source_watermark",
6594 ],
6595 ),
6596 (
6597 "graph_edges",
6598 vec![
6599 "edge_key",
6600 "from_id",
6601 "to_id",
6602 "kind",
6603 "properties_json",
6604 "provenance_json",
6605 "freshness_json",
6606 "row_hash",
6607 "source_watermark",
6608 ],
6609 ),
6610 (
6611 "graph_projection_versions",
6612 vec![
6613 "scope",
6614 "projection_version",
6615 "content_hash",
6616 "source_watermark",
6617 "observed_at_unix",
6618 ],
6619 ),
6620 (
6621 "graph_tombstones",
6622 vec!["row_key", "row_kind", "deleted_at_unix"],
6623 ),
6624 ("graph_node_properties", vec!["node_id", "key", "value"]),
6625 ("graph_edge_properties", vec!["edge_key", "key", "value"]),
6626 ];
6627 for (table, required_columns) in required_tables {
6628 if !tables.contains(table) {
6629 diagnostics.push(format!("graph.db schema drift: missing table {table}"));
6630 continue;
6631 }
6632 let columns = sqlite_column_names(conn, table)?;
6633 for column in required_columns {
6634 if !columns.contains(column) {
6635 diagnostics.push(format!(
6636 "graph.db schema drift: missing column {table}.{column}"
6637 ));
6638 }
6639 }
6640 }
6641
6642 let indexes = sqlite_string_set(
6643 conn,
6644 "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
6645 )?;
6646 for index in [
6647 "idx_graph_nodes_kind",
6648 "idx_graph_edges_from_kind",
6649 "idx_graph_edges_to_kind",
6650 "idx_graph_edges_edge_key",
6651 "idx_graph_node_properties_key_value_node",
6652 "idx_graph_edge_properties_key_value_edge",
6653 ] {
6654 if !indexes.contains(index) {
6655 diagnostics.push(format!("graph.db schema drift: missing index {index}"));
6656 }
6657 }
6658
6659 if tables.contains("graph_edges") {
6660 let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
6661 let rows = stmt.query_map([], |row| {
6662 Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
6663 })?;
6664 let mut fks = BTreeSet::new();
6665 for row in rows {
6666 fks.insert(row?);
6667 }
6668 for expected in [
6669 ("from_id".to_string(), "id".to_string()),
6670 ("to_id".to_string(), "id".to_string()),
6671 ] {
6672 if !fks.contains(&expected) {
6673 diagnostics.push(format!(
6674 "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
6675 expected.0, expected.1
6676 ));
6677 }
6678 }
6679 }
6680
6681 Ok(diagnostics)
6682}
6683
6684fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
6685 let mut stmt = conn.prepare(sql)?;
6686 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6687 let mut diagnostics = Vec::new();
6688 for row in rows {
6689 diagnostics.push(row?);
6690 }
6691 Ok(diagnostics)
6692}
6693
6694fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6695 let mut diagnostics = sqlite_query_diagnostics(
6696 conn,
6697 r#"
6698 SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
6699 FROM graph_nodes
6700 GROUP BY id
6701 HAVING COUNT(*) > 1
6702 ORDER BY id
6703 "#,
6704 )?;
6705 diagnostics.extend(sqlite_query_diagnostics(
6706 conn,
6707 r#"
6708 SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
6709 FROM graph_edges
6710 GROUP BY from_id, to_id, kind
6711 HAVING COUNT(*) > 1
6712 ORDER BY from_id, kind, to_id
6713 "#,
6714 )?);
6715 diagnostics.extend(sqlite_query_diagnostics(
6716 conn,
6717 r#"
6718 SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
6719 FROM graph_edges
6720 GROUP BY edge_key
6721 HAVING COUNT(*) > 1
6722 ORDER BY edge_key
6723 "#,
6724 )?);
6725 Ok(diagnostics)
6726}
6727
6728fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6729 sqlite_query_diagnostics(
6730 conn,
6731 r#"
6732 SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6733 FROM graph_edges e
6734 LEFT JOIN graph_nodes n ON n.id = e.from_id
6735 WHERE n.id IS NULL
6736 UNION ALL
6737 SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6738 FROM graph_edges e
6739 LEFT JOIN graph_nodes n ON n.id = e.to_id
6740 WHERE n.id IS NULL
6741 ORDER BY 1
6742 "#,
6743 )
6744}
6745
6746fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6747 let mut diagnostics = Vec::new();
6748 let mut node_stmt = conn.prepare(
6749 "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6750 )?;
6751 let node_rows = node_stmt.query_map([], |row| {
6752 Ok((
6753 row.get::<_, String>(0)?,
6754 row.get::<_, String>(1)?,
6755 row.get::<_, String>(2)?,
6756 row.get::<_, Option<String>>(3)?,
6757 ))
6758 })?;
6759 for row in node_rows {
6760 let (id, properties_json, provenance_json, freshness_json) = row?;
6761 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6762 diagnostics.push(format!(
6763 "graph_nodes {id} properties_json is invalid: {err}"
6764 ));
6765 }
6766 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6767 diagnostics.push(format!(
6768 "graph_nodes {id} provenance_json is invalid: {err}"
6769 ));
6770 }
6771 if let Some(freshness_json) = freshness_json
6772 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6773 {
6774 diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
6775 }
6776 }
6777
6778 let mut edge_stmt = conn.prepare(
6779 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6780 )?;
6781 let edge_rows = edge_stmt.query_map([], |row| {
6782 Ok((
6783 row.get::<_, String>(0)?,
6784 row.get::<_, String>(1)?,
6785 row.get::<_, String>(2)?,
6786 row.get::<_, String>(3)?,
6787 row.get::<_, String>(4)?,
6788 row.get::<_, String>(5)?,
6789 row.get::<_, Option<String>>(6)?,
6790 ))
6791 })?;
6792 for row in edge_rows {
6793 let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
6794 row?;
6795 let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
6796 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6797 diagnostics.push(format!(
6798 "graph_edges {edge} properties_json is invalid: {err}"
6799 ));
6800 }
6801 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6802 diagnostics.push(format!(
6803 "graph_edges {edge} provenance_json is invalid: {err}"
6804 ));
6805 }
6806 if let Some(freshness_json) = freshness_json
6807 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6808 {
6809 diagnostics.push(format!(
6810 "graph_edges {edge} freshness_json is invalid: {err}"
6811 ));
6812 }
6813 }
6814 Ok(diagnostics)
6815}
6816
6817fn sqlite_graph_projection_metadata_diagnostics(
6818 conn: &Connection,
6819 scope: Option<&str>,
6820) -> Result<Vec<String>> {
6821 let mut diagnostics = Vec::new();
6822 let scope_key = scope.unwrap_or("root");
6823 let version = conn
6824 .query_row(
6825 r#"
6826 SELECT projection_version, content_hash, source_watermark
6827 FROM graph_projection_versions
6828 WHERE scope = ?1
6829 "#,
6830 [scope_key],
6831 |row| {
6832 Ok((
6833 row.get::<_, String>(0)?,
6834 row.get::<_, Option<String>>(1)?,
6835 row.get::<_, Option<String>>(2)?,
6836 ))
6837 },
6838 )
6839 .optional()?;
6840 let Some((projection_version, content_hash, _source_watermark)) = version else {
6841 diagnostics.push(format!(
6842 "graph projection metadata is missing for scope {scope_key}"
6843 ));
6844 return Ok(diagnostics);
6845 };
6846 if projection_version != GRAPH_PROJECTION_VERSION {
6847 diagnostics.push(format!(
6848 "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
6849 ));
6850 }
6851 if content_hash.is_none() {
6852 diagnostics.push("projection content hash is missing".to_string());
6853 }
6854
6855 let meta_id = graph_projection_meta_id(scope);
6856 let meta_properties = conn
6857 .query_row(
6858 "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
6859 (&meta_id, GRAPH_PROJECTION_META_KIND),
6860 |row| row.get::<_, String>(0),
6861 )
6862 .optional()?;
6863 let Some(meta_properties) = meta_properties else {
6864 diagnostics.push(format!("projection_meta node {meta_id} is missing"));
6865 return Ok(diagnostics);
6866 };
6867 let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
6868 .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
6869 if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
6870 diagnostics.push(format!(
6871 "projection_meta node {meta_id} has stale projection_version"
6872 ));
6873 }
6874 if properties.get("content_hash") != content_hash.as_ref() {
6875 diagnostics.push(format!(
6876 "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
6877 ));
6878 }
6879 Ok(diagnostics)
6880}
6881
6882pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
6883 let mut node_stmt = conn.prepare(
6884 "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6885 )?;
6886 let node_rows = node_stmt.query_map([], |row| {
6887 let properties_json: String = row.get(3)?;
6888 let provenance_json: String = row.get(4)?;
6889 let freshness_json: Option<String> = row.get(5)?;
6890 Ok((
6891 row.get::<_, String>(0)?,
6892 row.get::<_, String>(1)?,
6893 row.get::<_, String>(2)?,
6894 properties_json,
6895 provenance_json,
6896 freshness_json,
6897 ))
6898 })?;
6899 let mut nodes = Vec::new();
6900 for row in node_rows {
6901 let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
6902 nodes.push(ConvexNodeRow {
6903 external_id,
6904 kind,
6905 label,
6906 properties: serde_json::from_str(&properties_json)?,
6907 provenance: serde_json::from_str(&provenance_json)?,
6908 freshness: freshness_json
6909 .map(|value| serde_json::from_str(&value))
6910 .transpose()?,
6911 });
6912 }
6913
6914 let mut edge_stmt = conn.prepare(
6915 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6916 )?;
6917 let edge_rows = edge_stmt.query_map([], |row| {
6918 let properties_json: String = row.get(4)?;
6919 let provenance_json: String = row.get(5)?;
6920 let freshness_json: Option<String> = row.get(6)?;
6921 Ok((
6922 row.get::<_, String>(0)?,
6923 row.get::<_, String>(1)?,
6924 row.get::<_, String>(2)?,
6925 row.get::<_, String>(3)?,
6926 properties_json,
6927 provenance_json,
6928 freshness_json,
6929 ))
6930 })?;
6931 let mut edges = Vec::new();
6932 for row in edge_rows {
6933 let (
6934 edge_key,
6935 from_external_id,
6936 to_external_id,
6937 kind,
6938 properties_json,
6939 provenance_json,
6940 freshness_json,
6941 ) = row?;
6942 edges.push(ConvexEdgeRow {
6943 edge_key,
6944 from_external_id,
6945 to_external_id,
6946 kind,
6947 properties: serde_json::from_str(&properties_json)?,
6948 provenance: serde_json::from_str(&provenance_json)?,
6949 freshness: freshness_json
6950 .map(|value| serde_json::from_str(&value))
6951 .transpose()?,
6952 });
6953 }
6954 Ok(ConvexProjectionRows { nodes, edges })
6955}
6956
6957fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
6958 format!("{}.{}({})", index.table, index.name, index.fields.join(","))
6959}
6960
6961fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
6962 value
6963 .get("indexes")
6964 .or_else(|| value.get("requiredIndexes"))
6965 .or_else(|| {
6966 value
6967 .get("metadata")
6968 .and_then(|metadata| metadata.get("indexes"))
6969 })
6970}
6971
6972fn convex_snapshot_declared_indexes(
6973 value: &serde_json::Value,
6974) -> Result<Option<Vec<ConvexRequiredIndex>>> {
6975 convex_snapshot_index_value(value)
6976 .map(|indexes| {
6977 serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
6978 .context("parsing Convex snapshot index metadata")
6979 })
6980 .transpose()
6981}
6982
6983fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
6984 let required = convex_required_indexes();
6985 let Some(declared) = convex_snapshot_declared_indexes(value)? else {
6986 return Ok(vec![format!(
6987 "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
6988 required
6989 .iter()
6990 .map(convex_required_index_label)
6991 .collect::<Vec<_>>()
6992 .join(", ")
6993 )]);
6994 };
6995 let declared = declared.into_iter().collect::<BTreeSet<_>>();
6996 let missing = required
6997 .iter()
6998 .filter(|index| !declared.contains(*index))
6999 .map(convex_required_index_label)
7000 .collect::<Vec<_>>();
7001 if missing.is_empty() {
7002 Ok(Vec::new())
7003 } else {
7004 Ok(vec![format!(
7005 "Convex snapshot is missing required index metadata: {}",
7006 missing.join(", ")
7007 )])
7008 }
7009}
7010
7011pub(crate) fn load_convex_projection_snapshot_value(
7012 snapshot_path: &Path,
7013) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7014 let content = fs::read_to_string(snapshot_path).with_context(|| {
7015 format!(
7016 "reading Convex projection snapshot {}",
7017 snapshot_path.display()
7018 )
7019 })?;
7020 let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7021 format!(
7022 "parsing Convex projection snapshot {}",
7023 snapshot_path.display()
7024 )
7025 })?;
7026 let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7027 .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7028 Ok((rows, value))
7029}
7030
7031pub(crate) fn append_sqlite_graph_doctor_checks(
7032 report: &mut GraphDbDoctorReport,
7033 root: &Path,
7034 scope: Option<&str>,
7035 graph_db: &Path,
7036) -> Option<substrate::SqliteReadOnlyConnection> {
7037 let rebuild = graph_db_rebuild_command(root, scope);
7038 let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7039 if !graph_db.exists() {
7040 report.push_check(graph_db_doctor_check(
7041 "sqlite_graph_db_exists",
7042 vec![format!("graph.db is missing at {}", graph_db.display())],
7043 vec![rebuild],
7044 ));
7045 return None;
7046 }
7047 report.push_check(graph_db_doctor_check(
7048 "sqlite_graph_db_exists",
7049 Vec::new(),
7050 vec![rebuild.clone()],
7051 ));
7052
7053 let conn = match open_sqlite_graph_db_readonly(graph_db) {
7054 Ok(conn) => conn,
7055 Err(err) => {
7056 report.push_check(graph_db_doctor_check(
7057 "sqlite_graph_db_open",
7058 vec![err.to_string()],
7059 vec![backup_rebuild],
7060 ));
7061 return None;
7062 }
7063 };
7064 report.push_check(graph_db_doctor_check(
7065 "sqlite_graph_db_open",
7066 Vec::new(),
7067 vec![rebuild.clone()],
7068 ));
7069 if let Some(recovery) = conn.recovery() {
7070 report.push_check(GraphDbDoctorCheck {
7071 name: "sqlite_graph_db_read_recovery".to_string(),
7072 status: "recovered".to_string(),
7073 fail_closed: false,
7074 diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7075 repair_commands: Vec::new(),
7076 });
7077 }
7078
7079 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7080 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7081 report.push_check(graph_db_doctor_check(
7082 "sqlite_schema",
7083 schema_diagnostics,
7084 vec![backup_rebuild.clone()],
7085 ));
7086
7087 let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7088 .unwrap_or_else(|err| {
7089 vec![format!(
7090 "graph projection metadata inspection failed: {err}"
7091 )]
7092 });
7093 report.push_check(graph_db_doctor_check(
7094 "sqlite_projection_metadata",
7095 metadata_diagnostics,
7096 vec![rebuild.clone()],
7097 ));
7098
7099 let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7100 .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7101 report.push_check(graph_db_doctor_check(
7102 "sqlite_duplicate_ids",
7103 duplicate_diagnostics,
7104 vec![backup_rebuild.clone()],
7105 ));
7106
7107 let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7108 .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7109 report.push_check(graph_db_doctor_check(
7110 "sqlite_orphan_edges",
7111 orphan_diagnostics,
7112 vec![rebuild.clone()],
7113 ));
7114
7115 let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7116 .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7117 report.push_check(graph_db_doctor_check(
7118 "sqlite_row_json",
7119 json_diagnostics,
7120 vec![backup_rebuild],
7121 ));
7122
7123 let tombstone_diagnostics =
7124 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7125 .unwrap_or_else(|err| {
7126 vec![format!(
7127 "graph tombstone retention inspection failed: {err}"
7128 )]
7129 });
7130 report.push_check(GraphDbDoctorCheck {
7131 name: "sqlite_tombstone_retention".to_string(),
7132 status: if tombstone_diagnostics.is_empty() {
7133 "ok".to_string()
7134 } else {
7135 "warning".to_string()
7136 },
7137 fail_closed: false,
7138 diagnostics: tombstone_diagnostics,
7139 repair_commands: Vec::new(),
7140 });
7141 let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7142 Ok(counts) => {
7143 let policy = graph_db_compaction_policy(root, scope, &counts, false);
7144 GraphDbDoctorCheck {
7145 name: "sqlite_compaction_policy".to_string(),
7146 status: policy.status.clone(),
7147 fail_closed: false,
7148 diagnostics: policy.proof,
7149 repair_commands: if policy.status == "recommended" {
7150 policy.recommendations
7151 } else {
7152 Vec::new()
7153 },
7154 }
7155 }
7156 Err(err) => GraphDbDoctorCheck {
7157 name: "sqlite_compaction_policy".to_string(),
7158 status: "warning".to_string(),
7159 fail_closed: false,
7160 diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7161 repair_commands: Vec::new(),
7162 },
7163 };
7164 report.push_check(compaction_check);
7165
7166 Some(conn)
7167}
7168
7169pub(crate) fn append_convex_snapshot_doctor_checks(
7170 report: &mut GraphDbDoctorReport,
7171 root: &Path,
7172 scope: Option<&str>,
7173 local_rows: Option<&ConvexProjectionRows>,
7174 snapshot_path: Option<&Path>,
7175) {
7176 let repair = convex_refresh_command(root, scope);
7177 let Some(snapshot_path) = snapshot_path else {
7178 report.push_check(graph_db_doctor_check(
7179 "convex_snapshot_present",
7180 vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7181 vec![format!(
7182 "tsift convex-sync {}{} --json > convex-rows.json",
7183 shell_quote(root.to_string_lossy().as_ref()),
7184 graph_db_scope_arg(scope)
7185 )],
7186 ));
7187 return;
7188 };
7189 report.push_check(graph_db_doctor_check(
7190 "convex_snapshot_present",
7191 Vec::new(),
7192 vec![repair.clone()],
7193 ));
7194
7195 let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7196 Ok(snapshot) => snapshot,
7197 Err(err) => {
7198 report.push_check(graph_db_doctor_check(
7199 "convex_snapshot_parse",
7200 vec![err.to_string()],
7201 vec![repair],
7202 ));
7203 return;
7204 }
7205 };
7206 report.push_check(graph_db_doctor_check(
7207 "convex_snapshot_parse",
7208 Vec::new(),
7209 vec![repair.clone()],
7210 ));
7211
7212 let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7213 report.push_check(graph_db_doctor_check(
7214 "convex_snapshot_rows",
7215 row_diagnostics,
7216 vec![repair.clone()],
7217 ));
7218
7219 let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7220 .unwrap_or_else(|err| vec![err.to_string()]);
7221 report.required_indexes = convex_required_indexes();
7222 report.push_check(graph_db_doctor_check(
7223 "convex_required_indexes",
7224 index_diagnostics,
7225 vec![
7226 "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7227 .to_string(),
7228 ],
7229 ));
7230
7231 if let Some(local_rows) = local_rows {
7232 let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7233 report.push_check(graph_db_doctor_check(
7234 "convex_projection_freshness",
7235 freshness.diagnostics,
7236 vec![repair],
7237 ));
7238 } else {
7239 report.push_check(graph_db_doctor_check(
7240 "convex_projection_freshness",
7241 vec![
7242 "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7243 .to_string(),
7244 ],
7245 vec![graph_db_rebuild_command(root, scope)],
7246 ));
7247 }
7248}
7249
7250fn graph_db_convex_snapshot_doctor_command(
7251 root: &Path,
7252 scope: Option<&str>,
7253 snapshot_path: &Path,
7254) -> String {
7255 format!(
7256 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7257 shell_quote(root.to_string_lossy().as_ref()),
7258 graph_db_scope_arg(scope),
7259 shell_quote(snapshot_path.to_string_lossy().as_ref())
7260 )
7261}
7262
7263fn graph_db_convex_snapshot_read_command(
7264 root: &Path,
7265 scope: Option<&str>,
7266 snapshot_path: &Path,
7267) -> String {
7268 format!(
7269 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7270 shell_quote(root.to_string_lossy().as_ref()),
7271 graph_db_scope_arg(scope),
7272 shell_quote(snapshot_path.to_string_lossy().as_ref())
7273 )
7274}
7275
7276fn convex_sync_snapshot_diff_command(
7277 root: &Path,
7278 scope: Option<&str>,
7279 snapshot_path: &Path,
7280) -> String {
7281 format!(
7282 "tsift convex-sync {}{} --snapshot {} --json",
7283 shell_quote(root.to_string_lossy().as_ref()),
7284 graph_db_scope_arg(scope),
7285 shell_quote(snapshot_path.to_string_lossy().as_ref())
7286 )
7287}
7288
7289pub(crate) struct GraphDbDriftInput<'a> {
7290 root: &'a Path,
7291 scope: Option<&'a str>,
7292 graph_db: &'a Path,
7293 snapshot_path: &'a Path,
7294 local: &'a ConvexProjectionRows,
7295 snapshot: &'a ConvexProjectionRows,
7296 snapshot_value: &'a serde_json::Value,
7297 warnings: Vec<String>,
7298}
7299
7300pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7301 let GraphDbDriftInput {
7302 root,
7303 scope,
7304 graph_db,
7305 snapshot_path,
7306 local,
7307 snapshot,
7308 snapshot_value,
7309 warnings,
7310 } = input;
7311 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7312 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7313 convex_rows_diff(local, Some(snapshot));
7314 let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7315 let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7316 .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7317 let local_hash = freshness.local_hash.clone();
7318 let snapshot_hash = freshness.snapshot_hash.clone();
7319 let stale_nodes = freshness.stale_nodes.clone();
7320 let stale_edges = freshness.stale_edges.clone();
7321
7322 let duplicate_failures = row_diagnostics
7323 .iter()
7324 .filter(|diagnostic| diagnostic.contains("duplicate"))
7325 .count();
7326 let orphan_failures = row_diagnostics
7327 .iter()
7328 .filter(|diagnostic| diagnostic.contains("references missing"))
7329 .count();
7330 let missing_required_indexes = index_diagnostics.len();
7331 let stale_projection_metadata =
7332 usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7333 let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7334 let has_drift = freshness.fail_closed
7335 || !node_upserts.is_empty()
7336 || !edge_upserts.is_empty()
7337 || !node_tombstones.is_empty()
7338 || !edge_tombstones.is_empty();
7339 let status = if hard_failures > 0 {
7340 "fail_closed"
7341 } else if has_drift {
7342 "drift"
7343 } else {
7344 "current"
7345 }
7346 .to_string();
7347
7348 let mut diagnostics = Vec::new();
7349 diagnostics.extend(row_diagnostics);
7350 diagnostics.extend(index_diagnostics);
7351 diagnostics.extend(freshness.diagnostics.clone());
7352 if has_drift {
7353 diagnostics.push(format!(
7354 "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7355 node_upserts.len(),
7356 edge_upserts.len(),
7357 node_tombstones.len(),
7358 edge_tombstones.len()
7359 ));
7360 }
7361
7362 let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7363 root,
7364 scope,
7365 snapshot_path,
7366 )];
7367 if status == "current" {
7368 next_commands.push(graph_db_convex_snapshot_read_command(
7369 root,
7370 scope,
7371 snapshot_path,
7372 ));
7373 } else {
7374 next_commands.push(convex_sync_snapshot_diff_command(
7375 root,
7376 scope,
7377 snapshot_path,
7378 ));
7379 next_commands.push(convex_refresh_command(root, scope));
7380 }
7381
7382 GraphDbDriftReport {
7383 root: root.to_string_lossy().to_string(),
7384 scope: scope.map(str::to_string),
7385 graph_db: graph_db.to_string_lossy().to_string(),
7386 convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7387 status: status.clone(),
7388 graph_reads_allowed: status == "current",
7389 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7390 local_hash,
7391 snapshot_hash,
7392 summary: GraphDbDriftSummary {
7393 node_upserts: node_upserts.len(),
7394 edge_upserts: edge_upserts.len(),
7395 node_tombstones: node_tombstones.len(),
7396 edge_tombstones: edge_tombstones.len(),
7397 stale_nodes: stale_nodes.len(),
7398 stale_edges: stale_edges.len(),
7399 stale_projection_metadata,
7400 duplicate_failures,
7401 orphan_failures,
7402 missing_required_indexes,
7403 },
7404 node_upserts: node_upserts
7405 .into_iter()
7406 .map(|row| row.external_id)
7407 .collect(),
7408 edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
7409 node_tombstones,
7410 edge_tombstones,
7411 stale_nodes,
7412 stale_edges,
7413 diagnostics,
7414 next_commands,
7415 required_indexes: convex_required_indexes(),
7416 warnings,
7417 }
7418}
7419
7420pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
7421 println!(
7422 "graph-db drift status: {} reads_allowed: {}",
7423 report.status, report.graph_reads_allowed
7424 );
7425 println!("graph_db: {}", report.graph_db);
7426 println!("convex_snapshot: {}", report.convex_snapshot);
7427 println!(
7428 "upserts: {} node(s), {} edge(s)",
7429 report.summary.node_upserts, report.summary.edge_upserts
7430 );
7431 println!(
7432 "tombstones: {} node(s), {} edge(s)",
7433 report.summary.node_tombstones, report.summary.edge_tombstones
7434 );
7435 for diagnostic in &report.diagnostics {
7436 println!("diagnostic: {diagnostic}");
7437 }
7438 for command in &report.next_commands {
7439 println!("next: {command}");
7440 }
7441}
7442
7443pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
7444 println!(
7445 "graph-db doctor backend: {} status: {}",
7446 report.backend, report.status
7447 );
7448 println!("graph_db: {}", report.graph_db);
7449 if let Some(snapshot) = &report.convex_snapshot {
7450 println!("convex_snapshot: {snapshot}");
7451 }
7452 for check in &report.checks {
7453 println!("check: {} {}", check.name, check.status);
7454 for diagnostic in &check.diagnostics {
7455 println!(" diagnostic: {diagnostic}");
7456 }
7457 }
7458 for command in &report.repair_commands {
7459 println!("repair: {command}");
7460 }
7461}
7462
7463pub(crate) fn graph_db_operator_report_from_disk(
7464 root: &Path,
7465 scope: Option<&str>,
7466 graph_db: &Path,
7467 operation: &str,
7468 refresh: Option<GraphDbRefreshSummary>,
7469 warnings: Vec<String>,
7470) -> Result<GraphDbOperatorReport> {
7471 if !graph_db.exists() {
7472 let next_commands = graph_db_operator_next_commands(root, scope, true);
7473 let counts = GraphDbOperatorCounts {
7474 nodes: 0,
7475 edges: 0,
7476 tombstones: GraphDbTombstoneCounts {
7477 nodes: 0,
7478 edges: 0,
7479 total: 0,
7480 },
7481 file_size_bytes: None,
7482 freelist_bytes: None,
7483 };
7484 return Ok(GraphDbOperatorReport {
7485 root: root.to_string_lossy().to_string(),
7486 scope: scope.map(str::to_string),
7487 graph_db: graph_db.to_string_lossy().to_string(),
7488 operation: operation.to_string(),
7489 status: "missing".to_string(),
7490 materialized: false,
7491 freshness: GraphDbFreshnessReport {
7492 status: "missing".to_string(),
7493 fail_closed: true,
7494 projection_version: None,
7495 content_hash: None,
7496 source_watermark: None,
7497 diagnostics: vec![
7498 "graph.db is missing; run graph-db refresh before trusting graph reads"
7499 .to_string(),
7500 ],
7501 },
7502 readiness: graph_effectiveness_blocked(
7503 "graph_db_missing",
7504 vec![
7505 "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
7506 ],
7507 next_commands.clone(),
7508 ),
7509 counts: counts.clone(),
7510 refresh,
7511 compaction: graph_db_compaction_policy(root, scope, &counts, false),
7512 recovery: None,
7513 next_commands,
7514 warnings,
7515 });
7516 }
7517
7518 let conn = open_sqlite_graph_db_readonly(graph_db)?;
7519 let recovery = conn.recovery();
7520 let mut warnings = warnings;
7521 if let Some(recovery) = recovery {
7522 warnings.push(graph_db_read_recovery_diagnostic(recovery));
7523 }
7524 let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
7525 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7526 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7527 if !schema_diagnostics.is_empty() {
7528 freshness.diagnostics.extend(schema_diagnostics);
7529 freshness.fail_closed = true;
7530 freshness.status = "stale".to_string();
7531 }
7532 let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
7533 let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
7534 warnings.extend(
7535 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7536 .unwrap_or_else(|err| {
7537 vec![format!(
7538 "graph tombstone retention inspection failed: {err}"
7539 )]
7540 }),
7541 );
7542 let status = if freshness.fail_closed {
7543 "stale"
7544 } else {
7545 "current"
7546 }
7547 .to_string();
7548
7549 Ok(GraphDbOperatorReport {
7550 root: root.to_string_lossy().to_string(),
7551 scope: scope.map(str::to_string),
7552 graph_db: graph_db.to_string_lossy().to_string(),
7553 operation: operation.to_string(),
7554 status,
7555 materialized: true,
7556 freshness,
7557 readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
7558 compaction: graph_db_compaction_policy(root, scope, &counts, false),
7559 counts,
7560 refresh,
7561 recovery,
7562 next_commands: graph_db_operator_next_commands(root, scope, false),
7563 warnings,
7564 })
7565}
7566
7567fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
7568 println!(
7569 "graph-db {} status: {} materialized: {}",
7570 report.operation, report.status, report.materialized
7571 );
7572 println!("graph_db: {}", report.graph_db);
7573 println!(
7574 "projection: version={} hash={} watermark={}",
7575 report
7576 .freshness
7577 .projection_version
7578 .as_deref()
7579 .unwrap_or("<missing>"),
7580 report
7581 .freshness
7582 .content_hash
7583 .as_deref()
7584 .unwrap_or("<missing>"),
7585 report
7586 .freshness
7587 .source_watermark
7588 .as_deref()
7589 .unwrap_or("<missing>")
7590 );
7591 println!(
7592 "rows: {} node(s), {} edge(s), {} tombstone(s)",
7593 report.counts.nodes, report.counts.edges, report.counts.tombstones.total
7594 );
7595 println!(
7596 "readiness: {} reason: {} fail_closed: {}",
7597 report.readiness.status, report.readiness.reason, report.readiness.fail_closed
7598 );
7599 if let Some(file_size) = report.counts.file_size_bytes {
7600 println!(
7601 "storage: {} byte(s), {} free byte(s)",
7602 file_size,
7603 report.counts.freelist_bytes.unwrap_or(0)
7604 );
7605 }
7606 if let Some(refresh) = &report.refresh {
7607 println!(
7608 "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
7609 refresh.tombstoned_nodes, refresh.tombstoned_edges
7610 );
7611 println!(
7612 "delta: {} node upsert(s), {} edge upsert(s), {} property row upsert(s), {} unchanged node(s), {} unchanged edge(s), {} unchanged property row(s), {} deleted property row(s), {} pruned tombstone(s)",
7613 refresh.upserted_nodes,
7614 refresh.upserted_edges,
7615 refresh.upserted_properties,
7616 refresh.unchanged_nodes,
7617 refresh.unchanged_edges,
7618 refresh.unchanged_properties,
7619 refresh.deleted_properties,
7620 refresh.pruned_tombstones
7621 );
7622 }
7623 println!(
7624 "compaction: {} tombstone_scan_rows={} live_rows={}",
7625 report.compaction.status,
7626 report.compaction.tombstone_scan_rows,
7627 report.compaction.live_rows
7628 );
7629 for proof in &report.compaction.proof {
7630 println!("compaction proof: {proof}");
7631 }
7632 if let Some(recovery) = report.recovery {
7633 println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
7634 }
7635 for diagnostic in &report.freshness.diagnostics {
7636 println!("diagnostic: {diagnostic}");
7637 }
7638 for diagnostic in &report.readiness.diagnostics {
7639 println!("readiness diagnostic: {diagnostic}");
7640 }
7641 for warning in &report.warnings {
7642 println!("warning: {warning}");
7643 }
7644 for command in &report.readiness.next_commands {
7645 println!("readiness next: {command}");
7646 }
7647 for command in &report.next_commands {
7648 println!("next: {command}");
7649 }
7650}
7651
7652pub(crate) fn print_graph_db_operator_report(
7653 report: &GraphDbOperatorReport,
7654 format: OutputFormat,
7655) -> Result<()> {
7656 if format.json_output {
7657 print_json_or_envelope(
7658 report,
7659 &format,
7660 "graph-db",
7661 &report.operation,
7662 ToolEnvelopeSummary {
7663 text: format!(
7664 "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
7665 report.operation,
7666 report.status,
7667 report.counts.nodes,
7668 report.counts.edges,
7669 report.counts.tombstones.total
7670 ),
7671 metrics: vec![
7672 envelope_metric("operation", &report.operation),
7673 envelope_metric("status", &report.status),
7674 envelope_metric("nodes", report.counts.nodes),
7675 envelope_metric("edges", report.counts.edges),
7676 envelope_metric("tombstones", report.counts.tombstones.total),
7677 envelope_metric("compaction", &report.compaction.status),
7678 envelope_metric("readiness", &report.readiness.status),
7679 ],
7680 },
7681 false,
7682 report.next_commands.clone(),
7683 )
7684 } else {
7685 print_graph_db_operator_human(report);
7686 Ok(())
7687 }
7688}
7689
7690fn status_run_command_without_notes(run: &str) -> &str {
7691 run.split_once(" (")
7692 .map(|(command, _)| command)
7693 .unwrap_or(run)
7694}
7695
7696fn status_summarize_extract_command(run: &str) -> &str {
7697 let run = status_run_command_without_notes(run);
7698 run.split(" && ")
7699 .find(|command| command.contains("summarize --extract"))
7700 .unwrap_or(run)
7701}
7702
7703fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
7704 report
7705 .recommendations
7706 .run
7707 .as_deref()
7708 .filter(|command| command.contains("summarize --extract"))
7709 .map(status_summarize_extract_command)
7710 .unwrap_or("tsift summarize --extract .")
7711 .to_string()
7712}
7713
7714fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
7715 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7716 readiness.diagnostics.push(format!(
7717 "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
7718 ));
7719 readiness
7720}
7721
7722fn graph_db_semantic_readiness(
7723 root: &Path,
7724 scope: Option<&str>,
7725 semantic_row_count: Option<usize>,
7726) -> GraphEffectivenessReadiness {
7727 if let Some(row_count) = semantic_row_count
7728 && row_count > 0
7729 {
7730 return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
7731 }
7732
7733 let report = match status::check_status(root) {
7734 Ok(report) => report,
7735 Err(err) => {
7736 return graph_effectiveness_blocked(
7737 "status_check_unavailable",
7738 vec![format!(
7739 "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
7740 )],
7741 vec![graph_db_refresh_command(root, scope)],
7742 );
7743 }
7744 };
7745
7746 match &report.summaries {
7747 status::SummaryStatus::Available {
7748 cached_files,
7749 total_indexed_files,
7750 coverage_pct,
7751 ..
7752 } => {
7753 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7754 readiness.diagnostics.push(format!(
7755 "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
7756 ));
7757 readiness
7758 }
7759 status::SummaryStatus::None { .. } => {
7760 let summarize = graph_db_status_summarize_command(&report);
7761 let index_command = report
7762 .recommendations
7763 .run
7764 .as_deref()
7765 .filter(|cmd| cmd.contains("index"))
7766 .map(str::to_string);
7767 let mut repair = Vec::new();
7768 if let Some(cmd) = index_command {
7769 repair.push(cmd);
7770 }
7771 repair.push(summarize.clone());
7772 repair.push(graph_db_refresh_command(root, scope));
7773 graph_effectiveness_blocked(
7774 "summary_cache_empty",
7775 vec![format!(
7776 "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7777 summarize,
7778 root.display(),
7779 graph_db_refresh_command(root, scope)
7780 )],
7781 repair,
7782 )
7783 }
7784 status::SummaryStatus::Unavailable => {
7785 let mut repair: Vec<String> = report.recommendations.run.clone().into_iter().collect();
7786 let summarize = "tsift summarize --extract .".to_string();
7787 repair.push(summarize);
7788 repair.push(graph_db_refresh_command(root, scope));
7789 graph_effectiveness_blocked(
7790 "summary_cache_unavailable",
7791 vec![
7792 "summary cache unavailable because the source index is missing; build the index, extract summaries, and refresh the graph before relying on semantic graph evidence".to_string(),
7793 ],
7794 repair,
7795 )
7796 }
7797 }
7798}
7799
7800pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
7801 let report = match status::check_status(root) {
7802 Ok(report) => report,
7803 Err(err) => {
7804 return vec![format!(
7805 "status check unavailable after graph-db refresh: {err:#}"
7806 )];
7807 }
7808 };
7809
7810 let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7811 Some(graph_db_status_summarize_command(&report))
7812 } else {
7813 None
7814 };
7815 let mut warnings = report.reminders;
7816 if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7817 let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
7818 warnings.push(format!(
7819 "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7820 run,
7821 root.display(),
7822 graph_db_refresh_command(root, scope)
7823 ));
7824 }
7825 dedupe_preserve_order(warnings)
7826}
7827
7828pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
7829 println!(
7830 "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
7831 report.applied, report.pruned_tombstones, report.reclaimed_bytes
7832 );
7833 println!("graph_db: {}", report.graph_db);
7834 println!(
7835 "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7836 report.counts_before.nodes,
7837 report.counts_before.edges,
7838 report.counts_before.tombstones.total,
7839 report.counts_before.file_size_bytes.unwrap_or(0),
7840 report.counts_before.freelist_bytes.unwrap_or(0)
7841 );
7842 println!(
7843 "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7844 report.counts_after.nodes,
7845 report.counts_after.edges,
7846 report.counts_after.tombstones.total,
7847 report.counts_after.file_size_bytes.unwrap_or(0),
7848 report.counts_after.freelist_bytes.unwrap_or(0)
7849 );
7850 for proof in &report.compaction_after.proof {
7851 println!("proof: {proof}");
7852 }
7853 for warning in &report.warnings {
7854 println!("warning: {warning}");
7855 }
7856 for command in &report.next_commands {
7857 println!("next: {command}");
7858 }
7859}
7860
7861fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
7862 raw.iter()
7863 .map(|value| {
7864 let (key, filter_value) = value
7865 .split_once('=')
7866 .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
7867 let key = key.trim();
7868 let filter_value = filter_value.trim();
7869 if key.is_empty() || filter_value.is_empty() {
7870 bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
7871 }
7872 Ok(GraphDbPropertyFilter {
7873 key: key.to_string(),
7874 value: filter_value.to_string(),
7875 })
7876 })
7877 .collect()
7878}
7879
7880fn graph_db_query_options(
7881 cursor: Option<String>,
7882 limit: Option<usize>,
7883 property_filters: &[String],
7884) -> Result<GraphDbQueryOptions> {
7885 Ok(GraphDbQueryOptions {
7886 cursor,
7887 limit: limit.filter(|limit| *limit > 0),
7888 property_filters: parse_graph_db_property_filters(property_filters)?,
7889 })
7890}
7891
7892fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
7893 GraphQueryOptions {
7894 cursor: options.cursor.clone(),
7895 limit: options.limit,
7896 property_filters: options
7897 .property_filters
7898 .iter()
7899 .map(|filter| GraphPropertyFilter {
7900 key: filter.key.clone(),
7901 value: filter.value.clone(),
7902 })
7903 .collect(),
7904 }
7905}
7906
7907fn graph_db_page_report_from_store(
7908 page: GraphQueryPage,
7909 property_filters: Vec<GraphDbPropertyFilter>,
7910) -> GraphDbPageReport {
7911 GraphDbPageReport {
7912 cursor: page.cursor,
7913 limit: page.limit,
7914 next_cursor: page.next_cursor,
7915 returned_nodes: page.returned_nodes,
7916 returned_edges: page.returned_edges,
7917 truncated: page.truncated,
7918 property_filters,
7919 diagnostics: page.diagnostics,
7920 }
7921}
7922
7923fn graph_db_neighborhood_ranking_gate(
7924 ranked_neighbor_cap: usize,
7925) -> GraphDbNeighborhoodRankingGate {
7926 GraphDbNeighborhoodRankingGate {
7927 status: "held_default_order_unchanged".to_string(),
7928 ranked_output_default: false,
7929 default_order: "stable_node_id".to_string(),
7930 default_change_gate: "community_search_quality_metrics".to_string(),
7931 required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
7932 .iter()
7933 .map(|workload| (*workload).to_string())
7934 .collect(),
7935 required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
7936 .iter()
7937 .map(|metric| (*metric).to_string())
7938 .collect(),
7939 max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
7940 min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
7941 min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
7942 min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
7943 diagnostics: vec![
7944 "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
7945 format!(
7946 "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
7947 ),
7948 "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
7949 ],
7950 }
7951}
7952
7953fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
7954 match limit {
7955 Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
7956 Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
7957 }
7958}
7959
7960fn graph_db_ranked_neighbors(
7961 center_id: &str,
7962 nodes: &[SubstrateGraphNode],
7963 edges: &[SubstrateGraphEdge],
7964 cap: usize,
7965) -> Vec<GraphDbRankedNeighbor> {
7966 resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
7967}
7968
7969fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
7970 center_id: &str,
7971 depth: usize,
7972 edge_kind: Option<&str>,
7973 limit: Option<usize>,
7974 unranked_nodes: &[SubstrateGraphNode],
7975 unranked_edges: &[SubstrateGraphEdge],
7976 store: &S,
7977) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
7978 use std::time::Instant;
7979 let max_nodes = match limit {
7980 Some(0) | None => 200,
7981 Some(n) => n.clamp(10, 500),
7982 };
7983 let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
7984 .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
7985 if let Some(kind) = edge_kind {
7986 options = options.with_edge_kind(kind);
7987 }
7988 let start = Instant::now();
7989 let result = store.ranked_neighborhood(center_id, &options)?;
7990 let latency = start.elapsed().as_micros();
7991 let Some(ranked) = result else {
7992 return Ok(None);
7993 };
7994 let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
7995 let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
7996 let overlap_count = ranked_ids.intersection(&unranked_ids).count();
7997 let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
7998 0.0
7999 } else {
8000 (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8001 };
8002 let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8003 let mut name_count = BTreeMap::<&str, usize>::new();
8004 for n in nodes {
8005 *name_count.entry(&n.label).or_default() += 1;
8006 }
8007 name_count.values().filter(|&&c| c > 1).count()
8008 };
8009 let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8010 if nodes.is_empty() {
8011 return 100.0;
8012 }
8013 let with_handle = nodes
8014 .iter()
8015 .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8016 .count();
8017 (with_handle as f64 / nodes.len() as f64) * 100.0
8018 };
8019 let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8020 if nodes.is_empty() {
8021 return 0.0;
8022 }
8023 let semantic_kinds = [
8024 "semantic_concept",
8025 "semantic_entity",
8026 "symbol",
8027 "file",
8028 "source_handle",
8029 ];
8030 let useful = nodes
8031 .iter()
8032 .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8033 .count();
8034 let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8035 let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8036 (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8037 / nodes.len() as f64
8038 };
8039 let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8040 let edge_pairs: Vec<(String, String)> = ranked
8041 .edges
8042 .iter()
8043 .map(|e| (e.from_id.clone(), e.to_id.clone()))
8044 .collect();
8045 let cr = tsift_graph::detect_communities(&edge_pairs);
8046 let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8047 let mut fully_kept = 0usize;
8048 let mut partially_pruned = 0usize;
8049 let mut fully_pruned = 0usize;
8050 let mut pruned_kinds = BTreeSet::new();
8051 let mut pruned_labels = Vec::new();
8052 for comm in &cr.communities {
8053 let kept_in_comm: Vec<&str> = comm
8054 .members
8055 .iter()
8056 .filter(|m| kept_labels.contains(m.name.as_str()))
8057 .map(|m| m.name.as_str())
8058 .collect();
8059 if kept_in_comm.len() == comm.members.len() {
8060 fully_kept += 1;
8061 } else if kept_in_comm.is_empty() {
8062 fully_pruned += 1;
8063 for m in &comm.members {
8064 if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8065 pruned_kinds.insert(n.kind.clone());
8066 }
8067 pruned_labels.push(m.name.clone());
8068 }
8069 } else {
8070 partially_pruned += 1;
8071 }
8072 }
8073 pruned_labels.truncate(5);
8074 Some(CommunityTruncationSummary {
8075 total_communities: cr.communities.len(),
8076 fully_kept,
8077 partially_pruned,
8078 fully_pruned,
8079 pruned_community_kinds: pruned_kinds.into_iter().collect(),
8080 pruned_community_top_labels: pruned_labels,
8081 })
8082 } else {
8083 None
8084 };
8085 Ok(Some(GraphDbRankedNeighborhoodComparison {
8086 traversal_nodes: ranked.nodes.len(),
8087 traversal_edges: ranked.edges.len(),
8088 pruned_count: ranked.pruned_count,
8089 total_discovered: ranked.total_discovered,
8090 latency_micros: latency,
8091 overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8092 useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8093 / 1000.0,
8094 useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8095 .round()
8096 / 1000.0,
8097 duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8098 duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8099 handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8100 handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8101 / 100.0,
8102 community_truncation_summary,
8103 diagnostics: vec![
8104 format!(
8105 "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8106 ranked.nodes.len(),
8107 ranked.edges.len(),
8108 ranked.pruned_count,
8109 ranked.total_discovered,
8110 latency
8111 ),
8112 format!(
8113 "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8114 overlap_pct,
8115 overlap_count,
8116 unranked_ids.len(),
8117 ranked_ids.len()
8118 ),
8119 "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8120 ],
8121 }))
8122}
8123
8124struct GraphDbBudgetedSubgraph {
8125 nodes: Vec<SubstrateGraphNode>,
8126 edges: Vec<SubstrateGraphEdge>,
8127 report: GraphDbOutputBudgetReport,
8128 truncated: bool,
8129 next_cursor: Option<String>,
8130}
8131
8132const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8133const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8134const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8135
8136fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8137 match limit {
8138 Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8139 Some(limit) => limit
8140 .saturating_mul(320)
8141 .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8142 }
8143}
8144
8145fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8146 if matches!(limit, Some(0) | None) {
8147 return match kind {
8148 "source_handle" => 10,
8149 "worker_context" | "worker_result" => 8,
8150 "semantic_concept" | "semantic_entity" => 10,
8151 "file" | "symbol" | "route" => 12,
8152 _ => 8,
8153 };
8154 }
8155 let base = limit.unwrap_or(0).max(1);
8156 match kind {
8157 "source_handle" => base.saturating_add(4),
8158 "worker_context" | "worker_result" => base.saturating_add(2),
8159 "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8160 "file" | "symbol" | "route" => base.saturating_add(4),
8161 _ => base.saturating_add(1),
8162 }
8163}
8164
8165fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8166 if matches!(limit, Some(0) | None) {
8167 return match kind {
8168 "mentions" | "mentions_concept" | "mentions_entity" => 24,
8169 "semantic_relation" | "calls" | "defines" => 20,
8170 _ => 16,
8171 };
8172 }
8173 let base = limit.unwrap_or(0).max(1);
8174 match kind {
8175 "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8176 "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8177 _ => base.saturating_add(2),
8178 }
8179}
8180
8181fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8182 serde_json::to_vec(value)
8183 .map(|bytes| bytes.len().div_ceil(4).max(1))
8184 .unwrap_or(1)
8185}
8186
8187fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8188 let mut parts = vec![node.kind.clone(), node.label.clone()];
8189 for key in [
8190 "detail",
8191 "description",
8192 "source_ref",
8193 "path",
8194 "source_file",
8195 "source_symbol",
8196 "text_preview",
8197 ] {
8198 if let Some(value) = node.properties.get(key) {
8199 parts.push(value.clone());
8200 }
8201 }
8202 parts.join(" ")
8203}
8204
8205fn graph_db_semantic_scores_for_query(
8206 query: Option<&str>,
8207 nodes: &[SubstrateGraphNode],
8208) -> BTreeMap<String, f64> {
8209 let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8210 return BTreeMap::new();
8211 };
8212 let query_embedding = semantic_embedding(query);
8213 nodes
8214 .iter()
8215 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8216 .filter_map(|node| {
8217 let embedding = node
8218 .properties
8219 .get("embedding")
8220 .and_then(|value| parse_semantic_embedding_property(value))?;
8221 Some((
8222 node.id.clone(),
8223 semantic_cosine(&query_embedding, &embedding),
8224 ))
8225 })
8226 .collect()
8227}
8228
8229fn graph_db_depth_by_id(
8230 origin_ids: &[String],
8231 edges: &[SubstrateGraphEdge],
8232) -> BTreeMap<String, usize> {
8233 let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8234 for edge in edges {
8235 adjacency
8236 .entry(edge.from_id.clone())
8237 .or_default()
8238 .push(edge.to_id.clone());
8239 adjacency
8240 .entry(edge.to_id.clone())
8241 .or_default()
8242 .push(edge.from_id.clone());
8243 }
8244
8245 let mut depth_by_id = BTreeMap::<String, usize>::new();
8246 let mut queue = VecDeque::<String>::new();
8247 for origin in origin_ids {
8248 if depth_by_id.insert(origin.clone(), 0).is_none() {
8249 queue.push_back(origin.clone());
8250 }
8251 }
8252 while let Some(current) = queue.pop_front() {
8253 let depth = depth_by_id.get(¤t).copied().unwrap_or(0);
8254 for next in adjacency.get(¤t).into_iter().flatten() {
8255 if depth_by_id.contains_key(next) {
8256 continue;
8257 }
8258 depth_by_id.insert(next.clone(), depth.saturating_add(1));
8259 queue.push_back(next.clone());
8260 }
8261 }
8262 depth_by_id
8263}
8264
8265fn graph_db_source_covered_ids(
8266 nodes: &[SubstrateGraphNode],
8267 edges: &[SubstrateGraphEdge],
8268) -> BTreeSet<String> {
8269 let source_ids = nodes
8270 .iter()
8271 .filter(|node| node.kind == "source_handle")
8272 .map(|node| node.id.as_str())
8273 .collect::<BTreeSet<_>>();
8274 let mut covered = source_ids
8275 .iter()
8276 .map(|id| (*id).to_string())
8277 .collect::<BTreeSet<_>>();
8278 for edge in edges {
8279 if source_ids.contains(edge.from_id.as_str()) {
8280 covered.insert(edge.to_id.clone());
8281 }
8282 if source_ids.contains(edge.to_id.as_str()) {
8283 covered.insert(edge.from_id.clone());
8284 }
8285 }
8286 covered
8287}
8288
8289fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8290 for key in [
8291 "observed_at_unix",
8292 "completed_at_unix",
8293 "created_at_unix",
8294 "started_at_unix",
8295 ] {
8296 if let Some(value) = node.properties.get(key)
8297 && let Ok(epoch) = value.parse::<i64>()
8298 {
8299 return epoch.div_euclid(86_400).clamp(0, 40_000);
8300 }
8301 }
8302 0
8303}
8304
8305fn graph_db_node_kind_score(kind: &str) -> i64 {
8306 match kind {
8307 "source_handle" => 180,
8308 "worker_context" => 170,
8309 "worker_result" => 160,
8310 "semantic_concept" | "semantic_entity" => 150,
8311 "backlog" | "job_packet" => 130,
8312 "symbol" => 120,
8313 "file" => 110,
8314 "route" => 105,
8315 "session" => 90,
8316 _ => 40,
8317 }
8318}
8319
8320fn graph_db_edge_kind_score(kind: &str) -> i64 {
8321 match kind {
8322 "mentions_concept" | "mentions_entity" => 180,
8323 "semantic_relation" => 170,
8324 "mentions" => 165,
8325 "requests_context" | "scopes_context" | "scopes_source" => 155,
8326 "explains_result" => 150,
8327 "calls" => 145,
8328 "defines" | "handled_by" | "defines_route" => 130,
8329 "contains" | "targets" => 120,
8330 "records_memory_source" | "has_vector_handle" => 115,
8331 _ => 40,
8332 }
8333}
8334
8335fn graph_db_node_usefulness_score(
8336 node: &SubstrateGraphNode,
8337 depth_by_id: &BTreeMap<String, usize>,
8338 semantic_scores: &BTreeMap<String, f64>,
8339 source_covered_ids: &BTreeSet<String>,
8340 origin_ids: &[String],
8341) -> i64 {
8342 if origin_ids.iter().any(|origin| origin == &node.id) {
8343 return 1_000_000;
8344 }
8345 let semantic = semantic_scores
8346 .get(&node.id)
8347 .map(|score| (score.max(0.0) * 1_000.0) as i64)
8348 .unwrap_or(0);
8349 let depth_penalty = depth_by_id
8350 .get(&node.id)
8351 .map(|depth| (*depth as i64).saturating_mul(55))
8352 .unwrap_or(180);
8353 let source_coverage = if source_covered_ids.contains(&node.id)
8354 || node.properties.contains_key("source_ref")
8355 || node.properties.contains_key("path")
8356 {
8357 120
8358 } else {
8359 0
8360 };
8361 graph_db_node_kind_score(&node.kind)
8362 + semantic
8363 + source_coverage
8364 + graph_db_recency_score(node).min(80)
8365 - depth_penalty
8366}
8367
8368fn graph_db_edge_usefulness_score(
8369 edge: &SubstrateGraphEdge,
8370 node_score_by_id: &BTreeMap<String, i64>,
8371 depth_by_id: &BTreeMap<String, usize>,
8372) -> i64 {
8373 let endpoint_score = node_score_by_id
8374 .get(&edge.from_id)
8375 .copied()
8376 .unwrap_or_default()
8377 .max(
8378 node_score_by_id
8379 .get(&edge.to_id)
8380 .copied()
8381 .unwrap_or_default(),
8382 );
8383 let depth_penalty = depth_by_id
8384 .get(&edge.from_id)
8385 .into_iter()
8386 .chain(depth_by_id.get(&edge.to_id))
8387 .min()
8388 .map(|depth| (*depth as i64).saturating_mul(35))
8389 .unwrap_or(140);
8390 graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8391}
8392
8393fn graph_db_push_drop(
8394 drops: &mut BTreeMap<(String, String, String), usize>,
8395 item: &str,
8396 kind: &str,
8397 reason: &str,
8398) {
8399 *drops
8400 .entry((item.to_string(), kind.to_string(), reason.to_string()))
8401 .or_default() += 1;
8402}
8403
8404fn graph_db_budget_drop_report(
8405 drops: BTreeMap<(String, String, String), usize>,
8406) -> Vec<GraphDbDroppedByBudget> {
8407 drops
8408 .into_iter()
8409 .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
8410 item,
8411 kind,
8412 reason,
8413 dropped,
8414 })
8415 .collect()
8416}
8417
8418fn graph_db_apply_output_budget(
8419 origin_ids: &[String],
8420 semantic_scores: &BTreeMap<String, f64>,
8421 nodes: Vec<SubstrateGraphNode>,
8422 edges: Vec<SubstrateGraphEdge>,
8423 limit: Option<usize>,
8424) -> GraphDbBudgetedSubgraph {
8425 graph_db_apply_output_budget_with_depths_and_cursor(
8426 origin_ids,
8427 semantic_scores,
8428 nodes,
8429 edges,
8430 limit,
8431 None,
8432 None,
8433 )
8434}
8435
8436fn graph_db_apply_output_budget_with_depths_and_cursor(
8437 origin_ids: &[String],
8438 semantic_scores: &BTreeMap<String, f64>,
8439 nodes: Vec<SubstrateGraphNode>,
8440 edges: Vec<SubstrateGraphEdge>,
8441 limit: Option<usize>,
8442 depth_overrides: Option<&BTreeMap<String, usize>>,
8443 cursor: Option<&str>,
8444) -> GraphDbBudgetedSubgraph {
8445 let max_tokens = graph_db_output_token_cap(limit);
8446 let candidate_nodes = nodes.len();
8447 let candidate_edges = edges.len();
8448 let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
8449 if let Some(depth_overrides) = depth_overrides {
8450 for (id, depth) in depth_overrides {
8451 depth_by_id
8452 .entry(id.clone())
8453 .and_modify(|current| *current = (*current).min(*depth))
8454 .or_insert(*depth);
8455 }
8456 }
8457 let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
8458 let node_score_by_id = nodes
8459 .iter()
8460 .map(|node| {
8461 (
8462 node.id.clone(),
8463 graph_db_node_usefulness_score(
8464 node,
8465 &depth_by_id,
8466 semantic_scores,
8467 &source_covered_ids,
8468 origin_ids,
8469 ),
8470 )
8471 })
8472 .collect::<BTreeMap<_, _>>();
8473
8474 let mut node_candidates = nodes.iter().collect::<Vec<_>>();
8475 node_candidates.sort_by(|left, right| {
8476 node_score_by_id
8477 .get(&right.id)
8478 .cmp(&node_score_by_id.get(&left.id))
8479 .then_with(|| left.kind.cmp(&right.kind))
8480 .then_with(|| left.label.cmp(&right.label))
8481 .then_with(|| left.id.cmp(&right.id))
8482 });
8483
8484 let cursor_skip = if let Some(cursor) = cursor {
8485 node_candidates
8486 .iter()
8487 .position(|node| node.id == cursor)
8488 .map(|pos| pos.saturating_add(1))
8489 .unwrap_or(0)
8490 } else {
8491 0
8492 };
8493 if cursor_skip > 0 {
8494 node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
8495 }
8496
8497 let mut selected_node_ids = BTreeSet::new();
8498 let mut selected_node_counts = BTreeMap::<String, usize>::new();
8499 let mut estimated_tokens = 0usize;
8500 let mut drops = BTreeMap::<(String, String, String), usize>::new();
8501 for node in &node_candidates {
8502 let kind_count = selected_node_counts
8503 .get(&node.kind)
8504 .copied()
8505 .unwrap_or_default();
8506 if !origin_ids.iter().any(|origin| origin == &node.id)
8507 && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
8508 {
8509 graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
8510 continue;
8511 }
8512 let tokens = graph_db_estimated_tokens(node);
8513 if !origin_ids.iter().any(|origin| origin == &node.id)
8514 && estimated_tokens.saturating_add(tokens) > max_tokens
8515 {
8516 graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
8517 continue;
8518 }
8519 selected_node_ids.insert(node.id.clone());
8520 *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
8521 estimated_tokens = estimated_tokens.saturating_add(tokens);
8522 }
8523
8524 let has_remaining_candidates = node_candidates
8525 .iter()
8526 .any(|node| !selected_node_ids.contains(&node.id));
8527
8528 let mut selected_nodes = nodes
8529 .into_iter()
8530 .filter(|node| selected_node_ids.contains(&node.id))
8531 .collect::<Vec<_>>();
8532
8533 let mut edge_candidates = edges
8534 .iter()
8535 .filter(|edge| {
8536 selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
8537 })
8538 .collect::<Vec<_>>();
8539 let edge_score_by_key = edge_candidates
8540 .iter()
8541 .map(|edge| {
8542 (
8543 graph_db_edge_key(edge),
8544 graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
8545 )
8546 })
8547 .collect::<BTreeMap<_, _>>();
8548 edge_candidates.sort_by(|left, right| {
8549 edge_score_by_key
8550 .get(&graph_db_edge_key(right))
8551 .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
8552 .then_with(|| left.kind.cmp(&right.kind))
8553 .then_with(|| left.from_id.cmp(&right.from_id))
8554 .then_with(|| left.to_id.cmp(&right.to_id))
8555 });
8556
8557 let endpoint_dropped_edges = edges
8558 .iter()
8559 .filter(|edge| {
8560 !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
8561 })
8562 .count();
8563 if endpoint_dropped_edges > 0 {
8564 drops.insert(
8565 (
8566 "edge".to_string(),
8567 "*".to_string(),
8568 "endpoint_node_dropped".to_string(),
8569 ),
8570 endpoint_dropped_edges,
8571 );
8572 }
8573
8574 let mut selected_edge_ids = BTreeSet::new();
8575 let mut selected_edge_counts = BTreeMap::<String, usize>::new();
8576 for edge in edge_candidates {
8577 let kind_count = selected_edge_counts
8578 .get(&edge.kind)
8579 .copied()
8580 .unwrap_or_default();
8581 if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
8582 graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
8583 continue;
8584 }
8585 let tokens = graph_db_estimated_tokens(edge);
8586 if estimated_tokens.saturating_add(tokens) > max_tokens {
8587 graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
8588 continue;
8589 }
8590 selected_edge_ids.insert(graph_db_edge_key(edge));
8591 *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
8592 estimated_tokens = estimated_tokens.saturating_add(tokens);
8593 }
8594
8595 let selected_edges = edges
8596 .into_iter()
8597 .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
8598 .collect::<Vec<_>>();
8599 let dropped_by_budget = graph_db_budget_drop_report(drops);
8600 let truncated = has_remaining_candidates;
8601 let next_cursor = if truncated {
8602 selected_nodes.last().map(|node| node.id.clone())
8603 } else {
8604 None
8605 };
8606 let mut diagnostics = vec![
8607 "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
8608 .to_string(),
8609 format!(
8610 "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
8611 selected_nodes.len(),
8612 candidate_nodes,
8613 selected_edges.len(),
8614 candidate_edges,
8615 max_tokens
8616 ),
8617 ];
8618 if cursor.is_some() {
8619 diagnostics.push(format!(
8620 "cursor skipped {} previously returned candidate(s)",
8621 cursor_skip
8622 ));
8623 }
8624 if next_cursor.is_some() {
8625 diagnostics.push(
8626 "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
8627 );
8628 }
8629 selected_nodes.shrink_to_fit();
8630
8631 GraphDbBudgetedSubgraph {
8632 nodes: selected_nodes,
8633 edges: selected_edges,
8634 report: GraphDbOutputBudgetReport {
8635 max_tokens,
8636 estimated_tokens,
8637 selected_nodes: selected_node_ids.len(),
8638 selected_edges: selected_edge_ids.len(),
8639 candidate_nodes,
8640 candidate_edges,
8641 dropped_by_budget,
8642 diagnostics,
8643 },
8644 truncated,
8645 next_cursor,
8646 }
8647}
8648
8649fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
8650 if edge.id.is_empty() {
8651 substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
8652 } else {
8653 edge.id.clone()
8654 }
8655}
8656
8657fn graph_db_schema() -> GraphDbSchema {
8658 GraphDbSchema {
8659 contract_versions: vec![
8660 GraphDbSchemaContract {
8661 name: "graph_db_evidence",
8662 version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
8663 description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
8664 },
8665 GraphDbSchemaContract {
8666 name: "worker_prompt_packet",
8667 version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
8668 description: "conflict-matrix worker prompt packet with owned scope, scheduler fields, stable graph handles, expected tests, expansion commands, token budget, semantic ranking reasons, worker feedback closure controls, and fail-closed prompt text",
8669 },
8670 GraphDbSchemaContract {
8671 name: "conflict_matrix",
8672 version: CONFLICT_MATRIX_CONTRACT_VERSION,
8673 description: "parallel-dispatch decision report keyed by graph evidence packets, scheduler block fields, hard file/symbol/test/config gates, and soft worker-feedback closure ranking",
8674 },
8675 GraphDbSchemaContract {
8676 name: "context_pack_graph_orchestration",
8677 version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
8678 description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
8679 },
8680 GraphDbSchemaContract {
8681 name: "session_review_follow_up",
8682 version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
8683 description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
8684 },
8685 GraphDbSchemaContract {
8686 name: "dispatch_trace",
8687 version: DISPATCH_TRACE_CONTRACT_VERSION,
8688 description: "operator review trace linking backlog, job packets, worker results, source handles, semantic rows, scheduler fields, evidence packet ids, worker feedback closure controls, and worker prompt packets",
8689 },
8690 GraphDbSchemaContract {
8691 name: "dependency_dag",
8692 version: DEPENDENCY_DAG_CONTRACT_VERSION,
8693 description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
8694 },
8695 ],
8696 node_fields: vec![
8697 GraphDbSchemaField {
8698 name: "id",
8699 value_type: "string",
8700 description: "Stable provider-neutral node id",
8701 },
8702 GraphDbSchemaField {
8703 name: "kind",
8704 value_type: "string",
8705 description: "Application-defined node family such as file, symbol, or backlog",
8706 },
8707 GraphDbSchemaField {
8708 name: "label",
8709 value_type: "string",
8710 description: "Human-readable label",
8711 },
8712 GraphDbSchemaField {
8713 name: "properties",
8714 value_type: "object<string,string>",
8715 description: "Adapter-specific string properties",
8716 },
8717 GraphDbSchemaField {
8718 name: "provenance",
8719 value_type: "array",
8720 description: "Source system and source reference metadata",
8721 },
8722 GraphDbSchemaField {
8723 name: "freshness",
8724 value_type: "object|null",
8725 description: "Optional content hash and observed timestamp",
8726 },
8727 ],
8728 edge_fields: vec![
8729 GraphDbSchemaField {
8730 name: "id",
8731 value_type: "string",
8732 description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
8733 },
8734 GraphDbSchemaField {
8735 name: "from_id",
8736 value_type: "string",
8737 description: "Source node id",
8738 },
8739 GraphDbSchemaField {
8740 name: "to_id",
8741 value_type: "string",
8742 description: "Target node id",
8743 },
8744 GraphDbSchemaField {
8745 name: "kind",
8746 value_type: "string",
8747 description: "Application-defined edge relation",
8748 },
8749 GraphDbSchemaField {
8750 name: "properties",
8751 value_type: "object<string,string>",
8752 description: "Adapter-specific string properties",
8753 },
8754 GraphDbSchemaField {
8755 name: "provenance",
8756 value_type: "array",
8757 description: "Source system and source reference metadata",
8758 },
8759 GraphDbSchemaField {
8760 name: "freshness",
8761 value_type: "object|null",
8762 description: "Optional content hash and observed timestamp",
8763 },
8764 ],
8765 operations: vec![
8766 GraphDbSchemaOperation {
8767 command: "refresh",
8768 description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
8769 },
8770 GraphDbSchemaOperation {
8771 command: "status",
8772 description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
8773 },
8774 GraphDbSchemaOperation {
8775 command: "doctor",
8776 description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
8777 },
8778 GraphDbSchemaOperation {
8779 command: "drift",
8780 description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
8781 },
8782 GraphDbSchemaOperation {
8783 command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
8784 description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
8785 },
8786 GraphDbSchemaOperation {
8787 command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
8788 description: "Benchmark experimental read-only GraphStore backend prototypes against SQLite on bounded real, optional full-project, and synthetic projections across refresh/status/path tiers/evidence/conflict-matrix/dispatch-trace and emit promotion hold/eligibility gates",
8789 },
8790 GraphDbSchemaOperation {
8791 command: "evidence <target> [--depth N] [--limit N]",
8792 description: "Return a bounded versioned graph-db handoff packet for a backlog id or job packet handle, including packet_id, projection hash, worker_context rows, source_handle rows, worker_result rows, semantic_concept/entity rows, shortest paths, replay commands, repair commands, and next commands",
8793 },
8794 GraphDbSchemaOperation {
8795 command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
8796 description: "Resolve a natural-language phrase to cached semantic concept/entity seed nodes, then return an incident/outgoing GraphStore neighborhood around those seeds for general knowledge retrieval without changing stable neighborhood pagination defaults",
8797 },
8798 GraphDbSchemaOperation {
8799 command: "dispatch-trace [target...] --path <session> [--format json|html]",
8800 description: "Export a compact graph-backed dispatch trace with evidence packet ids, worker-result feedback closure summaries, graph links, and conflict-matrix worker prompt packets",
8801 },
8802 GraphDbSchemaOperation {
8803 command: "dependency-dag [target...] --path <session>",
8804 description: "Extract a versioned agent-doc dependency DAG from backlog ids, explicit depends-on text, shared file/symbol/test/config evidence, semantic overlap, and worker-result follow-up ids",
8805 },
8806 GraphDbSchemaOperation {
8807 command: "schema",
8808 description: "Return record and operation schemas",
8809 },
8810 GraphDbSchemaOperation {
8811 command: "node <id>",
8812 description: "Return one node by stable id",
8813 },
8814 GraphDbSchemaOperation {
8815 command: "edge <id>",
8816 description: "Return one edge by stable edge id",
8817 },
8818 GraphDbSchemaOperation {
8819 command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8820 description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
8821 },
8822 GraphDbSchemaOperation {
8823 command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8824 description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
8825 },
8826 GraphDbSchemaOperation {
8827 command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
8828 description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
8829 },
8830 GraphDbSchemaOperation {
8831 command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
8832 description: "Return a directed outgoing subgraph around a node using batched SQLite recursive traversal plus pushed filters/paging when available; JSON also includes additive ranked_neighbors while default nodes remain stable-id ordered",
8833 },
8834 GraphDbSchemaOperation {
8835 command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
8836 description: "Return the shortest directed path by node id, optionally bounded by hop count",
8837 },
8838 ],
8839 }
8840}
8841
8842pub(crate) fn sqlite_graph_freshness(
8843 store: &SqliteGraphStore,
8844 scope: &str,
8845) -> Result<GraphDbFreshnessReport> {
8846 let version = store.projection_version(scope)?;
8847 let Some(version) = version else {
8848 return Ok(GraphDbFreshnessReport {
8849 status: "missing".to_string(),
8850 fail_closed: true,
8851 projection_version: None,
8852 content_hash: None,
8853 source_watermark: None,
8854 diagnostics: vec![
8855 "graph projection metadata is missing; rebuild the graph before trusting reads"
8856 .to_string(),
8857 ],
8858 });
8859 };
8860 let mut diagnostics = Vec::new();
8861 let fail_closed =
8862 version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
8863 if version.projection_version != GRAPH_PROJECTION_VERSION {
8864 diagnostics.push(format!(
8865 "projection version mismatch: expected {} got {}",
8866 GRAPH_PROJECTION_VERSION, version.projection_version
8867 ));
8868 }
8869 if version.content_hash.is_none() {
8870 diagnostics.push("projection content hash is missing".to_string());
8871 }
8872 Ok(GraphDbFreshnessReport {
8873 status: if fail_closed { "stale" } else { "current" }.to_string(),
8874 fail_closed,
8875 projection_version: Some(version.projection_version),
8876 content_hash: version.content_hash,
8877 source_watermark: version.source_watermark,
8878 diagnostics,
8879 })
8880}
8881
8882pub(crate) fn convex_graph_freshness(
8883 local: &ConvexProjectionRows,
8884 snapshot: &ConvexProjectionRows,
8885 scope: Option<&str>,
8886) -> GraphDbFreshnessReport {
8887 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
8888 GraphDbFreshnessReport {
8889 status: freshness.status,
8890 fail_closed: freshness.fail_closed,
8891 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
8892 content_hash: freshness.snapshot_hash,
8893 source_watermark: None,
8894 diagnostics: freshness.diagnostics,
8895 }
8896}
8897
8898pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
8899 let (nodes, edges) = store.graph_counts()?;
8900 let files = store.file_count()?;
8901 Ok(GraphDbFreshnessReport {
8902 status: "current".to_string(),
8903 fail_closed: false,
8904 projection_version: Some("tokensave-readonly".to_string()),
8905 content_hash: None,
8906 source_watermark: Some(store.db_path().to_string_lossy().to_string()),
8907 diagnostics: vec![format!(
8908 "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
8909 nodes, edges, files
8910 )],
8911 })
8912}
8913
8914pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
8915 match TokensaveDb::discover(root) {
8916 Ok(Some(store)) => {
8917 report.push_check(GraphDbDoctorCheck {
8918 name: "tokensave_db_open".to_string(),
8919 status: "ok".to_string(),
8920 fail_closed: false,
8921 diagnostics: vec![format!(
8922 "opened tokensave database at {}",
8923 store.db_path().display()
8924 )],
8925 repair_commands: Vec::new(),
8926 });
8927 match (store.node_count(), store.edge_count(), store.file_count()) {
8928 (Ok(nodes), Ok(edges), Ok(files)) => {
8929 report.push_check(GraphDbDoctorCheck {
8930 name: "tokensave_counts".to_string(),
8931 status: "ok".to_string(),
8932 fail_closed: false,
8933 diagnostics: vec![format!(
8934 "tokensave contains {} node(s), {} edge(s), {} file(s)",
8935 nodes, edges, files
8936 )],
8937 repair_commands: Vec::new(),
8938 });
8939 }
8940 (nodes, edges, files) => {
8941 report.push_check(graph_db_doctor_check(
8942 "tokensave_counts",
8943 vec![format!(
8944 "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
8945 nodes.err(),
8946 edges.err(),
8947 files.err()
8948 )],
8949 Vec::new(),
8950 ));
8951 }
8952 }
8953 }
8954 Ok(None) => report.push_check(graph_db_doctor_check(
8955 "tokensave_db_exists",
8956 vec![format!(
8957 "tokensave database is missing at {}",
8958 root.join(".tokensave").join("tokensave.db").display()
8959 )],
8960 Vec::new(),
8961 )),
8962 Err(err) => report.push_check(graph_db_doctor_check(
8963 "tokensave_db_open",
8964 vec![err.to_string()],
8965 Vec::new(),
8966 )),
8967 }
8968}
8969
8970pub(crate) fn graph_db_resolve_evidence_target(
8971 store: &impl GraphStore,
8972 target: &str,
8973) -> Result<Option<SubstrateGraphNode>> {
8974 store.resolve_evidence_target(
8975 target,
8976 &[
8977 "backlog",
8978 "job_packet",
8979 "worker_result",
8980 "worker_context",
8981 "source_handle",
8982 ],
8983 )
8984}
8985
8986fn graph_db_reachable_nodes_by_kind(
8987 store: &impl GraphStore,
8988 from_id: &str,
8989 kind: &str,
8990 depth: usize,
8991 limit: usize,
8992) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
8993 store.reachable_nodes_by_kind(from_id, kind, depth, limit)
8994}
8995
8996fn graph_db_evidence_completed_queue_drift_warnings(
8997 store: &impl GraphStore,
8998 target: &SubstrateGraphNode,
8999 worker_results: &[SubstrateGraphNode],
9000) -> Result<Vec<String>> {
9001 let ref_id = target.properties.get("ref_id").map(String::as_str);
9002 let has_completed_result = worker_results.iter().any(|node| {
9003 node.properties.get("status").map(String::as_str) == Some("completed")
9004 && node.properties.get("ref_id").map(String::as_str) == ref_id
9005 });
9006 if !has_completed_result {
9007 return Ok(Vec::new());
9008 }
9009 let active_jobs = store
9010 .nodes_by_kind("job_packet")?
9011 .into_iter()
9012 .filter(|node| {
9013 node.properties.get("ref_id").map(String::as_str) == ref_id
9014 && node.label.starts_with("do #")
9015 })
9016 .collect::<Vec<_>>();
9017 if active_jobs.is_empty() {
9018 return Ok(Vec::new());
9019 }
9020 let repair = match (target.properties.get("path"), ref_id) {
9021 (Some(path), Some(id)) => format!(
9022 "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9023 shell_quote(path),
9024 shell_quote(id),
9025 shell_quote(id)
9026 ),
9027 _ => {
9028 "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9029 }
9030 };
9031 Ok(vec![format!(
9032 "queue-head drift: target {} has {} active queued do packet(s) but already has a completed worker_result; {repair}; do not redispatch or reactivate the completed item",
9033 target.label,
9034 active_jobs.len()
9035 )])
9036}
9037
9038fn graph_db_evidence_next_commands(
9039 root: &Path,
9040 scope: Option<&str>,
9041 target: &SubstrateGraphNode,
9042 worker_context: &[SubstrateGraphNode],
9043 source_handles: &[SubstrateGraphNode],
9044 worker_results: &[SubstrateGraphNode],
9045 semantic_related: &[SubstrateGraphNode],
9046) -> Vec<String> {
9047 let mut commands = BTreeSet::new();
9048 if let Some(expand) = target.properties.get("expand") {
9049 commands.insert(expand.clone());
9050 }
9051 for worker in worker_context {
9052 if let Some(expand) = worker.properties.get("expand") {
9053 commands.insert(expand.clone());
9054 }
9055 }
9056 for source in source_handles {
9057 if let Some(expand) = source.properties.get("expand") {
9058 commands.insert(expand.clone());
9059 }
9060 }
9061 for result in worker_results {
9062 if let Some(expand) = result.properties.get("expand") {
9063 commands.insert(expand.clone());
9064 }
9065 }
9066 for semantic in semantic_related {
9067 if let Some(expand) = semantic.properties.get("expand") {
9068 commands.insert(expand.clone());
9069 }
9070 }
9071 commands.insert(format!(
9072 "tsift graph-db --path {}{} status --json",
9073 shell_quote(root.to_string_lossy().as_ref()),
9074 graph_db_scope_arg(scope)
9075 ));
9076 commands.insert(format!(
9077 "tsift graph-db --path {}{} doctor --json",
9078 shell_quote(root.to_string_lossy().as_ref()),
9079 graph_db_scope_arg(scope)
9080 ));
9081 commands.into_iter().collect()
9082}
9083
9084fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9085 vec![
9086 format!(
9087 "tsift graph-db --path {}{} refresh --json",
9088 shell_quote(root.to_string_lossy().as_ref()),
9089 graph_db_scope_arg(scope)
9090 ),
9091 format!(
9092 "tsift graph-db --path {}{} doctor --json",
9093 shell_quote(root.to_string_lossy().as_ref()),
9094 graph_db_scope_arg(scope)
9095 ),
9096 ]
9097}
9098
9099fn graph_db_evidence_replay_commands(
9100 root: &Path,
9101 scope: Option<&str>,
9102 target: &str,
9103 depth: usize,
9104 limit: usize,
9105) -> Vec<String> {
9106 vec![
9107 format!(
9108 "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9109 shell_quote(root.to_string_lossy().as_ref()),
9110 graph_db_scope_arg(scope),
9111 shell_quote(target),
9112 depth,
9113 limit
9114 ),
9115 format!(
9116 "tsift conflict-matrix --path {} {} --json",
9117 shell_quote(root.to_string_lossy().as_ref()),
9118 shell_quote(target)
9119 ),
9120 ]
9121}
9122
9123fn graph_db_evidence_packet_id(
9124 target: &str,
9125 target_node: &SubstrateGraphNode,
9126 freshness: &GraphDbFreshnessReport,
9127) -> String {
9128 stable_handle(
9129 "gevd",
9130 &format!(
9131 "{}:{}:{}:{}",
9132 GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9133 target,
9134 target_node.id,
9135 freshness.content_hash.as_deref().unwrap_or("no-hash")
9136 ),
9137 )
9138}
9139
9140pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9141 input: GraphDbEvidenceInput<'_, S>,
9142) -> Result<GraphDbEvidenceReport> {
9143 let GraphDbEvidenceInput {
9144 root,
9145 scope,
9146 backend,
9147 target,
9148 depth,
9149 limit,
9150 cursor,
9151 store,
9152 freshness,
9153 mut warnings,
9154 } = input;
9155 let repair_commands = graph_db_repair_commands(root, scope);
9156 if freshness.fail_closed {
9157 bail!(
9158 "graph database evidence failed closed for {} backend: {}; repair: {}",
9159 backend,
9160 freshness.diagnostics.join("; "),
9161 repair_commands.join("; ")
9162 );
9163 }
9164 let semantic_readiness =
9165 graph_db_semantic_readiness(root, scope, graph_store_semantic_node_count(store).ok());
9166 if semantic_readiness.fail_closed {
9167 warnings.push(format!(
9168 "graph evidence semantic readiness blocked: {} — {}",
9169 semantic_readiness.reason,
9170 semantic_readiness.diagnostics.join("; ")
9171 ));
9172 warnings.push(format!(
9173 "repair: {}",
9174 semantic_readiness.next_commands.join("; then ")
9175 ));
9176 }
9177 let target_node = graph_db_resolve_evidence_target(store, target)?
9178 .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9179 let max_rows = if limit == 0 { usize::MAX } else { limit };
9180 let mut reachable = store.reachable_nodes_by_kinds(
9181 &target_node.id,
9182 &[
9183 "worker_context",
9184 "source_handle",
9185 "worker_result",
9186 "semantic_concept",
9187 "semantic_entity",
9188 ],
9189 depth,
9190 max_rows,
9191 )?;
9192 let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9193 let source_paths = reachable.remove("source_handle").unwrap_or_default();
9194 let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9195 let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9196 semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9197 semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9198 left_path
9199 .hops
9200 .cmp(&right_path.hops)
9201 .then(left_node.kind.cmp(&right_node.kind))
9202 .then(left_node.label.cmp(&right_node.label))
9203 .then(left_node.id.cmp(&right_node.id))
9204 });
9205 if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9206 semantic_paths.truncate(max_rows);
9207 }
9208
9209 let evidence_nodes = worker_paths
9210 .iter()
9211 .chain(source_paths.iter())
9212 .chain(worker_result_paths.iter())
9213 .chain(semantic_paths.iter())
9214 .map(|(node, _)| node.clone())
9215 .collect::<Vec<_>>();
9216 let evidence_depth_by_id = worker_paths
9217 .iter()
9218 .chain(source_paths.iter())
9219 .chain(worker_result_paths.iter())
9220 .chain(semantic_paths.iter())
9221 .map(|(node, path)| (node.id.clone(), path.hops))
9222 .collect::<BTreeMap<_, _>>();
9223 let target_query = graph_db_node_search_text(&target_node);
9224 let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9225 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9226 std::slice::from_ref(&target_node.id),
9227 &semantic_scores,
9228 evidence_nodes,
9229 Vec::new(),
9230 Some(limit),
9231 Some(&evidence_depth_by_id),
9232 cursor,
9233 );
9234 let output_budget = budgeted.report;
9235 let truncated = budgeted.truncated;
9236 let next_cursor = budgeted.next_cursor;
9237 let retained_evidence_ids = budgeted
9238 .nodes
9239 .iter()
9240 .map(|node| node.id.as_str())
9241 .collect::<BTreeSet<_>>();
9242 let worker_context = worker_paths
9243 .iter()
9244 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9245 .map(|(node, _)| node.clone())
9246 .collect::<Vec<_>>();
9247 let source_handles = source_paths
9248 .iter()
9249 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9250 .map(|(node, _)| node.clone())
9251 .collect::<Vec<_>>();
9252 let worker_results = worker_result_paths
9253 .iter()
9254 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9255 .map(|(node, _)| node.clone())
9256 .collect::<Vec<_>>();
9257 let semantic_related = semantic_paths
9258 .iter()
9259 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9260 .map(|(node, _)| node.clone())
9261 .collect::<Vec<_>>();
9262 warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9263 store,
9264 &target_node,
9265 &worker_results,
9266 )?);
9267 if worker_context.is_empty()
9268 && source_handles.is_empty()
9269 && worker_results.is_empty()
9270 && semantic_related.is_empty()
9271 {
9272 warnings.push(format!(
9273 "graph-db evidence target {} resolved to a {} node but has no projection-linked context rows; add source/file tokens to the backlog text or rerun graph-db refresh after the session document is indexed",
9274 target, target_node.kind
9275 ));
9276 }
9277 let shortest_paths = worker_paths
9278 .iter()
9279 .chain(source_paths.iter())
9280 .chain(worker_result_paths.iter())
9281 .chain(semantic_paths.iter())
9282 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9283 .map(|(node, path)| GraphDbEvidencePath {
9284 to: node.id.clone(),
9285 kind: node.kind.clone(),
9286 label: node.label.clone(),
9287 path: Some(path.clone()),
9288 expand: node.properties.get("expand").cloned(),
9289 })
9290 .collect::<Vec<_>>();
9291 let next_commands = graph_db_evidence_next_commands(
9292 root,
9293 scope,
9294 &target_node,
9295 &worker_context,
9296 &source_handles,
9297 &worker_results,
9298 &semantic_related,
9299 );
9300 let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9301 let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9302 let projection_hash = freshness.content_hash.clone();
9303
9304 Ok(GraphDbEvidenceReport {
9305 root: root.to_string_lossy().to_string(),
9306 scope: scope.map(str::to_string),
9307 backend: backend.to_string(),
9308 contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9309 target: target.to_string(),
9310 packet_id,
9311 projection_hash,
9312 freshness,
9313 target_node: target_node.into(),
9314 worker_context: worker_context.into_iter().map(Into::into).collect(),
9315 source_handles: source_handles.into_iter().map(Into::into).collect(),
9316 worker_results: worker_results.into_iter().map(Into::into).collect(),
9317 semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9318 shortest_paths,
9319 output_budget: Some(output_budget),
9320 truncated,
9321 next_cursor,
9322 next_commands,
9323 replay_commands,
9324 repair_commands,
9325 fixture_coverage: GraphDbFixtureCoverage {
9326 test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9327 .to_string(),
9328 fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
9329 assertions: vec![
9330 "backlog id and job packet handle resolve to graph nodes".to_string(),
9331 "worker_context rows are reachable from queued work".to_string(),
9332 "source_handle rows are reachable through bounded shortest paths".to_string(),
9333 "worker_result rows are reachable from completed or blocked work".to_string(),
9334 ],
9335 },
9336 warnings,
9337 })
9338}
9339
9340fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
9341 println!(
9342 "graph-db evidence backend: {} target: {} [{}] packet:{}",
9343 report.backend, report.target_node.id, report.target_node.kind, report.packet_id
9344 );
9345 let page_info = if report.truncated {
9346 let cursor = report.next_cursor.as_deref().unwrap_or("?");
9347 format!(" (truncated, next_cursor: {cursor})")
9348 } else {
9349 String::new()
9350 };
9351 println!(
9352 "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
9353 report.worker_context.len(),
9354 report.source_handles.len(),
9355 report.worker_results.len(),
9356 report.semantic_related.len(),
9357 report.shortest_paths.len()
9358 );
9359 for path in &report.shortest_paths {
9360 if let Some(graph_path) = &path.path {
9361 println!(
9362 "path: {} hop(s) {}",
9363 graph_path.hops,
9364 graph_path.nodes.join(" -> ")
9365 );
9366 }
9367 }
9368 for command in &report.next_commands {
9369 println!("next: {command}");
9370 }
9371 for warning in &report.warnings {
9372 println!("warning: {warning}");
9373 }
9374}
9375
9376pub(crate) fn print_graph_db_evidence_report(
9377 report: &GraphDbEvidenceReport,
9378 format: OutputFormat,
9379) -> Result<()> {
9380 if format.json_output {
9381 let page_info = if report.truncated {
9382 let cursor = report.next_cursor.as_deref().unwrap_or("?");
9383 format!(" (truncated, next_cursor: {cursor})")
9384 } else {
9385 String::new()
9386 };
9387 print_json_or_envelope(
9388 report,
9389 &format,
9390 "graph-db",
9391 "evidence",
9392 ToolEnvelopeSummary {
9393 text: format!(
9394 "Graph DB evidence for {} returned {} worker context row(s), {} source handle(s), {} worker result row(s), {} semantic row(s), and {} shortest path(s){page_info}",
9395 report.target,
9396 report.worker_context.len(),
9397 report.source_handles.len(),
9398 report.worker_results.len(),
9399 report.semantic_related.len(),
9400 report.shortest_paths.len()
9401 ),
9402 metrics: vec![
9403 envelope_metric("backend", &report.backend),
9404 envelope_metric("worker_context", report.worker_context.len()),
9405 envelope_metric("source_handles", report.source_handles.len()),
9406 envelope_metric("worker_results", report.worker_results.len()),
9407 envelope_metric("semantic_related", report.semantic_related.len()),
9408 envelope_metric("paths", report.shortest_paths.len()),
9409 ],
9410 },
9411 report.truncated,
9412 report.next_commands.clone(),
9413 )
9414 } else {
9415 print_graph_db_evidence_human(report);
9416 Ok(())
9417 }
9418}
9419
9420pub(crate) fn graph_db_report_from_store(
9421 root: &Path,
9422 scope: Option<&str>,
9423 backend: &str,
9424 query: GraphDbQuery,
9425 store: &impl GraphStore,
9426 freshness: GraphDbFreshnessReport,
9427 warnings: Vec<String>,
9428) -> Result<GraphDbReport> {
9429 if freshness.fail_closed {
9430 bail!(
9431 "graph database read failed closed for {} backend: {}",
9432 backend,
9433 freshness.diagnostics.join("; ")
9434 );
9435 }
9436 let mut report = GraphDbReport {
9437 root: root.to_string_lossy().to_string(),
9438 scope: scope.map(str::to_string),
9439 backend: backend.to_string(),
9440 query: format!("{query:?}"),
9441 freshness,
9442 readiness: None,
9443 schema: None,
9444 node: None,
9445 edge: None,
9446 nodes: Vec::new(),
9447 edges: Vec::new(),
9448 ranked_neighbors: Vec::new(),
9449 semantic_related: Vec::new(),
9450 neighborhood_ranking_gate: None,
9451 ranked_neighborhood_comparison: None,
9452 knowledge_retrieval: None,
9453 output_budget: None,
9454 path: None,
9455 page: None,
9456 warnings,
9457 };
9458
9459 match query {
9460 GraphDbQuery::Refresh => {
9461 bail!("graph-db refresh must be handled by the refresh command path");
9462 }
9463 GraphDbQuery::Status => {
9464 bail!("graph-db status must be handled by the status command path");
9465 }
9466 GraphDbQuery::Doctor => {
9467 bail!("graph-db doctor must be handled by the doctor command path");
9468 }
9469 GraphDbQuery::Drift => {
9470 bail!("graph-db drift must be handled by the drift command path");
9471 }
9472 GraphDbQuery::Compact { .. } => {
9473 bail!("graph-db compact must be handled by the compact command path");
9474 }
9475 GraphDbQuery::BackendEval { .. } => {
9476 bail!("graph-db backend-eval must be handled by the benchmark command path");
9477 }
9478 GraphDbQuery::Evidence { .. } => {
9479 bail!("graph-db evidence must be handled by the evidence command path");
9480 }
9481 GraphDbQuery::Related {
9482 query,
9483 kind,
9484 depth,
9485 seed_limit,
9486 limit,
9487 } => {
9488 let semantic =
9489 semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
9490 let SemanticRelatedReport {
9491 items,
9492 warnings: semantic_warnings,
9493 ..
9494 } = semantic;
9495 let readiness = graph_db_semantic_readiness(
9496 root,
9497 scope,
9498 (!items.is_empty()).then_some(items.len()),
9499 );
9500 report.warnings.extend(semantic_warnings);
9501 let seed_ids = items
9502 .iter()
9503 .map(|item| item.handle.clone())
9504 .collect::<Vec<_>>();
9505 let semantic_scores = items
9506 .iter()
9507 .map(|item| (item.handle.clone(), item.score))
9508 .collect::<BTreeMap<_, _>>();
9509 let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
9510 let seed_count = seed_ids.len();
9511 let mut diagnostics = subgraph.diagnostics;
9512 let budgeted = graph_db_apply_output_budget(
9513 &seed_ids,
9514 &semantic_scores,
9515 subgraph.nodes,
9516 subgraph.edges,
9517 Some(limit),
9518 );
9519 let budget_report = budgeted.report;
9520 let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
9521 diagnostics.extend(budget_report.diagnostics.clone());
9522 diagnostics.extend(readiness.diagnostics.clone());
9523
9524 report.readiness = Some(readiness);
9525 report.semantic_related = items;
9526 if let Some(seed_id) = seed_ids.first() {
9527 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
9528 report.ranked_neighbors = graph_db_ranked_neighbors(
9529 seed_id,
9530 &budgeted.nodes,
9531 &budgeted.edges,
9532 ranked_neighbor_cap,
9533 );
9534 report.neighborhood_ranking_gate =
9535 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9536 }
9537 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9538 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9539 report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
9540 mode: "semantic_seeded_neighborhood".to_string(),
9541 query,
9542 seed_kind: semantic_related_kind_name(kind).to_string(),
9543 seed_limit,
9544 seed_count,
9545 depth,
9546 limit,
9547 node_count: report.nodes.len(),
9548 edge_count: report.edges.len(),
9549 truncated: subgraph.truncated || dropped_by_budget,
9550 traversal: "incident_plus_outgoing_edges".to_string(),
9551 freshness_boundary:
9552 "semantic rows must come from refreshed summary or tsift-memory graph records"
9553 .to_string(),
9554 privacy_boundary:
9555 "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
9556 .to_string(),
9557 diagnostics,
9558 });
9559 report.output_budget = Some(budget_report);
9560 }
9561 GraphDbQuery::Schema => {
9562 report.schema = Some(graph_db_schema());
9563 }
9564 GraphDbQuery::Node { id } => {
9565 report.node = store.node(&id)?.map(Into::into);
9566 }
9567 GraphDbQuery::Edge { id } => {
9568 report.edge = store.edge(&id)?.map(Into::into);
9569 }
9570 GraphDbQuery::Edges {
9571 edge_kind,
9572 cursor,
9573 limit,
9574 property_filters,
9575 } => {
9576 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9577 let paged = store.paged_edges(
9578 edge_kind.as_deref(),
9579 graph_db_query_options_for_store(&options),
9580 )?;
9581 report.edges = paged.edges.into_iter().map(Into::into).collect();
9582 report.page = Some(graph_db_page_report_from_store(
9583 paged.page,
9584 options.property_filters,
9585 ));
9586 }
9587 GraphDbQuery::Incident {
9588 id,
9589 edge_kind,
9590 cursor,
9591 limit,
9592 property_filters,
9593 } => {
9594 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9595 let paged = store.paged_incident_edges(
9596 &id,
9597 edge_kind.as_deref(),
9598 graph_db_query_options_for_store(&options),
9599 )?;
9600 report.edges = paged.edges.into_iter().map(Into::into).collect();
9601 report.page = Some(graph_db_page_report_from_store(
9602 paged.page,
9603 options.property_filters,
9604 ));
9605 }
9606 GraphDbQuery::Kind {
9607 kind,
9608 cursor,
9609 limit,
9610 property_filters,
9611 } => {
9612 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9613 let paged =
9614 store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
9615 report.nodes = paged.nodes.into_iter().map(Into::into).collect();
9616 report.edges = paged.edges.into_iter().map(Into::into).collect();
9617 report.page = Some(graph_db_page_report_from_store(
9618 paged.page,
9619 options.property_filters,
9620 ));
9621 }
9622 GraphDbQuery::Neighborhood {
9623 id,
9624 depth,
9625 edge_kind,
9626 cursor,
9627 limit,
9628 property_filters,
9629 } => {
9630 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9631 if let Some(paged) = store.paged_neighborhood(
9632 &id,
9633 depth,
9634 edge_kind.as_deref(),
9635 graph_db_query_options_for_store(&options),
9636 )? {
9637 let budgeted = graph_db_apply_output_budget(
9638 std::slice::from_ref(&id),
9639 &BTreeMap::new(),
9640 paged.nodes,
9641 paged.edges,
9642 options.limit,
9643 );
9644 let budget_report = budgeted.report;
9645 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
9646 let ranked_neighbors = graph_db_ranked_neighbors(
9647 &id,
9648 &budgeted.nodes,
9649 &budgeted.edges,
9650 ranked_neighbor_cap,
9651 );
9652 let comparison = graph_db_ranked_neighborhood_comparison(
9653 &id,
9654 depth,
9655 edge_kind.as_deref(),
9656 options.limit,
9657 &budgeted.nodes,
9658 &budgeted.edges,
9659 store,
9660 )?;
9661 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9662 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9663 report.ranked_neighbors = ranked_neighbors;
9664 report.neighborhood_ranking_gate =
9665 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9666 let mut page =
9667 graph_db_page_report_from_store(paged.page, options.property_filters);
9668 page.returned_nodes = report.nodes.len();
9669 page.returned_edges = report.edges.len();
9670 page.truncated |= !budget_report.dropped_by_budget.is_empty();
9671 page.diagnostics.extend(budget_report.diagnostics.clone());
9672 report.page = Some(page);
9673 report.output_budget = Some(budget_report);
9674 if let Some(comparison) = comparison {
9675 report.ranked_neighborhood_comparison = Some(comparison);
9676 }
9677 }
9678 }
9679 GraphDbQuery::Path {
9680 from,
9681 to,
9682 edge_kind,
9683 max_hops,
9684 } => {
9685 report.path =
9686 store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
9687 if let Some(max_hops) = max_hops
9688 && report.path.is_none()
9689 {
9690 report.warnings.push(format!(
9691 "no directed path found within --max-hops {}",
9692 max_hops
9693 ));
9694 }
9695 }
9696 GraphDbQuery::Map { .. } => {
9697 bail!("graph-db map must be handled by the map command path");
9698 }
9699 }
9700 Ok(report)
9701}
9702
9703pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
9704 if compact {
9705 println!(
9706 "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
9707 report.backend,
9708 report.query,
9709 report.nodes.len() + usize::from(report.node.is_some()),
9710 report.edges.len() + usize::from(report.edge.is_some()),
9711 report.freshness.status
9712 );
9713 return;
9714 }
9715 println!("graph-db backend: {}", report.backend);
9716 println!("freshness: {}", report.freshness.status);
9717 if let Some(readiness) = &report.readiness {
9718 println!(
9719 "readiness: {} reason: {} fail_closed: {}",
9720 readiness.status, readiness.reason, readiness.fail_closed
9721 );
9722 for diagnostic in &readiness.diagnostics {
9723 println!("readiness diagnostic: {diagnostic}");
9724 }
9725 for command in &readiness.next_commands {
9726 println!("readiness next: {command}");
9727 }
9728 }
9729 if let Some(schema) = &report.schema {
9730 println!(
9731 "schema: {} node fields, {} edge fields, {} operations",
9732 schema.node_fields.len(),
9733 schema.edge_fields.len(),
9734 schema.operations.len()
9735 );
9736 }
9737 if let Some(node) = &report.node {
9738 println!("node: {} [{}] {}", node.id, node.kind, node.label);
9739 }
9740 if let Some(edge) = &report.edge {
9741 let edge_full: SubstrateGraphEdge = edge.into();
9742 println!(
9743 "edge: {} {} -{}-> {}",
9744 graph_db_edge_key(&edge_full),
9745 edge.from_id,
9746 edge.kind,
9747 edge.to_id
9748 );
9749 }
9750 if let Some(knowledge) = &report.knowledge_retrieval {
9751 println!(
9752 "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
9753 knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
9754 );
9755 }
9756 for item in &report.semantic_related {
9757 println!(
9758 "semantic_seed: {:.3} [{}] {} ({})",
9759 item.score, item.kind, item.label, item.handle
9760 );
9761 }
9762 for node in &report.nodes {
9763 println!("node: {} [{}] {}", node.id, node.kind, node.label);
9764 }
9765 for edge in &report.edges {
9766 let edge_full: SubstrateGraphEdge = edge.into();
9767 println!(
9768 "edge: {} {} -{}-> {}",
9769 graph_db_edge_key(&edge_full),
9770 edge.from_id,
9771 edge.kind,
9772 edge.to_id
9773 );
9774 }
9775 for neighbor in &report.ranked_neighbors {
9776 println!(
9777 "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
9778 neighbor.rank,
9779 neighbor.score,
9780 neighbor
9781 .depth
9782 .map(|depth| depth.to_string())
9783 .unwrap_or_else(|| "unknown".to_string()),
9784 neighbor.node_id,
9785 neighbor.kind,
9786 neighbor.label
9787 );
9788 }
9789 if let Some(gate) = &report.neighborhood_ranking_gate {
9790 println!(
9791 "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
9792 gate.status, gate.default_order, gate.ranked_output_default
9793 );
9794 }
9795 if let Some(path) = &report.path {
9796 println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
9797 }
9798 if let Some(page) = &report.page {
9799 if let Some(next_cursor) = &page.next_cursor {
9800 println!("next_cursor: {next_cursor}");
9801 }
9802 for diagnostic in &page.diagnostics {
9803 println!("page: {diagnostic}");
9804 }
9805 }
9806 for warning in &report.warnings {
9807 println!("warning: {warning}");
9808 }
9809}
9810
9811pub(crate) fn graph_db_backend_eval_phase_timing(
9812 name: &str,
9813 duration_micros: u128,
9814 detail: &str,
9815) -> GraphDbBackendEvalPhaseTiming {
9816 GraphDbBackendEvalPhaseTiming {
9817 name: name.to_string(),
9818 duration_micros,
9819 detail: detail.to_string(),
9820 }
9821}
9822
9823pub(crate) fn graph_db_backend_eval_timed_phase<T>(
9824 phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
9825 name: &str,
9826 detail: &str,
9827 run: impl FnOnce() -> Result<T>,
9828) -> Result<T> {
9829 let started = Instant::now();
9830 let result = run();
9831 phases.push(graph_db_backend_eval_phase_timing(
9832 name,
9833 started.elapsed().as_micros(),
9834 detail,
9835 ));
9836 result
9837}
9838
9839pub(crate) fn graph_db_backend_eval_refresh_total_micros(
9840 phases: &[GraphDbBackendEvalPhaseTiming],
9841) -> u128 {
9842 phases
9843 .iter()
9844 .filter(|phase| phase.name != "conflict_matrix_preparation")
9845 .map(|phase| phase.duration_micros)
9846 .sum()
9847}
9848
9849pub(crate) fn graph_db_backend_eval_cached_refresh(
9850 root: &Path,
9851 scope: Option<&str>,
9852 source_watermark: Option<&str>,
9853) -> Result<
9854 Option<(
9855 TraversalGraphBuild,
9856 SqliteProjectionRefresh,
9857 Vec<GraphDbBackendEvalPhaseTiming>,
9858 )>,
9859> {
9860 let Some(source_watermark) = source_watermark else {
9861 return Ok(None);
9862 };
9863 let graph_db = graph_substrate_db_path(root, scope);
9864 if !graph_db.exists() {
9865 return Ok(None);
9866 }
9867
9868 let started = Instant::now();
9869 let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
9870 Ok(store) => store,
9871 Err(_) => return Ok(None),
9872 };
9873 if store.has_user_triggers().unwrap_or(true) {
9874 return Ok(None);
9875 }
9876 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
9877 if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
9878 return Ok(None);
9879 }
9880
9881 let phases = vec![
9882 graph_db_backend_eval_phase_timing(
9883 "source_graph_build",
9884 started.elapsed().as_micros(),
9885 "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
9886 ),
9887 graph_db_backend_eval_phase_timing(
9888 "projection_rows",
9889 0,
9890 "reused cached provider-neutral projection rows from graph.db",
9891 ),
9892 graph_db_backend_eval_phase_timing(
9893 "sqlite_open",
9894 0,
9895 "reused existing graph.db projection without opening a write transaction",
9896 ),
9897 ];
9898 let refresh = SqliteProjectionRefresh {
9899 scope: scope.unwrap_or("root").to_string(),
9900 projection_version: freshness
9901 .projection_version
9902 .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
9903 source_watermark: Some(source_watermark.to_string()),
9904 tombstoned_nodes: Vec::new(),
9905 tombstoned_edges: Vec::new(),
9906 upserted_nodes: 0,
9907 upserted_edges: 0,
9908 unchanged_nodes: 0,
9909 unchanged_edges: 0,
9910 upserted_properties: 0,
9911 unchanged_properties: 0,
9912 deleted_properties: 0,
9913 deleted_nodes: 0,
9914 deleted_edges: 0,
9915 pruned_tombstones: 0,
9916 file_size_bytes_before: None,
9917 file_size_bytes_after: None,
9918 phase_timings: Vec::new(),
9919 };
9920 Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
9921}
9922
9923pub(crate) fn graph_db_backend_eval_reused_cached_projection(
9924 phases: &[GraphDbBackendEvalPhaseTiming],
9925) -> bool {
9926 phases.iter().any(|phase| {
9927 phase.name == "source_graph_build"
9928 && phase.detail.contains("reused current graph.db projection")
9929 })
9930}
9931
9932pub(crate) fn graph_db_backend_eval_update_source_watermark(
9933 root: &Path,
9934 path_hint: &Path,
9935 scope: Option<&str>,
9936) -> Result<()> {
9937 let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
9938 return Ok(());
9939 };
9940 let graph_db = graph_substrate_db_path(root, scope);
9941 let mut store = SqliteGraphStore::open(&graph_db)?;
9942 store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
9943 Ok(())
9944}
9945
9946pub(crate) fn graph_db_backend_eval_refresh_with_profile(
9947 root: &Path,
9948 path_hint: &Path,
9949 scope: Option<&str>,
9950) -> Result<(
9951 TraversalGraphBuild,
9952 SqliteProjectionRefresh,
9953 Vec<GraphDbBackendEvalPhaseTiming>,
9954)> {
9955 let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
9956 if let Some(cached) =
9957 graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
9958 {
9959 return Ok(cached);
9960 }
9961
9962 let mut phases = Vec::new();
9963 let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
9964 "bounded session projection: index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads; skips global call-edge materialization because full-projection is the complete-call-graph regression guard"
9965 } else {
9966 "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
9967 };
9968 let source_graph = graph_db_backend_eval_timed_phase(
9969 &mut phases,
9970 "source_graph_build",
9971 source_graph_detail,
9972 || build_traversal_graph_source_with_options(root, path_hint, scope, false),
9973 )?;
9974 let projection = graph_db_backend_eval_timed_phase(
9975 &mut phases,
9976 "projection_rows",
9977 "provider-neutral GraphStore node/edge row construction before SQLite persistence",
9978 || traversal_projection_from_graph(root, scope, &source_graph),
9979 )?;
9980 let graph_db = graph_substrate_db_path(root, scope);
9981 let mut store = graph_db_backend_eval_timed_phase(
9982 &mut phases,
9983 "sqlite_open",
9984 "open the local SQLite graph.db with WAL and busy-timeout settings",
9985 || SqliteGraphStore::open(&graph_db),
9986 )?;
9987 let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
9988 .ok()
9989 .flatten();
9990 let refresh = store.replace_projection_with_version(
9991 scope.unwrap_or("root"),
9992 &projection,
9993 Some(GRAPH_PROJECTION_VERSION),
9994 refreshed_source_watermark
9995 .or(source_watermark)
9996 .or_else(|| graph_projection_content_hash(&projection)),
9997 )?;
9998 phases.extend(
9999 refresh
10000 .phase_timings
10001 .iter()
10002 .map(|phase| GraphDbBackendEvalPhaseTiming {
10003 name: phase.name.clone(),
10004 duration_micros: phase.duration_micros,
10005 detail: phase.detail.clone(),
10006 }),
10007 );
10008 Ok((source_graph, refresh, phases))
10009}
10010
10011fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10012 root.join(".tsift/backend-eval-cache")
10013}
10014
10015fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10016 graph_db_backend_eval_disk_cache_dir(root)
10017 .join(kind)
10018 .join(format!("{key}.json.gz"))
10019}
10020
10021fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10022 graph_db_backend_eval_disk_cache_dir(root)
10023 .join(kind)
10024 .join(format!("{key}.json"))
10025}
10026
10027#[derive(Default, Clone)]
10028struct GraphDbBackendEvalDiskCacheReadProfile {
10029 file_read_micros: u128,
10030 gzip_decode_micros: u128,
10031 serde_decode_micros: u128,
10032 legacy: bool,
10033}
10034
10035fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10036 root: &Path,
10037 kind: &str,
10038 key: &str,
10039) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10040 let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10041 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10042 let read_started = Instant::now();
10043 let read_result = fs::read(&path);
10044 profile.file_read_micros = read_started.elapsed().as_micros();
10045 if let Ok(bytes) = read_result {
10046 let decode_started = Instant::now();
10047 let mut decoder = GzDecoder::new(bytes.as_slice());
10048 let mut decoded = Vec::new();
10049 let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10050 profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10051 if decode_ok {
10052 let serde_started = Instant::now();
10053 let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10054 profile.serde_decode_micros = serde_started.elapsed().as_micros();
10055 if let Some(value) = parsed {
10056 return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10057 }
10058 }
10059 }
10060
10061 let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10062 let legacy_started = Instant::now();
10063 let bytes = fs::read(legacy_path).ok()?;
10064 profile.file_read_micros = profile
10065 .file_read_micros
10066 .saturating_add(legacy_started.elapsed().as_micros());
10067 let serde_started = Instant::now();
10068 let value = serde_json::from_slice(&bytes).ok()?;
10069 profile.serde_decode_micros = profile
10070 .serde_decode_micros
10071 .saturating_add(serde_started.elapsed().as_micros());
10072 profile.legacy = true;
10073 Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10074}
10075
10076#[derive(Default, Clone)]
10077struct GraphDbBackendEvalDiskCacheWriteProfile {
10078 serde_encode_micros: u128,
10079 gzip_encode_micros: u128,
10080 file_write_micros: u128,
10081}
10082
10083fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10084 root: &Path,
10085 kind: &str,
10086 key: &str,
10087 value: &T,
10088) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10089 let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10090 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10091 let parent = path.parent()?;
10092 if fs::create_dir_all(parent).is_err() {
10093 return None;
10094 }
10095 let serde_started = Instant::now();
10096 let bytes = serde_json::to_vec(value).ok()?;
10097 profile.serde_encode_micros = serde_started.elapsed().as_micros();
10098 let gzip_started = Instant::now();
10099 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10100 if encoder.write_all(&bytes).is_err() {
10101 return None;
10102 }
10103 let encoded = encoder.finish().ok()?;
10104 profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10105 let write_started = Instant::now();
10106 if fs::write(&path, &encoded).is_err() {
10107 return None;
10108 }
10109 profile.file_write_micros = write_started.elapsed().as_micros();
10110 Some((encoded.len() as u64, bytes.len() as u64, profile))
10111}
10112
10113fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10114 let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10115 let Ok(entries) = fs::read_dir(dir) else {
10116 return (0, 0);
10117 };
10118 let keep_name = format!("{keep_key}.json.gz");
10119 let mut pruned_files = 0usize;
10120 let mut pruned_bytes = 0u64;
10121 for entry in entries.flatten() {
10122 let path = entry.path();
10123 if !path.is_file() {
10124 continue;
10125 }
10126 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10127 continue;
10128 };
10129 if name == keep_name {
10130 continue;
10131 }
10132 let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10133 if !is_backend_eval_cache {
10134 continue;
10135 }
10136 let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10137 if fs::remove_file(&path).is_ok() {
10138 pruned_files += 1;
10139 pruned_bytes += bytes;
10140 }
10141 }
10142 (pruned_files, pruned_bytes)
10143}
10144
10145fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10146 root: &Path,
10147 source_root: &Path,
10148) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10149 let mut rows = Vec::new();
10150 let mut entries = walk::walk_files(source_root)?;
10151 entries.sort_by(|left, right| left.path.cmp(&right.path));
10152 for entry in entries {
10153 if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10154 continue;
10155 }
10156 if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10157 continue;
10158 }
10159 let bytes = fs::read(&entry.path)
10160 .with_context(|| format!("reading source input {}", entry.path.display()))?;
10161 rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10162 path: traversal_watermark_path(root, &entry.path),
10163 bytes: bytes.len() as u64,
10164 content_hash: content_hash(&bytes)?,
10165 });
10166 }
10167 Ok(rows)
10168}
10169
10170fn graph_db_backend_eval_full_projection_source_watermark(
10171 root: &Path,
10172 scope: Option<&str>,
10173) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10174 let path_hint = root;
10175 let mut detail_parts = Vec::new();
10176 let mut parts = vec![
10177 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10178 format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10179 "watermark_kind:stable_full_projection_inputs".to_string(),
10180 format!("scope:{}", scope.unwrap_or("root")),
10181 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10182 ];
10183
10184 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10185 match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10186 Some(db_path) => {
10187 let db = index::IndexDb::open_read_only_resilient(db_path)?;
10188 parts.push("index_mode:indexed".to_string());
10189 detail_parts.push("mode=indexed".to_string());
10190 parts.push(format!(
10191 "index_source_root:{}",
10192 traversal_watermark_path(root, &gate.source_root)
10193 ));
10194
10195 let symbols = db
10196 .all_symbols()?
10197 .into_iter()
10198 .filter(|symbol| {
10199 !traversal_path_is_generated_artifact(
10200 root,
10201 &gate.source_root,
10202 Path::new(&symbol.file),
10203 ) && !traversal_path_is_session_markdown(
10204 root,
10205 &gate.source_root,
10206 Path::new(&symbol.file),
10207 )
10208 })
10209 .collect::<Vec<_>>();
10210 let symbols_hash = content_hash(&symbols)?;
10211 detail_parts.push(format!("symbols={symbols_hash}"));
10212 parts.push(format!("index_symbols:{symbols_hash}"));
10213
10214 let edges = db
10215 .all_stored_edges()?
10216 .into_iter()
10217 .filter(|edge| {
10218 !traversal_path_is_generated_artifact(
10219 root,
10220 &gate.source_root,
10221 Path::new(&edge.caller_file),
10222 ) && !traversal_path_is_session_markdown(
10223 root,
10224 &gate.source_root,
10225 Path::new(&edge.caller_file),
10226 )
10227 })
10228 .collect::<Vec<_>>();
10229 let edges_hash = content_hash(&edges)?;
10230 detail_parts.push(format!("call_edges={edges_hash}"));
10231 parts.push(format!("index_call_edges:{edges_hash}"));
10232
10233 let routes = db
10234 .all_routes()?
10235 .into_iter()
10236 .filter(|route| {
10237 !traversal_path_is_generated_artifact(
10238 root,
10239 &gate.source_root,
10240 Path::new(&route.file),
10241 ) && !traversal_path_is_session_markdown(
10242 root,
10243 &gate.source_root,
10244 Path::new(&route.file),
10245 )
10246 })
10247 .collect::<Vec<_>>();
10248 let routes_hash = content_hash(&routes)?;
10249 detail_parts.push(format!("routes={routes_hash}"));
10250 parts.push(format!("index_routes:{routes_hash}"));
10251 }
10252 None => {
10253 parts.push("index_mode:raw_fallback".to_string());
10254 detail_parts.push("mode=raw_fallback".to_string());
10255 parts.push(format!(
10256 "raw_source_root:{}",
10257 traversal_watermark_path(root, &gate.source_root)
10258 ));
10259 let raw_rows =
10260 graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10261 let raw_hash = content_hash(&raw_rows)?;
10262 detail_parts.push(format!("raw_source_files={raw_hash}"));
10263 parts.push(format!("raw_source_files:{raw_hash}"));
10264 }
10265 }
10266
10267 parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10268 detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10269 let summaries_start = parts.len();
10270 push_traversal_summaries_watermark_part(root, &mut parts)?;
10271 let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10272 detail_parts.push(format!("summaries={summaries_hash}"));
10273 let value = content_hash(&parts)?;
10274 detail_parts.push(format!("watermark={value}"));
10275 Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10276 value,
10277 detail: detail_parts.join(" "),
10278 })
10279}
10280
10281fn graph_db_backend_eval_full_projection_cache_key(
10282 root: &Path,
10283 scope: Option<&str>,
10284) -> Result<(String, String, String)> {
10285 let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10286 let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10287 root,
10288 scope,
10289 &source_watermark.value,
10290 )?;
10291 Ok((source_watermark.value, key, source_watermark.detail))
10292}
10293
10294fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10295 root: &Path,
10296 scope: Option<&str>,
10297 source_watermark: &str,
10298) -> Result<String> {
10299 content_hash(&serde_json::json!({
10300 "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10301 "root": root.display().to_string(),
10302 "scope": scope.unwrap_or("root"),
10303 "source_watermark": source_watermark,
10304 }))
10305}
10306
10307pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10308 root: &Path,
10309 scope: Option<&str>,
10310) -> Result<(
10311 GraphProjection,
10312 Vec<String>,
10313 Vec<GraphDbBackendEvalPhaseTiming>,
10314 GraphDbBackendEvalFullProjectionCacheStats,
10315)> {
10316 let (source_watermark, key, source_watermark_detail) =
10317 graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10318 let lookup_started = Instant::now();
10319 if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10320 graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10321 root,
10322 "full_projection",
10323 &key,
10324 )
10325 && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
10326 && cached.key == key
10327 && cached.source_watermark == source_watermark
10328 {
10329 let lookup_overhead_micros = lookup_started
10330 .elapsed()
10331 .as_micros()
10332 .saturating_sub(read_profile.file_read_micros)
10333 .saturating_sub(read_profile.gzip_decode_micros)
10334 .saturating_sub(read_profile.serde_decode_micros);
10335 let prune_started = Instant::now();
10336 let (pruned_files, pruned_bytes) =
10337 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
10338 let prune_micros = prune_started.elapsed().as_micros();
10339 let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
10340 hit: true,
10341 disk_bytes,
10342 json_bytes,
10343 pruned_files,
10344 pruned_bytes,
10345 };
10346 let read_detail_suffix = if read_profile.legacy {
10347 " (legacy uncompressed cache path)"
10348 } else {
10349 ""
10350 };
10351 return Ok((
10352 cached.projection,
10353 cached.warnings,
10354 vec![
10355 graph_db_backend_eval_phase_timing(
10356 "full_projection.cache_lookup",
10357 lookup_overhead_micros,
10358 &format!(
10359 "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
10360 ),
10361 ),
10362 graph_db_backend_eval_phase_timing(
10363 "full_projection.cache.file_read",
10364 read_profile.file_read_micros,
10365 &format!(
10366 "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
10367 ),
10368 ),
10369 graph_db_backend_eval_phase_timing(
10370 "full_projection.cache.gzip_decode",
10371 read_profile.gzip_decode_micros,
10372 "gunzip the compressed projection cache bytes",
10373 ),
10374 graph_db_backend_eval_phase_timing(
10375 "full_projection.cache.serde_decode",
10376 read_profile.serde_decode_micros,
10377 "serde_json deserialize the decoded projection cache payload",
10378 ),
10379 graph_db_backend_eval_phase_timing(
10380 "full_projection.cache.prune",
10381 prune_micros,
10382 "prune sibling cache files older than the current key",
10383 ),
10384 graph_db_backend_eval_phase_timing(
10385 "full_projection.source_graph_build",
10386 0,
10387 "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10388 ),
10389 graph_db_backend_eval_phase_timing(
10390 "full_projection.projection_rows",
10391 0,
10392 "reused cached provider-neutral full-project projection rows",
10393 ),
10394 ],
10395 cache_stats,
10396 ));
10397 }
10398
10399 let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
10400 let mut phases = vec![graph_db_backend_eval_phase_timing(
10401 "full_projection.cache_lookup",
10402 lookup_started.elapsed().as_micros(),
10403 &format!(
10404 "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
10405 ),
10406 )];
10407 let full_source = graph_db_backend_eval_timed_phase(
10408 &mut phases,
10409 "full_projection.source_graph_build",
10410 "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
10411 || build_traversal_graph_source_with_options(root, root, scope, false),
10412 )?;
10413 let projection = graph_db_backend_eval_timed_phase(
10414 &mut phases,
10415 "full_projection.projection_rows",
10416 "provider-neutral row construction for the opt-in full-project projection dataset",
10417 || traversal_projection_from_graph(root, scope, &full_source),
10418 )?;
10419 let warnings = full_source.warnings;
10420 let refreshed_source_watermark =
10421 graph_db_backend_eval_full_projection_source_watermark(root, scope)
10422 .map(|watermark| watermark.value)
10423 .unwrap_or_else(|_| source_watermark.clone());
10424 let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10425 root,
10426 scope,
10427 &refreshed_source_watermark,
10428 )?;
10429 let cache = GraphDbBackendEvalFullProjectionCache {
10430 version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
10431 key: write_key.clone(),
10432 source_watermark: refreshed_source_watermark,
10433 projection: projection.clone(),
10434 warnings: warnings.clone(),
10435 };
10436 if let Some((disk_bytes, json_bytes, write_profile)) =
10437 graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
10438 {
10439 cache_stats.disk_bytes = disk_bytes;
10440 cache_stats.json_bytes = json_bytes;
10441 phases.push(graph_db_backend_eval_phase_timing(
10442 "full_projection.cache.serde_encode",
10443 write_profile.serde_encode_micros,
10444 "serde_json serialize the projection cache payload before compression",
10445 ));
10446 phases.push(graph_db_backend_eval_phase_timing(
10447 "full_projection.cache.gzip_encode",
10448 write_profile.gzip_encode_micros,
10449 "gzip-compress the serialized projection cache payload",
10450 ));
10451 phases.push(graph_db_backend_eval_phase_timing(
10452 "full_projection.cache.file_write",
10453 write_profile.file_write_micros,
10454 "write the compressed projection cache bytes to .tsift/backend-eval-cache",
10455 ));
10456 }
10457 let prune_started = Instant::now();
10458 let (pruned_files, pruned_bytes) =
10459 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
10460 phases.push(graph_db_backend_eval_phase_timing(
10461 "full_projection.cache.prune",
10462 prune_started.elapsed().as_micros(),
10463 "prune sibling cache files older than the current key",
10464 ));
10465 cache_stats.pruned_files = pruned_files;
10466 cache_stats.pruned_bytes = pruned_bytes;
10467 Ok((projection, warnings, phases, cache_stats))
10468}
10469
10470fn graph_db_backend_eval_timed(
10471 name: &str,
10472 run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
10473) -> (
10474 GraphDbBackendEvalOperation,
10475 Option<GraphDbBackendEvalSignature>,
10476) {
10477 let started = Instant::now();
10478 match run() {
10479 Ok((rows, value)) => (
10480 GraphDbBackendEvalOperation {
10481 name: name.to_string(),
10482 supported: true,
10483 status: "ok".to_string(),
10484 duration_micros: started.elapsed().as_micros(),
10485 rows,
10486 error: None,
10487 },
10488 Some(GraphDbBackendEvalSignature {
10489 operation: name.to_string(),
10490 value,
10491 }),
10492 ),
10493 Err(err) => (
10494 GraphDbBackendEvalOperation {
10495 name: name.to_string(),
10496 supported: false,
10497 status: "error".to_string(),
10498 duration_micros: started.elapsed().as_micros(),
10499 rows: None,
10500 error: Some(format!("{err:#}")),
10501 },
10502 None,
10503 ),
10504 }
10505}
10506
10507fn graph_db_backend_eval_parity(
10508 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10509 candidate_signatures: &[GraphDbBackendEvalSignature],
10510) -> GraphDbBackendEvalParity {
10511 let Some(sqlite_signatures) = sqlite_signatures else {
10512 return GraphDbBackendEvalParity {
10513 matches_sqlite: true,
10514 diagnostics: Vec::new(),
10515 };
10516 };
10517 let sqlite = sqlite_signatures
10518 .iter()
10519 .map(|signature| (signature.operation.as_str(), &signature.value))
10520 .collect::<BTreeMap<_, _>>();
10521 let candidate = candidate_signatures
10522 .iter()
10523 .map(|signature| (signature.operation.as_str(), &signature.value))
10524 .collect::<BTreeMap<_, _>>();
10525 let mut diagnostics = Vec::new();
10526 for (operation, sqlite_value) in sqlite {
10527 match candidate.get(operation) {
10528 Some(candidate_value) if *candidate_value == sqlite_value => {}
10529 Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
10530 None => diagnostics.push(format!(
10531 "{operation} did not complete for candidate backend"
10532 )),
10533 }
10534 }
10535 GraphDbBackendEvalParity {
10536 matches_sqlite: diagnostics.is_empty(),
10537 diagnostics,
10538 }
10539}
10540
10541pub(crate) fn graph_db_backend_eval_targets(
10542 store: &impl GraphStore,
10543 requested: &[String],
10544) -> Result<Vec<String>> {
10545 let requested = requested
10546 .iter()
10547 .filter_map(|target| normalize_conflict_target(target))
10548 .collect::<Vec<_>>();
10549 if !requested.is_empty() {
10550 return Ok(requested);
10551 }
10552
10553 for kind in ["backlog", "job_packet"] {
10554 let nodes = store.nodes_by_kind(kind)?;
10555 if let Some(node) = nodes.first() {
10556 if let Some(ref_id) = node.properties.get("ref_id") {
10557 return Ok(vec![ref_id.clone()]);
10558 }
10559 return Ok(vec![node.id.clone()]);
10560 }
10561 }
10562 Ok(Vec::new())
10563}
10564
10565fn graph_db_backend_eval_path_targets(
10566 store: &impl GraphStore,
10567 max_hops: usize,
10568) -> Result<Option<(String, String, usize)>> {
10569 let synthetic_from = "gsym-synthetic-0000";
10570 let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
10571 if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
10572 let outgoing = store.outgoing_edges(synthetic_from, None)?;
10573 if outgoing.len() > 1
10574 && let Some(edge) = outgoing.first()
10575 {
10576 return Ok(Some((
10577 edge.from_id.clone(),
10578 edge.to_id.clone(),
10579 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10580 )));
10581 }
10582 return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
10583 }
10584
10585 Ok(store.sample_edge(None)?.map(|edge| {
10586 (
10587 edge.from_id,
10588 edge.to_id,
10589 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10590 )
10591 }))
10592}
10593
10594fn graph_db_backend_eval_path_operation<S: GraphStore>(
10595 store: &S,
10596 configured_max_hops: usize,
10597) -> (
10598 GraphDbBackendEvalOperation,
10599 Option<GraphDbBackendEvalSignature>,
10600) {
10601 let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10602 "path_max_hops".to_string()
10603 } else {
10604 format!("path_max_hops_{configured_max_hops}")
10605 };
10606 graph_db_backend_eval_timed(&operation_name, || {
10607 let (from, to, effective_max_hops) =
10608 graph_db_backend_eval_path_targets(store, configured_max_hops)?
10609 .context("backend-eval path probe requires at least one traversable edge")?;
10610 let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
10611 let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10612 Some(format!(
10613 "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
10614 GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
10615 ))
10616 } else if path.is_none() && effective_max_hops == configured_max_hops {
10617 Some(format!(
10618 "path probe truncated at {configured_max_hops} hops before a route was found"
10619 ))
10620 } else {
10621 None
10622 };
10623 Ok((
10624 path.as_ref().map(|path| path.nodes.len()),
10625 serde_json::json!({
10626 "from": from,
10627 "to": to,
10628 "configured_max_hops": configured_max_hops,
10629 "effective_max_hops": effective_max_hops,
10630 "hops": path.as_ref().map(|path| path.hops),
10631 "nodes": path.as_ref().map(|path| &path.nodes),
10632 "found": path.is_some(),
10633 "warning": warning,
10634 }),
10635 ))
10636 })
10637}
10638
10639fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
10640 store: &S,
10641 depth: usize,
10642 limit: usize,
10643) -> (
10644 GraphDbBackendEvalOperation,
10645 Option<GraphDbBackendEvalSignature>,
10646) {
10647 graph_db_backend_eval_timed("neighborhood", || {
10648 let edge = match store.sample_edge(Some("calls"))? {
10649 Some(edge) => edge,
10650 None => store.sample_edge(None)?.context(
10651 "backend-eval neighborhood probe requires at least one traversable edge",
10652 )?,
10653 };
10654 let page = store
10655 .paged_neighborhood(
10656 &edge.from_id,
10657 depth,
10658 Some(&edge.kind),
10659 GraphQueryOptions {
10660 limit: Some(limit.max(1)),
10661 ..GraphQueryOptions::default()
10662 },
10663 )?
10664 .with_context(|| {
10665 format!(
10666 "backend-eval neighborhood target not found: {}",
10667 edge.from_id
10668 )
10669 })?;
10670 Ok((
10671 Some(page.nodes.len() + page.edges.len()),
10672 serde_json::json!({
10673 "center": edge.from_id,
10674 "kind": edge.kind,
10675 "depth": depth,
10676 "limit": limit.max(1),
10677 "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10678 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10679 "truncated": page.page.truncated,
10680 }),
10681 ))
10682 })
10683}
10684
10685fn graph_db_backend_eval_related_operation<S: GraphStore>(
10686 root: &Path,
10687 scope: Option<&str>,
10688 store: &S,
10689 depth: usize,
10690 limit: usize,
10691) -> (
10692 GraphDbBackendEvalOperation,
10693 Option<GraphDbBackendEvalSignature>,
10694) {
10695 graph_db_backend_eval_timed("related", || {
10696 let query = "backend evaluation";
10697 let semantic = semantic_related_report_from_store(
10698 root,
10699 scope,
10700 query,
10701 3,
10702 SemanticRelatedKind::All,
10703 store,
10704 )?;
10705 let seed_ids = semantic
10706 .items
10707 .iter()
10708 .map(|item| item.handle.clone())
10709 .collect::<Vec<_>>();
10710 let subgraph =
10711 graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
10712 Ok((
10713 Some(subgraph.nodes.len() + subgraph.edges.len()),
10714 serde_json::json!({
10715 "query": query,
10716 "seed_ids": seed_ids,
10717 "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10718 "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10719 "truncated": subgraph.truncated,
10720 "warnings": semantic.warnings,
10721 "diagnostics": subgraph.diagnostics,
10722 }),
10723 ))
10724 })
10725}
10726
10727fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
10728 serde_json::json!({
10729 "target": report.target,
10730 "target_node_id": report.target_node.id,
10731 "target_kind": report.target_node.kind,
10732 "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
10733 "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
10734 "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
10735 "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
10736 "path_count": report.shortest_paths.len(),
10737 })
10738}
10739
10740fn graph_db_backend_eval_target_resolution_signature(
10741 resolved: &[(String, SubstrateGraphNode)],
10742) -> serde_json::Value {
10743 serde_json::json!({
10744 "targets": resolved.iter().map(|(target, node)| {
10745 serde_json::json!({
10746 "target": target,
10747 "target_node_id": node.id,
10748 "target_kind": node.kind,
10749 "target_label": node.label,
10750 })
10751 }).collect::<Vec<_>>(),
10752 })
10753}
10754
10755fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
10756 serde_json::json!({
10757 "targets": report.targets,
10758 "can_parallel": report.can_parallel,
10759 "fail_closed": report.fail_closed,
10760 "cross_target_parallel_safe": report.cross_target_parallel_safe,
10761 "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
10762 "candidates": report.candidates.iter().map(|candidate| {
10763 serde_json::json!({
10764 "target": candidate.target,
10765 "risk": conflict_risk_label(candidate.risk),
10766 "owned_files": candidate.owned_files,
10767 "owned_symbols": candidate.owned_symbols,
10768 "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
10769 "previously_completed": candidate.previously_completed,
10770 "parallel_safe": candidate.parallel_safe,
10771 })
10772 }).collect::<Vec<_>>(),
10773 "conflicts": report.conflicts.iter().map(|pair| {
10774 serde_json::json!({
10775 "left": pair.left,
10776 "right": pair.right,
10777 "risk": conflict_risk_label(pair.risk),
10778 })
10779 }).collect::<Vec<_>>(),
10780 })
10781}
10782
10783fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
10784 serde_json::json!({
10785 "targets": report.targets,
10786 "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10787 "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
10788 "evidence_packet_ids": report.evidence_packet_ids,
10789 "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
10790 "truncated": report.truncated,
10791 })
10792}
10793
10794fn graph_db_backend_eval_edge_scan_probe(
10795 store: &impl GraphStore,
10796) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
10797 if let Some((edge, filter)) = store.sample_edge_with_property()? {
10798 return Ok((edge, vec![filter]));
10799 }
10800 let edge = store
10801 .sample_edge(None)?
10802 .context("backend-eval edge scan requires at least one edge")?;
10803 Ok((edge, Vec::new()))
10804}
10805
10806#[allow(clippy::too_many_arguments)]
10807fn graph_db_backend_eval_report_for_store<S: GraphStore>(
10808 backend: &str,
10809 adapter: &str,
10810 read_only: bool,
10811 root: &Path,
10812 path: &Path,
10813 scope: Option<&str>,
10814 targets: &[String],
10815 depth: usize,
10816 limit: usize,
10817 impact_limit: usize,
10818 store: &S,
10819 freshness: GraphDbFreshnessReport,
10820 refresh_operation: GraphDbBackendEvalOperation,
10821 refresh_signature: Option<GraphDbBackendEvalSignature>,
10822 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10823 extra_warnings: Vec<String>,
10824 prepared: &ConflictMatrixPreparedInputs,
10825 projection_load: &str,
10826 lock_behavior: &str,
10827 install_portability: &str,
10828) -> (
10829 GraphDbBackendEvalBackendReport,
10830 Vec<GraphDbBackendEvalSignature>,
10831) {
10832 let mut operations = vec![refresh_operation];
10833 let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
10834
10835 let (operation, signature) = graph_db_backend_eval_timed("status", || {
10836 let (nodes, edges) = store.graph_counts()?;
10837 Ok((
10838 Some(nodes + edges),
10839 serde_json::json!({
10840 "freshness": freshness.status,
10841 "nodes": nodes,
10842 "edges": edges,
10843 }),
10844 ))
10845 });
10846 operations.push(operation);
10847 signatures.extend(signature);
10848
10849 let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
10850 let edge = store
10851 .sample_edge(None)?
10852 .context("backend-eval edge lookup requires at least one edge")?;
10853 let edge_id = graph_db_edge_key(&edge);
10854 let found = store
10855 .edge(&edge_id)?
10856 .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
10857 Ok((
10858 Some(1),
10859 serde_json::json!({
10860 "edge_id": edge_id,
10861 "from_id": found.from_id,
10862 "to_id": found.to_id,
10863 "kind": found.kind,
10864 }),
10865 ))
10866 });
10867 operations.push(operation);
10868 signatures.extend(signature);
10869
10870 let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
10871 let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
10872 let page = store.paged_edges(
10873 Some(&edge.kind),
10874 GraphQueryOptions {
10875 limit: Some(limit.max(1)),
10876 property_filters: filters.clone(),
10877 ..GraphQueryOptions::default()
10878 },
10879 )?;
10880 Ok((
10881 Some(page.edges.len()),
10882 serde_json::json!({
10883 "kind": edge.kind,
10884 "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
10885 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10886 "truncated": page.page.truncated,
10887 }),
10888 ))
10889 });
10890 operations.push(operation);
10891 signatures.extend(signature);
10892
10893 let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
10894 let edge = store
10895 .sample_edge(None)?
10896 .context("backend-eval incident edge scan requires at least one edge")?;
10897 let page = store.paged_incident_edges(
10898 &edge.from_id,
10899 Some(&edge.kind),
10900 GraphQueryOptions {
10901 limit: Some(limit.max(1)),
10902 ..GraphQueryOptions::default()
10903 },
10904 )?;
10905 Ok((
10906 Some(page.edges.len()),
10907 serde_json::json!({
10908 "node_id": edge.from_id,
10909 "kind": edge.kind,
10910 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10911 "truncated": page.page.truncated,
10912 }),
10913 ))
10914 });
10915 operations.push(operation);
10916 signatures.extend(signature);
10917
10918 let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
10919 operations.push(operation);
10920 signatures.extend(signature);
10921
10922 let (operation, signature) =
10923 graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
10924 operations.push(operation);
10925 signatures.extend(signature);
10926
10927 for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
10928 .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
10929 {
10930 let (operation, signature) =
10931 graph_db_backend_eval_path_operation(store, configured_max_hops);
10932 operations.push(operation);
10933 signatures.extend(signature);
10934 }
10935
10936 let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
10937 let resolved = targets
10938 .iter()
10939 .map(|target| {
10940 let node = graph_db_resolve_evidence_target(store, target)?
10941 .with_context(|| format!("backend-eval target not found: {target}"))?;
10942 Ok((target.clone(), node))
10943 })
10944 .collect::<Result<Vec<_>>>()?;
10945 let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
10946 Ok((Some(resolved.len()), signature))
10947 });
10948 operations.push(operation);
10949 signatures.extend(signature);
10950
10951 let mut evidence_for_report = None;
10952 let mut graph_snapshot_for_trace = None;
10953 let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
10954 let resolved_targets =
10955 resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
10956 let evidence = collect_conflict_matrix_evidence_packets(
10957 root,
10958 scope,
10959 backend,
10960 &resolved_targets,
10961 depth,
10962 limit,
10963 store,
10964 freshness.clone(),
10965 )?;
10966 let report = &evidence
10967 .first()
10968 .context("backend-eval evidence requires at least one target")?
10969 .report;
10970 let rows = evidence
10971 .iter()
10972 .map(|entry| {
10973 entry.report.worker_context.len()
10974 + entry.report.source_handles.len()
10975 + entry.report.worker_results.len()
10976 + entry.report.semantic_related.len()
10977 })
10978 .sum();
10979 let signature = graph_db_backend_eval_evidence_signature(report);
10980 evidence_for_report = Some((resolved_targets, evidence));
10981 Ok((Some(rows), signature))
10982 });
10983 operations.push(operation);
10984 signatures.extend(signature);
10985
10986 let mut conflict_for_trace = None;
10987 let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
10988 let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
10989 let graph =
10990 conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
10991 let shared_preparation =
10992 conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
10993 ConflictMatrixGraphPreparedInputs {
10994 targets,
10995 graph,
10996 evidence,
10997 shared_preparation,
10998 }
10999 } else {
11000 prepare_conflict_matrix_graph_orchestration(
11001 root,
11002 scope,
11003 backend,
11004 targets,
11005 prepared,
11006 depth,
11007 limit,
11008 store,
11009 freshness.clone(),
11010 )?
11011 };
11012 let report = build_conflict_matrix_report_from_prepared_graph(
11013 root,
11014 path,
11015 scope,
11016 depth,
11017 limit,
11018 impact_limit,
11019 freshness.clone(),
11020 extra_warnings.clone(),
11021 prepared,
11022 &graph_prepared,
11023 )?;
11024 let signature = graph_db_backend_eval_conflict_signature(&report);
11025 let rows = report.candidates.len() + report.conflicts.len();
11026 conflict_for_trace = Some(report);
11027 graph_snapshot_for_trace = Some(graph_prepared.graph);
11028 Ok((Some(rows), signature))
11029 });
11030 operations.push(operation);
11031 signatures.extend(signature);
11032
11033 let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11034 let conflict = conflict_for_trace
11035 .take()
11036 .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11037 let graph = graph_snapshot_for_trace
11038 .take()
11039 .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11040 let report = build_dispatch_trace_report_from_conflict_snapshot(
11041 root,
11042 scope,
11043 conflict,
11044 graph.nodes,
11045 graph.edges,
11046 depth,
11047 limit,
11048 Vec::new(),
11049 )?;
11050 Ok((
11051 Some(report.nodes.len() + report.edges.len()),
11052 graph_db_backend_eval_dispatch_signature(&report),
11053 ))
11054 });
11055 operations.push(operation);
11056 signatures.extend(signature);
11057
11058 let total_micros = operations
11059 .iter()
11060 .map(|operation| operation.duration_micros)
11061 .sum();
11062 let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11063 (
11064 GraphDbBackendEvalBackendReport {
11065 backend: backend.to_string(),
11066 adapter: adapter.to_string(),
11067 read_only,
11068 projection_load: projection_load.to_string(),
11069 operations,
11070 total_micros,
11071 parity,
11072 lock_behavior: lock_behavior.to_string(),
11073 install_portability: install_portability.to_string(),
11074 },
11075 signatures,
11076 )
11077}
11078
11079pub(crate) fn graph_db_backend_eval_refresh_operation(
11080 duration_micros: u128,
11081 rows: usize,
11082 value: serde_json::Value,
11083) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11084 (
11085 GraphDbBackendEvalOperation {
11086 name: "refresh".to_string(),
11087 supported: true,
11088 status: "ok".to_string(),
11089 duration_micros,
11090 rows: Some(rows),
11091 error: None,
11092 },
11093 GraphDbBackendEvalSignature {
11094 operation: "refresh".to_string(),
11095 value,
11096 },
11097 )
11098}
11099
11100pub(crate) fn graph_db_backend_eval_synthetic_projection(
11101 nodes: usize,
11102 fanout: usize,
11103) -> GraphProjection {
11104 let nodes = nodes.max(12);
11105 let symbol_count = nodes.saturating_sub(9).max(1);
11106 let source = GraphProvenance::new("backend-eval", "synthetic");
11107 let mut projection_nodes = vec![
11108 SubstrateGraphNode::new(
11109 "projection:tsift-traversal:synthetic",
11110 GRAPH_PROJECTION_META_KIND,
11111 "synthetic projection",
11112 )
11113 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11114 .with_property(
11115 "content_hash",
11116 format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11117 )
11118 .with_provenance(source.clone()),
11119 SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11120 .with_property("ref_id", "synthetic-session"),
11121 SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11122 .with_property("ref_id", "synthetic")
11123 .with_property("path", "tasks/software/synthetic.md")
11124 .with_property("line", "1")
11125 .with_property(
11126 "expand",
11127 "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11128 ),
11129 SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11130 .with_property("ref_id", "synthetic"),
11131 SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11132 .with_property("target", "synthetic")
11133 .with_property("summary", "Synthetic worker owns synthetic.rs")
11134 .with_property(
11135 "expand",
11136 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11137 ),
11138 SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11139 .with_property("file", "synthetic.rs")
11140 .with_property("start", "1")
11141 .with_property("end", "80")
11142 .with_property(
11143 "expand",
11144 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11145 ),
11146 SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11147 .with_property("path", "synthetic.rs"),
11148 SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11149 .with_property("handle", "gsem-synthetic")
11150 .with_property("label", "backend evaluation")
11151 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11152 .with_property(
11153 "embedding",
11154 semantic_embedding_property("backend evaluation"),
11155 ),
11156 SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11157 .with_property("ref_id", "synthetic")
11158 .with_property("status", "completed")
11159 .with_property("touched_files", "synthetic.rs")
11160 .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11161 ];
11162 for idx in 0..symbol_count {
11163 projection_nodes.push(
11164 SubstrateGraphNode::new(
11165 format!("gsym-synthetic-{idx:04}"),
11166 "symbol",
11167 format!("synthetic_symbol_{idx:04}"),
11168 )
11169 .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11170 .with_property("path", "synthetic.rs")
11171 .with_property("line", (idx + 1).to_string()),
11172 );
11173 }
11174
11175 let mut projection_edges = vec![
11176 SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11177 SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11178 SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11179 SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11180 SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11181 SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11182 SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11183 SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11184 SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11185 ];
11186 for idx in 0..symbol_count {
11187 let from = format!("gsym-synthetic-{idx:04}");
11188 for offset in 1..=fanout.max(1).min(symbol_count) {
11189 let to_idx = (idx + offset) % symbol_count;
11190 if to_idx != idx {
11191 projection_edges.push(SubstrateGraphEdge::new(
11192 from.clone(),
11193 format!("gsym-synthetic-{to_idx:04}"),
11194 "calls",
11195 ));
11196 }
11197 }
11198 }
11199
11200 GraphProjection {
11201 nodes: projection_nodes,
11202 edges: projection_edges
11203 .into_iter()
11204 .map(|edge| {
11205 edge.with_property("dataset", "synthetic")
11206 .with_provenance(source.clone())
11207 })
11208 .collect(),
11209 }
11210}
11211
11212pub(crate) fn graph_db_backend_eval_promotion(
11213 datasets: &[GraphDbBackendEvalDataset],
11214 candidates: &[GraphDbExperimentalBackend],
11215) -> Vec<GraphDbBackendPromotionDecision> {
11216 let mut decisions = Vec::new();
11217 for candidate in candidates {
11218 let mut reasons = Vec::new();
11219 let mut faster_everywhere = true;
11220 let mut parity_everywhere = true;
11221 for dataset in datasets {
11222 let Some(sqlite_report) = dataset
11223 .backends
11224 .iter()
11225 .find(|backend| backend.backend == "sqlite")
11226 else {
11227 parity_everywhere = false;
11228 faster_everywhere = false;
11229 reasons.push(format!(
11230 "{} dataset is missing SQLite baseline",
11231 dataset.name
11232 ));
11233 continue;
11234 };
11235 let sqlite_total = sqlite_report.total_micros;
11236 let Some(candidate_report) = dataset
11237 .backends
11238 .iter()
11239 .find(|backend| backend.backend == candidate.name())
11240 else {
11241 parity_everywhere = false;
11242 reasons.push(format!("{} dataset did not run", dataset.name));
11243 continue;
11244 };
11245 if !candidate_report.parity.matches_sqlite {
11246 parity_everywhere = false;
11247 reasons.push(format!("{} parity differed from SQLite", dataset.name));
11248 }
11249 if candidate_report.total_micros >= sqlite_total {
11250 faster_everywhere = false;
11251 reasons.push(format!(
11252 "{} total {}us did not beat SQLite {}us",
11253 dataset.name, candidate_report.total_micros, sqlite_total
11254 ));
11255 }
11256 let sqlite_operations = sqlite_report
11257 .operations
11258 .iter()
11259 .map(|operation| (operation.name.as_str(), operation.duration_micros))
11260 .collect::<BTreeMap<_, _>>();
11261 for operation in &candidate_report.operations {
11262 if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11263 && operation.duration_micros >= *sqlite_duration
11264 {
11265 faster_everywhere = false;
11266 reasons.push(format!(
11267 "{} {} operation {}us did not beat SQLite {}us",
11268 dataset.name, operation.name, operation.duration_micros, sqlite_duration
11269 ));
11270 }
11271 }
11272 if candidate_report
11273 .operations
11274 .iter()
11275 .any(|operation| operation.status != "ok")
11276 {
11277 parity_everywhere = false;
11278 reasons.push(format!("{} has failed benchmark operations", dataset.name));
11279 }
11280 }
11281 let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11282 reasons.push(reason.to_string());
11283 reasons.push(
11284 "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11285 .to_string(),
11286 );
11287 "hold"
11288 } else if parity_everywhere && faster_everywhere {
11289 reasons.push(
11290 "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11291 .to_string(),
11292 );
11293 "eligible"
11294 } else {
11295 reasons.push(
11296 "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11297 .to_string(),
11298 );
11299 "hold"
11300 };
11301 decisions.push(GraphDbBackendPromotionDecision {
11302 backend: candidate.name().to_string(),
11303 decision: decision.to_string(),
11304 reasons: dedupe_preserve_order(reasons),
11305 gate: candidate.promotion_gate(),
11306 });
11307 }
11308 decisions
11309}
11310
11311pub(crate) fn graph_db_backend_eval_metrics(
11312 datasets: &[GraphDbBackendEvalDataset],
11313) -> BTreeMap<String, f64> {
11314 let mut metrics = BTreeMap::new();
11315 for dataset in datasets {
11316 let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11317 metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11318 metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11319 metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11320 for backend in &dataset.backends {
11321 let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
11322 metrics.insert(
11323 format!("{prefix}.total_duration_micros"),
11324 backend.total_micros as f64,
11325 );
11326 append_graph_db_backend_eval_normalized_duration_metric(
11327 &mut metrics,
11328 &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
11329 backend.total_micros,
11330 graph_rows,
11331 );
11332 for operation in &backend.operations {
11333 metrics.insert(
11334 format!("{prefix}.{}.duration_micros", operation.name),
11335 operation.duration_micros as f64,
11336 );
11337 append_graph_db_backend_eval_normalized_duration_metric(
11338 &mut metrics,
11339 &format!(
11340 "{prefix}.{}.duration_micros_per_1k_graph_rows",
11341 operation.name
11342 ),
11343 operation.duration_micros,
11344 graph_rows,
11345 );
11346 if let Some(rows) = operation.rows {
11347 metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
11348 }
11349 }
11350 }
11351 }
11352 metrics
11353}
11354
11355pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
11356 dataset.nodes + dataset.edges
11357}
11358
11359pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
11360 metrics: &mut BTreeMap<String, f64>,
11361 key: &str,
11362 duration_micros: u128,
11363 graph_rows: usize,
11364) {
11365 if graph_rows == 0 {
11366 return;
11367 }
11368 metrics.insert(
11369 key.to_string(),
11370 duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
11371 );
11372}
11373
11374pub(crate) fn append_graph_db_backend_eval_phase_metrics(
11375 metrics: &mut BTreeMap<String, f64>,
11376 dataset: &str,
11377 graph_rows: usize,
11378 phases: &[GraphDbBackendEvalPhaseTiming],
11379) {
11380 for phase in phases {
11381 metrics.insert(
11382 format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
11383 phase.duration_micros as f64,
11384 );
11385 append_graph_db_backend_eval_normalized_duration_metric(
11386 metrics,
11387 &format!(
11388 "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
11389 phase.name
11390 ),
11391 phase.duration_micros,
11392 graph_rows,
11393 );
11394 }
11395}
11396
11397fn graph_db_backend_eval_base_command(
11398 root: &Path,
11399 scope: Option<&str>,
11400 full_projection: bool,
11401) -> String {
11402 let full_projection_arg = if full_projection {
11403 " --full-projection"
11404 } else {
11405 ""
11406 };
11407 format!(
11408 "tsift graph-db --path {}{} --json backend-eval{}",
11409 shell_quote(root.to_string_lossy().as_ref()),
11410 graph_db_scope_arg(scope),
11411 full_projection_arg
11412 )
11413}
11414
11415pub(crate) fn graph_db_backend_eval_metric_digest_command(
11416 root: &Path,
11417 scope: Option<&str>,
11418 full_projection: bool,
11419) -> String {
11420 format!(
11421 "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11422 graph_db_backend_eval_base_command(root, scope, full_projection)
11423 )
11424}
11425
11426fn graph_db_backend_eval_repeated_sample_command(
11427 root: &Path,
11428 scope: Option<&str>,
11429 full_projection: bool,
11430) -> String {
11431 format!(
11432 "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11433 graph_db_backend_eval_base_command(root, scope, full_projection)
11434 )
11435}
11436
11437fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
11438 let mut required_metrics = Vec::new();
11439 for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
11440 required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
11441 required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
11442 for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
11443 required_metrics.push(format!(
11444 "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
11445 ));
11446 required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
11447 }
11448 }
11449 GraphDbHopCapPromotionGate {
11450 status: "hold_64_default_until_gate_passes".to_string(),
11451 current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
11452 candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
11453 required_backend: perf_gate::BASELINE_BACKEND.to_string(),
11454 required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
11455 .iter()
11456 .map(|workload| (*workload).to_string())
11457 .collect(),
11458 required_metrics,
11459 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11460 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11461 decision_rule:
11462 "keep 64 as the user-facing default until each candidate tier has repeated real, full_projection, and synthetic_deep_chain SQLite samples within the latency-regression budget and returning useful path rows; full_projection samples are binding only after a cold populate leg proves a cache-hit leg"
11463 .to_string(),
11464 }
11465}
11466
11467fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
11468 let candidate_backends = [
11469 GraphDbExperimentalBackend::Falkordb,
11470 GraphDbExperimentalBackend::Kuzu,
11471 GraphDbExperimentalBackend::Surrealdb,
11472 ]
11473 .into_iter()
11474 .map(|backend| GraphDbBackendAdapterSpikeCandidate {
11475 backend: backend.name().to_string(),
11476 adapter_label: backend.adapter_label().to_string(),
11477 projection_load: backend.projection_load().to_string(),
11478 lock_behavior: backend.lock_behavior().to_string(),
11479 install_portability: backend.install_portability().to_string(),
11480 })
11481 .collect();
11482
11483 GraphDbBackendAdapterSpikeGate {
11484 status: "hold_real_optional_adapter_required".to_string(),
11485 candidate_backends,
11486 required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
11487 .iter()
11488 .map(|workload| (*workload).to_string())
11489 .collect(),
11490 required_checks: vec![
11491 "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
11492 "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
11493 "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
11494 "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
11495 "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
11496 .to_string(),
11497 "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
11498 "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
11499 ],
11500 decision_rule:
11501 "do not promote a read-only prototype; FalkorDB, Kuzu, or SurrealDB can only advance after a real optional adapter proves projection writes/load, lock semantics, install portability, full parity, and faster-than-SQLite results across every required workload"
11502 .to_string(),
11503 evidence_plan: "plans/gback-evidence.md".to_string(),
11504 }
11505}
11506
11507pub(crate) fn graph_db_backend_eval_performance_gate(
11508 root: &Path,
11509 scope: Option<&str>,
11510 full_projection: bool,
11511) -> GraphDbBackendEvalPerformanceGate {
11512 let mut required_metrics = vec![
11513 "real.sqlite.refresh.duration_micros".to_string(),
11514 "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
11515 "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
11516 "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
11517 "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11518 "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11519 "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
11520 "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
11521 "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11522 "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
11523 "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
11524 "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
11525 "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
11526 "real.sqlite.conflict_matrix.duration_micros".to_string(),
11527 "real.sqlite.dispatch_trace.duration_micros".to_string(),
11528 "real.sqlite.path_max_hops.duration_micros".to_string(),
11529 "real.sqlite.path_max_hops_128.duration_micros".to_string(),
11530 "real.sqlite.path_max_hops_256.duration_micros".to_string(),
11531 "real.sqlite.path_max_hops_512.duration_micros".to_string(),
11532 "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
11533 "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
11534 "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
11535 "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
11536 "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11537 "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11538 "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
11539 .to_string(),
11540 "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11541 .to_string(),
11542 "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11543 "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11544 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
11545 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
11546 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
11547 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
11548 "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11549 .to_string(),
11550 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
11551 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
11552 .to_string(),
11553 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
11554 .to_string(),
11555 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
11556 .to_string(),
11557 ];
11558 if full_projection {
11559 required_metrics.extend([
11560 "full_projection.cache.hit".to_string(),
11561 "full_projection.cache.disk_bytes".to_string(),
11562 "full_projection.cache.compression_ratio".to_string(),
11563 "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
11564 "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11565 "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
11566 .to_string(),
11567 "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
11568 .to_string(),
11569 "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
11570 "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
11571 "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
11572 "full_projection.sqlite.neighborhood.duration_micros".to_string(),
11573 "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
11574 "full_projection.sqlite.evidence.duration_micros".to_string(),
11575 "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
11576 "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
11577 "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
11578 "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
11579 "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
11580 "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
11581 ]);
11582 }
11583 GraphDbBackendEvalPerformanceGate {
11584 baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
11585 ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
11586 .to_string(),
11587 opt_in_real_profile:
11588 "pass --full-projection to add the full-project dataset when checking for large projection regressions"
11589 .to_string(),
11590 full_projection_cache_hit_gate: if full_projection {
11591 "binding full_projection performance evidence requires a cold populate leg followed by cache-leg samples with full_projection.cache.hit=1; cache-miss samples are diagnostics, not backend or hop-cap promotion proof"
11592 .to_string()
11593 } else {
11594 "not evaluated until --full-projection is enabled".to_string()
11595 },
11596 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11597 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11598 normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
11599 required_metrics,
11600 digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
11601 repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
11602 root,
11603 scope,
11604 full_projection,
11605 ),
11606 hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
11607 backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
11608 }
11609}
11610
11611#[cfg(feature = "backend-surrealdb")]
11612fn graph_db_backend_eval_path_segment(value: &str) -> String {
11613 value
11614 .chars()
11615 .map(|ch| {
11616 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
11617 ch
11618 } else {
11619 '_'
11620 }
11621 })
11622 .collect()
11623}
11624
11625#[cfg(feature = "backend-surrealdb")]
11626fn graph_db_backend_eval_surrealdb_store_path(
11627 root: &Path,
11628 scope: Option<&str>,
11629 dataset: &str,
11630) -> PathBuf {
11631 root.join(".tsift/backend-eval-cache/surrealdb")
11632 .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
11633 .join(graph_db_backend_eval_path_segment(dataset))
11634 .join("surrealkv")
11635}
11636
11637pub(crate) struct GraphDbBackendEvalOptions<'a> {
11638 path: &'a Path,
11639 scope: Option<&'a str>,
11640 candidates: &'a [String],
11641 targets: &'a [String],
11642 full_projection: bool,
11643}
11644
11645#[allow(clippy::too_many_arguments)]
11646pub(crate) fn graph_db_backend_eval_dataset(
11647 name: &str,
11648 root: &Path,
11649 path: &Path,
11650 scope: Option<&str>,
11651 targets: &[String],
11652 depth: usize,
11653 limit: usize,
11654 impact_limit: usize,
11655 candidates: &[GraphDbExperimentalBackend],
11656 sqlite_store: &SqliteGraphStore,
11657 sqlite_freshness: GraphDbFreshnessReport,
11658 sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
11659 sqlite_rows: ConvexProjectionRows,
11660 extra_warnings: Vec<String>,
11661 prepared: &ConflictMatrixPreparedInputs,
11662) -> Result<GraphDbBackendEvalDataset> {
11663 let (nodes, edges) = sqlite_store.graph_counts()?;
11664 let (sqlite_operation, sqlite_signature) = sqlite_refresh;
11665 let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
11666 "sqlite",
11667 "SQLite GraphStore correctness baseline",
11668 false,
11669 root,
11670 path,
11671 scope,
11672 targets,
11673 depth,
11674 limit,
11675 impact_limit,
11676 sqlite_store,
11677 sqlite_freshness,
11678 sqlite_operation,
11679 Some(sqlite_signature),
11680 None,
11681 extra_warnings.clone(),
11682 prepared,
11683 "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
11684 "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
11685 "bundled rusqlite baseline; no external service or runtime required",
11686 );
11687
11688 let mut backends = vec![sqlite_report];
11689 for candidate in candidates {
11690 #[cfg(feature = "backend-surrealdb")]
11691 if *candidate == GraphDbExperimentalBackend::Surrealdb {
11692 let started = Instant::now();
11693 let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
11694 let (store, warm_start) =
11695 SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
11696 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11697 let rows = candidate_nodes + candidate_edges;
11698 let mut refresh_meta = serde_json::json!({
11699 "nodes": candidate_nodes,
11700 "edges": candidate_edges,
11701 });
11702 if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
11703 refresh_meta["warm_start"] = serde_json::json!("cache_hit");
11704 }
11705 let refresh = graph_db_backend_eval_refresh_operation(
11706 started.elapsed().as_micros(),
11707 rows,
11708 refresh_meta,
11709 );
11710 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11711 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11712 candidate.name(),
11713 "SurrealDB SurrealKV optional adapter spike",
11714 false,
11715 root,
11716 path,
11717 scope,
11718 targets,
11719 depth,
11720 limit,
11721 impact_limit,
11722 &store,
11723 freshness,
11724 refresh.0,
11725 Some(refresh.1),
11726 Some(&sqlite_signatures),
11727 extra_warnings.clone(),
11728 prepared,
11729 "provider-neutral rows written into an embedded/file-backed SurrealDB SurrealKV store through the optional tsift-surrealdb adapter; warm-start reuses existing store when row hash matches",
11730 "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
11731 "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
11732 );
11733 backends.push(candidate_report);
11734 continue;
11735 }
11736 let started = Instant::now();
11737 let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
11738 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11739 let rows = candidate_nodes + candidate_edges;
11740 let refresh = graph_db_backend_eval_refresh_operation(
11741 started.elapsed().as_micros(),
11742 rows,
11743 serde_json::json!({
11744 "nodes": candidate_nodes,
11745 "edges": candidate_edges,
11746 }),
11747 );
11748 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11749 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11750 candidate.name(),
11751 candidate.adapter_label(),
11752 true,
11753 root,
11754 path,
11755 scope,
11756 targets,
11757 depth,
11758 limit,
11759 impact_limit,
11760 &store,
11761 freshness,
11762 refresh.0,
11763 Some(refresh.1),
11764 Some(&sqlite_signatures),
11765 extra_warnings.clone(),
11766 prepared,
11767 candidate.projection_load(),
11768 candidate.lock_behavior(),
11769 candidate.install_portability(),
11770 );
11771 backends.push(candidate_report);
11772 }
11773
11774 Ok(GraphDbBackendEvalDataset {
11775 name: name.to_string(),
11776 target_count: targets.len(),
11777 nodes,
11778 edges,
11779 backends,
11780 })
11781}
11782
11783pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
11784 println!(
11785 "graph-db backend-eval baseline:{} candidates:{}",
11786 report.baseline_backend,
11787 report.candidates.join(", ")
11788 );
11789 for phase in &report.phase_timings {
11790 println!(
11791 "phase:{} {}us {}",
11792 phase.name, phase.duration_micros, phase.detail
11793 );
11794 }
11795 for dataset in &report.datasets {
11796 println!(
11797 "dataset:{} targets:{} rows:{}",
11798 dataset.name,
11799 dataset.target_count,
11800 dataset.nodes + dataset.edges
11801 );
11802 for backend in &dataset.backends {
11803 println!(
11804 " backend:{} total:{}us parity:{}",
11805 backend.backend, backend.total_micros, backend.parity.matches_sqlite
11806 );
11807 println!(" projection-load: {}", backend.projection_load);
11808 println!(" lock-behavior: {}", backend.lock_behavior);
11809 println!(" install-portability: {}", backend.install_portability);
11810 for operation in &backend.operations {
11811 println!(
11812 " {} {} {}us",
11813 operation.name, operation.status, operation.duration_micros
11814 );
11815 }
11816 for diagnostic in &backend.parity.diagnostics {
11817 println!(" parity: {diagnostic}");
11818 }
11819 }
11820 }
11821 for decision in &report.promotion {
11822 println!("promotion {}: {}", decision.backend, decision.decision);
11823 println!(" gate: {}", decision.gate.status);
11824 for reason in &decision.reasons {
11825 println!(" reason: {reason}");
11826 }
11827 for check in &decision.gate.required_checks {
11828 println!(" check: {check}");
11829 }
11830 }
11831 println!("metric-digest: {}", report.metric_digest_command);
11832 println!(
11833 "repeat-samples: {}",
11834 report.performance_gate.repeated_sample_command
11835 );
11836}
11837
11838fn traversal_expand_command(root: &Path, handle: &str) -> String {
11839 format!(
11840 "tsift traverse {} --path {} --depth 1 --limit 50",
11841 shell_quote(handle),
11842 shell_quote(root.to_string_lossy().as_ref())
11843 )
11844}
11845
11846fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
11847 let display = relativize(file, root);
11848 let handle = stable_handle("gfil", &format!("file:{display}"));
11849 TraversalNode {
11850 handle: handle.clone(),
11851 kind: "file".to_string(),
11852 label: display.clone(),
11853 ref_id: Some(display.clone()),
11854 path: Some(display),
11855 line: None,
11856 detail: None,
11857 properties: BTreeMap::new(),
11858 expand: traversal_expand_command(root, &handle),
11859 }
11860}
11861
11862fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
11863 let mut node = traversal_file_node(root, file);
11864 if let Some(path) = node.path.clone() {
11865 node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
11866 node.expand = source_read_command(root, &path, 1, 80);
11867 }
11868 node
11869}
11870
11871fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
11872 let file = relativize(&symbol.file, root);
11873 let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
11874 let handle = stable_handle("gsym", &key);
11875 TraversalNode {
11876 handle: handle.clone(),
11877 kind: "symbol".to_string(),
11878 label: symbol.name.clone(),
11879 ref_id: Some(symbol.name.clone()),
11880 path: Some(file),
11881 line: Some(symbol.line),
11882 detail: Some(format!("{} {}", symbol.language, symbol.kind)),
11883 properties: BTreeMap::new(),
11884 expand: traversal_expand_command(root, &handle),
11885 }
11886}
11887
11888fn traversal_ast_span_expand_command(
11889 root: &Path,
11890 file: &str,
11891 symbol: &index::StoredSymbol,
11892 span: &AstSpanPreview,
11893) -> String {
11894 if symbol.language == "markdown" {
11895 markdown_ast_command(root, file, Some(&span.handle))
11896 } else {
11897 let line_count = span
11898 .end_line
11899 .saturating_sub(span.start_line)
11900 .saturating_add(1)
11901 .max(1);
11902 source_read_command(root, file, span.start_line, line_count)
11903 }
11904}
11905
11906fn traversal_ast_span_node(
11907 root: &Path,
11908 symbol: &index::StoredSymbol,
11909 source: &[u8],
11910 symbols: &[index::StoredSymbol],
11911) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
11912 let span = stored_symbol_ast_span(symbol, source, symbols, usize::MAX)?;
11913 let file = relativize(&symbol.file, root);
11914 let mut properties = BTreeMap::new();
11915 properties.insert("layer".to_string(), "ast_navigation".to_string());
11916 properties.insert("language".to_string(), symbol.language.clone());
11917 properties.insert("symbol_kind".to_string(), symbol.kind.clone());
11918 properties.insert("node_kind".to_string(), span.node_kind.clone());
11919 properties.insert("start_byte".to_string(), span.start_byte.to_string());
11920 properties.insert("end_byte".to_string(), span.end_byte.to_string());
11921 properties.insert("end_line".to_string(), span.end_line.to_string());
11922 if let Some(body_start_byte) = span.body_start_byte {
11923 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
11924 }
11925 if let Some(body_end_byte) = span.body_end_byte {
11926 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
11927 }
11928 if let Some(body_start_line) = span.body_start_line {
11929 properties.insert("body_start_line".to_string(), body_start_line.to_string());
11930 }
11931 if let Some(body_end_line) = span.body_end_line {
11932 properties.insert("body_end_line".to_string(), body_end_line.to_string());
11933 }
11934 if let Some(parent_handle) = &span.parent_handle {
11935 properties.insert("parent_handle".to_string(), parent_handle.clone());
11936 }
11937 if !span.child_handles.is_empty() {
11938 properties.insert("child_handles".to_string(), span.child_handles.join(","));
11939 }
11940 if let Some(parent_module) = &symbol.parent_module {
11941 properties.insert("parent_module".to_string(), parent_module.clone());
11942 }
11943 if let Some(markdown) = &span.markdown {
11944 properties.insert(
11945 "markdown_block_kind".to_string(),
11946 markdown_ast_block_kind(&symbol.kind),
11947 );
11948 if let Some(heading_level) = markdown.heading_level {
11949 properties.insert("heading_level".to_string(), heading_level.to_string());
11950 }
11951 if !markdown.section_path.is_empty() {
11952 properties.insert(
11953 "section_path".to_string(),
11954 markdown.section_path.join(" > "),
11955 );
11956 }
11957 if let Some(section_handle) = &markdown.section_handle {
11958 properties.insert("section_handle".to_string(), section_handle.clone());
11959 }
11960 if let Some(list_depth) = markdown.list_depth {
11961 properties.insert("list_depth".to_string(), list_depth.to_string());
11962 }
11963 if let Some(fence_language) = &markdown.fence_language {
11964 properties.insert("fence_language".to_string(), fence_language.clone());
11965 }
11966 }
11967
11968 let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
11969 let node = TraversalNode {
11970 handle: span.handle.clone(),
11971 kind: "ast_span".to_string(),
11972 label: symbol.name.clone(),
11973 ref_id: Some(symbol.name.clone()),
11974 path: Some(file.clone()),
11975 line: Some(line),
11976 detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
11977 properties,
11978 expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
11979 };
11980 let entry = TraversalAstSpanIndexEntry {
11981 handle: span.handle,
11982 symbol_handle: String::new(),
11983 file_handle: None,
11984 file,
11985 name: symbol.name.clone(),
11986 kind: symbol.kind.clone(),
11987 language: symbol.language.clone(),
11988 node_kind: span.node_kind,
11989 start_byte: span.start_byte,
11990 end_byte: span.end_byte,
11991 parent_module: symbol.parent_module.clone(),
11992 markdown: span.markdown,
11993 };
11994 Some((node, entry))
11995}
11996
11997fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
11998 let handle = stable_handle("gsym", &format!("symbol:{name}"));
11999 TraversalNode {
12000 handle: handle.clone(),
12001 kind: "symbol".to_string(),
12002 label: name.to_string(),
12003 ref_id: Some(name.to_string()),
12004 path: None,
12005 line: None,
12006 detail: Some("unresolved call target".to_string()),
12007 properties: BTreeMap::new(),
12008 expand: traversal_expand_command(root, &handle),
12009 }
12010}
12011
12012fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12013 let file = relativize(&route.file, root);
12014 let method = route.method.as_deref().unwrap_or("any");
12015 let key = format!(
12016 "route:{file}:{}:{}:{}",
12017 route.line, method, route.route_path
12018 );
12019 let handle = stable_handle("grte", &key);
12020 TraversalNode {
12021 handle: handle.clone(),
12022 kind: "route".to_string(),
12023 label: format!("{} {}", method.to_uppercase(), route.route_path),
12024 ref_id: Some(route.route_path.clone()),
12025 path: Some(file),
12026 line: Some(route.line),
12027 detail: Some(format!(
12028 "{} route handled by {}",
12029 route.framework, route.handler_name
12030 )),
12031 properties: BTreeMap::new(),
12032 expand: traversal_expand_command(root, &handle),
12033 }
12034}
12035
12036fn traversal_cargo_workspace_node(
12037 root: &Path,
12038 workspace: &multiplicity::CargoWorkspaceInfo,
12039) -> TraversalNode {
12040 let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12041 .to_string_lossy()
12042 .replace('\\', "/");
12043 let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12044 .to_string_lossy()
12045 .replace('\\', "/");
12046 let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12047 let mut properties = BTreeMap::new();
12048 properties.insert("layer".to_string(), "cargo_workspace".to_string());
12049 properties.insert("workspace_root".to_string(), workspace_root.clone());
12050 properties.insert("members".to_string(), workspace.members.join(","));
12051 properties.insert(
12052 "default_members".to_string(),
12053 workspace.default_members.join(","),
12054 );
12055 TraversalNode {
12056 handle: handle.clone(),
12057 kind: "cargo_workspace".to_string(),
12058 label: if workspace_root.is_empty() {
12059 "root cargo workspace".to_string()
12060 } else {
12061 workspace_root
12062 },
12063 ref_id: Some(workspace.id.clone()),
12064 path: Some(manifest),
12065 line: None,
12066 detail: Some("Cargo workspace manifest".to_string()),
12067 properties,
12068 expand: traversal_expand_command(root, &handle),
12069 }
12070}
12071
12072fn traversal_cargo_package_node(
12073 root: &Path,
12074 package: &multiplicity::CargoPackageInfo,
12075) -> TraversalNode {
12076 let manifest = relativize_pathbuf(&package.manifest_path, root)
12077 .to_string_lossy()
12078 .replace('\\', "/");
12079 let package_root = relativize_pathbuf(&package.package_root, root)
12080 .to_string_lossy()
12081 .replace('\\', "/");
12082 let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12083 .to_string_lossy()
12084 .replace('\\', "/");
12085 let handle = stable_handle(
12086 "gcpk",
12087 &format!("cargo-package:{manifest}:{}", package.name),
12088 );
12089 let mut properties = BTreeMap::new();
12090 properties.insert("layer".to_string(), "cargo_package".to_string());
12091 properties.insert("package_name".to_string(), package.name.clone());
12092 properties.insert(
12093 "normalized_name".to_string(),
12094 package.normalized_name.clone(),
12095 );
12096 properties.insert("package_root".to_string(), package_root.clone());
12097 properties.insert("workspace_root".to_string(), workspace_root);
12098 properties.insert("features".to_string(), package.features.join(","));
12099 properties.insert("targets".to_string(), package.targets.join(","));
12100 properties.insert(
12101 "dependencies".to_string(),
12102 package
12103 .dependencies
12104 .iter()
12105 .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12106 .collect::<Vec<_>>()
12107 .join(","),
12108 );
12109 TraversalNode {
12110 handle: handle.clone(),
12111 kind: "cargo_package".to_string(),
12112 label: package.name.clone(),
12113 ref_id: Some(package.scope_id.clone()),
12114 path: Some(manifest),
12115 line: None,
12116 detail: Some(format!(
12117 "Cargo package in {}",
12118 if package_root.is_empty() {
12119 "."
12120 } else {
12121 package_root.as_str()
12122 }
12123 )),
12124 properties,
12125 expand: traversal_expand_command(root, &handle),
12126 }
12127}
12128
12129fn traversal_session_node(
12130 root: &Path,
12131 markdown_path: &Path,
12132 session_id: Option<&str>,
12133) -> TraversalNode {
12134 let display = relativize_pathbuf(markdown_path, root)
12135 .to_string_lossy()
12136 .replace('\\', "/");
12137 let handle = stable_handle("gses", &format!("session:{display}"));
12138 TraversalNode {
12139 handle: handle.clone(),
12140 kind: "session".to_string(),
12141 label: session_id.unwrap_or(&display).to_string(),
12142 ref_id: session_id.map(str::to_string),
12143 path: Some(display),
12144 line: None,
12145 detail: Some("agent-doc session artifact".to_string()),
12146 properties: BTreeMap::new(),
12147 expand: traversal_expand_command(root, &handle),
12148 }
12149}
12150
12151fn traversal_backlog_node(
12152 root: &Path,
12153 markdown_path: &Path,
12154 id: &str,
12155 text: &str,
12156 line: i64,
12157) -> TraversalNode {
12158 let display = relativize_pathbuf(markdown_path, root)
12159 .to_string_lossy()
12160 .replace('\\', "/");
12161 let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12162 TraversalNode {
12163 handle: handle.clone(),
12164 kind: "backlog".to_string(),
12165 label: format!("#{id}"),
12166 ref_id: Some(id.to_string()),
12167 path: Some(display),
12168 line: Some(line),
12169 detail: Some(text.to_string()),
12170 properties: BTreeMap::new(),
12171 expand: traversal_expand_command(root, &handle),
12172 }
12173}
12174
12175fn traversal_job_packet_node(
12176 root: &Path,
12177 markdown_path: &Path,
12178 label: &str,
12179 ref_id: Option<&str>,
12180 detail: &str,
12181 line: i64,
12182) -> TraversalNode {
12183 let display = relativize_pathbuf(markdown_path, root)
12184 .to_string_lossy()
12185 .replace('\\', "/");
12186 let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12187 TraversalNode {
12188 handle: handle.clone(),
12189 kind: "job_packet".to_string(),
12190 label: label.to_string(),
12191 ref_id: ref_id.map(str::to_string),
12192 path: Some(display),
12193 line: Some(line),
12194 detail: Some(detail.to_string()),
12195 properties: BTreeMap::new(),
12196 expand: traversal_expand_command(root, &handle),
12197 }
12198}
12199
12200#[derive(Clone, Debug)]
12201struct ParsedWorkerResult {
12202 id: String,
12203 status: String,
12204 touched_files: Vec<String>,
12205 tests: Vec<String>,
12206 follow_up_ids: Vec<String>,
12207}
12208
12209fn traversal_worker_result_node(
12210 root: &Path,
12211 markdown_path: &Path,
12212 parsed: &ParsedWorkerResult,
12213 line_text: &str,
12214 line: i64,
12215) -> TraversalNode {
12216 let display = relativize_pathbuf(markdown_path, root)
12217 .to_string_lossy()
12218 .replace('\\', "/");
12219 let handle = stable_handle(
12220 "wres",
12221 &format!(
12222 "worker-result:{display}:{}:{}:{}",
12223 parsed.id, parsed.status, line
12224 ),
12225 );
12226 let mut properties = BTreeMap::new();
12227 properties.insert("status".to_string(), parsed.status.clone());
12228 if !parsed.touched_files.is_empty() {
12229 properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12230 }
12231 if !parsed.tests.is_empty() {
12232 properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12233 }
12234 if !parsed.follow_up_ids.is_empty() {
12235 properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12236 }
12237 TraversalNode {
12238 handle: handle.clone(),
12239 kind: "worker_result".to_string(),
12240 label: format!("{} #{}", parsed.status, parsed.id),
12241 ref_id: Some(parsed.id.clone()),
12242 path: Some(display),
12243 line: Some(line),
12244 detail: Some(line_text.trim().to_string()),
12245 properties,
12246 expand: traversal_expand_command(root, &handle),
12247 }
12248}
12249
12250fn traversal_tokens(input: &str) -> BTreeSet<String> {
12251 input
12252 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12253 .flat_map(|part| part.split(['_', '-']))
12254 .map(str::trim)
12255 .filter(|part| part.len() >= 3)
12256 .map(|part| part.to_ascii_lowercase())
12257 .collect()
12258}
12259
12260fn traversal_ast_span_contains(
12261 parent: &TraversalAstSpanIndexEntry,
12262 child: &TraversalAstSpanIndexEntry,
12263) -> bool {
12264 parent.handle != child.handle
12265 && parent.file == child.file
12266 && parent.start_byte <= child.start_byte
12267 && parent.end_byte >= child.end_byte
12268}
12269
12270fn traversal_ast_parent_handle<'a>(
12271 entry: &TraversalAstSpanIndexEntry,
12272 entries: &'a [TraversalAstSpanIndexEntry],
12273) -> Option<&'a str> {
12274 entries
12275 .iter()
12276 .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12277 .min_by_key(|candidate| {
12278 (
12279 candidate.end_byte.saturating_sub(candidate.start_byte),
12280 candidate.start_byte,
12281 candidate.end_byte,
12282 candidate.kind.as_str(),
12283 candidate.name.as_str(),
12284 candidate.node_kind.as_str(),
12285 )
12286 })
12287 .map(|candidate| candidate.handle.as_str())
12288}
12289
12290fn traversal_ast_enclosing_module_handle<'a>(
12291 entry: &TraversalAstSpanIndexEntry,
12292 entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12293 parent_by_handle: &BTreeMap<String, String>,
12294) -> Option<&'a str> {
12295 let mut current = parent_by_handle.get(&entry.handle);
12296 while let Some(handle) = current {
12297 let Some(parent) = entries_by_handle.get(handle) else {
12298 break;
12299 };
12300 if matches!(parent.kind.as_str(), "module" | "mod")
12301 || entry
12302 .parent_module
12303 .as_deref()
12304 .is_some_and(|module| module == parent.name)
12305 {
12306 return Some(parent.handle.as_str());
12307 }
12308 current = parent_by_handle.get(&parent.handle);
12309 }
12310 None
12311}
12312
12313fn link_ast_navigation_edges(
12314 graph: &mut TraversalGraphBuild,
12315 entries: &[TraversalAstSpanIndexEntry],
12316) {
12317 let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12318 let entries_by_handle = entries
12319 .iter()
12320 .map(|entry| (entry.handle.clone(), entry.clone()))
12321 .collect::<BTreeMap<_, _>>();
12322 let mut parent_by_handle = BTreeMap::<String, String>::new();
12323 let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
12324
12325 for entry in entries {
12326 entries_by_file
12327 .entry(entry.file.clone())
12328 .or_default()
12329 .push(entry.clone());
12330 }
12331
12332 for file_entries in entries_by_file.values() {
12333 for entry in file_entries {
12334 let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
12335 if let Some(parent) = &parent {
12336 parent_by_handle.insert(entry.handle.clone(), parent.clone());
12337 }
12338 let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
12339 children_by_parent
12340 .entry(sibling_key)
12341 .or_default()
12342 .push(entry.clone());
12343 }
12344 }
12345
12346 for entry in entries {
12347 let parent = parent_by_handle.get(&entry.handle);
12348 if let Some(parent) = parent {
12349 graph.add_edge(
12350 parent,
12351 &entry.handle,
12352 "contains",
12353 Some("AST parent contains child span".to_string()),
12354 1,
12355 );
12356 graph.add_edge(
12357 parent,
12358 &entry.handle,
12359 "child",
12360 Some("AST child span".to_string()),
12361 1,
12362 );
12363 graph.add_edge(
12364 &entry.handle,
12365 parent,
12366 "parent",
12367 Some("AST parent span".to_string()),
12368 1,
12369 );
12370 } else if let Some(file_handle) = &entry.file_handle {
12371 graph.add_edge(
12372 file_handle,
12373 &entry.handle,
12374 "contains",
12375 Some("file contains top-level AST span".to_string()),
12376 1,
12377 );
12378 }
12379
12380 if let Some(module_handle) =
12381 traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
12382 {
12383 graph.add_edge(
12384 &entry.handle,
12385 module_handle,
12386 "enclosing_module",
12387 Some("nearest enclosing module AST span".to_string()),
12388 1,
12389 );
12390 }
12391
12392 if entry.language == "markdown"
12393 && let Some(markdown) = &entry.markdown
12394 && let Some(section_handle) = &markdown.section_handle
12395 && section_handle != &entry.handle
12396 {
12397 graph.add_edge(
12398 section_handle,
12399 &entry.handle,
12400 "contains_markdown_block",
12401 Some("Markdown section contains block".to_string()),
12402 1,
12403 );
12404 graph.add_edge(
12405 &entry.handle,
12406 section_handle,
12407 "enclosing_section",
12408 Some("Markdown enclosing section".to_string()),
12409 1,
12410 );
12411 }
12412 }
12413
12414 for siblings in children_by_parent.values_mut() {
12415 siblings.sort_by(|left, right| {
12416 left.start_byte
12417 .cmp(&right.start_byte)
12418 .then(left.end_byte.cmp(&right.end_byte))
12419 .then(left.kind.cmp(&right.kind))
12420 .then(left.name.cmp(&right.name))
12421 .then(left.node_kind.cmp(&right.node_kind))
12422 .then(left.handle.cmp(&right.handle))
12423 });
12424 for pair in siblings.windows(2) {
12425 let previous = &pair[0];
12426 let next = &pair[1];
12427 graph.add_edge(
12428 &previous.handle,
12429 &next.handle,
12430 "next_sibling",
12431 Some("next AST sibling span".to_string()),
12432 1,
12433 );
12434 graph.add_edge(
12435 &next.handle,
12436 &previous.handle,
12437 "previous_sibling",
12438 Some("previous AST sibling span".to_string()),
12439 1,
12440 );
12441 }
12442 }
12443}
12444
12445fn traversal_markdown_embedded_symbol_node(
12446 root: &Path,
12447 entry: &TraversalAstSpanIndexEntry,
12448 markdown: &MarkdownSpanMetadata,
12449 embedded: &MarkdownEmbeddedSymbol,
12450) -> TraversalNode {
12451 let mut properties = BTreeMap::new();
12452 properties.insert("layer".to_string(), "embedded_code".to_string());
12453 properties.insert("embedded".to_string(), "true".to_string());
12454 properties.insert("language".to_string(), embedded.language.clone());
12455 properties.insert("symbol_kind".to_string(), embedded.kind.clone());
12456 properties.insert("node_kind".to_string(), embedded.node_kind.clone());
12457 properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
12458 properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
12459 properties.insert("end_line".to_string(), embedded.end_line.to_string());
12460 properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
12461 properties.insert(
12462 "markdown_block_kind".to_string(),
12463 markdown_ast_block_kind(&entry.kind),
12464 );
12465 if let Some(body_start_byte) = embedded.body_start_byte {
12466 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12467 }
12468 if let Some(body_end_byte) = embedded.body_end_byte {
12469 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12470 }
12471 if let Some(body_start_line) = embedded.body_start_line {
12472 properties.insert("body_start_line".to_string(), body_start_line.to_string());
12473 }
12474 if let Some(body_end_line) = embedded.body_end_line {
12475 properties.insert("body_end_line".to_string(), body_end_line.to_string());
12476 }
12477 if let Some(fence_language) = &markdown.fence_language {
12478 properties.insert("fence_language".to_string(), fence_language.clone());
12479 }
12480 if !markdown.section_path.is_empty() {
12481 properties.insert(
12482 "section_path".to_string(),
12483 markdown.section_path.join(" > "),
12484 );
12485 }
12486 if let Some(section_handle) = &markdown.section_handle {
12487 properties.insert("section_handle".to_string(), section_handle.clone());
12488 }
12489 let line_count = embedded
12490 .end_line
12491 .saturating_sub(embedded.start_line)
12492 .saturating_add(1)
12493 .max(1);
12494 TraversalNode {
12495 handle: embedded.handle.clone(),
12496 kind: "ast_span".to_string(),
12497 label: embedded.name.clone(),
12498 ref_id: Some(embedded.name.clone()),
12499 path: Some(entry.file.clone()),
12500 line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
12501 detail: Some(format!(
12502 "{} {} embedded in Markdown fence",
12503 embedded.language, embedded.kind
12504 )),
12505 properties,
12506 expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
12507 }
12508}
12509
12510fn link_markdown_embedded_code_edges(
12511 graph: &mut TraversalGraphBuild,
12512 root: &Path,
12513 entries: &[TraversalAstSpanIndexEntry],
12514) {
12515 for entry in entries {
12516 let Some(markdown) = &entry.markdown else {
12517 continue;
12518 };
12519 for embedded in &markdown.embedded_symbols {
12520 let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
12521 graph.add_node(node);
12522 graph.add_edge(
12523 &entry.handle,
12524 &embedded.handle,
12525 "contains",
12526 Some("Markdown fence contains embedded AST symbol".to_string()),
12527 1,
12528 );
12529 graph.add_edge(
12530 &entry.handle,
12531 &embedded.handle,
12532 "child",
12533 Some("embedded code symbol".to_string()),
12534 1,
12535 );
12536 graph.add_edge(
12537 &entry.handle,
12538 &embedded.handle,
12539 "contains_embedded_symbol",
12540 Some("Markdown fence contains embedded code symbol".to_string()),
12541 1,
12542 );
12543 graph.add_edge(
12544 &embedded.handle,
12545 &entry.handle,
12546 "parent",
12547 Some("Markdown fence parent span".to_string()),
12548 1,
12549 );
12550 graph.add_edge(
12551 &embedded.handle,
12552 &entry.handle,
12553 "embedded_in_fence",
12554 Some("embedded code symbol belongs to Markdown fence".to_string()),
12555 1,
12556 );
12557 if let Some(section_handle) = &markdown.section_handle
12558 && section_handle != &entry.handle
12559 {
12560 graph.add_edge(
12561 section_handle,
12562 &embedded.handle,
12563 "contains_embedded_code",
12564 Some("Markdown section contains embedded code symbol".to_string()),
12565 1,
12566 );
12567 graph.add_edge(
12568 &embedded.handle,
12569 section_handle,
12570 "enclosing_section",
12571 Some("Markdown enclosing section".to_string()),
12572 1,
12573 );
12574 }
12575 }
12576 }
12577}
12578
12579fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
12580 let mut tokens = traversal_tokens(&node.label);
12581 if let Some(ref_id) = &node.ref_id {
12582 tokens.extend(traversal_tokens(ref_id));
12583 }
12584 if let Some(path) = &node.path {
12585 tokens.extend(traversal_tokens(path));
12586 }
12587 if let Some(detail) = &node.detail {
12588 tokens.extend(traversal_tokens(detail));
12589 }
12590 tokens
12591}
12592
12593fn parse_agent_doc_session_id(content: &str) -> Option<String> {
12594 content.lines().find_map(|line| {
12595 let trimmed = line.trim();
12596 trimmed
12597 .strip_prefix("agent_doc_session:")
12598 .map(str::trim)
12599 .filter(|value| !value.is_empty())
12600 .map(str::to_string)
12601 })
12602}
12603
12604fn parse_backlog_line(line: &str) -> Option<(String, String)> {
12605 let trimmed = line.trim();
12606 if !trimmed.starts_with("- [") {
12607 return None;
12608 }
12609 let start = trimmed.find("[#")?;
12610 let after_start = start + 2;
12611 let rest = &trimmed[after_start..];
12612 let end = rest.find(']')?;
12613 let id = rest[..end].trim();
12614 if id.is_empty() {
12615 return None;
12616 }
12617 let text = rest[end + 1..].trim().to_string();
12618 Some((id.to_string(), text))
12619}
12620
12621fn parse_queue_dispatch_line(line: &str) -> Option<String> {
12622 let trimmed = line.trim();
12623 ["dispatch ", "preset "].iter().find_map(|prefix| {
12624 trimmed
12625 .strip_prefix(prefix)
12626 .map(str::trim)
12627 .filter(|value| !value.is_empty())
12628 .map(str::to_string)
12629 })
12630}
12631
12632fn parse_queue_do_line(line: &str) -> Option<String> {
12633 let trimmed = line.trim();
12634 let rest = trimmed.strip_prefix("- do [#")?;
12635 let end = rest.find(']')?;
12636 let id = rest[..end].trim();
12637 (!id.is_empty()).then(|| id.to_string())
12638}
12639
12640fn markdown_code_spans(input: &str) -> Vec<String> {
12641 input
12642 .split('`')
12643 .enumerate()
12644 .filter(|(idx, _)| idx % 2 == 1)
12645 .map(|(_, part)| part.trim().to_string())
12646 .filter(|part| !part.is_empty())
12647 .collect()
12648}
12649
12650fn push_traversal_token_index(
12651 index: &mut HashMap<String, Vec<usize>>,
12652 tokens: &BTreeSet<String>,
12653 entry_index: usize,
12654) {
12655 for token in tokens {
12656 index.entry(token.clone()).or_default().push(entry_index);
12657 }
12658}
12659
12660impl<'a> TraversalCodeLookup<'a> {
12661 fn new(
12662 symbols: &'a [TraversalSymbolIndexEntry],
12663 files: &'a [TraversalFileIndexEntry],
12664 routes: &'a [TraversalRouteIndexEntry],
12665 multiplicities: &'a [TraversalMultiplicityIndexEntry],
12666 ) -> Self {
12667 let mut symbol_index = HashMap::new();
12668 for (idx, entry) in symbols.iter().enumerate() {
12669 push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
12670 }
12671 let mut file_index = HashMap::new();
12672 let mut file_path_index = HashMap::new();
12673 for (idx, entry) in files.iter().enumerate() {
12674 push_traversal_token_index(&mut file_index, &entry.tokens, idx);
12675 if let Some(path) = entry.node.path.as_ref() {
12676 file_path_index.insert(path.clone(), path.clone());
12677 }
12678 }
12679 let mut route_index = HashMap::new();
12680 for (idx, entry) in routes.iter().enumerate() {
12681 push_traversal_token_index(&mut route_index, &entry.tokens, idx);
12682 }
12683 let mut multiplicity_index = HashMap::new();
12684 for (idx, entry) in multiplicities.iter().enumerate() {
12685 push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
12686 }
12687 Self {
12688 symbols,
12689 files,
12690 routes,
12691 multiplicities,
12692 symbol_index,
12693 file_index,
12694 route_index,
12695 multiplicity_index,
12696 file_path_index,
12697 }
12698 }
12699
12700 fn touched_files_for_line(&self, line: &str) -> Vec<String> {
12701 let mut touched_files = BTreeSet::new();
12702 for candidate in markdown_code_spans(line)
12703 .into_iter()
12704 .chain(line.split_whitespace().map(str::to_string))
12705 {
12706 for path in traversal_path_candidates(&candidate) {
12707 if let Some(file) = self.file_path_index.get(&path) {
12708 touched_files.insert(file.clone());
12709 }
12710 }
12711 }
12712 touched_files.into_iter().collect()
12713 }
12714}
12715
12716fn traversal_path_candidates(candidate: &str) -> Vec<String> {
12717 let trimmed = candidate.trim_matches(|ch: char| {
12718 matches!(
12719 ch,
12720 '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
12721 )
12722 });
12723 if trimmed.is_empty() {
12724 return Vec::new();
12725 }
12726 let mut candidates = vec![trimmed.to_string()];
12727 if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
12728 && !path.is_empty()
12729 && line_suffix.chars().all(|ch| ch.is_ascii_digit())
12730 {
12731 candidates.push(path.to_string());
12732 }
12733 candidates
12734}
12735
12736fn parse_worker_result_line(
12737 line: &str,
12738 lookup: &TraversalCodeLookup<'_>,
12739) -> Vec<ParsedWorkerResult> {
12740 if line.trim_start().starts_with("- [") {
12741 return Vec::new();
12742 }
12743 let lower = line.to_ascii_lowercase();
12744 let status =
12745 if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
12746 {
12747 "completed"
12748 } else if lower.contains("blocked") || lower.contains("externally blocked") {
12749 "blocked"
12750 } else {
12751 return Vec::new();
12752 };
12753 let result_prefix_end = ["follow-up", "follow up", "next:"]
12754 .iter()
12755 .filter_map(|marker| lower.find(marker))
12756 .min()
12757 .unwrap_or(line.len());
12758 let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
12759 if ids.is_empty() {
12760 return Vec::new();
12761 }
12762 let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
12763 let all_ids = extract_conflict_target_refs(line);
12764
12765 let touched_files = lookup.touched_files_for_line(line);
12766 let tests = markdown_code_spans(line)
12767 .into_iter()
12768 .filter(|span| span.to_ascii_lowercase().contains("test"))
12769 .collect::<Vec<_>>();
12770
12771 ids.iter()
12772 .map(|id| ParsedWorkerResult {
12773 id: id.clone(),
12774 status: status.to_string(),
12775 touched_files: touched_files.clone(),
12776 tests: tests.clone(),
12777 follow_up_ids: all_ids
12778 .iter()
12779 .filter(|other| *other != id && !result_ids.contains(*other))
12780 .cloned()
12781 .collect(),
12782 })
12783 .collect()
12784}
12785
12786fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
12787 let hinted_path = if path_hint.is_absolute() {
12788 path_hint.to_path_buf()
12789 } else {
12790 root.join(path_hint)
12791 };
12792 if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
12793 return Some(hinted_path);
12794 }
12795 None
12796}
12797
12798fn traversal_markdown_content_looks_like_session(content: &str) -> bool {
12799 parse_agent_doc_session_id(content).is_some()
12800 || content.contains("<!-- agent:exchange")
12801 || content.contains("<!-- agent:backlog")
12802 || content.contains("## Backlog")
12803}
12804
12805fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
12806 let candidate = if path.is_absolute() {
12807 path.to_path_buf()
12808 } else {
12809 source_root.join(path)
12810 };
12811 if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
12812 return false;
12813 }
12814 if !matches!(
12815 candidate.extension().and_then(|ext| ext.to_str()),
12816 Some("md" | "mdx")
12817 ) {
12818 return false;
12819 }
12820 fs::read_to_string(&candidate)
12821 .map(|content| traversal_markdown_content_looks_like_session(&content))
12822 .unwrap_or(false)
12823}
12824
12825fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
12826 if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
12827 return Ok(vec![hinted_path]);
12828 }
12829 let mut files = Vec::new();
12830 let walker = ignore::WalkBuilder::new(root)
12831 .hidden(true)
12832 .git_ignore(true)
12833 .git_global(true)
12834 .git_exclude(true)
12835 .build();
12836 for result in walker {
12837 let entry =
12838 result.with_context(|| format!("walking markdown files under {}", root.display()))?;
12839 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
12840 continue;
12841 }
12842 if traversal_path_is_generated_artifact(root, root, entry.path()) {
12843 continue;
12844 }
12845 if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
12846 files.push(entry.path().to_path_buf());
12847 }
12848 }
12849 files.sort();
12850 Ok(files)
12851}
12852
12853fn traversal_watermark_path(root: &Path, path: &Path) -> String {
12854 path.strip_prefix(root)
12855 .unwrap_or(path)
12856 .to_string_lossy()
12857 .replace('\\', "/")
12858}
12859
12860fn push_traversal_metadata_watermark_part(
12861 root: &Path,
12862 path: &Path,
12863 label: &str,
12864 parts: &mut Vec<String>,
12865) {
12866 let display = traversal_watermark_path(root, path);
12867 match fs::metadata(path) {
12868 Ok(metadata) => {
12869 let (secs, nanos) = metadata
12870 .modified()
12871 .ok()
12872 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
12873 .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
12874 .unwrap_or((0, 0));
12875 parts.push(format!(
12876 "{label}:{display}:len={}:mtime={secs}.{nanos}",
12877 metadata.len()
12878 ));
12879 }
12880 Err(_) => parts.push(format!("{label}:{display}:missing")),
12881 }
12882}
12883
12884#[derive(Serialize)]
12885struct TraversalSummaryWatermarkRow<'a> {
12886 symbol_name: &'a str,
12887 file_path: &'a str,
12888 entities: &'a Option<Vec<summarize::Entity>>,
12889 relationships: &'a Option<Vec<summarize::Relationship>>,
12890 concept_labels: &'a Option<Vec<String>>,
12891}
12892
12893fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
12894 let summaries_db = root.join(".tsift/summaries.db");
12895 if !summaries_db.exists() {
12896 parts.push("summaries_db:absent".to_string());
12897 return Ok(());
12898 }
12899
12900 match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
12901 .and_then(|summary_db| summary_db.all())
12902 {
12903 Ok(summaries) => {
12904 let rows = summaries
12905 .iter()
12906 .map(|summary| TraversalSummaryWatermarkRow {
12907 symbol_name: &summary.symbol_name,
12908 file_path: &summary.file_path,
12909 entities: &summary.entities,
12910 relationships: &summary.relationships,
12911 concept_labels: &summary.concept_labels,
12912 })
12913 .collect::<Vec<_>>();
12914 parts.push(format!(
12915 "summaries_db:rows={}:semantic_hash={}",
12916 rows.len(),
12917 content_hash(&rows)?
12918 ));
12919 }
12920 Err(_) => {
12921 push_traversal_metadata_watermark_part(
12922 root,
12923 &summaries_db,
12924 "summaries_db_unreadable",
12925 parts,
12926 );
12927 }
12928 }
12929 Ok(())
12930}
12931
12932#[cfg(test)]
12933fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
12934 resolution::relative_path_is_generated_artifact(relative)
12935}
12936
12937fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
12938 resolution::path_is_generated_artifact(root, source_root, path)
12939}
12940
12941fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
12942 resolution::index_snapshot_part_is_generated(root, source_root, part)
12943}
12944
12945pub(crate) fn traversal_source_watermark(
12946 root: &Path,
12947 path_hint: &Path,
12948 scope: Option<&str>,
12949 session_only: bool,
12950) -> Result<Option<String>> {
12951 let mut parts = vec![
12952 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
12953 format!("scope:{}", scope.unwrap_or("root")),
12954 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
12955 format!("session_only:{session_only}"),
12956 ];
12957
12958 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
12959 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
12960 Ok(targets) => targets,
12961 Err(_) => return Ok(None),
12962 };
12963 let Some(target) = targets.into_iter().next() else {
12964 return Ok(None);
12965 };
12966 let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
12967 Ok(db) => db,
12968 Err(_) => return Ok(None),
12969 };
12970 parts.push(format!("index_label:{}", target.label));
12971 parts.push(format!(
12972 "index_scope:{}",
12973 target.scope_name.as_deref().unwrap_or("root")
12974 ));
12975 parts.push(format!(
12976 "index_source_root:{}",
12977 traversal_watermark_path(root, &target.source_root)
12978 ));
12979 let mut snapshot_rows = 0usize;
12980 for part in db.source_snapshot_parts()? {
12981 if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
12982 continue;
12983 }
12984 snapshot_rows += 1;
12985 parts.push(format!("index_snapshot:{part}"));
12986 }
12987 parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
12988 }
12989
12990 let markdown_files = markdown_files_for_traversal(root, path_hint)?;
12991 parts.push(format!("markdown_count:{}", markdown_files.len()));
12992 for markdown_path in markdown_files {
12993 push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
12994 }
12995
12996 push_traversal_summaries_watermark_part(root, &mut parts)?;
12997
12998 Ok(Some(content_hash(&parts)?))
12999}
13000
13001fn ranked_symbol_matches<'a>(
13002 query_tokens: &BTreeSet<String>,
13003 entries: &'a [TraversalSymbolIndexEntry],
13004 index: &HashMap<String, Vec<usize>>,
13005) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13006 let mut scores = BTreeMap::<usize, usize>::new();
13007 for token in query_tokens {
13008 if let Some(indices) = index.get(token) {
13009 for idx in indices {
13010 *scores.entry(*idx).or_default() += 1;
13011 }
13012 }
13013 }
13014 let mut matches = scores
13015 .into_iter()
13016 .map(|(idx, score)| (score, &entries[idx]))
13017 .collect::<Vec<_>>();
13018 matches.sort_by(|(left_score, left), (right_score, right)| {
13019 right_score
13020 .cmp(left_score)
13021 .then_with(|| left.node.label.cmp(&right.node.label))
13022 .then_with(|| left.handle.cmp(&right.handle))
13023 });
13024 matches
13025}
13026
13027fn ranked_file_matches<'a>(
13028 query_tokens: &BTreeSet<String>,
13029 entries: &'a [TraversalFileIndexEntry],
13030 index: &HashMap<String, Vec<usize>>,
13031) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13032 let mut scores = BTreeMap::<usize, usize>::new();
13033 for token in query_tokens {
13034 if let Some(indices) = index.get(token) {
13035 for idx in indices {
13036 *scores.entry(*idx).or_default() += 1;
13037 }
13038 }
13039 }
13040 let mut matches = scores
13041 .into_iter()
13042 .map(|(idx, score)| (score, &entries[idx]))
13043 .collect::<Vec<_>>();
13044 matches.sort_by(|(left_score, left), (right_score, right)| {
13045 right_score
13046 .cmp(left_score)
13047 .then_with(|| left.node.label.cmp(&right.node.label))
13048 .then_with(|| left.handle.cmp(&right.handle))
13049 });
13050 matches
13051}
13052
13053fn ranked_route_matches<'a>(
13054 query_tokens: &BTreeSet<String>,
13055 entries: &'a [TraversalRouteIndexEntry],
13056 index: &HashMap<String, Vec<usize>>,
13057) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13058 let mut scores = BTreeMap::<usize, usize>::new();
13059 for token in query_tokens {
13060 if let Some(indices) = index.get(token) {
13061 for idx in indices {
13062 *scores.entry(*idx).or_default() += 1;
13063 }
13064 }
13065 }
13066 let mut matches = scores
13067 .into_iter()
13068 .map(|(idx, score)| (score, &entries[idx]))
13069 .collect::<Vec<_>>();
13070 matches.sort_by(|(left_score, left), (right_score, right)| {
13071 right_score
13072 .cmp(left_score)
13073 .then_with(|| left.node.label.cmp(&right.node.label))
13074 .then_with(|| left.handle.cmp(&right.handle))
13075 });
13076 matches
13077}
13078
13079fn ranked_multiplicity_matches<'a>(
13080 query_tokens: &BTreeSet<String>,
13081 entries: &'a [TraversalMultiplicityIndexEntry],
13082 index: &HashMap<String, Vec<usize>>,
13083) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13084 let mut scores = BTreeMap::<usize, usize>::new();
13085 for token in query_tokens {
13086 if let Some(indices) = index.get(token) {
13087 for idx in indices {
13088 *scores.entry(*idx).or_default() += 1;
13089 }
13090 }
13091 }
13092 let mut matches = scores
13093 .into_iter()
13094 .map(|(idx, score)| (score, &entries[idx]))
13095 .collect::<Vec<_>>();
13096 matches.sort_by(|(left_score, left), (right_score, right)| {
13097 right_score
13098 .cmp(left_score)
13099 .then_with(|| left.node.kind.cmp(&right.node.kind))
13100 .then_with(|| left.node.label.cmp(&right.node.label))
13101 .then_with(|| left.handle.cmp(&right.handle))
13102 });
13103 matches
13104}
13105
13106fn link_backlog_to_code_nodes(
13107 graph: &mut TraversalGraphBuild,
13108 backlog: &TraversalNode,
13109 text: &str,
13110 lookup: &TraversalCodeLookup<'_>,
13111 limit: usize,
13112) {
13113 let mut query_tokens = traversal_tokens(text);
13114 if let Some(ref_id) = &backlog.ref_id {
13115 query_tokens.extend(traversal_tokens(ref_id));
13116 }
13117 if query_tokens.is_empty() {
13118 return;
13119 }
13120
13121 for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13122 .into_iter()
13123 .take(limit)
13124 {
13125 graph.add_edge(
13126 &backlog.handle,
13127 &entry.handle,
13128 "mentions",
13129 Some("backlog text matches symbol tokens".to_string()),
13130 score,
13131 );
13132 }
13133
13134 for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13135 .into_iter()
13136 .take(limit.min(5))
13137 {
13138 graph.add_edge(
13139 &backlog.handle,
13140 &entry.handle,
13141 "mentions",
13142 Some("backlog text matches file tokens".to_string()),
13143 score,
13144 );
13145 }
13146
13147 for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13148 .into_iter()
13149 .take(limit.min(5))
13150 {
13151 graph.add_edge(
13152 &backlog.handle,
13153 &entry.handle,
13154 "mentions",
13155 Some("backlog text matches route tokens".to_string()),
13156 score,
13157 );
13158 }
13159
13160 for (score, entry) in ranked_multiplicity_matches(
13161 &query_tokens,
13162 lookup.multiplicities,
13163 &lookup.multiplicity_index,
13164 )
13165 .into_iter()
13166 .take(limit.min(5))
13167 {
13168 graph.add_edge(
13169 &backlog.handle,
13170 &entry.handle,
13171 "mentions",
13172 Some("backlog text matches multiplicity tokens".to_string()),
13173 score,
13174 );
13175 }
13176}
13177
13178fn load_agent_doc_traversal_nodes(
13179 root: &Path,
13180 path_hint: &Path,
13181 graph: &mut TraversalGraphBuild,
13182 lookup: &TraversalCodeLookup<'_>,
13183) -> Result<()> {
13184 for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13185 let content = match fs::read_to_string(&markdown_path) {
13186 Ok(content) => content,
13187 Err(err) => {
13188 graph.warnings.push(format!(
13189 "session artifact unavailable: {}: {err}",
13190 markdown_path.display()
13191 ));
13192 continue;
13193 }
13194 };
13195 if !traversal_markdown_content_looks_like_session(&content) {
13196 continue;
13197 }
13198
13199 let session_id = parse_agent_doc_session_id(&content);
13200 let session = traversal_session_node(root, &markdown_path, session_id.as_deref());
13201 graph.add_node(session.clone());
13202 let lines = content.lines().collect::<Vec<_>>();
13203 let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13204 for (idx, line) in lines.iter().enumerate() {
13205 let Some((id, text)) = parse_backlog_line(line) else {
13206 continue;
13207 };
13208 let backlog = traversal_backlog_node(root, &markdown_path, &id, &text, idx as i64 + 1);
13209 graph.add_node(backlog.clone());
13210 backlog_by_id.insert(id.clone(), backlog.clone());
13211 graph.add_edge(
13212 &session.handle,
13213 &backlog.handle,
13214 "contains",
13215 Some("session backlog item".to_string()),
13216 1,
13217 );
13218 link_backlog_to_code_nodes(graph, &backlog, &text, lookup, 8);
13219 }
13220
13221 let mut in_queue = false;
13222 let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13223 for (idx, line) in lines.iter().enumerate() {
13224 let trimmed = line.trim();
13225 if trimmed.starts_with("<!-- agent:queue") {
13226 in_queue = true;
13227 continue;
13228 }
13229 if trimmed.starts_with("<!-- /agent:queue") {
13230 in_queue = false;
13231 continue;
13232 }
13233 if !in_queue {
13234 continue;
13235 }
13236 if let Some(dispatch) = parse_queue_dispatch_line(line) {
13237 let dispatch_ref = dispatch.strip_prefix('#').unwrap_or(dispatch.as_str());
13238 let node = traversal_job_packet_node(
13239 root,
13240 &markdown_path,
13241 &format!("dispatch {dispatch}"),
13242 Some(dispatch_ref),
13243 "agent-doc dispatch preset",
13244 idx as i64 + 1,
13245 );
13246 graph.add_node(node.clone());
13247 graph.add_edge(
13248 &session.handle,
13249 &node.handle,
13250 "contains",
13251 Some("session queued dispatch".to_string()),
13252 1,
13253 );
13254 continue;
13255 }
13256 if let Some(id) = parse_queue_do_line(line) {
13257 let detail = backlog_by_id
13258 .get(&id)
13259 .and_then(|node| node.detail.clone())
13260 .unwrap_or_else(|| "queued backlog item".to_string());
13261 let node = traversal_job_packet_node(
13262 root,
13263 &markdown_path,
13264 &format!("do #{id}"),
13265 Some(&id),
13266 &detail,
13267 idx as i64 + 1,
13268 );
13269 graph.add_node(node.clone());
13270 graph.add_edge(
13271 &session.handle,
13272 &node.handle,
13273 "contains",
13274 Some("session queued job packet".to_string()),
13275 1,
13276 );
13277 if let Some(backlog) = backlog_by_id.get(&id) {
13278 graph.add_edge(
13279 &node.handle,
13280 &backlog.handle,
13281 "targets",
13282 Some("queued backlog item".to_string()),
13283 1,
13284 );
13285 }
13286 job_by_id.insert(id, node);
13287 }
13288 }
13289
13290 let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13291 for (idx, line) in lines.iter().enumerate() {
13292 for parsed in parse_worker_result_line(line, lookup) {
13293 let line_no = idx as i64 + 1;
13294 if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13295 continue;
13296 }
13297 let result =
13298 traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13299 graph.add_node(result.clone());
13300 graph.add_edge(
13301 &session.handle,
13302 &result.handle,
13303 "contains",
13304 Some("session worker result".to_string()),
13305 1,
13306 );
13307 if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13308 graph.add_edge(
13309 &backlog.handle,
13310 &result.handle,
13311 "has_result",
13312 Some(format!("worker result {}", parsed.status)),
13313 1,
13314 );
13315 }
13316 if let Some(job) = job_by_id.get(&parsed.id) {
13317 graph.add_edge(
13318 &job.handle,
13319 &result.handle,
13320 "has_result",
13321 Some(format!("queued worker result {}", parsed.status)),
13322 1,
13323 );
13324 }
13325 let mut result_text = line.to_string();
13326 if !parsed.touched_files.is_empty() {
13327 result_text.push(' ');
13328 result_text.push_str(&parsed.touched_files.join(" "));
13329 }
13330 link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13331 }
13332 }
13333 }
13334 Ok(())
13335}
13336
13337#[derive(Debug, Clone)]
13338struct AgentDocIndexGate {
13339 db_path: Option<PathBuf>,
13340 source_root: PathBuf,
13341 diagnostics: Vec<String>,
13342}
13343
13344#[derive(Clone, Hash, PartialEq, Eq)]
13345struct AgentDocIndexGateCacheKey {
13346 root: PathBuf,
13347 path_hint: PathBuf,
13348 scope: Option<String>,
13349 packet_label: String,
13350}
13351
13352fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13353 std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13354> {
13355 static CACHE: std::sync::OnceLock<
13356 std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13357 > = std::sync::OnceLock::new();
13358 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13359}
13360
13361fn prepare_agent_doc_index_gate_cached(
13362 root: &Path,
13363 path_hint: &Path,
13364 scope: Option<&str>,
13365 packet_label: &str,
13366) -> (AgentDocIndexGate, String) {
13367 let key = AgentDocIndexGateCacheKey {
13368 root: root.to_path_buf(),
13369 path_hint: path_hint.to_path_buf(),
13370 scope: scope.map(str::to_string),
13371 packet_label: packet_label.to_string(),
13372 };
13373 if let Ok(cache) = agent_doc_index_gate_cache().lock()
13374 && let Some(cached) = cache.get(&key)
13375 {
13376 return (
13377 cached.clone(),
13378 "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13379 );
13380 }
13381 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13382 if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13383 cache.insert(key, gate.clone());
13384 }
13385 (
13386 gate,
13387 "fresh inspection/refresh — cache miss on this preparation key".to_string(),
13388 )
13389}
13390
13391fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
13392 match state {
13393 SearchIndexState::Fresh => None,
13394 SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
13395 SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
13396 }
13397}
13398
13399fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
13400 rebuild_search_target_detail(&RebuildSearchTarget {
13401 label: target.label.clone(),
13402 reason,
13403 reindex_cmd: target.reindex_cmd.clone(),
13404 })
13405}
13406
13407fn index_refresh_diagnostic(
13408 target: &SearchIndexTarget,
13409 reason: RebuildSearchReason,
13410 summary: &index::IndexSummary,
13411 packet_label: &str,
13412) -> String {
13413 let changed = summary.new + summary.modified + summary.deleted;
13414 format!(
13415 "index refreshed: {}; updated {} changed file{} before {}",
13416 index_reason_detail(target, reason),
13417 changed,
13418 if changed == 1 { "" } else { "s" },
13419 packet_label
13420 )
13421}
13422
13423fn index_refresh_fallback_diagnostic(
13424 target: &SearchIndexTarget,
13425 reason: RebuildSearchReason,
13426 err: &anyhow::Error,
13427 packet_label: &str,
13428) -> String {
13429 format!(
13430 "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
13431 index_reason_detail(target, reason),
13432 packet_label
13433 )
13434}
13435
13436fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
13437 if let Some(scope_name) = scope
13438 && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
13439 {
13440 return scope.source_root;
13441 }
13442 if let Some(scope_name) = scope
13443 && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
13444 {
13445 return package.package_root;
13446 }
13447 if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
13448 return scope.source_root;
13449 }
13450 if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
13451 return package.package_root;
13452 }
13453 if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
13454 return scope.source_root;
13455 }
13456 root.to_path_buf()
13457}
13458
13459fn prepare_agent_doc_index_gate(
13460 root: &Path,
13461 path_hint: &Path,
13462 scope: Option<&str>,
13463 packet_label: &str,
13464) -> AgentDocIndexGate {
13465 let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
13466 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13467 Ok(targets) => targets,
13468 Err(err) => {
13469 return AgentDocIndexGate {
13470 db_path: None,
13471 source_root: fallback_source_root,
13472 diagnostics: vec![format!(
13473 "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13474 )],
13475 };
13476 }
13477 };
13478 let Some(target) = targets.into_iter().next() else {
13479 return AgentDocIndexGate {
13480 db_path: None,
13481 source_root: fallback_source_root,
13482 diagnostics: vec![format!(
13483 "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
13484 )],
13485 };
13486 };
13487
13488 let state = match inspect_search_index(&target) {
13489 Ok(state) => state,
13490 Err(err) => {
13491 return AgentDocIndexGate {
13492 db_path: None,
13493 source_root: target.source_root,
13494 diagnostics: vec![format!(
13495 "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13496 )],
13497 };
13498 }
13499 };
13500
13501 let Some(reason) = index_reason_for_state(state) else {
13502 return AgentDocIndexGate {
13503 db_path: Some(target.db_path),
13504 source_root: target.source_root,
13505 diagnostics: Vec::new(),
13506 };
13507 };
13508
13509 match apply_search_index_update(root, &target) {
13510 Ok(summary) => {
13511 index::inspect_scope_invalidate_all();
13517 let diagnostics = vec![index_refresh_diagnostic(
13518 &target,
13519 reason,
13520 &summary,
13521 packet_label,
13522 )];
13523 AgentDocIndexGate {
13524 db_path: Some(target.db_path),
13525 source_root: target.source_root,
13526 diagnostics,
13527 }
13528 }
13529 Err(err) => {
13530 let diagnostics = vec![index_refresh_fallback_diagnostic(
13531 &target,
13532 reason,
13533 &err,
13534 packet_label,
13535 )];
13536 AgentDocIndexGate {
13537 db_path: None,
13538 source_root: target.source_root,
13539 diagnostics,
13540 }
13541 }
13542 }
13543}
13544
13545fn add_raw_source_file_nodes(
13546 root: &Path,
13547 source_root: &Path,
13548 graph: &mut TraversalGraphBuild,
13549 file_entries: &mut Vec<TraversalFileIndexEntry>,
13550) -> Result<()> {
13551 let mut entries = walk::walk_files(source_root)?;
13552 entries.sort_by(|left, right| left.path.cmp(&right.path));
13553 for entry in entries {
13554 let file = entry.path.to_string_lossy();
13555 let node = traversal_raw_source_file_node(root, file.as_ref());
13556 let entry = TraversalFileIndexEntry {
13557 handle: node.handle.clone(),
13558 tokens: traversal_node_tokens(&node),
13559 node: node.clone(),
13560 };
13561 graph.add_node(node);
13562 file_entries.push(entry);
13563 }
13564 Ok(())
13565}
13566
13567fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
13568 if scope_root.is_empty() {
13569 return true;
13570 }
13571 path == scope_root || path.starts_with(&format!("{scope_root}/"))
13572}
13573
13574fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
13575 let path = Path::new(file);
13576 if path.is_absolute() {
13577 return path.to_path_buf();
13578 }
13579 let source_candidate = source_root.join(path);
13580 if source_candidate.exists() {
13581 source_candidate
13582 } else {
13583 root.join(path)
13584 }
13585}
13586
13587fn cargo_import_alias_from_line(line: &str) -> Option<String> {
13588 let trimmed = line.trim();
13589 let rest = trimmed
13590 .strip_prefix("pub use ")
13591 .or_else(|| trimmed.strip_prefix("use "))
13592 .or_else(|| trimmed.strip_prefix("extern crate "))?;
13593 let alias = rest
13594 .split([':', ';', ' ', '\t'])
13595 .next()
13596 .unwrap_or_default()
13597 .trim();
13598 (!alias.is_empty()).then(|| alias.to_string())
13599}
13600
13601fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
13602 let mut aliases = BTreeSet::new();
13603 for entry in walk::walk_files(&package.package_root)? {
13604 if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
13605 continue;
13606 }
13607 let content = fs::read_to_string(&entry.path)
13608 .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
13609 aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
13610 }
13611 Ok(aliases)
13612}
13613
13614fn load_multiplicity_traversal_nodes(
13615 root: &Path,
13616 source_root: &Path,
13617 graph: &mut TraversalGraphBuild,
13618 file_handle_by_path: &HashMap<String, String>,
13619 multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
13620) -> Result<()> {
13621 let inventory = multiplicity::discover_cargo_inventory(source_root)?;
13622 let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
13623 for workspace in &inventory.workspaces {
13624 let node = traversal_cargo_workspace_node(root, workspace);
13625 workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
13626 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13627 handle: node.handle.clone(),
13628 tokens: traversal_node_tokens(&node),
13629 node: node.clone(),
13630 });
13631 graph.add_node(node);
13632 }
13633
13634 let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
13635 let mut package_nodes = Vec::new();
13636 for package in &inventory.packages {
13637 let node = traversal_cargo_package_node(root, package);
13638 package_handle_by_name
13639 .entry(package.name.clone())
13640 .or_default()
13641 .push(node.handle.clone());
13642 package_handle_by_name
13643 .entry(package.normalized_name.clone())
13644 .or_default()
13645 .push(node.handle.clone());
13646 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13647 handle: node.handle.clone(),
13648 tokens: traversal_node_tokens(&node),
13649 node: node.clone(),
13650 });
13651 graph.add_node(node.clone());
13652 package_nodes.push((package, node));
13653 }
13654
13655 for (package, node) in &package_nodes {
13656 if let Some(workspace_handle) =
13657 workspace_handle_by_root.get(&package.relative_workspace_root)
13658 {
13659 graph.add_edge(
13660 workspace_handle,
13661 &node.handle,
13662 "contains_package",
13663 Some("Cargo workspace member package".to_string()),
13664 1,
13665 );
13666 }
13667 let package_root = relativize_pathbuf(&package.package_root, root)
13668 .to_string_lossy()
13669 .replace('\\', "/");
13670 for (file, handle) in file_handle_by_path {
13671 if relative_path_inside_scope(file, &package_root) {
13672 graph.add_edge(
13673 &node.handle,
13674 handle,
13675 "owns_file",
13676 Some("Cargo package owns source file".to_string()),
13677 1,
13678 );
13679 }
13680 }
13681 for dependency in &package.dependencies {
13682 if let Some(handles) = package_handle_by_name.get(&dependency.name)
13683 && handles.len() == 1
13684 {
13685 graph.add_edge(
13686 &node.handle,
13687 &handles[0],
13688 "declares_dependency",
13689 Some(format!("{} Cargo dependency", dependency.kind)),
13690 1,
13691 );
13692 }
13693 }
13694 for alias in cargo_import_aliases(package)? {
13695 if let Some(handles) = package_handle_by_name.get(&alias)
13696 && handles.len() == 1
13697 && handles[0] != node.handle
13698 {
13699 graph.add_edge(
13700 &node.handle,
13701 &handles[0],
13702 "uses_crate",
13703 Some("Rust use/extern crate reference".to_string()),
13704 1,
13705 );
13706 graph.add_edge(
13707 &node.handle,
13708 &handles[0],
13709 "imports",
13710 Some("Rust use/extern crate import".to_string()),
13711 1,
13712 );
13713 }
13714 }
13715 }
13716
13717 Ok(())
13718}
13719
13720fn build_traversal_graph_source_with_options(
13721 root: &Path,
13722 path_hint: &Path,
13723 scope: Option<&str>,
13724 session_only: bool,
13725) -> Result<TraversalGraphBuild> {
13726 let mut graph = TraversalGraphBuild::default();
13727 let mut symbol_entries = Vec::new();
13728 let mut file_entries = Vec::new();
13729 let mut route_entries = Vec::new();
13730 let mut multiplicity_entries = Vec::new();
13731 let mut file_handle_by_path = HashMap::<String, String>::new();
13732 let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
13733 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13734 let (gate, _cache_detail) =
13735 prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
13736 graph.warnings.extend(gate.diagnostics);
13737 let gate_source_root = gate.source_root.clone();
13738
13739 match gate.db_path {
13740 Some(db_path) if db_path.exists() => {
13741 let db = index::IndexDb::open_read_only_resilient(&db_path)?;
13742 let file_paths = db.file_paths()?;
13743 for file in file_paths {
13744 if traversal_path_is_generated_artifact(
13745 root,
13746 &gate_source_root,
13747 Path::new(&file),
13748 ) {
13749 continue;
13750 }
13751 let node = traversal_file_node(root, &file);
13752 let entry = TraversalFileIndexEntry {
13753 handle: node.handle.clone(),
13754 tokens: traversal_node_tokens(&node),
13755 node: node.clone(),
13756 };
13757 if let Some(path) = entry.node.path.as_ref() {
13758 file_handle_by_path.insert(path.clone(), entry.handle.clone());
13759 }
13760 graph.add_node(node);
13761 file_entries.push(entry);
13762 }
13763
13764 let symbols = db.all_symbols()?;
13765 let mut symbol_by_file_name_line = HashMap::new();
13766 let mut span_by_file_name_line = HashMap::new();
13767 let mut first_symbol_by_name = BTreeMap::<String, String>::new();
13768 let mut first_span_by_name = BTreeMap::<String, String>::new();
13769 let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
13770 let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
13771 for symbol in symbols.iter().filter(|symbol| {
13772 !traversal_path_is_generated_artifact(
13773 root,
13774 &gate_source_root,
13775 Path::new(&symbol.file),
13776 )
13777 }) {
13778 let node = traversal_symbol_node(root, symbol);
13779 let file = relativize(&symbol.file, root);
13780 symbol_by_file_name_line.insert(
13781 format!("{file}:{}:{}", symbol.line, symbol.name),
13782 node.handle.clone(),
13783 );
13784 first_symbol_by_name
13785 .entry(symbol.name.clone())
13786 .or_insert_with(|| node.handle.clone());
13787 let entry = TraversalSymbolIndexEntry {
13788 handle: node.handle.clone(),
13789 tokens: traversal_node_tokens(&node),
13790 node: node.clone(),
13791 };
13792 graph.add_node(node.clone());
13793 if let Some(file_handle) = file_handle_by_path.get(&file) {
13794 graph.add_edge(
13795 file_handle,
13796 &node.handle,
13797 "defines",
13798 Some("file defines symbol".to_string()),
13799 1,
13800 );
13801 }
13802 if !source_by_file.contains_key(&symbol.file) {
13803 let source_path =
13804 traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
13805 source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
13806 }
13807 if let Some(Some(source)) = source_by_file.get(&symbol.file)
13808 && let Some((ast_node, mut ast_entry)) =
13809 traversal_ast_span_node(root, symbol, source, &symbols)
13810 {
13811 ast_entry.symbol_handle = node.handle.clone();
13812 ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
13813 span_by_file_name_line.insert(
13814 format!("{file}:{}:{}", symbol.line, symbol.name),
13815 ast_node.handle.clone(),
13816 );
13817 first_span_by_name
13818 .entry(symbol.name.clone())
13819 .or_insert_with(|| ast_node.handle.clone());
13820 graph.add_node(ast_node.clone());
13821 graph.add_edge(
13822 &node.handle,
13823 &ast_node.handle,
13824 "has_ast_span",
13825 Some("symbol projects to indexed AST span".to_string()),
13826 1,
13827 );
13828 graph.add_edge(
13829 &ast_node.handle,
13830 &node.handle,
13831 "represents_symbol",
13832 Some("AST span represents indexed symbol".to_string()),
13833 1,
13834 );
13835 ast_entries.push(ast_entry);
13836 }
13837 symbol_entries.push(entry);
13838 }
13839 link_ast_navigation_edges(&mut graph, &ast_entries);
13840 link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
13841
13842 if !bounded_session_projection {
13843 for edge in db.all_stored_edges()? {
13844 if traversal_path_is_generated_artifact(
13845 root,
13846 &gate_source_root,
13847 Path::new(&edge.caller_file),
13848 ) {
13849 continue;
13850 }
13851 let caller_file = relativize(&edge.caller_file, root);
13852 let caller_key =
13853 format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
13854 let Some(caller_handle) =
13855 symbol_by_file_name_line.get(&caller_key).cloned()
13856 else {
13857 continue;
13858 };
13859 let callee_handle = if let Some(handle) =
13860 first_symbol_by_name.get(&edge.callee_name)
13861 {
13862 handle.clone()
13863 } else {
13864 let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
13865 let handle = node.handle.clone();
13866 graph.add_node(node);
13867 handle
13868 };
13869 graph.add_edge(
13870 &caller_handle,
13871 &callee_handle,
13872 "calls",
13873 Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
13874 1,
13875 );
13876 if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
13877 && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
13878 {
13879 graph.add_edge(
13880 caller_span,
13881 callee_span,
13882 "calls",
13883 Some(format!(
13884 "AST call site {}:{}",
13885 caller_file, edge.call_site_line
13886 )),
13887 1,
13888 );
13889 }
13890 }
13891 }
13892
13893 for route in db.all_routes()? {
13894 if traversal_path_is_generated_artifact(
13895 root,
13896 &gate_source_root,
13897 Path::new(&route.file),
13898 ) {
13899 continue;
13900 }
13901 let node = traversal_route_node(root, &route);
13902 let entry = TraversalRouteIndexEntry {
13903 handle: node.handle.clone(),
13904 tokens: traversal_node_tokens(&node),
13905 node: node.clone(),
13906 };
13907 graph.add_node(node.clone());
13908 if let Some(path) = node.path.as_ref()
13909 && let Some(file_handle) = file_handle_by_path.get(path)
13910 {
13911 graph.add_edge(
13912 file_handle,
13913 &node.handle,
13914 "defines_route",
13915 Some("file declares route".to_string()),
13916 1,
13917 );
13918 }
13919 let handler_handle =
13920 if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
13921 handle.clone()
13922 } else {
13923 let node = traversal_unresolved_symbol_node(root, &route.handler_name);
13924 let handle = node.handle.clone();
13925 graph.add_node(node);
13926 handle
13927 };
13928 graph.add_edge(
13929 &entry.handle,
13930 &handler_handle,
13931 "handled_by",
13932 Some("route handler reference".to_string()),
13933 1,
13934 );
13935 if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
13936 graph.add_edge(
13937 &entry.handle,
13938 handler_span,
13939 "handled_by",
13940 Some("route handler AST span".to_string()),
13941 1,
13942 );
13943 graph.add_edge(
13944 handler_span,
13945 &entry.handle,
13946 "handles_route",
13947 Some("AST span handles route".to_string()),
13948 1,
13949 );
13950 }
13951 route_entries.push(entry);
13952 }
13953 }
13954 _ => {
13955 add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
13956 .with_context(|| {
13957 format!(
13958 "loading raw source fallback nodes from {}",
13959 gate_source_root.display()
13960 )
13961 })?;
13962 for entry in &file_entries {
13963 if let Some(path) = entry.node.path.as_ref() {
13964 file_handle_by_path.insert(path.clone(), entry.handle.clone());
13965 }
13966 }
13967 }
13968 }
13969 load_multiplicity_traversal_nodes(
13970 root,
13971 &gate_source_root,
13972 &mut graph,
13973 &file_handle_by_path,
13974 &mut multiplicity_entries,
13975 )?;
13976 }
13977
13978 let code_lookup = TraversalCodeLookup::new(
13979 &symbol_entries,
13980 &file_entries,
13981 &route_entries,
13982 &multiplicity_entries,
13983 );
13984 load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
13985 Ok(graph)
13986}
13987
13988#[cfg(test)]
13989fn build_traversal_graph_source(
13990 root: &Path,
13991 path_hint: &Path,
13992 scope: Option<&str>,
13993) -> Result<TraversalGraphBuild> {
13994 build_traversal_graph_source_with_options(root, path_hint, scope, false)
13995}
13996
13997pub(crate) fn write_traversal_graph_store_with_options(
13998 root: &Path,
13999 path_hint: &Path,
14000 scope: Option<&str>,
14001 session_only: bool,
14002) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14003 let source_graph =
14004 build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14005 let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14006 let graph_db = graph_substrate_db_path(root, scope);
14007 let mut store = SqliteGraphStore::open(&graph_db)?;
14008 let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14009 .ok()
14010 .flatten()
14011 .or_else(|| graph_projection_content_hash(&projection));
14012 let refresh = store.replace_projection_with_version(
14013 scope.unwrap_or("root"),
14014 &projection,
14015 Some(GRAPH_PROJECTION_VERSION),
14016 source_watermark,
14017 )?;
14018 Ok((source_graph, refresh))
14019}
14020
14021pub(crate) fn write_traversal_graph_store(
14022 root: &Path,
14023 path_hint: &Path,
14024 scope: Option<&str>,
14025) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14026 write_traversal_graph_store_with_options(root, path_hint, scope, false)
14027}
14028
14029fn refresh_traversal_graph_store_with_options(
14030 root: &Path,
14031 path_hint: &Path,
14032 scope: Option<&str>,
14033 session_only: bool,
14034) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14035 let (source_graph, refresh) =
14036 write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14037 let graph_db = graph_substrate_db_path(root, scope);
14038 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14039 let mut graph = traversal_graph_from_store(root, &store)?;
14040 graph.warnings = source_graph.warnings;
14041 Ok((graph, refresh))
14042}
14043
14044fn refresh_traversal_graph_store(
14045 root: &Path,
14046 path_hint: &Path,
14047 scope: Option<&str>,
14048) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14049 refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14050}
14051
14052pub(crate) fn build_traversal_graph(
14053 root: &Path,
14054 path_hint: &Path,
14055 scope: Option<&str>,
14056) -> Result<TraversalGraphBuild> {
14057 let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14058 Ok(graph)
14059}
14060
14061fn traversal_query_kind_priority(kind: &str) -> usize {
14062 match kind {
14063 "backlog" => 0,
14064 "job_packet" => 1,
14065 "worker_result" => 2,
14066 "symbol" => 3,
14067 "ast_span" => 4,
14068 "file" => 5,
14069 "route" => 6,
14070 "cargo_package" => 7,
14071 "cargo_workspace" => 8,
14072 "session" => 9,
14073 "semantic_concept" => 10,
14074 "semantic_entity" => 11,
14075 _ => 12,
14076 }
14077}
14078
14079fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14080 let trimmed = query.trim();
14081 if trimmed.is_empty() {
14082 return None;
14083 }
14084 let kind_priority = traversal_query_kind_priority(&node.kind);
14085 if node.handle == trimmed {
14086 return Some((0, kind_priority, node.handle.clone()));
14087 }
14088 if node.path.as_deref() == Some(trimmed) {
14089 let path_priority = if node.kind == "file" {
14090 0
14091 } else {
14092 kind_priority.saturating_add(1)
14093 };
14094 return Some((1, path_priority, node.handle.clone()));
14095 }
14096 let normalized_backlog = trimmed.trim_start_matches('#');
14097 if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14098 {
14099 return Some((2, kind_priority, node.handle.clone()));
14100 }
14101 if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14102 return Some((3, kind_priority, node.handle.clone()));
14103 }
14104 None
14105}
14106
14107fn resolve_traversal_node<'a>(
14108 graph: &'a TraversalGraphBuild,
14109 query: &str,
14110) -> Option<&'a TraversalNode> {
14111 graph
14112 .nodes
14113 .values()
14114 .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14115 .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14116 .map(|(_, node)| node)
14117}
14118
14119fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14120 let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14121 for edge in edges {
14122 adj.entry(edge.from.clone())
14123 .or_default()
14124 .insert(edge.to.clone());
14125 adj.entry(edge.to.clone())
14126 .or_default()
14127 .insert(edge.from.clone());
14128 }
14129 adj.into_iter()
14130 .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14131 .collect()
14132}
14133
14134fn traversal_shortest_handles(
14135 edges: &[TraversalEdge],
14136 from: &str,
14137 to: &str,
14138) -> Option<Vec<String>> {
14139 if from == to {
14140 return Some(vec![from.to_string()]);
14141 }
14142 let adj = traversal_adjacency(edges);
14143 if !adj.contains_key(from) || !adj.contains_key(to) {
14144 return None;
14145 }
14146 let mut visited = BTreeSet::new();
14147 let mut queue = VecDeque::new();
14148 let mut parent = BTreeMap::<String, String>::new();
14149 visited.insert(from.to_string());
14150 queue.push_back(from.to_string());
14151 while let Some(current) = queue.pop_front() {
14152 if let Some(neighbors) = adj.get(¤t) {
14153 for neighbor in neighbors {
14154 if visited.insert(neighbor.clone()) {
14155 parent.insert(neighbor.clone(), current.clone());
14156 if neighbor == to {
14157 let mut path = vec![to.to_string()];
14158 let mut cursor = to.to_string();
14159 while let Some(prev) = parent.get(&cursor) {
14160 path.push(prev.clone());
14161 cursor = prev.clone();
14162 }
14163 path.reverse();
14164 return Some(path);
14165 }
14166 queue.push_back(neighbor.clone());
14167 }
14168 }
14169 }
14170 }
14171 None
14172}
14173
14174fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14175 let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14176 for edge in edges {
14177 let neighbor = if edge.from == current {
14178 edge.to.as_str()
14179 } else if edge.to == current {
14180 edge.from.as_str()
14181 } else {
14182 continue;
14183 };
14184 let score = traversal_relation_score(edge, current);
14185 best_score_by_neighbor
14186 .entry(neighbor.to_string())
14187 .and_modify(|best| *best = (*best).max(score))
14188 .or_insert(score);
14189 }
14190 let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14191 ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14192 right_score
14193 .cmp(left_score)
14194 .then_with(|| left_handle.cmp(right_handle))
14195 });
14196 ranked.into_iter().map(|(handle, _)| handle).collect()
14197}
14198
14199fn traversal_neighborhood_handles(
14200 edges: &[TraversalEdge],
14201 origin: &str,
14202 depth: usize,
14203 limit: usize,
14204) -> BTreeSet<String> {
14205 let mut seen = BTreeSet::new();
14206 let mut queue = VecDeque::new();
14207 seen.insert(origin.to_string());
14208 queue.push_back((origin.to_string(), 0usize));
14209 while let Some((current, current_depth)) = queue.pop_front() {
14210 if current_depth >= depth {
14211 continue;
14212 }
14213 for neighbor in traversal_scored_neighbors(edges, ¤t) {
14214 if limit > 0 && seen.len() >= limit {
14215 return seen;
14216 }
14217 if seen.insert(neighbor.clone()) {
14218 queue.push_back((neighbor, current_depth + 1));
14219 }
14220 }
14221 }
14222 seen
14223}
14224
14225fn traversal_edges_between(
14226 handles: &BTreeSet<String>,
14227 edges: &[TraversalEdge],
14228) -> Vec<TraversalEdge> {
14229 edges
14230 .iter()
14231 .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14232 .cloned()
14233 .collect()
14234}
14235
14236fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14237 let mut result = Vec::new();
14238 for pair in path.windows(2) {
14239 if let Some(edge) = edges.iter().find(|edge| {
14240 (edge.from == pair[0] && edge.to == pair[1])
14241 || (edge.from == pair[1] && edge.to == pair[0])
14242 }) {
14243 result.push(edge.clone());
14244 }
14245 }
14246 result
14247}
14248
14249fn sorted_traversal_nodes<'a>(
14250 nodes: impl IntoIterator<Item = &'a TraversalNode>,
14251) -> Vec<TraversalNode> {
14252 let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14253 nodes.sort_by(|left, right| {
14254 left.kind
14255 .cmp(&right.kind)
14256 .then_with(|| left.label.cmp(&right.label))
14257 .then_with(|| left.path.cmp(&right.path))
14258 .then_with(|| left.handle.cmp(&right.handle))
14259 });
14260 nodes
14261}
14262
14263fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14264 let base = match edge.relation.as_str() {
14265 "mentions" => 100,
14266 "contains" => 80,
14267 "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14268 "contains_embedded_symbol" | "embedded_in_fence" => 77,
14269 "contains_markdown_block"
14270 | "contains_embedded_code"
14271 | "enclosing_module"
14272 | "enclosing_section" => 76,
14273 "calls" => {
14274 if edge.from == origin {
14275 70
14276 } else {
14277 65
14278 }
14279 }
14280 "handled_by" | "handles_route" => 68,
14281 "defines_route" => 62,
14282 "imports" => 62,
14283 "previous_sibling" | "next_sibling" => 54,
14284 "mentions_concept" | "mentions_entity" => 66,
14285 "semantic_relation" => 64,
14286 "tagged_concept" | "related_concept" => 58,
14287 "defines" => {
14288 if edge.from == origin {
14289 60
14290 } else {
14291 55
14292 }
14293 }
14294 _ => 10,
14295 };
14296 base + edge.weight
14297}
14298
14299fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
14300 match edge.relation.as_str() {
14301 "mentions" => "matched from backlog/session text".to_string(),
14302 "contains" => "contained in the selected session artifact".to_string(),
14303 "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
14304 "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
14305 "parent" => "parent AST span".to_string(),
14306 "child" => "child AST span".to_string(),
14307 "previous_sibling" => "previous AST sibling".to_string(),
14308 "next_sibling" => "next AST sibling".to_string(),
14309 "contains_markdown_block" => "Markdown section block".to_string(),
14310 "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
14311 "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
14312 "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
14313 "enclosing_module" => "nearest enclosing module".to_string(),
14314 "enclosing_section" => "nearest enclosing Markdown section".to_string(),
14315 "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
14316 "defines" => "file that defines the selected symbol".to_string(),
14317 "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
14318 "defines_route" => "file that declares the selected route".to_string(),
14319 "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
14320 "handled_by" => "route handled by the selected symbol".to_string(),
14321 "handles_route" => "route handled by the selected AST span".to_string(),
14322 "imports" => "import dependency from the selected package".to_string(),
14323 "mentions_concept" => "cached summary concept for the selected source".to_string(),
14324 "mentions_entity" => "cached summary entity for the selected source".to_string(),
14325 "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
14326 "tagged_concept" => "concept label attached to the selected entity".to_string(),
14327 "related_concept" => "co-occurring cached summary concept".to_string(),
14328 "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
14329 "calls" => "caller of the selected symbol".to_string(),
14330 other => format!("connected by {other}"),
14331 }
14332}
14333
14334fn traversal_recommendations(
14335 graph: &TraversalGraphBuild,
14336 origin: Option<&str>,
14337 shortest_path: Option<&[String]>,
14338 limit: usize,
14339) -> Vec<TraversalRecommendation> {
14340 let Some(origin) = origin else {
14341 return Vec::new();
14342 };
14343 let mut recommendations = Vec::new();
14344 let mut seen = BTreeSet::new();
14345
14346 if let Some(path) = shortest_path
14347 && path.len() > 1
14348 && path.first().is_some_and(|handle| handle == origin)
14349 && let Some(next) = graph.nodes.get(&path[1])
14350 {
14351 seen.insert(next.handle.clone());
14352 recommendations.push(TraversalRecommendation {
14353 handle: next.handle.clone(),
14354 kind: next.kind.clone(),
14355 label: next.label.clone(),
14356 reason: "next hop on shortest path".to_string(),
14357 score: 1_000,
14358 expand: next.expand.clone(),
14359 });
14360 }
14361
14362 let mut candidates = graph
14363 .edges
14364 .iter()
14365 .filter_map(|edge| {
14366 let neighbor = if edge.from == origin {
14367 edge.to.as_str()
14368 } else if edge.to == origin {
14369 edge.from.as_str()
14370 } else {
14371 return None;
14372 };
14373 let node = graph.nodes.get(neighbor)?;
14374 Some((traversal_relation_score(edge, origin), edge, node))
14375 })
14376 .collect::<Vec<_>>();
14377 candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
14378 right_score
14379 .cmp(left_score)
14380 .then_with(|| left.kind.cmp(&right.kind))
14381 .then_with(|| left.label.cmp(&right.label))
14382 .then_with(|| left.handle.cmp(&right.handle))
14383 });
14384
14385 let max = if limit == 0 { usize::MAX } else { limit };
14386 for (score, edge, node) in candidates {
14387 if recommendations.len() >= max {
14388 break;
14389 }
14390 if seen.insert(node.handle.clone()) {
14391 recommendations.push(TraversalRecommendation {
14392 handle: node.handle.clone(),
14393 kind: node.kind.clone(),
14394 label: node.label.clone(),
14395 reason: traversal_recommendation_reason(edge, origin),
14396 score,
14397 expand: node.expand.clone(),
14398 });
14399 }
14400 }
14401
14402 recommendations
14403}
14404
14405fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
14406 let scale = nodes.saturating_add(edges);
14407 if scale <= 80 {
14408 ExplorationBudget {
14409 project_size: "small".to_string(),
14410 max_source_windows: 8,
14411 lines_per_window: 96,
14412 relationship_limit: 40,
14413 }
14414 } else if scale <= 800 {
14415 ExplorationBudget {
14416 project_size: "medium".to_string(),
14417 max_source_windows: 6,
14418 lines_per_window: 80,
14419 relationship_limit: 32,
14420 }
14421 } else {
14422 ExplorationBudget {
14423 project_size: "large".to_string(),
14424 max_source_windows: 4,
14425 lines_per_window: 64,
14426 relationship_limit: 24,
14427 }
14428 }
14429}
14430
14431fn exploration_node_label(node: &TraversalNode) -> String {
14432 format!("{}:{}", node.kind, node.label)
14433}
14434
14435fn exploration_source_window_for_node(
14436 root: &Path,
14437 node: &TraversalNode,
14438 budget: &ExplorationBudget,
14439) -> Option<ExplorationSourceWindow> {
14440 let file = node.path.as_ref()?;
14441 let anchor = node
14442 .line
14443 .and_then(|line| usize::try_from(line).ok())
14444 .and_then(|line| line.checked_add(1))
14445 .unwrap_or(1);
14446 let context_before = budget.lines_per_window / 3;
14447 let start = anchor.saturating_sub(context_before).max(1);
14448 let end = start
14449 .saturating_add(budget.lines_per_window)
14450 .saturating_sub(1);
14451 let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
14452 Some(ExplorationSourceWindow {
14453 handle,
14454 file: file.clone(),
14455 start,
14456 end,
14457 reason: format!("cluster around {}", exploration_node_label(node)),
14458 expand: source_read_command(root, file, start, budget.lines_per_window),
14459 })
14460}
14461
14462fn build_exploration_packet(
14463 root: &Path,
14464 totals: &TraversalTotals,
14465 selected_nodes: &[TraversalNode],
14466 selected_edges: &[TraversalEdge],
14467) -> ExplorationPacket {
14468 let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
14469 let node_by_handle = selected_nodes
14470 .iter()
14471 .map(|node| (node.handle.as_str(), node))
14472 .collect::<BTreeMap<_, _>>();
14473 let relationship_map = selected_edges
14474 .iter()
14475 .take(budget.relationship_limit)
14476 .filter_map(|edge| {
14477 let from = node_by_handle.get(edge.from.as_str())?;
14478 let to = node_by_handle.get(edge.to.as_str())?;
14479 Some(ExplorationRelation {
14480 from: exploration_node_label(from),
14481 relation: edge.relation.clone(),
14482 to: exploration_node_label(to),
14483 label: edge.label.clone(),
14484 })
14485 })
14486 .collect::<Vec<_>>();
14487
14488 let mut seen_windows = BTreeSet::new();
14489 let mut source_windows = Vec::new();
14490 for node in selected_nodes {
14491 if source_windows.len() >= budget.max_source_windows {
14492 break;
14493 }
14494 let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
14495 continue;
14496 };
14497 let key = (window.file.clone(), window.start, window.end);
14498 if seen_windows.insert(key) {
14499 source_windows.push(window);
14500 }
14501 }
14502
14503 ExplorationPacket {
14504 budget,
14505 relationship_map,
14506 source_windows,
14507 worker_context: Vec::new(),
14508 no_reread_guidance:
14509 "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
14510 .to_string(),
14511 }
14512}
14513
14514pub(crate) fn traversal_report(
14515 root: &Path,
14516 scope: Option<&str>,
14517 graph: TraversalGraphBuild,
14518 query: Option<&str>,
14519 target: Option<&str>,
14520 depth: usize,
14521 limit: usize,
14522) -> Result<TraversalReport> {
14523 let totals = TraversalTotals {
14524 nodes: graph.nodes.len(),
14525 edges: graph.edges.len(),
14526 };
14527 let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
14528 let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
14529 if let Some(query) = query
14530 && origin_node.is_none()
14531 {
14532 bail!("traversal node not found: {}", query);
14533 }
14534 if let Some(target) = target
14535 && target_node.is_none()
14536 {
14537 bail!("traversal target not found: {}", target);
14538 }
14539
14540 let (mode, selected_nodes, selected_edges, shortest_path) =
14541 if let (Some(origin), Some(target)) = (origin_node, target_node) {
14542 if let Some(handles) =
14543 traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
14544 {
14545 let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
14546 let nodes = handles
14547 .iter()
14548 .filter_map(|handle| graph.nodes.get(handle).cloned())
14549 .collect::<Vec<_>>();
14550 let edges = traversal_path_edges(&handles, &graph.edges);
14551 let path = TraversalPathReport {
14552 from: origin.clone(),
14553 to: target.clone(),
14554 hops: handles.len().saturating_sub(1),
14555 nodes: nodes.clone(),
14556 edges: edges.clone(),
14557 };
14558 (
14559 "path".to_string(),
14560 nodes,
14561 traversal_edges_between(&handle_set, &graph.edges),
14562 Some(path),
14563 )
14564 } else {
14565 (
14566 "path".to_string(),
14567 vec![origin.clone(), target.clone()],
14568 Vec::new(),
14569 None,
14570 )
14571 }
14572 } else if let Some(origin) = origin_node {
14573 let handles =
14574 traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
14575 let nodes =
14576 sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
14577 let edges = traversal_edges_between(&handles, &graph.edges);
14578 ("neighborhood".to_string(), nodes, edges, None)
14579 } else {
14580 let mut nodes = sorted_traversal_nodes(graph.nodes.values());
14581 let truncated_nodes = limit > 0 && nodes.len() > limit;
14582 if truncated_nodes {
14583 nodes.truncate(limit);
14584 }
14585 let handles = nodes
14586 .iter()
14587 .map(|node| node.handle.clone())
14588 .collect::<BTreeSet<_>>();
14589 let mut edges = traversal_edges_between(&handles, &graph.edges);
14590 let truncated_edges = limit > 0 && edges.len() > limit;
14591 if truncated_edges {
14592 edges.truncate(limit);
14593 }
14594 ("export".to_string(), nodes, edges, None)
14595 };
14596
14597 let shortest_handles = shortest_path.as_ref().map(|path| {
14598 path.nodes
14599 .iter()
14600 .map(|node| node.handle.clone())
14601 .collect::<Vec<_>>()
14602 });
14603 let recommendations = traversal_recommendations(
14604 &graph,
14605 origin_node.map(|node| node.handle.as_str()),
14606 shortest_handles.as_deref(),
14607 if limit == 0 { 10 } else { limit.min(10) },
14608 );
14609 let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
14610 let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
14611
14612 Ok(TraversalReport {
14613 root: root.to_string_lossy().to_string(),
14614 scope: scope.map(str::to_string),
14615 mode,
14616 totals,
14617 query: query.map(str::to_string),
14618 target: target.map(str::to_string),
14619 nodes: selected_nodes,
14620 edges: selected_edges,
14621 shortest_path,
14622 recommendations,
14623 exploration,
14624 truncated,
14625 warnings: graph.warnings,
14626 })
14627}
14628
14629fn html_escape(input: &str) -> String {
14630 input
14631 .replace('&', "&")
14632 .replace('<', "<")
14633 .replace('>', ">")
14634 .replace('"', """)
14635 .replace('\'', "'")
14636}
14637
14638pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
14639 let json = serde_json::to_string(report)?.replace("</", "<\\/");
14640 let mut html = String::new();
14641 html.push_str(
14642 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
14643 );
14644 html.push_str(
14645 r#"<style>
14646:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
14647@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
14648*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif;line-height:1.4}.page{max-width:1280px;margin:0 auto;padding:20px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:14px}.top h1{font-size:22px;margin:0}.meta{color:var(--muted);font-size:13px}.toolbar{display:flex;gap:8px;align-items:center}.toolbar input{min-width:220px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--text);padding:8px 10px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:14px;min-height:650px}.graph-panel,.side{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.graph-panel{position:relative}.legend{position:absolute;left:12px;top:12px;display:flex;flex-wrap:wrap;gap:6px;max-width:calc(100% - 24px)}.legend span{font-size:12px;background:color-mix(in srgb,var(--panel) 86%,transparent);border:1px solid var(--line);border-radius:999px;padding:4px 8px}.side{padding:14px;overflow:auto}.side h2{font-size:15px;margin:0 0 8px}.selected{border-top:1px solid var(--line);margin-top:12px;padding-top:12px}.list{display:grid;gap:8px}.row{border:1px solid var(--line);border-radius:6px;padding:8px;cursor:pointer}.row:hover{border-color:var(--accent)}.kind{font-size:11px;text-transform:uppercase;color:var(--muted);letter-spacing:.04em}.label{font-weight:650;overflow-wrap:anywhere}.handle,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted)}svg{width:100%;height:650px;display:block}.edge{stroke:var(--edge);stroke-width:1.4;opacity:.72}.edge.semantic{stroke:var(--semantic);stroke-width:1.8}.node{stroke:var(--panel);stroke-width:2;cursor:pointer}.node.semantic{stroke:var(--semantic);stroke-width:2.5}.node-label{font-size:12px;paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round;fill:var(--text);pointer-events:none}.hidden{display:none}@media(max-width:900px){.top{display:block}.toolbar{margin-top:12px}.layout{grid-template-columns:1fr}.side{max-height:360px}svg{height:560px}}
14649</style>"#,
14650 );
14651 html.push_str("</head><body>");
14652 html.push_str("<div class=\"page\">");
14653 html.push_str(&format!(
14654 "<header class=\"top\"><div><h1>tsift traversal graph</h1><div class=\"meta\">mode <code>{}</code> | nodes <code>{}</code>/<code>{}</code> | edges <code>{}</code>/<code>{}</code></div></div><div class=\"toolbar\"><input id=\"filter\" type=\"search\" placeholder=\"Filter nodes\"></div></header>",
14655 html_escape(&report.mode),
14656 report.nodes.len(),
14657 report.totals.nodes,
14658 report.edges.len(),
14659 report.totals.edges
14660 ));
14661 html.push_str(
14662 r#"<main class="layout"><section class="graph-panel"><div id="legend" class="legend"></div><svg id="graph-canvas" role="img" aria-label="Traversal graph"></svg></section><aside class="side"><h2>Nodes</h2><div id="node-list" class="list"></div><div id="selected" class="selected"></div></aside></main>"#,
14663 );
14664 html.push_str("<script id=\"graph-data\" type=\"application/json\">");
14665 html.push_str(&json);
14666 html.push_str(
14667 r##"</script><script>
14668const report = JSON.parse(document.getElementById("graph-data").textContent);
14669const svg = document.getElementById("graph-canvas");
14670const list = document.getElementById("node-list");
14671const selected = document.getElementById("selected");
14672const filter = document.getElementById("filter");
14673const legend = document.getElementById("legend");
14674const nodes = report.nodes.map((node, index) => ({...node, index}));
14675const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
14676const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
14677const colorByKind = new Map([
14678 ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
14679 ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
14680 ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
14681 ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
14682]);
14683function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
14684function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
14685function text(value){ return value == null ? "" : String(value); }
14686function matches(node, query){
14687 if (!query) return true;
14688 const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
14689 return haystack.includes(query);
14690}
14691function layout(){
14692 const rect = svg.getBoundingClientRect();
14693 const width = rect.width || 900;
14694 const height = rect.height || 650;
14695 const cx = width / 2;
14696 const cy = height / 2;
14697 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14698 const counts = new Map();
14699 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
14700 const offsets = new Map();
14701 for (const node of nodes) {
14702 const group = kinds.indexOf(node.kind);
14703 const index = offsets.get(node.kind) || 0;
14704 offsets.set(node.kind, index + 1);
14705 const groupCount = counts.get(node.kind) || 1;
14706 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
14707 const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
14708 node.x = cx + Math.cos(angle) * ring;
14709 node.y = cy + Math.sin(angle) * ring;
14710 }
14711}
14712function draw(){
14713 const query = filter.value.trim().toLowerCase();
14714 const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
14715 svg.innerHTML = "";
14716 for (const edge of edges) {
14717 if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
14718 const from = nodeByHandle.get(edge.from);
14719 const to = nodeByHandle.get(edge.to);
14720 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
14721 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
14722 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
14723 line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
14724 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
14725 svg.appendChild(line);
14726 }
14727 for (const node of nodes) {
14728 if (!visible.has(node.handle)) continue;
14729 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
14730 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
14731 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
14732 circle.setAttribute("fill", color(node.kind));
14733 circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
14734 circle.addEventListener("click", () => selectNode(node));
14735 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
14736 svg.appendChild(circle);
14737 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
14738 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
14739 label.setAttribute("class", "node-label");
14740 label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
14741 svg.appendChild(label);
14742 }
14743 renderList(query);
14744}
14745function renderLegend(){
14746 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14747 legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">●</b> ${kind}</span>`).join("");
14748}
14749function renderList(query){
14750 const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
14751 list.innerHTML = rows.map(node => `<div class="row" data-handle="${node.handle}"><div class="kind">${node.kind}</div><div class="label">${escapeHtml(node.label)}</div><div class="handle">${node.handle}</div></div>`).join("");
14752 for (const row of list.querySelectorAll(".row")) {
14753 row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
14754 }
14755}
14756function selectNode(node){
14757 const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
14758 selected.innerHTML = `<h2>${escapeHtml(node.label)}</h2><div class="kind">${node.kind}</div><p class="handle">${node.handle}</p>${node.path ? `<p>${escapeHtml(node.path)}${node.line != null ? ":" + node.line : ""}</p>` : ""}${node.detail ? `<p>${escapeHtml(node.detail)}</p>` : ""}<p><code>${escapeHtml(node.expand)}</code></p><h2>Edges</h2><div class="list">${adjacent.map(edge => `<div class="row"><div class="kind">${edge.relation}</div><div>${escapeHtml(edge.from)} -> ${escapeHtml(edge.to)}</div>${edge.label ? `<div>${escapeHtml(edge.label)}</div>` : ""}</div>`).join("") || "<div class=\"meta\">No visible edges.</div>"}</div>`;
14759}
14760function escapeHtml(value){
14761 return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));
14762}
14763filter.addEventListener("input", draw);
14764window.addEventListener("resize", () => { layout(); draw(); });
14765renderLegend();
14766layout();
14767draw();
14768if (nodes.length) selectNode(nodes[0]);
14769</script></div></body></html>"##,
14770 );
14771 Ok(html)
14772}
14773
14774fn semantic_related_report_from_store(
14775 root: &Path,
14776 scope: Option<&str>,
14777 query: &str,
14778 limit: usize,
14779 kind: SemanticRelatedKind,
14780 store: &impl GraphStore,
14781) -> Result<SemanticRelatedReport> {
14782 if query.trim().is_empty() {
14783 bail!("semantic query cannot be empty");
14784 }
14785
14786 let query_embedding = semantic_embedding(query);
14787 let node_kinds: &[&str] = match kind {
14788 SemanticRelatedKind::Concept => &["semantic_concept"],
14789 SemanticRelatedKind::Entity => &["semantic_entity"],
14790 SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
14791 };
14792
14793 let items = store
14794 .semantic_top_candidates(&query_embedding, node_kinds, limit)?
14795 .into_iter()
14796 .map(|candidate| {
14797 let node = candidate.node;
14798 SemanticRelatedItem {
14799 handle: node
14800 .properties
14801 .get("handle")
14802 .cloned()
14803 .unwrap_or_else(|| node.id.clone()),
14804 kind: node.kind,
14805 label: node.label,
14806 score: candidate.score,
14807 file_path: node
14808 .properties
14809 .get("source_file")
14810 .or_else(|| node.properties.get("path"))
14811 .cloned(),
14812 source_symbol: node.properties.get("source_symbol").cloned(),
14813 detail: node
14814 .properties
14815 .get("description")
14816 .or_else(|| node.properties.get("detail"))
14817 .cloned(),
14818 expand: node
14819 .properties
14820 .get("expand")
14821 .cloned()
14822 .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
14823 }
14824 })
14825 .collect::<Vec<_>>();
14826
14827 let mut warnings = Vec::new();
14828 if items.is_empty() {
14829 warnings.push(
14830 "no semantic graph rows found; run `tsift summarize --extract <path>` first"
14831 .to_string(),
14832 );
14833 }
14834
14835 Ok(SemanticRelatedReport {
14836 root: root.to_string_lossy().to_string(),
14837 scope: scope.map(str::to_string),
14838 query: query.to_string(),
14839 embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
14840 count: items.len(),
14841 items,
14842 warnings,
14843 })
14844}
14845
14846fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
14847 Ok(store.nodes_by_kind("semantic_concept")?.len()
14848 + store.nodes_by_kind("semantic_entity")?.len())
14849}
14850
14851fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
14852 if limit == 0 {
14853 return 0;
14854 }
14855 limit.saturating_mul(4).clamp(
14856 GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
14857 GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
14858 )
14859}
14860
14861fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
14862 if limit == 0 {
14863 return usize::MAX;
14864 }
14865 limit.saturating_mul(3).max(limit).max(seed_count)
14866}
14867
14868fn graph_db_semantic_seeded_neighborhood(
14869 store: &impl GraphStore,
14870 seed_ids: &[String],
14871 depth: usize,
14872 limit: usize,
14873) -> Result<GraphDbSemanticSeededSubgraph> {
14874 let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
14875 let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
14876 let mut diagnostics = vec![
14877 "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
14878 "seed expansion traverses both outgoing and incident edges so code, markdown, conversation, and memory adapters can link into semantic rows without reversing their edge direction".to_string(),
14879 format!(
14880 "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
14881 if edge_scan_cap == 0 {
14882 "unbounded".to_string()
14883 } else {
14884 edge_scan_cap.to_string()
14885 },
14886 if node_discovery_cap == usize::MAX {
14887 "unbounded".to_string()
14888 } else {
14889 node_discovery_cap.to_string()
14890 }
14891 ),
14892 ];
14893
14894 let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
14895 .with_edge_scan_cap(edge_scan_cap)
14896 .with_node_discovery_cap(node_discovery_cap);
14897 let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
14898
14899 for seed_id in &result.missing_seed_ids {
14900 diagnostics.push(format!(
14901 "semantic seed {seed_id} was not present in the graph store"
14902 ));
14903 }
14904
14905 if result.skipped_by_edge_cap > 0 {
14906 diagnostics.push(format!(
14907 "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
14908 result.skipped_by_edge_cap
14909 ));
14910 }
14911 if result.skipped_by_node_cap > 0 {
14912 diagnostics.push(format!(
14913 "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
14914 result.skipped_by_node_cap
14915 ));
14916 }
14917
14918 if result.truncated {
14919 diagnostics.push(format!(
14920 "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
14921 result.total_discovered
14922 ));
14923 }
14924
14925 Ok(GraphDbSemanticSeededSubgraph {
14926 nodes: result.nodes,
14927 edges: result.edges,
14928 truncated: result.truncated,
14929 diagnostics,
14930 })
14931}
14932
14933#[allow(clippy::too_many_arguments)]
14934fn cmd_semantic_related(
14935 query: &str,
14936 path: &Path,
14937 scope: Option<&str>,
14938 limit: usize,
14939 kind: SemanticRelatedKind,
14940 json_output: bool,
14941 compact: bool,
14942 pretty: bool,
14943 terse: bool,
14944 schema: bool,
14945) -> Result<()> {
14946 let root = lint::resolve_project_root_or_canonical_path(path)?;
14947 write_traversal_graph_store(&root, path, scope)?;
14948 let graph_db = graph_substrate_db_path(&root, scope);
14949 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14950 let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
14951 if let Some(recovery) = store.read_only_recovery() {
14952 report
14953 .warnings
14954 .push(graph_db_read_recovery_diagnostic(recovery));
14955 }
14956
14957 if json_output {
14958 println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
14959 } else if compact {
14960 for item in &report.items {
14961 println!(
14962 "{:.3}\t{}\t{}\t{}",
14963 item.score, item.kind, item.label, item.handle
14964 );
14965 }
14966 for warning in &report.warnings {
14967 eprintln!("warning: {warning}");
14968 }
14969 } else {
14970 println!(
14971 "Related semantic graph rows for {:?} ({})",
14972 report.query, report.embedding_model
14973 );
14974 for item in &report.items {
14975 println!(
14976 " {:.3} [{}] {} ({})",
14977 item.score, item.kind, item.label, item.handle
14978 );
14979 if let Some(detail) = &item.detail {
14980 println!(" {}", detail);
14981 }
14982 if let Some(file_path) = &item.file_path {
14983 println!(" file: {}", file_path);
14984 }
14985 println!(" expand: {}", item.expand);
14986 }
14987 for warning in &report.warnings {
14988 eprintln!("warning: {warning}");
14989 }
14990 }
14991
14992 Ok(())
14993}
14994
14995#[derive(Serialize)]
14996struct SourceLinePreview {
14997 line: usize,
14998 text: String,
14999}
15000
15001#[derive(Serialize)]
15002pub(crate) struct SourceRangePreview {
15003 start: usize,
15004 end: usize,
15005 total_lines: usize,
15006 truncated_before: bool,
15007 truncated_after: bool,
15008}
15009
15010#[derive(Serialize)]
15011struct SourceExpandCommands {
15012 #[serde(skip_serializing_if = "Option::is_none")]
15013 before: Option<String>,
15014 #[serde(skip_serializing_if = "Option::is_none")]
15015 after: Option<String>,
15016 #[serde(skip_serializing_if = "Option::is_none")]
15017 body: Option<String>,
15018 file: String,
15019 #[serde(skip_serializing_if = "Option::is_none")]
15020 markdown_ast: Option<String>,
15021}
15022
15023#[derive(Serialize)]
15024struct SourceSymbolRef {
15025 handle: String,
15026 name: String,
15027 kind: String,
15028 language: String,
15029 file: String,
15030 line: usize,
15031 #[serde(skip_serializing_if = "Option::is_none")]
15032 end_line: Option<usize>,
15033 #[serde(skip_serializing_if = "Option::is_none")]
15034 signature: Option<String>,
15035 #[serde(skip_serializing_if = "Option::is_none")]
15036 span: Option<AstSpanPreview>,
15037 expand: String,
15038}
15039
15040#[derive(Serialize)]
15041struct SourceSummaryRef {
15042 handle: String,
15043 symbol_name: String,
15044 file_path: String,
15045 summary: String,
15046 expand: String,
15047}
15048
15049#[derive(Serialize)]
15050struct SourceReadReport {
15051 handle: String,
15052 root: String,
15053 file: String,
15054 range: SourceRangePreview,
15055 preview: Vec<SourceLinePreview>,
15056 symbols: Vec<SourceSymbolRef>,
15057 summaries: Vec<SourceSummaryRef>,
15058 #[serde(skip_serializing_if = "Option::is_none")]
15059 markdown: Option<SourceReadMarkdownProjection>,
15060 expand: SourceExpandCommands,
15061 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15062 warnings: Vec<String>,
15063}
15064
15065#[derive(Serialize)]
15066struct SourceReadAstExpandCommands {
15067 window: String,
15068 file_window: String,
15069 #[serde(skip_serializing_if = "Option::is_none")]
15070 markdown_ast: Option<String>,
15071}
15072
15073#[derive(Serialize)]
15074struct SourceReadAstReport {
15075 handle: String,
15076 root: String,
15077 file: String,
15078 range: SourceRangePreview,
15079 symbols: Vec<SourceSymbolRef>,
15080 summaries: Vec<SourceSummaryRef>,
15081 #[serde(skip_serializing_if = "Option::is_none")]
15082 markdown: Option<SourceReadMarkdownProjection>,
15083 expand: SourceReadAstExpandCommands,
15084 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15085 warnings: Vec<String>,
15086}
15087
15088#[derive(Serialize)]
15089struct SymbolReadTarget {
15090 handle: String,
15091 name: String,
15092 kind: String,
15093 language: String,
15094 file: String,
15095 line: usize,
15096 #[serde(skip_serializing_if = "Option::is_none")]
15097 end_line: Option<usize>,
15098 #[serde(skip_serializing_if = "Option::is_none")]
15099 signature: Option<String>,
15100 #[serde(skip_serializing_if = "Option::is_none")]
15101 parent_module: Option<String>,
15102 #[serde(skip_serializing_if = "Option::is_none")]
15103 visibility: Option<String>,
15104 #[serde(skip_serializing_if = "Option::is_none")]
15105 span: Option<AstSpanPreview>,
15106}
15107
15108#[derive(Serialize)]
15109struct SymbolReadExpandCommands {
15110 source_window: String,
15111 #[serde(skip_serializing_if = "Option::is_none")]
15112 body: Option<String>,
15113 file: String,
15114 explain: String,
15115 callers: String,
15116 callees: String,
15117 #[serde(skip_serializing_if = "Option::is_none")]
15118 markdown_ast: Option<String>,
15119}
15120
15121#[derive(Serialize)]
15122struct SymbolReadReport {
15123 handle: String,
15124 root: String,
15125 query: String,
15126 symbol: SymbolReadTarget,
15127 range: SourceRangePreview,
15128 body: Vec<SourceLinePreview>,
15129 child_symbols: Vec<SourceSymbolRef>,
15130 summaries: Vec<SourceSummaryRef>,
15131 expand: SymbolReadExpandCommands,
15132 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15133 warnings: Vec<String>,
15134}
15135
15136#[derive(Clone)]
15137pub(crate) struct MarkdownAstRawNode {
15138 handle: String,
15139 span_handle: String,
15140 name: String,
15141 kind: String,
15142 block_kind: String,
15143 node_kind: String,
15144 start_byte: usize,
15145 end_byte: usize,
15146 body_start_byte: Option<usize>,
15147 body_end_byte: Option<usize>,
15148}
15149
15150#[derive(Clone)]
15151pub(crate) struct MarkdownAstProjection {
15152 source_hash: String,
15153 nodes: Vec<MarkdownAstRawNode>,
15154 parse_duration_micros: u128,
15155 cache_hit: bool,
15156}
15157
15158#[derive(Clone)]
15159struct MarkdownAstCacheEntry {
15160 source_hash: String,
15161 nodes: Vec<MarkdownAstRawNode>,
15162 parse_duration_micros: u128,
15163}
15164
15165static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15166 OnceLock::new();
15167
15168#[derive(Serialize, Clone)]
15169struct MarkdownAstNodeMetadata {
15170 #[serde(skip_serializing_if = "Option::is_none")]
15171 heading_level: Option<usize>,
15172 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15173 section_path: Vec<String>,
15174 #[serde(skip_serializing_if = "Option::is_none")]
15175 section_handle: Option<String>,
15176 #[serde(skip_serializing_if = "Option::is_none")]
15177 list_depth: Option<usize>,
15178 #[serde(skip_serializing_if = "Option::is_none")]
15179 list_marker: Option<String>,
15180 #[serde(skip_serializing_if = "Option::is_none")]
15181 list_order: Option<usize>,
15182 #[serde(skip_serializing_if = "Option::is_none")]
15183 fence_language: Option<String>,
15184 #[serde(skip_serializing_if = "Option::is_none")]
15185 fence_marker: Option<String>,
15186 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15187 embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15188}
15189
15190#[derive(Serialize, Clone)]
15191struct MarkdownAstNodeExpand {
15192 source_window: String,
15193 source_body: String,
15194 symbol_read: String,
15195 edit_intents: String,
15196}
15197
15198#[derive(Serialize, Clone)]
15199struct MarkdownAstCacheReport {
15200 source_hash: String,
15201 cache_hit: bool,
15202 parse_duration_micros: u128,
15203 node_count: usize,
15204 section_count: usize,
15205 list_item_count: usize,
15206 code_block_count: usize,
15207}
15208
15209#[derive(Serialize, Clone)]
15210struct MarkdownAstPhaseTiming {
15211 name: String,
15212 duration_micros: u128,
15213 detail: String,
15214}
15215
15216#[derive(Serialize, Clone)]
15217struct MarkdownAstOutlineEntry {
15218 handle: String,
15219 span_handle: String,
15220 name: String,
15221 kind: String,
15222 block_kind: String,
15223 line: usize,
15224 end_line: usize,
15225 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15226 section_path: Vec<String>,
15227 child_count: usize,
15228 expand: String,
15229}
15230
15231#[derive(Serialize, Clone)]
15232struct MarkdownAstProjectionPreview {
15233 mode: String,
15234 total_nodes: usize,
15235 returned_nodes: usize,
15236 omitted_nodes: usize,
15237 selected_node: Option<String>,
15238 cache: MarkdownAstCacheReport,
15239 outline: Vec<MarkdownAstOutlineEntry>,
15240 phase_timings: Vec<MarkdownAstPhaseTiming>,
15241}
15242
15243#[derive(Serialize)]
15244struct SourceReadMarkdownProjection {
15245 handle: String,
15246 mode: String,
15247 total_nodes: usize,
15248 visible_nodes: usize,
15249 outline: Vec<MarkdownAstOutlineEntry>,
15250 expand: String,
15251}
15252
15253#[derive(Serialize, Clone)]
15254struct SourceByteRangePreview {
15255 start: usize,
15256 end: usize,
15257}
15258
15259#[derive(Serialize, Clone)]
15260struct MarkdownAstNode {
15261 handle: String,
15262 span_handle: String,
15263 name: String,
15264 kind: String,
15265 block_kind: String,
15266 node_kind: String,
15267 line: usize,
15268 end_line: usize,
15269 byte_span: SourceByteRangePreview,
15270 #[serde(skip_serializing_if = "Option::is_none")]
15271 body_byte_span: Option<SourceByteRangePreview>,
15272 parent_handle: Option<String>,
15273 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15274 child_handles: Vec<String>,
15275 metadata: MarkdownAstNodeMetadata,
15276 expand: MarkdownAstNodeExpand,
15277}
15278
15279#[derive(Serialize)]
15280struct MarkdownAstExpandCommands {
15281 file: String,
15282 source_read: String,
15283 edit_intents: String,
15284}
15285
15286#[derive(Serialize)]
15287struct MarkdownAstReport {
15288 handle: String,
15289 root: String,
15290 file: String,
15291 range: SourceRangePreview,
15292 projection: MarkdownAstProjectionPreview,
15293 nodes: Vec<MarkdownAstNode>,
15294 expand: MarkdownAstExpandCommands,
15295 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15296 warnings: Vec<String>,
15297}
15298
15299pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
15300 let candidate = if file.is_absolute() {
15301 file.to_path_buf()
15302 } else {
15303 root.join(file)
15304 };
15305 let canonical = candidate
15306 .canonicalize()
15307 .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
15308 if !canonical.is_file() {
15309 bail!("source file is not a regular file: {}", canonical.display());
15310 }
15311 let canonical_root = root
15312 .canonicalize()
15313 .with_context(|| format!("canonicalizing project root {}", root.display()))?;
15314 if !canonical.starts_with(&canonical_root) {
15315 bail!(
15316 "source file {} is outside project root {}",
15317 canonical.display(),
15318 canonical_root.display()
15319 );
15320 }
15321 Ok(canonical)
15322}
15323
15324pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
15325 source_read_window_command(root, file, start, lines)
15326}
15327
15328pub(crate) fn source_read_window_command(
15329 root: &Path,
15330 file: &str,
15331 start: usize,
15332 lines: usize,
15333) -> String {
15334 format!(
15335 "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
15336 shell_quote(file),
15337 shell_quote(&root.to_string_lossy()),
15338 start,
15339 lines
15340 )
15341}
15342
15343pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
15344 format!(
15345 "tsift --envelope source-read {} --path {} --budget normal",
15346 shell_quote(file),
15347 shell_quote(&root.to_string_lossy())
15348 )
15349}
15350
15351pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
15352 format!(
15353 "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
15354 shell_quote(symbol),
15355 shell_quote(&root.to_string_lossy()),
15356 shell_quote(file)
15357 )
15358}
15359
15360fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
15361 format!(
15362 "tsift --envelope explain {} --path {} --budget normal",
15363 shell_quote(symbol),
15364 shell_quote(&root.to_string_lossy())
15365 )
15366}
15367
15368fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
15369 format!(
15370 "tsift graph {} --path {} --{} --json",
15371 shell_quote(symbol),
15372 shell_quote(&root.to_string_lossy()),
15373 relation
15374 )
15375}
15376
15377fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
15378 format!(
15379 "tsift summarize {} --path {} --json",
15380 shell_quote(symbol),
15381 shell_quote(&root.to_string_lossy())
15382 )
15383}
15384
15385pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
15386 let mut command = format!(
15387 "tsift --envelope markdown-ast {} --path {} --budget normal",
15388 shell_quote(file),
15389 shell_quote(&root.to_string_lossy())
15390 );
15391 if let Some(node) = node {
15392 command.push_str(" --node ");
15393 command.push_str(&shell_quote(node));
15394 }
15395 command
15396}
15397
15398fn markdown_edit_intents_command(root: &Path) -> String {
15399 format!(
15400 "tsift --envelope edit-intents --path {} --budget normal",
15401 shell_quote(&root.to_string_lossy())
15402 )
15403}
15404
15405pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
15406 usize::try_from(symbol.line)
15407 .ok()
15408 .and_then(|line| line.checked_add(1))
15409 .unwrap_or(1)
15410}
15411
15412fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
15413 symbol
15414 .end_line
15415 .and_then(|line| usize::try_from(line).ok())
15416 .and_then(|line| line.checked_add(1))
15417}
15418
15419fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
15420 value.and_then(|byte| usize::try_from(byte).ok())
15421}
15422
15423fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
15424 let byte = byte.min(source.len());
15425 source[..byte]
15426 .iter()
15427 .filter(|value| **value == b'\n')
15428 .count()
15429 .saturating_add(1)
15430}
15431
15432fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
15433 source_line_for_byte(source, end_byte.saturating_sub(1))
15434}
15435
15436fn ast_span_handle(
15437 file: &str,
15438 name: &str,
15439 kind: &str,
15440 start_byte: usize,
15441 end_byte: usize,
15442) -> String {
15443 stable_handle(
15444 "span",
15445 &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
15446 )
15447}
15448
15449pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
15450 Some((
15451 symbol_span_byte(symbol.start_byte)?,
15452 symbol_span_byte(symbol.end_byte)?,
15453 ))
15454}
15455
15456pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
15457 Some((
15458 symbol_span_byte(symbol.start_byte)?,
15459 symbol_span_byte(symbol.end_byte)?,
15460 ))
15461}
15462
15463pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
15464 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15465 Some(ast_span_handle(
15466 &symbol.file,
15467 &symbol.name,
15468 &symbol.kind,
15469 start_byte,
15470 end_byte,
15471 ))
15472}
15473
15474fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
15475 left.file == right.file
15476 && left.name == right.name
15477 && left.kind == right.kind
15478 && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
15479}
15480
15481fn stored_symbol_parent_span_handle(
15482 symbol: &index::StoredSymbol,
15483 symbols: &[index::StoredSymbol],
15484) -> Option<String> {
15485 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15486 symbols
15487 .iter()
15488 .filter(|candidate| {
15489 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15490 return false;
15491 }
15492 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15493 else {
15494 return false;
15495 };
15496 candidate_start <= start_byte && candidate_end >= end_byte
15497 })
15498 .min_by_key(|candidate| {
15499 stored_symbol_span_bounds(candidate)
15500 .map(|(start, end)| end.saturating_sub(start))
15501 .unwrap_or(usize::MAX)
15502 })
15503 .and_then(stored_symbol_span_handle)
15504}
15505
15506fn stored_symbol_child_span_handles(
15507 symbol: &index::StoredSymbol,
15508 symbols: &[index::StoredSymbol],
15509 limit: usize,
15510) -> Vec<String> {
15511 let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
15512 return Vec::new();
15513 };
15514 symbols
15515 .iter()
15516 .filter(|candidate| {
15517 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15518 return false;
15519 }
15520 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15521 else {
15522 return false;
15523 };
15524 candidate_start >= start_byte && candidate_end <= end_byte
15525 })
15526 .take(limit)
15527 .filter_map(stored_symbol_span_handle)
15528 .collect()
15529}
15530
15531fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
15532 let start = start_byte.min(source.len());
15533 let line_end = source[start..]
15534 .iter()
15535 .position(|value| *value == b'\n')
15536 .map(|pos| start + pos)
15537 .unwrap_or(source.len());
15538 let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
15539 let marker = line.trim_start();
15540 let level = marker.chars().take_while(|ch| *ch == '#').count();
15541 (1..=6).contains(&level).then_some(level)
15542}
15543
15544fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
15545 let start = start_byte.min(source.len());
15546 let line_start = source[..start]
15547 .iter()
15548 .rposition(|value| *value == b'\n')
15549 .map(|pos| pos + 1)
15550 .unwrap_or(0);
15551 source[line_start..start]
15552 .iter()
15553 .map(|byte| match byte {
15554 b'\t' => 4,
15555 b' ' => 1,
15556 _ => 0,
15557 })
15558 .sum::<usize>()
15559 / 2
15560}
15561
15562fn markdown_enclosing_heading_symbols<'a>(
15563 file: &str,
15564 start_byte: usize,
15565 end_byte: usize,
15566 symbols: &'a [index::StoredSymbol],
15567) -> Vec<&'a index::StoredSymbol> {
15568 let mut headings = symbols
15569 .iter()
15570 .filter(|candidate| candidate.file == file && candidate.kind == "heading")
15571 .filter(|candidate| {
15572 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15573 else {
15574 return false;
15575 };
15576 candidate_start <= start_byte && candidate_end >= end_byte
15577 })
15578 .collect::<Vec<_>>();
15579 headings.sort_by(|left, right| {
15580 stored_symbol_span_bounds(left)
15581 .map(|(start, _)| start)
15582 .unwrap_or(usize::MAX)
15583 .cmp(
15584 &stored_symbol_span_bounds(right)
15585 .map(|(start, _)| start)
15586 .unwrap_or(usize::MAX),
15587 )
15588 .then(left.name.cmp(&right.name))
15589 });
15590 headings
15591}
15592
15593fn markdown_stored_symbol_metadata(
15594 symbol: &index::StoredSymbol,
15595 source: &[u8],
15596 symbols: &[index::StoredSymbol],
15597) -> Option<MarkdownSpanMetadata> {
15598 if symbol.language != "markdown" {
15599 return None;
15600 }
15601 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15602 let section_symbols =
15603 markdown_enclosing_heading_symbols(&symbol.file, start_byte, end_byte, symbols);
15604 let section_path = section_symbols
15605 .iter()
15606 .map(|heading| heading.name.clone())
15607 .collect::<Vec<_>>();
15608 let section_handle = section_symbols
15609 .last()
15610 .and_then(|heading| stored_symbol_span_handle(heading));
15611 let heading_level = (symbol.kind == "heading")
15612 .then(|| markdown_heading_level(source, start_byte))
15613 .flatten();
15614 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15615 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15616 let embedded_symbols = if symbol.kind == "code_block" {
15617 markdown_embedded_symbols(
15618 &symbol.file,
15619 source,
15620 symbol_span_byte(symbol.body_start_byte),
15621 symbol_span_byte(symbol.body_end_byte),
15622 fence_language.as_deref(),
15623 )
15624 } else {
15625 Vec::new()
15626 };
15627
15628 (heading_level.is_some()
15629 || !section_path.is_empty()
15630 || section_handle.is_some()
15631 || list_depth.is_some()
15632 || fence_language.is_some()
15633 || !embedded_symbols.is_empty())
15634 .then_some(MarkdownSpanMetadata {
15635 heading_level,
15636 section_path,
15637 section_handle,
15638 list_depth,
15639 fence_language,
15640 embedded_symbols,
15641 })
15642}
15643
15644fn markdown_symbol_hit_metadata(
15645 symbol: &index::SymbolHit,
15646 source: &[u8],
15647 start_byte: usize,
15648) -> Option<MarkdownSpanMetadata> {
15649 if symbol.language != "markdown" {
15650 return None;
15651 }
15652 let heading_level = (symbol.kind == "heading")
15653 .then(|| markdown_heading_level(source, start_byte))
15654 .flatten();
15655 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15656 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15657 let embedded_symbols = if symbol.kind == "code_block" {
15658 markdown_embedded_symbols(
15659 &symbol.file,
15660 source,
15661 symbol_span_byte(symbol.body_start_byte),
15662 symbol_span_byte(symbol.body_end_byte),
15663 fence_language.as_deref(),
15664 )
15665 } else {
15666 Vec::new()
15667 };
15668 (heading_level.is_some()
15669 || list_depth.is_some()
15670 || fence_language.is_some()
15671 || !embedded_symbols.is_empty())
15672 .then_some(MarkdownSpanMetadata {
15673 heading_level,
15674 section_path: Vec::new(),
15675 section_handle: None,
15676 list_depth,
15677 fence_language,
15678 embedded_symbols,
15679 })
15680}
15681
15682fn is_markdown_path(path: &Path) -> bool {
15683 path.extension()
15684 .and_then(|ext| ext.to_str())
15685 .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
15686 .unwrap_or(false)
15687}
15688
15689fn markdown_ast_block_kind(kind: &str) -> String {
15690 match kind {
15691 "heading" => "section",
15692 "code_block" => "fenced_code_block",
15693 "list_item" => "list_item",
15694 other => other,
15695 }
15696 .to_string()
15697}
15698
15699fn markdown_embedded_language_key(language: &str) -> Option<String> {
15700 let key = language
15701 .split_whitespace()
15702 .next()
15703 .unwrap_or("")
15704 .trim()
15705 .trim_start_matches("language-")
15706 .trim_start_matches("lang-")
15707 .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
15708 .to_ascii_lowercase();
15709 (!key.is_empty()).then_some(key)
15710}
15711
15712fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
15713 let key = markdown_embedded_language_key(language)?;
15714 let extension = match key.as_str() {
15715 "rust" => "rs",
15716 "python" => "py",
15717 "typescript" => "ts",
15718 "javascript" => "js",
15719 "kotlin" => "kt",
15720 "shell" | "sh" | "zsh" => "bash",
15721 other => other,
15722 };
15723 let lang = graph::Lang::from_extension(extension)?;
15724 (lang.name() != "markdown").then_some(lang)
15725}
15726
15727fn markdown_embedded_ast_span_handle(
15728 file: &str,
15729 language: &str,
15730 name: &str,
15731 kind: &str,
15732 start_byte: usize,
15733 end_byte: usize,
15734) -> String {
15735 stable_handle(
15736 "span",
15737 &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
15738 )
15739}
15740
15741fn markdown_embedded_symbols(
15742 file: &str,
15743 source: &[u8],
15744 body_start_byte: Option<usize>,
15745 body_end_byte: Option<usize>,
15746 fence_language: Option<&str>,
15747) -> Vec<MarkdownEmbeddedSymbol> {
15748 let Some(fence_language) = fence_language else {
15749 return Vec::new();
15750 };
15751 let Some(lang) = markdown_embedded_lang(fence_language) else {
15752 return Vec::new();
15753 };
15754 let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
15755 return Vec::new();
15756 };
15757 let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
15758 else {
15759 return Vec::new();
15760 };
15761 if body.is_empty() {
15762 return Vec::new();
15763 }
15764
15765 let Ok(symbols) = lang.extract_symbols(body) else {
15766 return Vec::new();
15767 };
15768 let language = lang.name().to_string();
15769 symbols
15770 .into_iter()
15771 .map(|symbol| {
15772 let start_byte = body_start_byte.saturating_add(symbol.start_byte);
15773 let end_byte = body_start_byte.saturating_add(symbol.end_byte);
15774 let body_start = symbol
15775 .body_start_byte
15776 .map(|byte| body_start_byte.saturating_add(byte));
15777 let body_end = symbol
15778 .body_end_byte
15779 .map(|byte| body_start_byte.saturating_add(byte));
15780 let start_line = source_line_for_byte(source, start_byte);
15781 let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
15782 MarkdownEmbeddedSymbol {
15783 handle: markdown_embedded_ast_span_handle(
15784 file,
15785 &language,
15786 &symbol.name,
15787 &symbol.kind,
15788 start_byte,
15789 end_byte,
15790 ),
15791 name: symbol.name,
15792 kind: symbol.kind,
15793 language: language.clone(),
15794 node_kind: symbol.node_kind,
15795 start_byte,
15796 end_byte,
15797 start_line,
15798 end_line,
15799 body_start_byte: body_start,
15800 body_end_byte: body_end,
15801 body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
15802 body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
15803 }
15804 })
15805 .collect()
15806}
15807
15808fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
15809 let start = start_byte.min(source.len());
15810 let line_start = source[..start]
15811 .iter()
15812 .rposition(|value| *value == b'\n')
15813 .map(|pos| pos + 1)
15814 .unwrap_or(0);
15815 let line_end = source[start..]
15816 .iter()
15817 .position(|value| *value == b'\n')
15818 .map(|pos| start + pos)
15819 .unwrap_or(source.len());
15820 std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
15821}
15822
15823fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
15824 let line = markdown_source_line(source, start_byte);
15825 let trimmed = line.trim_start();
15826 for marker in ["-", "*", "+"] {
15827 if trimmed
15828 .strip_prefix(marker)
15829 .and_then(|rest| rest.strip_prefix(' '))
15830 .is_some()
15831 {
15832 return (Some(marker.to_string()), None);
15833 }
15834 }
15835
15836 let digit_end = trimmed
15837 .find(|ch: char| !ch.is_ascii_digit())
15838 .unwrap_or(trimmed.len());
15839 let (digits, rest) = trimmed.split_at(digit_end);
15840 if !digits.is_empty() {
15841 for marker in [".", ")"] {
15842 if rest
15843 .strip_prefix(marker)
15844 .and_then(|value| value.strip_prefix(' '))
15845 .is_some()
15846 {
15847 return (
15848 Some(format!("{digits}{marker}")),
15849 digits.parse::<usize>().ok(),
15850 );
15851 }
15852 }
15853 }
15854 (None, None)
15855}
15856
15857fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
15858 let line = markdown_source_line(source, start_byte);
15859 let trimmed = line.trim_start();
15860 ["```", "~~~"]
15861 .into_iter()
15862 .find(|marker| trimmed.starts_with(marker))
15863 .map(str::to_string)
15864}
15865
15866fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
15867 let mut nodes = graph::Lang::Markdown
15868 .extract_symbols(source)
15869 .context("extracting Markdown AST nodes")?
15870 .into_iter()
15871 .map(|symbol| {
15872 let body_start_byte = symbol.body_start_byte;
15873 let body_end_byte = symbol.body_end_byte;
15874 let span_handle = ast_span_handle(
15875 file,
15876 &symbol.name,
15877 &symbol.kind,
15878 symbol.start_byte,
15879 symbol.end_byte,
15880 );
15881 MarkdownAstRawNode {
15882 handle: stable_handle(
15883 "mdast",
15884 &format!(
15885 "{}:{}:{}:{}:{}",
15886 file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
15887 ),
15888 ),
15889 span_handle,
15890 name: symbol.name,
15891 kind: symbol.kind.clone(),
15892 block_kind: markdown_ast_block_kind(&symbol.kind),
15893 node_kind: symbol.node_kind,
15894 start_byte: symbol.start_byte,
15895 end_byte: symbol.end_byte,
15896 body_start_byte,
15897 body_end_byte,
15898 }
15899 })
15900 .collect::<Vec<_>>();
15901 nodes.sort_by(|left, right| {
15902 left.start_byte
15903 .cmp(&right.start_byte)
15904 .then(left.end_byte.cmp(&right.end_byte))
15905 .then(left.kind.cmp(&right.kind))
15906 .then(left.name.cmp(&right.name))
15907 });
15908 Ok(nodes)
15909}
15910
15911pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
15912 let source_hash = blake3::hash(source).to_hex().to_string();
15913 let cache_key = format!("{file}:{source_hash}");
15914 let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
15915 if let Some(entry) = cache
15916 .lock()
15917 .expect("markdown ast cache poisoned")
15918 .get(&cache_key)
15919 {
15920 return Ok(MarkdownAstProjection {
15921 source_hash: entry.source_hash.clone(),
15922 nodes: entry.nodes.clone(),
15923 parse_duration_micros: entry.parse_duration_micros,
15924 cache_hit: true,
15925 });
15926 }
15927
15928 let started = Instant::now();
15929 let nodes = markdown_ast_extract_raw_nodes(file, source)?;
15930 let parse_duration_micros = started.elapsed().as_micros();
15931 cache.lock().expect("markdown ast cache poisoned").insert(
15932 cache_key,
15933 MarkdownAstCacheEntry {
15934 source_hash: source_hash.clone(),
15935 nodes: nodes.clone(),
15936 parse_duration_micros,
15937 },
15938 );
15939 Ok(MarkdownAstProjection {
15940 source_hash,
15941 nodes,
15942 parse_duration_micros,
15943 cache_hit: false,
15944 })
15945}
15946
15947fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
15948 MarkdownAstCacheReport {
15949 source_hash: projection.source_hash.clone(),
15950 cache_hit: projection.cache_hit,
15951 parse_duration_micros: projection.parse_duration_micros,
15952 node_count: projection.nodes.len(),
15953 section_count: projection
15954 .nodes
15955 .iter()
15956 .filter(|node| node.kind == "heading")
15957 .count(),
15958 list_item_count: projection
15959 .nodes
15960 .iter()
15961 .filter(|node| node.kind == "list_item")
15962 .count(),
15963 code_block_count: projection
15964 .nodes
15965 .iter()
15966 .filter(|node| node.kind == "code_block")
15967 .count(),
15968 }
15969}
15970
15971fn markdown_ast_node_direct_child_count(
15972 node: &MarkdownAstRawNode,
15973 nodes: &[MarkdownAstRawNode],
15974) -> usize {
15975 nodes
15976 .iter()
15977 .filter(|candidate| {
15978 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
15979 })
15980 .count()
15981}
15982
15983fn markdown_ast_outline_entry(
15984 root: &Path,
15985 file: &str,
15986 source: &[u8],
15987 nodes: &[MarkdownAstRawNode],
15988 node: &MarkdownAstRawNode,
15989 max_bytes: usize,
15990) -> MarkdownAstOutlineEntry {
15991 let line = source_line_for_byte(source, node.start_byte);
15992 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
15993 MarkdownAstOutlineEntry {
15994 handle: node.handle.clone(),
15995 span_handle: node.span_handle.clone(),
15996 name: truncate_for_budget(&node.name, max_bytes),
15997 kind: node.kind.clone(),
15998 block_kind: node.block_kind.clone(),
15999 line,
16000 end_line,
16001 section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16002 child_count: markdown_ast_node_direct_child_count(node, nodes),
16003 expand: markdown_ast_command(root, file, Some(&node.handle)),
16004 }
16005}
16006
16007fn markdown_ast_outline_entries(
16008 root: &Path,
16009 file: &str,
16010 source: &[u8],
16011 nodes: &[MarkdownAstRawNode],
16012 limit: usize,
16013 max_bytes: usize,
16014) -> Vec<MarkdownAstOutlineEntry> {
16015 let mut headings = nodes
16016 .iter()
16017 .filter(|node| node.kind == "heading")
16018 .collect::<Vec<_>>();
16019 let mut blocks = nodes
16020 .iter()
16021 .filter(|node| node.kind != "heading")
16022 .collect::<Vec<_>>();
16023 headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16024 blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16025 headings
16026 .into_iter()
16027 .chain(blocks)
16028 .take(limit)
16029 .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16030 .collect()
16031}
16032
16033fn markdown_ast_node_intersects_lines(
16034 source: &[u8],
16035 node: &MarkdownAstRawNode,
16036 start: usize,
16037 end: usize,
16038) -> bool {
16039 let line = source_line_for_byte(source, node.start_byte);
16040 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16041 line <= end && end_line >= start
16042}
16043
16044fn source_read_markdown_projection(
16045 root: &Path,
16046 file: &str,
16047 source: &[u8],
16048 start: usize,
16049 end: usize,
16050 budget: ResponseBudget,
16051) -> Result<SourceReadMarkdownProjection> {
16052 let projection = markdown_ast_projection(file, source)?;
16053 let visible_nodes = projection
16054 .nodes
16055 .iter()
16056 .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16057 .collect::<Vec<_>>();
16058 let mut outline_nodes = visible_nodes.clone();
16059 outline_nodes.sort_by_key(|node| {
16060 (
16061 node.kind != "heading",
16062 node.start_byte,
16063 node.end_byte,
16064 node.name.as_str(),
16065 )
16066 });
16067 let outline = outline_nodes
16068 .into_iter()
16069 .take(budget.preview_items())
16070 .map(|node| {
16071 markdown_ast_outline_entry(
16072 root,
16073 file,
16074 source,
16075 &projection.nodes,
16076 node,
16077 budget.preview_bytes(),
16078 )
16079 })
16080 .collect::<Vec<_>>();
16081 Ok(SourceReadMarkdownProjection {
16082 handle: stable_handle(
16083 "mdproj",
16084 &format!("{file}:{start}:{end}:{}", projection.source_hash),
16085 ),
16086 mode: "window_outline".to_string(),
16087 total_nodes: projection.nodes.len(),
16088 visible_nodes: visible_nodes.len(),
16089 outline,
16090 expand: markdown_ast_command(root, file, None),
16091 })
16092}
16093
16094fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16095 if parent.handle == child.handle {
16096 return false;
16097 }
16098 parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16099}
16100
16101fn markdown_ast_parent_handle(
16102 node: &MarkdownAstRawNode,
16103 nodes: &[MarkdownAstRawNode],
16104) -> Option<String> {
16105 nodes
16106 .iter()
16107 .filter(|candidate| markdown_ast_contains(candidate, node))
16108 .min_by_key(|candidate| {
16109 (
16110 candidate.end_byte.saturating_sub(candidate.start_byte),
16111 candidate.start_byte,
16112 )
16113 })
16114 .map(|candidate| candidate.handle.clone())
16115}
16116
16117fn markdown_ast_child_handles(
16118 node: &MarkdownAstRawNode,
16119 nodes: &[MarkdownAstRawNode],
16120 limit: usize,
16121) -> Vec<String> {
16122 nodes
16123 .iter()
16124 .filter(|candidate| {
16125 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16126 })
16127 .take(limit)
16128 .map(|candidate| candidate.handle.clone())
16129 .collect()
16130}
16131
16132fn markdown_ast_section_nodes<'a>(
16133 node: &MarkdownAstRawNode,
16134 nodes: &'a [MarkdownAstRawNode],
16135) -> Vec<&'a MarkdownAstRawNode> {
16136 let mut headings = nodes
16137 .iter()
16138 .filter(|candidate| candidate.kind == "heading")
16139 .filter(|candidate| {
16140 candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16141 })
16142 .collect::<Vec<_>>();
16143 headings.sort_by(|left, right| {
16144 left.start_byte
16145 .cmp(&right.start_byte)
16146 .then(left.end_byte.cmp(&right.end_byte))
16147 .then(left.name.cmp(&right.name))
16148 });
16149 headings
16150}
16151
16152fn markdown_ast_node_metadata(
16153 file: &str,
16154 node: &MarkdownAstRawNode,
16155 source: &[u8],
16156 nodes: &[MarkdownAstRawNode],
16157) -> MarkdownAstNodeMetadata {
16158 let section_nodes = markdown_ast_section_nodes(node, nodes);
16159 let section_path = section_nodes
16160 .iter()
16161 .map(|heading| heading.name.clone())
16162 .collect::<Vec<_>>();
16163 let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16164 let heading_level = (node.kind == "heading")
16165 .then(|| markdown_heading_level(source, node.start_byte))
16166 .flatten();
16167 let (list_marker, list_order) = if node.kind == "list_item" {
16168 markdown_list_attributes(source, node.start_byte)
16169 } else {
16170 (None, None)
16171 };
16172 let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16173 let embedded_symbols = if node.kind == "code_block" {
16174 markdown_embedded_symbols(
16175 file,
16176 source,
16177 node.body_start_byte,
16178 node.body_end_byte,
16179 fence_language.as_deref(),
16180 )
16181 } else {
16182 Vec::new()
16183 };
16184 MarkdownAstNodeMetadata {
16185 heading_level,
16186 section_path,
16187 section_handle,
16188 list_depth: (node.kind == "list_item")
16189 .then(|| markdown_list_depth(source, node.start_byte)),
16190 list_marker,
16191 list_order,
16192 fence_language,
16193 fence_marker: (node.kind == "code_block")
16194 .then(|| markdown_fence_marker(source, node.start_byte))
16195 .flatten(),
16196 embedded_symbols,
16197 }
16198}
16199
16200fn markdown_ast_node_expand(
16201 root: &Path,
16202 file: &str,
16203 node: &MarkdownAstRawNode,
16204 source: &[u8],
16205) -> MarkdownAstNodeExpand {
16206 let start_line = source_line_for_byte(source, node.start_byte);
16207 let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16208 let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16209 let body_start_line = node
16210 .body_start_byte
16211 .map(|byte| source_line_for_byte(source, byte))
16212 .unwrap_or(start_line);
16213 let body_end_line = node
16214 .body_end_byte
16215 .map(|byte| source_line_for_end_byte(source, byte))
16216 .unwrap_or(end_line)
16217 .max(body_start_line);
16218 let body_line_count = body_end_line
16219 .saturating_sub(body_start_line)
16220 .saturating_add(1)
16221 .max(1);
16222 MarkdownAstNodeExpand {
16223 source_window: source_read_command(root, file, start_line, line_count),
16224 source_body: source_read_command(root, file, body_start_line, body_line_count),
16225 symbol_read: source_symbol_read_command(root, &node.name, file),
16226 edit_intents: markdown_edit_intents_command(root),
16227 }
16228}
16229
16230fn markdown_ast_node(
16231 root: &Path,
16232 file: &str,
16233 node: &MarkdownAstRawNode,
16234 source: &[u8],
16235 nodes: &[MarkdownAstRawNode],
16236 child_limit: usize,
16237) -> MarkdownAstNode {
16238 let line = source_line_for_byte(source, node.start_byte);
16239 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16240 let body_byte_span = node
16241 .body_start_byte
16242 .zip(node.body_end_byte)
16243 .map(|(start, end)| SourceByteRangePreview { start, end });
16244 MarkdownAstNode {
16245 handle: node.handle.clone(),
16246 span_handle: node.span_handle.clone(),
16247 name: node.name.clone(),
16248 kind: node.kind.clone(),
16249 block_kind: node.block_kind.clone(),
16250 node_kind: node.node_kind.clone(),
16251 line,
16252 end_line,
16253 byte_span: SourceByteRangePreview {
16254 start: node.start_byte,
16255 end: node.end_byte,
16256 },
16257 body_byte_span,
16258 parent_handle: markdown_ast_parent_handle(node, nodes),
16259 child_handles: markdown_ast_child_handles(node, nodes, child_limit),
16260 metadata: markdown_ast_node_metadata(file, node, source, nodes),
16261 expand: markdown_ast_node_expand(root, file, node, source),
16262 }
16263}
16264
16265pub(crate) fn stored_symbol_ast_span(
16266 symbol: &index::StoredSymbol,
16267 source: &[u8],
16268 symbols: &[index::StoredSymbol],
16269 child_limit: usize,
16270) -> Option<AstSpanPreview> {
16271 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16272 let node_kind = symbol.node_kind.clone()?;
16273 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16274 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16275 Some(AstSpanPreview {
16276 handle: ast_span_handle(
16277 &symbol.file,
16278 &symbol.name,
16279 &symbol.kind,
16280 start_byte,
16281 end_byte,
16282 ),
16283 node_kind,
16284 start_byte,
16285 end_byte,
16286 start_line: source_line_for_byte(source, start_byte),
16287 end_line: source_line_for_end_byte(source, end_byte),
16288 body_start_byte,
16289 body_end_byte,
16290 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16291 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16292 parent_handle: stored_symbol_parent_span_handle(symbol, symbols),
16293 child_handles: stored_symbol_child_span_handles(symbol, symbols, child_limit),
16294 markdown: markdown_stored_symbol_metadata(symbol, source, symbols),
16295 })
16296}
16297
16298pub(crate) fn symbol_hit_ast_span(
16299 symbol: &index::SymbolHit,
16300 source: &[u8],
16301) -> Option<AstSpanPreview> {
16302 let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
16303 let node_kind = symbol.node_kind.clone()?;
16304 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16305 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16306 Some(AstSpanPreview {
16307 handle: ast_span_handle(
16308 &symbol.file,
16309 &symbol.name,
16310 &symbol.kind,
16311 start_byte,
16312 end_byte,
16313 ),
16314 node_kind,
16315 start_byte,
16316 end_byte,
16317 start_line: source_line_for_byte(source, start_byte),
16318 end_line: source_line_for_end_byte(source, end_byte),
16319 body_start_byte,
16320 body_end_byte,
16321 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16322 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16323 parent_handle: None,
16324 child_handles: Vec::new(),
16325 markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
16326 })
16327}
16328
16329pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
16330 usize::try_from(symbol.line)
16331 .ok()
16332 .and_then(|line| line.checked_add(1))
16333 .unwrap_or(1)
16334}
16335
16336pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
16337 symbol
16338 .end_line
16339 .and_then(|line| usize::try_from(line).ok())
16340 .and_then(|line| line.checked_add(1))
16341}
16342
16343fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
16344 if end == 0 {
16345 return false;
16346 }
16347 let symbol_start = source_symbol_line(symbol);
16348 let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
16349 symbol_start <= end && symbol_end >= start
16350}
16351
16352#[allow(clippy::too_many_arguments)]
16353fn load_source_symbols(
16354 root: &Path,
16355 file_abs: &Path,
16356 file_display: &str,
16357 source: &[u8],
16358 scope: Option<&str>,
16359 start: usize,
16360 end: usize,
16361 limit: usize,
16362 max_bytes: usize,
16363 warnings: &mut Vec<String>,
16364) -> Vec<SourceSymbolRef> {
16365 let db_path = match resolve_query_db_path(root, file_abs, scope) {
16366 Ok(path) => path,
16367 Err(err) => {
16368 warnings.push(format!("index refs unavailable: {err:#}"));
16369 return Vec::new();
16370 }
16371 };
16372 if !db_path.exists() {
16373 warnings.push(format!(
16374 "index refs unavailable: no index found at {}",
16375 db_path.display()
16376 ));
16377 return Vec::new();
16378 }
16379
16380 let db = match index::IndexDb::open_read_only_resilient(&db_path) {
16381 Ok(db) => db,
16382 Err(err) => {
16383 warnings.push(format!("index refs unavailable: {err:#}"));
16384 return Vec::new();
16385 }
16386 };
16387
16388 let file_key = file_abs.to_string_lossy().to_string();
16389 let symbols = match db.symbols_for_file(&file_key) {
16390 Ok(symbols) => symbols,
16391 Err(err) => {
16392 warnings.push(format!("symbol refs unavailable: {err:#}"));
16393 return Vec::new();
16394 }
16395 };
16396
16397 symbols
16398 .iter()
16399 .filter(|symbol| source_symbol_intersects(symbol, start, end))
16400 .take(limit)
16401 .map(|symbol| {
16402 let line = source_symbol_line(symbol);
16403 let end_line = source_symbol_end_line(symbol);
16404 let handle = stable_handle(
16405 "ssym",
16406 &format!("{}:{}:{}", file_display, symbol.name, line),
16407 );
16408 SourceSymbolRef {
16409 handle,
16410 name: truncate_for_budget(&symbol.name, max_bytes),
16411 kind: symbol.kind.clone(),
16412 language: symbol.language.clone(),
16413 file: file_display.to_string(),
16414 line,
16415 end_line,
16416 signature: symbol
16417 .signature
16418 .clone()
16419 .map(|signature| truncate_for_budget(&signature, max_bytes)),
16420 span: stored_symbol_ast_span(symbol, source, &symbols, limit),
16421 expand: source_symbol_read_command(root, &symbol.name, file_display),
16422 }
16423 })
16424 .collect()
16425}
16426
16427fn load_source_summaries(
16428 root: &Path,
16429 file_display: &str,
16430 limit: usize,
16431 max_bytes: usize,
16432 warnings: &mut Vec<String>,
16433) -> Vec<SourceSummaryRef> {
16434 let db_path = root.join(".tsift/summaries.db");
16435 if !db_path.exists() {
16436 return Vec::new();
16437 }
16438 let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
16439 Ok(db) => db,
16440 Err(err) => {
16441 warnings.push(format!("summary refs unavailable: {err:#}"));
16442 return Vec::new();
16443 }
16444 };
16445 let summaries = match db.get_by_file(file_display) {
16446 Ok(summaries) => summaries,
16447 Err(err) => {
16448 warnings.push(format!("summary refs unavailable: {err:#}"));
16449 return Vec::new();
16450 }
16451 };
16452
16453 summaries
16454 .into_iter()
16455 .take(limit)
16456 .map(|summary| SourceSummaryRef {
16457 handle: stable_handle(
16458 "sum",
16459 &format!(
16460 "{}:{}:{}",
16461 summary.file_path, summary.symbol_name, summary.id
16462 ),
16463 ),
16464 symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
16465 file_path: summary.file_path,
16466 summary: truncate_for_budget(&summary.summary, max_bytes),
16467 expand: source_summary_expand_command(root, &summary.symbol_name),
16468 })
16469 .collect()
16470}
16471
16472fn cmd_markdown_ast(
16473 file: &Path,
16474 path: &Path,
16475 node: Option<&str>,
16476 format: OutputFormat,
16477 absolute: bool,
16478 budget: ResponseBudget,
16479) -> Result<()> {
16480 let root = lint::resolve_project_root_or_canonical_path(path)?;
16481 let file_abs = resolve_source_file(&root, file)?;
16482 if !is_markdown_path(&file_abs) {
16483 bail!(
16484 "markdown-ast only supports Markdown files (.md/.mdx): {}",
16485 file_abs.display()
16486 );
16487 }
16488 let file_display = if absolute {
16489 file_abs.to_string_lossy().to_string()
16490 } else {
16491 relativize_pathbuf(&file_abs, &root)
16492 .to_string_lossy()
16493 .to_string()
16494 };
16495 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16496 let text = String::from_utf8_lossy(&source);
16497 let total_lines = text.lines().count();
16498 let projection = markdown_ast_projection(&file_display, &source)?;
16499 let raw_nodes = &projection.nodes;
16500 let max_items = budget.preview_items();
16501 let max_bytes = budget.preview_bytes();
16502
16503 let selected_nodes = if let Some(handle) = node {
16504 let matches = raw_nodes
16505 .iter()
16506 .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
16507 .collect::<Vec<_>>();
16508 if matches.is_empty() {
16509 bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
16510 }
16511 matches
16512 } else {
16513 raw_nodes.iter().take(max_items).collect::<Vec<_>>()
16514 };
16515 let nodes = selected_nodes
16516 .into_iter()
16517 .map(|raw| {
16518 let mut node =
16519 markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
16520 node.name = truncate_for_budget(&node.name, max_bytes);
16521 node
16522 })
16523 .collect::<Vec<_>>();
16524 let outline_started = Instant::now();
16525 let outline = markdown_ast_outline_entries(
16526 &root,
16527 &file_display,
16528 &source,
16529 raw_nodes,
16530 max_items,
16531 max_bytes,
16532 );
16533 let outline_duration_micros = outline_started.elapsed().as_micros();
16534 let projection_preview = MarkdownAstProjectionPreview {
16535 mode: if node.is_some() {
16536 "selected_node".to_string()
16537 } else {
16538 "outline_first".to_string()
16539 },
16540 total_nodes: raw_nodes.len(),
16541 returned_nodes: nodes.len(),
16542 omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
16543 selected_node: node.map(str::to_string),
16544 cache: markdown_ast_cache_report(&projection),
16545 outline,
16546 phase_timings: vec![
16547 MarkdownAstPhaseTiming {
16548 name: "parse_extract".to_string(),
16549 duration_micros: projection.parse_duration_micros,
16550 detail: if projection.cache_hit {
16551 "reused cached tree-sitter Markdown symbol extraction".to_string()
16552 } else {
16553 "tree-sitter Markdown symbol extraction".to_string()
16554 },
16555 },
16556 MarkdownAstPhaseTiming {
16557 name: "outline_projection".to_string(),
16558 duration_micros: outline_duration_micros,
16559 detail: "outline-first section/block preview construction".to_string(),
16560 },
16561 ],
16562 };
16563 let report = MarkdownAstReport {
16564 handle: stable_handle("mdastrep", &file_display),
16565 root: root.to_string_lossy().to_string(),
16566 file: file_display.clone(),
16567 range: SourceRangePreview {
16568 start: 1,
16569 end: total_lines,
16570 total_lines,
16571 truncated_before: false,
16572 truncated_after: false,
16573 },
16574 projection: projection_preview,
16575 nodes,
16576 expand: MarkdownAstExpandCommands {
16577 file: markdown_ast_command(&root, &file_display, None),
16578 source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
16579 edit_intents: markdown_edit_intents_command(&root),
16580 },
16581 warnings: Vec::new(),
16582 };
16583
16584 if format.json_output {
16585 let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
16586 let mut follow_up = vec![
16587 report.expand.file.clone(),
16588 report.expand.source_read.clone(),
16589 report.expand.edit_intents.clone(),
16590 ];
16591 follow_up.extend(
16592 report
16593 .nodes
16594 .iter()
16595 .map(|node| node.expand.source_window.clone()),
16596 );
16597 print_json_or_envelope(
16598 &report,
16599 &format,
16600 "markdown-ast",
16601 "ast",
16602 ToolEnvelopeSummary {
16603 text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
16604 metrics: vec![
16605 envelope_metric("nodes", report.nodes.len()),
16606 envelope_metric("total_nodes", report.projection.total_nodes),
16607 envelope_metric(
16608 "parse_duration_micros",
16609 report.projection.cache.parse_duration_micros,
16610 ),
16611 envelope_metric("total_lines", report.range.total_lines),
16612 ],
16613 },
16614 truncated,
16615 follow_up,
16616 )?;
16617 } else if format.compact {
16618 println!(
16619 "markdown-ast {} nodes:{} handle:{}",
16620 report.file,
16621 report.nodes.len(),
16622 report.handle
16623 );
16624 for node in &report.nodes {
16625 println!(
16626 " {} {} {}:{}-{}",
16627 node.handle, node.kind, node.name, node.line, node.end_line
16628 );
16629 }
16630 if node.is_none() && raw_nodes.len() > report.nodes.len() {
16631 println!("expand: {}", report.expand.file);
16632 }
16633 } else {
16634 println!(
16635 "Markdown AST `{}` nodes {} of {} ({})",
16636 report.file,
16637 report.nodes.len(),
16638 raw_nodes.len(),
16639 report.handle
16640 );
16641 for node in &report.nodes {
16642 println!(
16643 " {} `{}` {}:{}-{} — {}",
16644 node.handle,
16645 node.name,
16646 node.kind,
16647 node.line,
16648 node.end_line,
16649 node.expand.source_window
16650 );
16651 }
16652 if node.is_none() && raw_nodes.len() > report.nodes.len() {
16653 println!();
16654 println!("Expand:");
16655 println!(" file: {}", report.expand.file);
16656 }
16657 }
16658
16659 Ok(())
16660}
16661
16662#[allow(clippy::too_many_arguments)]
16663fn cmd_source_read(
16664 file: &Path,
16665 path: &Path,
16666 style: SourceReadStyle,
16667 start: usize,
16668 lines: usize,
16669 end: Option<usize>,
16670 scope: Option<&str>,
16671 format: OutputFormat,
16672 absolute: bool,
16673 budget: ResponseBudget,
16674) -> Result<()> {
16675 if start == 0 {
16676 bail!("--start is 1-based and must be greater than zero");
16677 }
16678 if lines == 0 {
16679 bail!("--lines must be greater than zero");
16680 }
16681 if let Some(end) = end
16682 && end < start
16683 {
16684 bail!("--end must be greater than or equal to --start");
16685 }
16686
16687 let root = lint::resolve_project_root_or_canonical_path(path)?;
16688 let file_abs = resolve_source_file(&root, file)?;
16689 let file_display = if absolute {
16690 file_abs.to_string_lossy().to_string()
16691 } else {
16692 relativize_pathbuf(&file_abs, &root)
16693 .to_string_lossy()
16694 .to_string()
16695 };
16696
16697 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16698 let text = String::from_utf8_lossy(&source);
16699 let all_lines: Vec<&str> = text.lines().collect();
16700 let total_lines = all_lines.len();
16701 if total_lines > 0 && start > total_lines {
16702 bail!(
16703 "--start {} is beyond end of {} ({} lines)",
16704 start,
16705 file_display,
16706 total_lines
16707 );
16708 }
16709 let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
16710 let end_line = requested_end.min(total_lines);
16711 let mut warnings = Vec::new();
16712 let max_items = budget.preview_items();
16713 let max_bytes = budget.preview_bytes();
16714 if style == SourceReadStyle::Ast {
16715 let symbols = load_source_symbols(
16716 &root,
16717 &file_abs,
16718 &file_display,
16719 &source,
16720 scope,
16721 start,
16722 end_line,
16723 max_items,
16724 max_bytes,
16725 &mut warnings,
16726 );
16727 let summaries =
16728 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
16729 let markdown = if is_markdown_path(&file_abs) {
16730 match source_read_markdown_projection(
16731 &root,
16732 &file_display,
16733 &source,
16734 start,
16735 end_line,
16736 budget,
16737 ) {
16738 Ok(markdown) => Some(markdown),
16739 Err(err) => {
16740 warnings.push(format!("markdown projection unavailable: {err:#}"));
16741 None
16742 }
16743 }
16744 } else {
16745 None
16746 };
16747 let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
16748 let report = SourceReadAstReport {
16749 handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
16750 root: root.to_string_lossy().to_string(),
16751 file: file_display.clone(),
16752 range: SourceRangePreview {
16753 start,
16754 end: end_line,
16755 total_lines,
16756 truncated_before: start > 1,
16757 truncated_after: end_line < total_lines,
16758 },
16759 symbols,
16760 summaries,
16761 markdown,
16762 expand: SourceReadAstExpandCommands {
16763 window: source_read_window_command(&root, &file_display, start, window_lines),
16764 file_window: source_read_window_command(
16765 &root,
16766 &file_display,
16767 1,
16768 total_lines.max(window_lines),
16769 ),
16770 markdown_ast: is_markdown_path(&file_abs)
16771 .then(|| markdown_ast_command(&root, &file_display, None)),
16772 },
16773 warnings,
16774 };
16775
16776 if format.json_output {
16777 let truncated = report.range.truncated_before
16778 || report.range.truncated_after
16779 || report.symbols.len() >= max_items
16780 || report.summaries.len() >= max_items;
16781 let follow_up = [
16782 Some(report.expand.window.clone()),
16783 Some(report.expand.file_window.clone()),
16784 report.expand.markdown_ast.clone(),
16785 ]
16786 .into_iter()
16787 .flatten()
16788 .collect::<Vec<_>>();
16789 print_json_or_envelope(
16790 &report,
16791 &format,
16792 "source-read",
16793 "ast",
16794 ToolEnvelopeSummary {
16795 text: format!(
16796 "source ast {}:{}-{}",
16797 report.file, report.range.start, report.range.end
16798 ),
16799 metrics: vec![
16800 envelope_metric("symbols", report.symbols.len()),
16801 envelope_metric("summaries", report.summaries.len()),
16802 envelope_metric(
16803 "markdown_nodes",
16804 report
16805 .markdown
16806 .as_ref()
16807 .map_or(0, |markdown| markdown.visible_nodes),
16808 ),
16809 ],
16810 },
16811 truncated,
16812 follow_up,
16813 )?;
16814 } else if format.compact {
16815 println!(
16816 "source-ast {}:{}-{} / {} handle:{}",
16817 report.file,
16818 report.range.start,
16819 report.range.end,
16820 report.range.total_lines,
16821 report.handle
16822 );
16823 for symbol in &report.symbols {
16824 println!(
16825 " {} {}:{} {}",
16826 symbol.name, symbol.file, symbol.line, symbol.expand
16827 );
16828 }
16829 if !report.summaries.is_empty() {
16830 println!("summaries[{}]", report.summaries.len());
16831 }
16832 for warning in &report.warnings {
16833 eprintln!("warning: {warning}");
16834 }
16835 } else {
16836 println!(
16837 "Source AST `{}` lines {}-{} of {} ({})",
16838 report.file,
16839 report.range.start,
16840 report.range.end,
16841 report.range.total_lines,
16842 report.handle
16843 );
16844 if !report.symbols.is_empty() {
16845 println!();
16846 println!("Symbol refs:");
16847 for symbol in &report.symbols {
16848 println!(
16849 " {} `{}` {}:{} — {}",
16850 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
16851 );
16852 }
16853 }
16854 if !report.summaries.is_empty() {
16855 println!();
16856 println!("Summary refs:");
16857 for summary in &report.summaries {
16858 println!(
16859 " {} `{}` — {}",
16860 summary.handle, summary.symbol_name, summary.expand
16861 );
16862 }
16863 }
16864 println!();
16865 println!("Expand:");
16866 println!(" window: {}", report.expand.window);
16867 println!(" file window: {}", report.expand.file_window);
16868 if let Some(markdown_ast) = &report.expand.markdown_ast {
16869 println!(" markdown: {}", markdown_ast);
16870 }
16871 for warning in &report.warnings {
16872 eprintln!("warning: {warning}");
16873 }
16874 }
16875
16876 return Ok(());
16877 }
16878 let max_bytes = budget.preview_bytes();
16879 let token_cap = budget.body_token_cap();
16880 let (preview, preview_end, body_truncated) = if total_lines == 0 {
16881 (Vec::new(), end_line, false)
16882 } else {
16883 let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
16884 (capped.preview, capped.capped_end, capped.was_capped)
16885 };
16886 let effective_end = if body_truncated {
16887 preview_end
16888 } else {
16889 end_line
16890 };
16891
16892 if body_truncated {
16893 warnings.push(format!(
16894 "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
16895 ));
16896 }
16897 let symbols = load_source_symbols(
16898 &root,
16899 &file_abs,
16900 &file_display,
16901 &source,
16902 scope,
16903 start,
16904 effective_end,
16905 max_items,
16906 max_bytes,
16907 &mut warnings,
16908 );
16909 let summaries =
16910 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
16911 let markdown = if is_markdown_path(&file_abs) {
16912 match source_read_markdown_projection(
16913 &root,
16914 &file_display,
16915 &source,
16916 start,
16917 effective_end,
16918 budget,
16919 ) {
16920 Ok(markdown) => Some(markdown),
16921 Err(err) => {
16922 warnings.push(format!("markdown projection unavailable: {err:#}"));
16923 None
16924 }
16925 }
16926 } else {
16927 None
16928 };
16929
16930 let expand = SourceExpandCommands {
16931 before: (start > 1).then(|| {
16932 let before_start = start.saturating_sub(lines).max(1);
16933 source_read_window_command(&root, &file_display, before_start, start - before_start)
16934 }),
16935 after: (effective_end < total_lines)
16936 .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
16937 body: body_truncated.then(|| {
16938 let remaining = end_line.saturating_sub(effective_end);
16939 source_read_window_command(&root, &file_display, effective_end + 1, remaining)
16940 }),
16941 file: source_read_ast_command(&root, &file_display),
16942 markdown_ast: is_markdown_path(&file_abs)
16943 .then(|| markdown_ast_command(&root, &file_display, None)),
16944 };
16945
16946 let report = SourceReadReport {
16947 handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
16948 root: root.to_string_lossy().to_string(),
16949 file: file_display,
16950 range: SourceRangePreview {
16951 start,
16952 end: effective_end,
16953 total_lines,
16954 truncated_before: start > 1,
16955 truncated_after: effective_end < total_lines,
16956 },
16957 preview,
16958 symbols,
16959 summaries,
16960 markdown,
16961 expand,
16962 warnings,
16963 };
16964
16965 if format.json_output {
16966 let truncated = report.range.truncated_before || report.range.truncated_after;
16967 let follow_up = [
16968 report.expand.before.clone(),
16969 report.expand.after.clone(),
16970 report.expand.body.clone(),
16971 Some(report.expand.file.clone()),
16972 report.expand.markdown_ast.clone(),
16973 ]
16974 .into_iter()
16975 .flatten()
16976 .collect::<Vec<_>>();
16977 print_json_or_envelope(
16978 &report,
16979 &format,
16980 "source-read",
16981 "window",
16982 ToolEnvelopeSummary {
16983 text: format!(
16984 "source window {}:{}-{}",
16985 report.file, report.range.start, report.range.end
16986 ),
16987 metrics: vec![
16988 envelope_metric("lines", report.preview.len()),
16989 envelope_metric("symbols", report.symbols.len()),
16990 envelope_metric("summaries", report.summaries.len()),
16991 envelope_metric(
16992 "markdown_nodes",
16993 report
16994 .markdown
16995 .as_ref()
16996 .map_or(0, |markdown| markdown.visible_nodes),
16997 ),
16998 ],
16999 },
17000 truncated,
17001 follow_up,
17002 )?;
17003 } else if format.compact {
17004 println!(
17005 "source {}:{}-{} / {} handle:{}",
17006 report.file,
17007 report.range.start,
17008 report.range.end,
17009 report.range.total_lines,
17010 report.handle
17011 );
17012 for line in &report.preview {
17013 println!("{:>5} {}", line.line, line.text);
17014 }
17015 if !report.symbols.is_empty() {
17016 println!("syms[{}]:", report.symbols.len());
17017 for symbol in &report.symbols {
17018 println!(" {} {}:{}", symbol.name, symbol.file, symbol.line);
17019 }
17020 }
17021 if report.range.truncated_before || report.range.truncated_after {
17022 println!("expand: {}", report.expand.file);
17023 }
17024 } else {
17025 println!(
17026 "Source window `{}` lines {}-{} of {} ({})",
17027 report.file,
17028 report.range.start,
17029 report.range.end,
17030 report.range.total_lines,
17031 report.handle
17032 );
17033 for line in &report.preview {
17034 println!("{:>5} | {}", line.line, line.text);
17035 }
17036 if !report.symbols.is_empty() {
17037 println!();
17038 println!("Symbol refs:");
17039 for symbol in &report.symbols {
17040 println!(
17041 " {} `{}` {}:{} — {}",
17042 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17043 );
17044 }
17045 }
17046 if !report.summaries.is_empty() {
17047 println!();
17048 println!("Summary refs:");
17049 for summary in &report.summaries {
17050 println!(
17051 " {} `{}` — {}",
17052 summary.handle, summary.symbol_name, summary.expand
17053 );
17054 }
17055 }
17056 if report.range.truncated_before || report.range.truncated_after {
17057 println!();
17058 println!("Expand:");
17059 if let Some(before) = &report.expand.before {
17060 println!(" before: {}", before);
17061 }
17062 if let Some(after) = &report.expand.after {
17063 println!(" after: {}", after);
17064 }
17065 println!(" file: {}", report.expand.file);
17066 }
17067 for warning in &report.warnings {
17068 eprintln!("warning: {warning}");
17069 }
17070 }
17071
17072 Ok(())
17073}
17074
17075#[allow(clippy::too_many_arguments)]
17076fn cmd_symbol_read(
17077 symbol: &str,
17078 file_hint: Option<&Path>,
17079 path: &Path,
17080 scope: Option<&str>,
17081 format: OutputFormat,
17082 absolute: bool,
17083 budget: ResponseBudget,
17084) -> Result<()> {
17085 let root = lint::resolve_project_root_or_canonical_path(path)?;
17086 let hinted_file_abs = file_hint
17087 .map(|file| resolve_source_file(&root, file))
17088 .transpose()?;
17089 let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17090 let db_path = resolve_query_db_path(&root, path_hint, scope)?;
17091 if !db_path.exists() {
17092 bail!(
17093 "index refs unavailable: no index found at {}",
17094 db_path.display()
17095 );
17096 }
17097 let db = index::IndexDb::open_read_only_resilient(&db_path)
17098 .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17099 let search_limit = budget.follow_up_items().max(10);
17100 let hits = db
17101 .symbol_search(symbol, search_limit)
17102 .with_context(|| format!("searching symbols for {symbol:?}"))?;
17103 let selected = hits
17104 .into_iter()
17105 .find(|hit| {
17106 let Some(hinted_file_abs) = &hinted_file_abs else {
17107 return true;
17108 };
17109 resolve_source_file(&root, Path::new(&hit.file))
17110 .map(|hit_file| hit_file == *hinted_file_abs)
17111 .unwrap_or(false)
17112 })
17113 .with_context(|| {
17114 let hint = file_hint
17115 .map(|file| format!(" in {}", file.display()))
17116 .unwrap_or_default();
17117 format!("no indexed symbol matched {symbol:?}{hint}")
17118 })?;
17119
17120 let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17121 let file_display = if absolute {
17122 file_abs.to_string_lossy().to_string()
17123 } else {
17124 relativize_pathbuf(&file_abs, &root)
17125 .to_string_lossy()
17126 .to_string()
17127 };
17128 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17129 let content_hash = blake3::hash(&source).to_hex().to_string();
17130 let text = String::from_utf8_lossy(&source);
17131 let all_lines: Vec<&str> = text.lines().collect();
17132 let total_lines = all_lines.len();
17133 let file_symbols = db
17134 .symbols_for_file(&file_abs.to_string_lossy())
17135 .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17136 let max_items = budget.preview_items();
17137 let max_bytes = budget.preview_bytes();
17138 let selected_start = symbol_hit_line(&selected);
17139 let selected_end = symbol_hit_end_line(&selected)
17140 .unwrap_or(selected_start)
17141 .max(selected_start);
17142 let stored_target = file_symbols.iter().find(|candidate| {
17143 candidate.name == selected.name
17144 && candidate.kind == selected.kind
17145 && source_symbol_line(candidate) == selected_start
17146 });
17147 let target_span = stored_target
17148 .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17149 .or_else(|| symbol_hit_ast_span(&selected, &source));
17150 let target_start = target_span
17151 .as_ref()
17152 .map(|span| span.start_line)
17153 .unwrap_or(selected_start);
17154 let target_end = target_span
17155 .as_ref()
17156 .map(|span| span.end_line)
17157 .or_else(|| stored_target.and_then(source_symbol_end_line))
17158 .unwrap_or(selected_end)
17159 .max(target_start);
17160 let target_bounds = stored_target
17161 .and_then(stored_symbol_span_bounds)
17162 .or_else(|| symbol_hit_span_bounds(&selected));
17163 let target_end = stored_target
17164 .and_then(source_symbol_end_line)
17165 .unwrap_or(target_end)
17166 .max(target_start);
17167 let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17168 let line_capped_end = target_start
17169 .saturating_add(body_line_budget)
17170 .saturating_sub(1)
17171 .min(target_end)
17172 .min(total_lines.max(target_start));
17173 let token_cap = budget.body_token_cap();
17174 let (body, effective_preview_end, body_truncated) =
17175 if total_lines == 0 || target_start > total_lines {
17176 (Vec::new(), line_capped_end, false)
17177 } else {
17178 let capped = build_token_capped_preview(
17179 &all_lines,
17180 target_start,
17181 line_capped_end,
17182 max_bytes,
17183 token_cap,
17184 );
17185 (capped.preview, capped.capped_end, capped.was_capped)
17186 };
17187 let preview_end = if body_truncated {
17188 effective_preview_end
17189 } else {
17190 line_capped_end
17191 };
17192 let child_symbols = file_symbols
17193 .iter()
17194 .filter(|candidate| {
17195 if let Some((target_start_byte, target_end_byte)) = target_bounds {
17196 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17197 else {
17198 return false;
17199 };
17200 return candidate_start >= target_start_byte
17201 && candidate_end <= target_end_byte
17202 && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17203 }
17204 let line = source_symbol_line(candidate);
17205 line > target_start && line <= target_end
17206 })
17207 .take(max_items)
17208 .map(|symbol| {
17209 let line = source_symbol_line(symbol);
17210 let end_line = source_symbol_end_line(symbol);
17211 SourceSymbolRef {
17212 handle: stable_handle(
17213 "ssym",
17214 &format!("{}:{}:{}", file_display, symbol.name, line),
17215 ),
17216 name: truncate_for_budget(&symbol.name, max_bytes),
17217 kind: symbol.kind.clone(),
17218 language: symbol.language.clone(),
17219 file: file_display.clone(),
17220 line,
17221 end_line,
17222 signature: symbol
17223 .signature
17224 .clone()
17225 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17226 span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
17227 expand: source_symbol_read_command(&root, &symbol.name, &file_display),
17228 }
17229 })
17230 .collect::<Vec<_>>();
17231 let mut warnings = Vec::new();
17232 if body_truncated {
17233 warnings.push(format!(
17234 "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
17235 ));
17236 }
17237 let summaries =
17238 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17239 let symbol_handle = stable_handle(
17240 "sread",
17241 &format!("{}:{}:{}", file_display, selected.name, target_start),
17242 );
17243 let source_lines = preview_end
17244 .saturating_sub(target_start)
17245 .saturating_add(1)
17246 .max(1);
17247 let expand = SymbolReadExpandCommands {
17248 source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
17249 body: body_truncated.then(|| {
17250 let remaining = target_end.saturating_sub(preview_end);
17251 source_read_window_command(&root, &file_display, preview_end + 1, remaining)
17252 }),
17253 file: source_read_ast_command(&root, &file_display),
17254 explain: source_symbol_expand_command(&root, &selected.name),
17255 callers: source_symbol_graph_command(&root, &selected.name, "callers"),
17256 callees: source_symbol_graph_command(&root, &selected.name, "callees"),
17257 markdown_ast: (selected.language == "markdown").then(|| {
17258 markdown_ast_command(
17259 &root,
17260 &file_display,
17261 target_span.as_ref().map(|span| span.handle.as_str()),
17262 )
17263 }),
17264 };
17265 let report = SymbolReadReport {
17266 handle: symbol_handle.clone(),
17267 root: root.to_string_lossy().to_string(),
17268 query: symbol.to_string(),
17269 symbol: SymbolReadTarget {
17270 handle: symbol_handle,
17271 name: selected.name.clone(),
17272 kind: selected.kind.clone(),
17273 language: selected.language.clone(),
17274 file: file_display.clone(),
17275 line: target_start,
17276 end_line: Some(target_end),
17277 signature: stored_target
17278 .and_then(|stored| stored.signature.clone())
17279 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17280 parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
17281 visibility: stored_target.and_then(|stored| stored.visibility.clone()),
17282 span: target_span,
17283 },
17284 range: SourceRangePreview {
17285 start: target_start,
17286 end: preview_end,
17287 total_lines,
17288 truncated_before: false,
17289 truncated_after: preview_end < target_end,
17290 },
17291 body,
17292 child_symbols,
17293 summaries,
17294 expand,
17295 warnings,
17296 };
17297
17298 if format.json_output {
17299 let truncated = report.range.truncated_after
17300 || report.body.iter().any(|line| line.text.len() >= max_bytes)
17301 || report.child_symbols.len() >= max_items;
17302 let follow_up = [
17303 Some(report.expand.source_window.clone()),
17304 report.expand.body.clone(),
17305 Some(report.expand.file.clone()),
17306 Some(report.expand.explain.clone()),
17307 Some(report.expand.callers.clone()),
17308 Some(report.expand.callees.clone()),
17309 ]
17310 .into_iter()
17311 .flatten()
17312 .chain(report.expand.markdown_ast.clone())
17313 .collect::<Vec<_>>();
17314 print_json_or_envelope(
17315 &report,
17316 &format,
17317 "symbol-read",
17318 "symbol",
17319 ToolEnvelopeSummary {
17320 text: format!(
17321 "symbol {} {}:{}-{}",
17322 report.symbol.name, report.symbol.file, report.range.start, report.range.end
17323 ),
17324 metrics: vec![
17325 envelope_metric("body_lines", report.body.len()),
17326 envelope_metric("child_symbols", report.child_symbols.len()),
17327 envelope_metric("summaries", report.summaries.len()),
17328 ],
17329 },
17330 truncated,
17331 follow_up,
17332 )?;
17333 } else if format.compact {
17334 println!(
17335 "symbol {} {}:{}-{} handle:{} hash:{}",
17336 report.symbol.name,
17337 report.symbol.file,
17338 report.range.start,
17339 report.range.end,
17340 report.handle,
17341 content_hash
17342 );
17343 for line in &report.body {
17344 println!("{:>5} {}", line.line, line.text);
17345 }
17346 if !report.child_symbols.is_empty() {
17347 println!("children[{}]:", report.child_symbols.len());
17348 for child in &report.child_symbols {
17349 println!(" {} {}:{}", child.name, child.file, child.line);
17350 }
17351 }
17352 } else {
17353 println!(
17354 "Symbol `{}` in `{}` lines {}-{} ({})",
17355 report.symbol.name,
17356 report.symbol.file,
17357 report.range.start,
17358 report.range.end,
17359 report.handle
17360 );
17361 for line in &report.body {
17362 println!("{:>5} | {}", line.line, line.text);
17363 }
17364 if !report.child_symbols.is_empty() {
17365 println!();
17366 println!("Child symbols:");
17367 for child in &report.child_symbols {
17368 println!(
17369 " {} `{}` {}:{} — {}",
17370 child.handle, child.name, child.file, child.line, child.expand
17371 );
17372 }
17373 }
17374 println!();
17375 println!("Expand:");
17376 println!(" source: {}", report.expand.source_window);
17377 println!(" file: {}", report.expand.file);
17378 println!(" explain: {}", report.expand.explain);
17379 println!(" callers: {}", report.expand.callers);
17380 println!(" callees: {}", report.expand.callees);
17381 for warning in &report.warnings {
17382 eprintln!("warning: {warning}");
17383 }
17384 }
17385
17386 Ok(())
17387}
17388
17389#[allow(clippy::too_many_arguments)]
17390#[derive(Serialize)]
17391struct ExplainBudgetDefinitionPreview {
17392 handle: String,
17393 #[serde(skip_serializing_if = "Option::is_none")]
17394 tag_alias: Option<String>,
17395 kind: String,
17396 name: String,
17397 file: String,
17398 line: i64,
17399 expand: String,
17400}
17401
17402#[derive(Serialize)]
17403struct ExplainBudgetEdgePreview {
17404 handle: String,
17405 #[serde(skip_serializing_if = "Option::is_none")]
17406 tag_alias: Option<String>,
17407 name: String,
17408 file: String,
17409 line: i64,
17410 expand: String,
17411}
17412
17413#[derive(Serialize)]
17414struct ExplainBudgetCommunityPreview {
17415 size: usize,
17416 members: Vec<String>,
17417}
17418
17419#[derive(Serialize)]
17420struct ExplainBudgetReport {
17421 symbol: String,
17422 max_items: usize,
17423 max_bytes: usize,
17424 definition_total: usize,
17425 callers_total: usize,
17426 callers_truncated_by_limit: bool,
17427 callees_total: usize,
17428 callees_truncated_by_limit: bool,
17429 truncated: bool,
17430 definitions: Vec<ExplainBudgetDefinitionPreview>,
17431 callers: Vec<ExplainBudgetEdgePreview>,
17432 callees: Vec<ExplainBudgetEdgePreview>,
17433 #[serde(skip_serializing_if = "Option::is_none")]
17434 community: Option<ExplainBudgetCommunityPreview>,
17435}
17436
17437#[allow(clippy::too_many_arguments)]
17438pub(crate) fn build_explain_budget_report(
17439 symbol: &str,
17440 _root: &Path,
17441 symbols: &[index::StoredSymbol],
17442 callers: &[index::StoredEdge],
17443 callers_total: usize,
17444 callers_truncated_by_limit: bool,
17445 callees: &[index::StoredEdge],
17446 callees_total: usize,
17447 callees_truncated_by_limit: bool,
17448 community: Option<&graph::Community>,
17449 budget: ResponseBudget,
17450) -> ExplainBudgetReport {
17451 let max_items = budget.preview_items();
17452 let max_bytes = budget.preview_bytes();
17453 let definitions = symbols
17454 .iter()
17455 .take(max_items)
17456 .map(|entry| {
17457 let symbol_ref = build_compact_symbol_ref(
17458 "edef",
17459 &format!(
17460 "{}:{}:{}:{}",
17461 entry.kind, entry.name, entry.file, entry.line
17462 ),
17463 &entry.name,
17464 entry.tags.as_deref(),
17465 max_bytes,
17466 );
17467 ExplainBudgetDefinitionPreview {
17468 handle: symbol_ref.handle,
17469 tag_alias: symbol_ref.tag_alias,
17470 kind: entry.kind.clone(),
17471 name: symbol_ref.name,
17472 file: truncate_for_budget(&entry.file, max_bytes),
17473 line: entry.line,
17474 expand: format!(
17475 "tsift search {} --exact --path {} --limit 20",
17476 shell_quote(&entry.name),
17477 shell_quote(&entry.file)
17478 ),
17479 }
17480 })
17481 .collect();
17482 let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
17483 .iter()
17484 .take(max_items)
17485 .map(|entry| {
17486 let symbol_ref = build_compact_symbol_ref(
17487 "ecall",
17488 &format!(
17489 "{}:{}:{}:{}",
17490 entry.caller_name, entry.caller_file, entry.call_site_line, symbol
17491 ),
17492 &entry.caller_name,
17493 None,
17494 max_bytes,
17495 );
17496 ExplainBudgetEdgePreview {
17497 handle: symbol_ref.handle,
17498 tag_alias: symbol_ref.tag_alias,
17499 name: symbol_ref.name,
17500 file: truncate_for_budget(&entry.caller_file, max_bytes),
17501 line: entry.call_site_line,
17502 expand: format!(
17503 "tsift explain {} --path {} --limit 0",
17504 shell_quote(&entry.caller_name),
17505 shell_quote(&entry.caller_file)
17506 ),
17507 }
17508 })
17509 .collect();
17510 let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
17511 .iter()
17512 .take(max_items)
17513 .map(|entry| {
17514 let symbol_ref = build_compact_symbol_ref(
17515 "eces",
17516 &format!(
17517 "{}:{}:{}:{}",
17518 entry.callee_name, entry.caller_file, entry.call_site_line, symbol
17519 ),
17520 &entry.callee_name,
17521 None,
17522 max_bytes,
17523 );
17524 ExplainBudgetEdgePreview {
17525 handle: symbol_ref.handle,
17526 tag_alias: symbol_ref.tag_alias,
17527 name: symbol_ref.name,
17528 file: truncate_for_budget(&entry.caller_file, max_bytes),
17529 line: entry.call_site_line,
17530 expand: format!(
17531 "tsift explain {} --path {} --limit 0",
17532 shell_quote(&entry.callee_name),
17533 shell_quote(&entry.caller_file)
17534 ),
17535 }
17536 })
17537 .collect();
17538 let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
17539 size: entry.members.len(),
17540 members: entry
17541 .members
17542 .iter()
17543 .take(max_items)
17544 .map(|member| truncate_for_budget(&member.name, max_bytes))
17545 .collect(),
17546 });
17547
17548 ExplainBudgetReport {
17549 symbol: symbol.to_string(),
17550 max_items,
17551 max_bytes,
17552 definition_total: symbols.len(),
17553 callers_total,
17554 callers_truncated_by_limit,
17555 callees_total,
17556 callees_truncated_by_limit,
17557 truncated: symbols.len() > max_items
17558 || callers_total > callers_preview.len()
17559 || callees_total > callees_preview.len()
17560 || community
17561 .map(|entry| entry.members.len() > max_items)
17562 .unwrap_or(false),
17563 definitions,
17564 callers: callers_preview,
17565 callees: callees_preview,
17566 community: community_preview,
17567 }
17568}
17569
17570pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
17571 println!(
17572 "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
17573 shell_quote(&report.symbol),
17574 report.definitions.len(),
17575 report.definition_total,
17576 report.callers.len(),
17577 report.callers_total,
17578 report.callees.len(),
17579 report.callees_total
17580 );
17581 for entry in &report.definitions {
17582 println!(
17583 "def {} {} {}:{} expand:{}",
17584 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17585 entry.kind,
17586 entry.file,
17587 entry.line,
17588 entry.expand
17589 );
17590 }
17591 for entry in &report.callers {
17592 println!(
17593 "caller {} {}:{} expand:{}",
17594 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17595 entry.file,
17596 entry.line,
17597 entry.expand
17598 );
17599 }
17600 for entry in &report.callees {
17601 println!(
17602 "callee {} {}:{} expand:{}",
17603 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17604 entry.file,
17605 entry.line,
17606 entry.expand
17607 );
17608 }
17609 if let Some(community) = &report.community {
17610 println!(
17611 "community size:{} members:{}",
17612 community.size,
17613 community.members.join(", ")
17614 );
17615 }
17616 if report.truncated {
17617 println!(
17618 "budget truncated items:{} bytes:{}",
17619 report.max_items, report.max_bytes
17620 );
17621 }
17622}
17623
17624const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
17634 ".git",
17635 "node_modules",
17636 "target",
17637 "__pycache__",
17638 ".venv",
17639 "vendor",
17640];
17641
17642const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
17643 "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
17644 "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
17645 "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
17646 "sh", "bash", "zsh", "sql", "css", "tsx",
17647];
17648
17649pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
17650 let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
17651 .iter()
17652 .map(|ext| (*ext).to_string())
17653 .collect::<BTreeSet<_>>();
17654
17655 let config_path = root.join(".naming.toml");
17656 if !config_path.exists() {
17657 return extensions;
17658 }
17659
17660 match tagpath::config::resolve(&config_path) {
17661 Ok(config) => {
17662 if let Some(grammars) = config.grammars {
17663 for grammar in grammars.languages.values() {
17664 for ext in &grammar.extensions {
17665 if let Some(normalized) = normalize_extension(ext) {
17666 extensions.insert(normalized);
17667 }
17668 }
17669 }
17670 }
17671 }
17672 Err(err) => {
17673 eprintln!("tagpath_policy_hint_config_unreadable: {err}");
17674 }
17675 }
17676 extensions
17677}
17678
17679pub(crate) fn tagpath_audit_policy_hints(
17680 rel_path: &str,
17681 supported_extensions: &BTreeSet<String>,
17682) -> Vec<String> {
17683 let path = Path::new(rel_path);
17684 let mut hints = BTreeSet::new();
17685 if let Some(parent) = path.parent() {
17686 for component in parent.components() {
17687 if let std::path::Component::Normal(name) = component {
17688 let name = name.to_string_lossy();
17689 if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
17690 hints.insert(format!("skip_dir:{name}"));
17691 }
17692 }
17693 }
17694 }
17695 if path
17696 .extension()
17697 .and_then(|ext| ext.to_str())
17698 .and_then(normalize_extension)
17699 .is_some_and(|ext| !supported_extensions.contains(&ext))
17700 {
17701 hints.insert("extension_unsupported".to_string());
17702 }
17703 hints.into_iter().collect()
17704}
17705
17706fn normalize_extension(ext: &str) -> Option<String> {
17707 let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
17708 if normalized.is_empty() {
17709 None
17710 } else {
17711 Some(normalized)
17712 }
17713}
17714
17715pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
17716 match status {
17717 diff_digest::DiffDigestFileStatus::Added => "added",
17718 diff_digest::DiffDigestFileStatus::Modified => "modified",
17719 diff_digest::DiffDigestFileStatus::Deleted => "deleted",
17720 }
17721}
17722
17723pub(crate) fn diff_digest_summary_label(
17724 state: diff_digest::DiffDigestSummaryState,
17725) -> &'static str {
17726 match state {
17727 diff_digest::DiffDigestSummaryState::Current => "current",
17728 diff_digest::DiffDigestSummaryState::Stale => "stale",
17729 diff_digest::DiffDigestSummaryState::Missing => "missing",
17730 diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
17731 }
17732}
17733
17734fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
17735 match state {
17736 test_digest::TestDigestSummaryState::Current => "current",
17737 test_digest::TestDigestSummaryState::Stale => "stale",
17738 test_digest::TestDigestSummaryState::Missing => "missing",
17739 test_digest::TestDigestSummaryState::Unavailable => "unavailable",
17740 }
17741}
17742
17743fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
17744 match state {
17745 log_digest::LogDigestSummaryState::Current => "current",
17746 log_digest::LogDigestSummaryState::Stale => "stale",
17747 log_digest::LogDigestSummaryState::Missing => "missing",
17748 log_digest::LogDigestSummaryState::Unavailable => "unavailable",
17749 }
17750}
17751
17752pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
17753 match mode {
17754 diff_digest::DiffDigestMode::WorkingTree => "worktree",
17755 diff_digest::DiffDigestMode::Cached => "cached",
17756 diff_digest::DiffDigestMode::Revision => "revision",
17757 }
17758}
17759
17760pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
17761 match (&report.mode, &report.revision) {
17762 (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
17763 (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
17764 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17765 format!("revision {revision}")
17766 }
17767 (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
17768 }
17769}
17770
17771pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
17772 match (&report.mode, &report.revision) {
17773 (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
17774 (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
17775 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17776 format!("No diff found for revision {revision}.")
17777 }
17778 (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
17779 }
17780}
17781
17782fn cmd_impact(
17783 path: &Path,
17784 cached: bool,
17785 revision: Option<&str>,
17786 scope: Option<&str>,
17787 limit: usize,
17788 format: OutputFormat,
17789) -> Result<()> {
17790 let report = impact::compute(
17791 path,
17792 impact::ImpactOptions {
17793 cached,
17794 revision,
17795 scope,
17796 limit,
17797 },
17798 )?;
17799 if format.json_output {
17800 println!(
17801 "{}",
17802 to_json_schema(
17803 &report,
17804 format.pretty,
17805 format.terse,
17806 format.ultra_terse,
17807 format.schema
17808 )?
17809 );
17810 return Ok(());
17811 }
17812
17813 if format.compact {
17814 println!(
17815 "impact mode:{} changed:{} symbols:{} tests:{}/{}",
17816 diff_digest_mode_label(report.mode),
17817 report.changed_files.len(),
17818 report.changed_symbols.len(),
17819 report.affected_tests.len(),
17820 report.affected_tests_total
17821 );
17822 for target in &report.affected_tests {
17823 println!(
17824 "{} reasons:{} command:{}",
17825 target.path,
17826 target.reasons.len(),
17827 target.commands.join(" && ")
17828 );
17829 }
17830 for warning in &report.warnings {
17831 println!("warning {warning}");
17832 }
17833 return Ok(());
17834 }
17835
17836 println!("Impact ({})", diff_digest_mode_label(report.mode));
17837 println!(" changed files: {}", report.changed_files.len());
17838 println!(" changed symbols: {}", report.changed_symbols.len());
17839 println!(
17840 " affected tests: {}/{}",
17841 report.affected_tests.len(),
17842 report.affected_tests_total
17843 );
17844 for target in &report.affected_tests {
17845 println!();
17846 println!("{}", target.path);
17847 for reason in &target.reasons {
17848 println!(" - {reason}");
17849 }
17850 if !target.symbols.is_empty() {
17851 println!(" symbols: {}", target.symbols.join(", "));
17852 }
17853 for command in &target.commands {
17854 println!(" run: {}", command);
17855 }
17856 }
17857 for warning in &report.warnings {
17858 println!("warning: {warning}");
17859 }
17860 Ok(())
17861}
17862
17863pub(crate) fn render_test_digest_from_input(
17864 path: &Path,
17865 input: &str,
17866 runner: Option<&str>,
17867 format: OutputFormat,
17868) -> Result<()> {
17869 let report = test_digest::compute(path, input, runner)?;
17870 if format.json_output {
17871 println!(
17872 "{}",
17873 to_json_schema(
17874 &report,
17875 format.pretty,
17876 format.terse,
17877 format.ultra_terse,
17878 format.schema
17879 )?
17880 );
17881 return Ok(());
17882 }
17883
17884 if report.failure_groups.is_empty() {
17885 println!("No failures detected (runner: {}).", report.runner);
17886 for warning in &report.warnings {
17887 println!("warning: {warning}");
17888 }
17889 return Ok(());
17890 }
17891
17892 if format.compact {
17893 println!(
17894 "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
17895 report.runner,
17896 report.failures,
17897 report.grouped_failures,
17898 report.counts.passed.unwrap_or(0),
17899 report.counts.failed.unwrap_or(report.grouped_failures),
17900 report.counts.skipped.unwrap_or(0),
17901 );
17902 for failure in &report.failure_groups {
17903 let tests = truncate_for_compact(&failure.tests.join(","), 60);
17904 let location = match (&failure.path, failure.line) {
17905 (Some(path), Some(line)) => format!("{path}:{line}"),
17906 (Some(path), None) => path.clone(),
17907 _ => "-".to_string(),
17908 };
17909 println!(
17910 "{} tests:{} count:{} summaries:{} msg:{}",
17911 location,
17912 tests,
17913 failure.occurrences,
17914 test_digest_summary_label(failure.summary_state),
17915 truncate_for_compact(&failure.message, 80)
17916 );
17917 }
17918 for warning in &report.warnings {
17919 println!("warning: {warning}");
17920 }
17921 return Ok(());
17922 }
17923
17924 println!("Test digest ({})", report.runner);
17925 println!(" failures: {}", report.failures);
17926 println!(" failure groups: {}", report.grouped_failures);
17927 if let Some(passed) = report.counts.passed {
17928 println!(" passed: {}", passed);
17929 }
17930 if let Some(failed) = report.counts.failed {
17931 println!(" failed: {}", failed);
17932 }
17933 if let Some(skipped) = report.counts.skipped {
17934 println!(" skipped: {}", skipped);
17935 }
17936
17937 for failure in &report.failure_groups {
17938 println!();
17939 match (&failure.path, failure.line, failure.column) {
17940 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
17941 (Some(path), Some(line), None) => println!("{path}:{line}"),
17942 (Some(path), None, _) => println!("{path}"),
17943 (None, _, _) => println!("(no file anchor)"),
17944 }
17945 println!(" tests: {}", failure.tests.join(", "));
17946 println!(" occurrences: {}", failure.occurrences);
17947 println!(" message: {}", failure.message);
17948 println!(
17949 " cached summaries: {}",
17950 test_digest_summary_label(failure.summary_state)
17951 );
17952 for summary in &failure.current_summaries {
17953 println!(
17954 " - {}: {}",
17955 summary.symbol,
17956 truncate_for_compact(&summary.summary, 160)
17957 );
17958 }
17959 }
17960 for warning in &report.warnings {
17961 println!("warning: {warning}");
17962 }
17963 Ok(())
17964}
17965
17966#[derive(Clone, Serialize, Deserialize)]
17967struct DispatchTraceSummary {
17968 backlog: usize,
17969 job_packet: usize,
17970 worker_result: usize,
17971 worker_context: usize,
17972 source_handle: usize,
17973 semantic_rows: usize,
17974}
17975
17976#[derive(Clone, Serialize, Deserialize)]
17977struct DispatchTraceReport {
17978 contract_version: String,
17979 root: String,
17980 #[serde(skip_serializing_if = "Option::is_none")]
17981 scope: Option<String>,
17982 targets: Vec<String>,
17983 projection_freshness: GraphDbFreshnessReport,
17984 projection_hashes: Vec<String>,
17985 evidence_packet_ids: Vec<String>,
17986 shared_preparation: ConflictMatrixSharedPreparationSummary,
17987 worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
17988 worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
17989 summary: DispatchTraceSummary,
17990 nodes: Vec<SubstrateTerseGraphNode>,
17991 edges: Vec<SubstrateTerseGraphEdge>,
17992 conflict_matrix_decisions: Vec<String>,
17993 replay_commands: Vec<String>,
17994 repair_commands: Vec<String>,
17995 truncated: bool,
17996 #[serde(skip_serializing_if = "Vec::is_empty", default)]
17997 warnings: Vec<String>,
17998}
17999
18000fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18001 matches!(
18002 kind,
18003 "session"
18004 | "backlog"
18005 | "job_packet"
18006 | "worker_result"
18007 | "worker_context"
18008 | "source_handle"
18009 | "semantic_concept"
18010 | "semantic_entity"
18011 | "file"
18012 | "symbol"
18013 | "route"
18014 )
18015}
18016
18017fn dispatch_trace_kind_rank(kind: &str) -> usize {
18018 match kind {
18019 "backlog" => 0,
18020 "job_packet" => 1,
18021 "worker_result" => 2,
18022 "worker_context" => 3,
18023 "source_handle" => 4,
18024 "file" => 5,
18025 "symbol" => 6,
18026 "route" => 7,
18027 "semantic_concept" => 8,
18028 "semantic_entity" => 9,
18029 "session" => 10,
18030 _ => 99,
18031 }
18032}
18033
18034fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18035 DispatchTraceSummary {
18036 backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18037 job_packet: nodes
18038 .iter()
18039 .filter(|node| node.kind == "job_packet")
18040 .count(),
18041 worker_result: nodes
18042 .iter()
18043 .filter(|node| node.kind == "worker_result")
18044 .count(),
18045 worker_context: nodes
18046 .iter()
18047 .filter(|node| node.kind == "worker_context")
18048 .count(),
18049 source_handle: nodes
18050 .iter()
18051 .filter(|node| node.kind == "source_handle")
18052 .count(),
18053 semantic_rows: nodes
18054 .iter()
18055 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18056 .count(),
18057 }
18058}
18059
18060fn dispatch_trace_shared_preparation_summary(
18061 graph_nodes: &[SubstrateGraphNode],
18062 graph_edges: &[SubstrateGraphEdge],
18063 conflict: &ConflictMatrixReport,
18064) -> ConflictMatrixSharedPreparationSummary {
18065 ConflictMatrixSharedPreparationSummary {
18066 evidence_cache_status: conflict
18067 .inputs
18068 .shared_preparation
18069 .evidence_cache_status
18070 .clone(),
18071 graph_nodes: graph_nodes.len(),
18072 graph_edges: graph_edges.len(),
18073 evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18074 source_handles: conflict
18075 .candidates
18076 .iter()
18077 .map(|candidate| candidate.source_handles.len())
18078 .sum(),
18079 worker_context: conflict
18080 .candidates
18081 .iter()
18082 .map(|candidate| candidate.worker_context_handles.len())
18083 .sum(),
18084 worker_results: conflict
18085 .candidates
18086 .iter()
18087 .map(|candidate| candidate.worker_feedback.total)
18088 .sum(),
18089 semantic_rows: conflict
18090 .candidates
18091 .iter()
18092 .map(|candidate| candidate.semantic_related.len())
18093 .sum(),
18094 dispatch_trace_snapshot_nodes: graph_nodes.len(),
18095 dispatch_trace_snapshot_edges: graph_edges.len(),
18096 }
18097}
18098
18099fn dispatch_trace_collect_ids(
18100 targets: &[String],
18101 candidates: &[ConflictMatrixCandidate],
18102 graph_nodes: &[SubstrateGraphNode],
18103 graph_edges: &[SubstrateGraphEdge],
18104 depth: usize,
18105 limit: usize,
18106) -> (BTreeSet<String>, bool) {
18107 let target_refs = targets
18108 .iter()
18109 .map(|target| target.trim_start_matches('#').to_string())
18110 .collect::<BTreeSet<_>>();
18111 let mut ids = BTreeSet::new();
18112 for candidate in candidates {
18113 ids.insert(candidate.target_node_id.clone());
18114 for source in &candidate.source_handles {
18115 ids.insert(source.handle.clone());
18116 }
18117 for handle in &candidate.worker_context_handles {
18118 ids.insert(handle.clone());
18119 }
18120 for semantic in &candidate.semantic_related {
18121 ids.insert(semantic.handle.clone());
18122 }
18123 }
18124 for node in graph_nodes {
18125 if !dispatch_trace_allowed_node_kind(&node.kind) {
18126 continue;
18127 }
18128 if node
18129 .properties
18130 .get("ref_id")
18131 .is_some_and(|ref_id| target_refs.contains(ref_id))
18132 {
18133 ids.insert(node.id.clone());
18134 }
18135 }
18136
18137 let node_by_id = graph_nodes
18138 .iter()
18139 .map(|node| (node.id.as_str(), node))
18140 .collect::<BTreeMap<_, _>>();
18141 let max_nodes = if limit == 0 {
18142 usize::MAX
18143 } else {
18144 limit
18145 .saturating_mul(targets.len().max(1))
18146 .saturating_mul(12)
18147 .max(64)
18148 };
18149 let mut truncated = false;
18150 for _ in 0..depth.max(1) {
18151 let before = ids.len();
18152 let current_ids = ids.clone();
18153 for edge in graph_edges {
18154 if ids.len() >= max_nodes {
18155 truncated = true;
18156 break;
18157 }
18158 let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18159 if !touches {
18160 continue;
18161 }
18162 for endpoint in [&edge.from_id, &edge.to_id] {
18163 let Some(node) = node_by_id.get(endpoint.as_str()) else {
18164 continue;
18165 };
18166 if dispatch_trace_allowed_node_kind(&node.kind) {
18167 ids.insert(endpoint.clone());
18168 }
18169 }
18170 }
18171 if ids.len() == before || truncated {
18172 break;
18173 }
18174 }
18175 (ids, truncated)
18176}
18177
18178#[allow(clippy::too_many_arguments)]
18179fn build_dispatch_trace_report_from_conflict_snapshot(
18180 root: &Path,
18181 scope: Option<&str>,
18182 conflict: ConflictMatrixReport,
18183 graph_nodes: Vec<SubstrateGraphNode>,
18184 graph_edges: Vec<SubstrateGraphEdge>,
18185 depth: usize,
18186 limit: usize,
18187 extra_warnings: Vec<String>,
18188) -> Result<DispatchTraceReport> {
18189 let shared_preparation =
18190 dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18191 let (ids, truncated) = dispatch_trace_collect_ids(
18192 &conflict.targets,
18193 &conflict.candidates,
18194 &graph_nodes,
18195 &graph_edges,
18196 depth,
18197 limit,
18198 );
18199 let mut nodes = graph_nodes
18200 .into_iter()
18201 .filter(|node| ids.contains(&node.id))
18202 .collect::<Vec<_>>();
18203 nodes.sort_by(|left, right| {
18204 dispatch_trace_kind_rank(&left.kind)
18205 .cmp(&dispatch_trace_kind_rank(&right.kind))
18206 .then(left.id.cmp(&right.id))
18207 });
18208 let node_ids = nodes
18209 .iter()
18210 .map(|node| node.id.as_str())
18211 .collect::<BTreeSet<_>>();
18212 let mut edges = graph_edges
18213 .into_iter()
18214 .filter(|edge| {
18215 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18216 })
18217 .collect::<Vec<_>>();
18218 edges.sort_by(|left, right| {
18219 left.from_id
18220 .cmp(&right.from_id)
18221 .then(left.kind.cmp(&right.kind))
18222 .then(left.to_id.cmp(&right.to_id))
18223 });
18224 let mut warnings = conflict.warnings;
18225 warnings.extend(extra_warnings);
18226
18227 Ok(DispatchTraceReport {
18228 contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
18229 root: conflict.root,
18230 scope: conflict.scope,
18231 targets: conflict.targets,
18232 projection_freshness: conflict.orchestration.projection_freshness,
18233 projection_hashes: conflict.orchestration.projection_hashes,
18234 evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
18235 shared_preparation,
18236 worker_prompt_packets: conflict.worker_prompt_packets,
18237 worker_feedback: conflict
18238 .candidates
18239 .iter()
18240 .map(|candidate| candidate.worker_feedback.clone())
18241 .collect(),
18242 summary: dispatch_trace_summary(&nodes),
18243 nodes: nodes.into_iter().map(Into::into).collect(),
18244 edges: edges.into_iter().map(Into::into).collect(),
18245 conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
18246 replay_commands: conflict.next_commands,
18247 repair_commands: graph_db_repair_commands(root, scope),
18248 truncated,
18249 warnings,
18250 })
18251}
18252
18253fn build_dispatch_trace_report(
18254 path: &Path,
18255 scope: Option<&str>,
18256 raw_targets: &[String],
18257 depth: usize,
18258 limit: usize,
18259 impact_limit: usize,
18260) -> Result<DispatchTraceReport> {
18261 let root = lint::resolve_project_root_or_canonical_path(path)?;
18262 let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
18263 if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
18264 write_traversal_graph_store(&root, path, scope)
18265 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
18266 }
18267 let graph_db = graph_substrate_db_path(&root, scope);
18268 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
18269 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
18270 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
18271 let extra_warnings = store
18272 .read_only_recovery()
18273 .map(graph_db_read_recovery_diagnostic)
18274 .into_iter()
18275 .collect::<Vec<_>>();
18276 let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
18277 let graph_prepared = prepare_conflict_matrix_graph_orchestration(
18278 &root,
18279 scope,
18280 "sqlite",
18281 raw_targets,
18282 &prepared,
18283 depth,
18284 limit,
18285 &store,
18286 freshness.clone(),
18287 )?;
18288 let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
18289 &prepared.preparation_cache.source_watermark,
18290 &prepared.preparation_cache.document_watermark,
18291 &prepared.preparation_cache.staged_diff_watermark,
18292 &[
18293 &format!("targets:{}", raw_targets.join(",")),
18294 &format!("depth:{depth}"),
18295 &format!("limit:{limit}"),
18296 ],
18297 );
18298 if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
18299 &root,
18300 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18301 &dt_cache_key,
18302 ) {
18303 return Ok(cached_report);
18304 }
18305 let conflict = build_conflict_matrix_report_from_prepared_graph(
18306 &root,
18307 path,
18308 scope,
18309 depth,
18310 limit,
18311 impact_limit,
18312 freshness,
18313 extra_warnings.clone(),
18314 &prepared,
18315 &graph_prepared,
18316 )?;
18317 let report = build_dispatch_trace_report_from_conflict_snapshot(
18318 &root,
18319 scope,
18320 conflict,
18321 graph_prepared.graph.nodes,
18322 graph_prepared.graph.edges,
18323 depth,
18324 limit,
18325 extra_warnings,
18326 )?;
18327 cycle_packet_cache::cycle_packet_write_cache(
18328 &root,
18329 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18330 &dt_cache_key,
18331 &report,
18332 );
18333 Ok(report)
18334}
18335
18336fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
18337 let json = serde_json::to_string(report)?.replace("</", "<\\/");
18338 let mut html = String::new();
18339 html.push_str(
18340 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
18341 );
18342 html.push_str(
18343 r#"<style>
18344:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
18345@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
18346*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif;line-height:1.4}.page{max-width:1280px;margin:0 auto;padding:20px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:14px}.top h1{font-size:22px;margin:0}.meta{color:var(--muted);font-size:13px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:14px}.panel,.side{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.side{padding:14px;overflow:auto;max-height:720px}.side h2{font-size:15px;margin:12px 0 8px}.side h2:first-child{margin-top:0}.list{display:grid;gap:8px}.row{border:1px solid var(--line);border-radius:6px;padding:8px}.kind{font-size:11px;text-transform:uppercase;color:var(--muted);letter-spacing:.04em}.label{font-weight:650;overflow-wrap:anywhere}.handle,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);overflow-wrap:anywhere}svg{width:100%;height:680px;display:block}.edge{stroke:var(--edge);stroke-width:1.4;opacity:.72}.node{stroke:var(--panel);stroke-width:2}.node-label{font-size:12px;paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round;fill:var(--text)}@media(max-width:900px){.top{display:block}.layout{grid-template-columns:1fr}.side{max-height:none}svg{height:560px}}
18347</style>"#,
18348 );
18349 html.push_str("</head><body><div class=\"page\">");
18350 html.push_str(&format!(
18351 "<header class=\"top\"><div><h1>tsift dispatch trace</h1><div class=\"meta\">targets <code>{}</code> | evidence <code>{}</code> | nodes <code>{}</code> | worker_prompt_packets <code>{}</code></div></div><div class=\"meta\"><code>{}</code></div></header>",
18352 html_escape(&report.targets.join(", ")),
18353 report.evidence_packet_ids.len(),
18354 report.nodes.len(),
18355 report.worker_prompt_packets.len(),
18356 html_escape(&report.contract_version)
18357 ));
18358 html.push_str(
18359 r#"<main class="layout"><section class="panel"><svg id="graph-canvas" role="img" aria-label="Dispatch trace graph"></svg></section><aside class="side"><h2>Worker Prompt Packets</h2><div id="packets" class="list"></div><h2>Worker Feedback</h2><div id="feedback" class="list"></div><h2>Nodes</h2><div id="nodes" class="list"></div></aside></main>"#,
18360 );
18361 html.push_str("<script id=\"trace-data\" type=\"application/json\">");
18362 html.push_str(&json);
18363 html.push_str(
18364 r##"</script><script>
18365const report = JSON.parse(document.getElementById("trace-data").textContent);
18366const svg = document.getElementById("graph-canvas");
18367const nodeList = document.getElementById("nodes");
18368const packets = document.getElementById("packets");
18369const feedback = document.getElementById("feedback");
18370const nodes = report.nodes.map((node, index) => ({...node, index}));
18371const nodeById = new Map(nodes.map(node => [node.id, node]));
18372const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
18373const colorByKind = new Map([["backlog","#dc2626"],["job_packet","#ea580c"],["worker_result","#15803d"],["worker_context","#475569"],["source_handle","#64748b"],["semantic_concept","#9a3412"],["semantic_entity","#b45309"],["file","#2563eb"],["symbol","#16a34a"],["route","#7c3aed"],["session","#0891b2"]]);
18374function color(kind){return colorByKind.get(kind)||"#6b7280";}
18375function text(value){return value == null ? "" : String(value);}
18376function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));}
18377function layout(){
18378 const rect = svg.getBoundingClientRect();
18379 const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
18380 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
18381 const counts = new Map();
18382 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
18383 const offsets = new Map();
18384 for (const node of nodes) {
18385 const group = kinds.indexOf(node.kind);
18386 const index = offsets.get(node.kind) || 0;
18387 offsets.set(node.kind, index + 1);
18388 const total = counts.get(node.kind) || 1;
18389 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
18390 const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
18391 node.x = cx + Math.cos(angle) * ring;
18392 node.y = cy + Math.sin(angle) * ring;
18393 }
18394}
18395function draw(){
18396 svg.innerHTML = "";
18397 for (const edge of edges) {
18398 const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
18399 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
18400 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
18401 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
18402 line.setAttribute("class", "edge");
18403 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
18404 svg.appendChild(line);
18405 }
18406 for (const node of nodes) {
18407 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
18408 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
18409 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
18410 circle.setAttribute("fill", color(node.kind));
18411 circle.setAttribute("class", "node");
18412 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
18413 svg.appendChild(circle);
18414 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
18415 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
18416 label.setAttribute("class", "node-label");
18417 label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
18418 svg.appendChild(label);
18419 }
18420}
18421packets.innerHTML = report.worker_prompt_packets.map(packet => `<div class="row"><div class="kind">${escapeHtml(packet.contract_version)} - ${escapeHtml(packet.risk)} - parallel_safe ${packet.parallel_safe ? "true" : "false"} - closure ${packet.worker_feedback ? packet.worker_feedback.closure_rank_score : 0}</div><div class="label">${escapeHtml(packet.title)}</div><div class="handle">${escapeHtml(packet.packet_id)}</div><div class="handle">blocks ${escapeHtml((packet.blocks||[]).join(", ") || "none")} | blocked_by ${escapeHtml((packet.blocked_by||[]).join(", ") || "none")}</div></div>`).join("") || "<div class=\"meta\">No packets.</div>";
18422feedback.innerHTML = report.worker_feedback.map(item => `<div class="row"><div class="kind">completed ${item.completed} - blocked ${item.blocked} - closure ${item.closure_rank_score}</div><div>files ${escapeHtml((item.touched_files||[]).join(", ") || "none")}</div><div>tests ${escapeHtml((item.expected_tests||[]).join(" && ") || "none")}</div>${item.repeated_blockage ? "<div class=\"label\">Repeated blockage</div>" : ""}${(item.stale_expected_tests||[]).length ? `<div class="label">Stale tests: ${escapeHtml(item.stale_expected_tests.join(", "))}</div>` : ""}${(item.follow_up_debt||[]).length ? `<div class="label">Follow-up debt: ${escapeHtml(item.follow_up_debt.join(", "))}</div>` : ""}</div>`).join("") || "<div class=\"meta\">No worker results.</div>";
18423nodeList.innerHTML = nodes.map(node => `<div class="row"><div class="kind">${escapeHtml(node.kind)}</div><div class="label">${escapeHtml(node.label)}</div><div class="handle">${escapeHtml(node.id)}</div></div>`).join("");
18424window.addEventListener("resize", () => { layout(); draw(); });
18425layout(); draw();
18426</script></div></body></html>"##,
18427 );
18428 Ok(html)
18429}
18430
18431struct DispatchTraceOptions<'a> {
18432 path: &'a Path,
18433 scope: Option<&'a str>,
18434 raw_targets: &'a [String],
18435 depth: usize,
18436 limit: usize,
18437 impact_limit: usize,
18438 trace_format: DispatchTraceFormat,
18439}
18440
18441fn cmd_dispatch_trace(
18442 options: DispatchTraceOptions<'_>,
18443 output_format: OutputFormat,
18444) -> Result<()> {
18445 let report = build_dispatch_trace_report(
18446 options.path,
18447 options.scope,
18448 options.raw_targets,
18449 options.depth,
18450 options.limit,
18451 options.impact_limit,
18452 )?;
18453 match options.trace_format {
18454 DispatchTraceFormat::Json => {
18455 if output_format.envelope {
18456 print_json_or_envelope(
18457 &report,
18458 &output_format,
18459 "dispatch-trace",
18460 "operator-review",
18461 ToolEnvelopeSummary {
18462 text: format!(
18463 "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
18464 report.targets.len(),
18465 report.nodes.len(),
18466 report.worker_prompt_packets.len()
18467 ),
18468 metrics: vec![
18469 envelope_metric("targets", report.targets.len()),
18470 envelope_metric("nodes", report.nodes.len()),
18471 envelope_metric("edges", report.edges.len()),
18472 envelope_metric(
18473 "worker_prompt_packets",
18474 report.worker_prompt_packets.len(),
18475 ),
18476 ],
18477 },
18478 report.truncated,
18479 report.replay_commands.clone(),
18480 )
18481 } else {
18482 println!(
18483 "{}",
18484 to_json_schema(
18485 &report,
18486 output_format.pretty,
18487 output_format.terse,
18488 output_format.ultra_terse,
18489 output_format.schema
18490 )?
18491 );
18492 Ok(())
18493 }
18494 }
18495 DispatchTraceFormat::Html => {
18496 println!("{}", dispatch_trace_html(&report)?);
18497 Ok(())
18498 }
18499 }
18500}
18501
18502#[derive(Clone, Debug)]
18503struct DependencyDagProfile {
18504 id: String,
18505 graph_node_id: String,
18506 label: String,
18507 path: Option<String>,
18508 line: Option<i64>,
18509 detail: Option<String>,
18510 source_files: BTreeSet<String>,
18511 source_symbols: BTreeSet<String>,
18512 config_files: BTreeSet<String>,
18513 expected_tests: BTreeSet<String>,
18514 semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
18515 worker_feedback: ConflictMatrixWorkerFeedback,
18516}
18517
18518#[derive(Clone, Debug, Serialize)]
18519struct DependencyDagNode {
18520 id: String,
18521 graph_node_id: String,
18522 label: String,
18523 #[serde(skip_serializing_if = "Option::is_none")]
18524 path: Option<String>,
18525 #[serde(skip_serializing_if = "Option::is_none")]
18526 line: Option<i64>,
18527 #[serde(skip_serializing_if = "Option::is_none")]
18528 detail: Option<String>,
18529 source_files: Vec<String>,
18530 source_symbols: Vec<String>,
18531 config_files: Vec<String>,
18532 expected_tests: Vec<String>,
18533 semantic_refs: Vec<ConflictMatrixSemanticRef>,
18534 worker_feedback: ConflictMatrixWorkerFeedback,
18535}
18536
18537#[derive(Clone, Debug, Serialize)]
18538struct DependencyDagEdge {
18539 from: String,
18540 to: String,
18541 kind: String,
18542 weight: usize,
18543 reasons: Vec<String>,
18544 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18545 shared_files: Vec<String>,
18546 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18547 shared_symbols: Vec<String>,
18548 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18549 shared_tests: Vec<String>,
18550 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18551 shared_config_files: Vec<String>,
18552 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18553 shared_semantic_refs: Vec<String>,
18554}
18555
18556#[derive(Clone, Debug, Serialize)]
18557struct DependencyDagTopoBatch {
18558 batch: usize,
18559 targets: Vec<String>,
18560}
18561
18562#[derive(Clone, Debug, Serialize)]
18563struct DependencyDagCycleDiagnostics {
18564 has_cycles: bool,
18565 blocked_nodes: Vec<String>,
18566 cycle_edges: Vec<DependencyDagEdge>,
18567}
18568
18569#[derive(Serialize)]
18570struct DependencyDagSummary {
18571 nodes: usize,
18572 edges: usize,
18573 topo_batches: usize,
18574 has_cycles: bool,
18575}
18576
18577#[derive(Serialize)]
18578struct DependencyDagReport {
18579 contract_version: &'static str,
18580 root: String,
18581 #[serde(skip_serializing_if = "Option::is_none")]
18582 scope: Option<String>,
18583 path: String,
18584 targets: Vec<String>,
18585 projection_freshness: GraphDbFreshnessReport,
18586 projection_hashes: Vec<String>,
18587 nodes: Vec<DependencyDagNode>,
18588 edges: Vec<DependencyDagEdge>,
18589 topo_batches: Vec<DependencyDagTopoBatch>,
18590 cycle_diagnostics: DependencyDagCycleDiagnostics,
18591 summary: DependencyDagSummary,
18592 replay_commands: Vec<String>,
18593 repair_commands: Vec<String>,
18594 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18595 warnings: Vec<String>,
18596}
18597
18598fn dependency_dag_backlog_node_for_target(
18599 store: &impl GraphStore,
18600 target: &str,
18601) -> Result<SubstrateGraphNode> {
18602 let resolved = graph_db_resolve_evidence_target(store, target)?
18603 .with_context(|| format!("dependency-dag target not found: {target}"))?;
18604 if resolved.kind == "backlog" {
18605 return Ok(resolved);
18606 }
18607 let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
18608 bail!(
18609 "dependency-dag target {} resolved to {} without a backlog ref_id",
18610 target,
18611 resolved.kind
18612 );
18613 };
18614 store
18615 .nodes_by_kind("backlog")?
18616 .into_iter()
18617 .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
18618 .min_by(|left, right| {
18619 left.properties
18620 .get("line")
18621 .and_then(|value| value.parse::<i64>().ok())
18622 .cmp(
18623 &right
18624 .properties
18625 .get("line")
18626 .and_then(|value| value.parse::<i64>().ok()),
18627 )
18628 .then(left.id.cmp(&right.id))
18629 })
18630 .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
18631}
18632
18633fn dependency_dag_resolve_backlog_nodes(
18634 root: &Path,
18635 path: &Path,
18636 store: &impl GraphStore,
18637 raw_targets: &[String],
18638) -> Result<Vec<SubstrateGraphNode>> {
18639 let mut nodes = Vec::new();
18640 let mut seen = BTreeSet::new();
18641 if raw_targets.is_empty() {
18642 let hinted_path = if path.is_absolute() {
18643 path.to_path_buf()
18644 } else {
18645 root.join(path)
18646 };
18647 let hinted_markdown = hinted_path
18648 .extension()
18649 .and_then(|ext| ext.to_str())
18650 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
18651 let hinted_rel = hinted_markdown.then(|| {
18652 relativize_pathbuf(&hinted_path, root)
18653 .to_string_lossy()
18654 .replace('\\', "/")
18655 });
18656 for node in store.nodes_by_kind("backlog")? {
18657 if let Some(expected_path) = &hinted_rel
18658 && node.properties.get("path") != Some(expected_path)
18659 {
18660 continue;
18661 }
18662 if seen.insert(node.id.clone()) {
18663 nodes.push(node);
18664 }
18665 }
18666 if nodes.is_empty() && hinted_rel.is_some() {
18667 for node in store.nodes_by_kind("backlog")? {
18668 if seen.insert(node.id.clone()) {
18669 nodes.push(node);
18670 }
18671 }
18672 }
18673 } else {
18674 for target in raw_targets {
18675 let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
18676 let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
18677 if seen.insert(node.id.clone()) {
18678 nodes.push(node);
18679 }
18680 }
18681 }
18682 if nodes.is_empty() {
18683 bail!("dependency-dag needs at least one resolvable backlog id");
18684 }
18685 nodes.sort_by(|left, right| {
18686 left.properties
18687 .get("line")
18688 .and_then(|value| value.parse::<i64>().ok())
18689 .cmp(
18690 &right
18691 .properties
18692 .get("line")
18693 .and_then(|value| value.parse::<i64>().ok()),
18694 )
18695 .then(left.id.cmp(&right.id))
18696 });
18697 Ok(nodes)
18698}
18699
18700fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
18701 node.properties
18702 .get("ref_id")
18703 .cloned()
18704 .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
18705}
18706
18707fn dependency_dag_node_profile(
18708 root: &Path,
18709 store: &impl GraphStore,
18710 node: &SubstrateGraphNode,
18711 graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
18712 graph_edges: &[SubstrateGraphEdge],
18713 depth: usize,
18714 limit: usize,
18715) -> Result<DependencyDagProfile> {
18716 let id = dependency_dag_node_id(node);
18717 let mut source_files = BTreeSet::new();
18718 let mut source_symbols = BTreeSet::new();
18719 for edge in graph_edges
18720 .iter()
18721 .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
18722 {
18723 let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
18724 continue;
18725 };
18726 match target.kind.as_str() {
18727 "file" | "route" => {
18728 if let Some(path) = target.properties.get("path") {
18729 source_files.insert(path.clone());
18730 }
18731 }
18732 "symbol" => {
18733 source_symbols.insert(target.label.clone());
18734 if let Some(path) = target.properties.get("path") {
18735 source_files.insert(path.clone());
18736 }
18737 }
18738 _ => {}
18739 }
18740 }
18741
18742 let max_rows = if limit == 0 { usize::MAX } else { limit };
18743 for (source, _) in
18744 graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
18745 {
18746 let terse: SubstrateTerseGraphNode = (&source).into();
18747 if let Some(handle) = conflict_matrix_source_handle(&terse) {
18748 source_files.insert(handle.file);
18749 }
18750 }
18751
18752 let worker_results = graph_nodes_by_id
18753 .values()
18754 .filter(|candidate| {
18755 candidate.kind == "worker_result"
18756 && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
18757 })
18758 .map(SubstrateTerseGraphNode::from)
18759 .collect::<Vec<_>>();
18760 let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
18761 let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
18762 let config_files = source_files
18763 .iter()
18764 .filter(|file| is_planner_config_path(file))
18765 .cloned()
18766 .collect();
18767
18768 let mut semantic_refs = BTreeMap::new();
18769 for kind in ["semantic_concept", "semantic_entity"] {
18770 for (semantic, _) in
18771 graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
18772 {
18773 let terse: SubstrateTerseGraphNode = (&semantic).into();
18774 let item = conflict_matrix_semantic_ref(root, &terse);
18775 semantic_refs
18776 .entry(format!("{}:{}", item.kind, item.label))
18777 .or_insert(item);
18778 }
18779 }
18780
18781 Ok(DependencyDagProfile {
18782 id,
18783 graph_node_id: node.id.clone(),
18784 label: node.label.clone(),
18785 path: node.properties.get("path").cloned(),
18786 line: node
18787 .properties
18788 .get("line")
18789 .and_then(|value| value.parse::<i64>().ok()),
18790 detail: node.properties.get("detail").cloned(),
18791 source_files,
18792 source_symbols,
18793 config_files,
18794 expected_tests,
18795 semantic_refs,
18796 worker_feedback,
18797 })
18798}
18799
18800fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
18801 let lower = text.to_ascii_lowercase();
18802 let mut refs = Vec::new();
18803 for marker in markers {
18804 let mut offset = 0usize;
18805 while let Some(pos) = lower[offset..].find(marker) {
18806 let start = offset + pos + marker.len();
18807 let segment = text[start..]
18808 .split(['\n', '.'])
18809 .next()
18810 .unwrap_or(&text[start..]);
18811 refs.extend(extract_conflict_target_refs(segment));
18812 offset = start;
18813 }
18814 }
18815 dedupe_preserve_order(refs)
18816}
18817
18818fn dependency_dag_push_edge(
18819 edges: &mut Vec<DependencyDagEdge>,
18820 seen: &mut BTreeSet<(String, String, String)>,
18821 edge: DependencyDagEdge,
18822) {
18823 if edge.from == edge.to {
18824 return;
18825 }
18826 if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
18827 edges.push(edge);
18828 }
18829}
18830
18831fn dependency_dag_explicit_edges(
18832 profiles: &[DependencyDagProfile],
18833 target_ids: &BTreeSet<String>,
18834 edges: &mut Vec<DependencyDagEdge>,
18835 seen: &mut BTreeSet<(String, String, String)>,
18836) {
18837 for profile in profiles {
18838 let detail = profile.detail.as_deref().unwrap_or_default();
18839 for dep in dependency_dag_marker_refs(
18840 detail,
18841 &[
18842 "depends on",
18843 "depends-on",
18844 "deps:",
18845 "after",
18846 "blocked by",
18847 "requires",
18848 ],
18849 ) {
18850 if target_ids.contains(&dep) {
18851 dependency_dag_push_edge(
18852 edges,
18853 seen,
18854 DependencyDagEdge {
18855 from: dep.clone(),
18856 to: profile.id.clone(),
18857 kind: "explicit_depends_on".to_string(),
18858 weight: 1000,
18859 reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
18860 shared_files: Vec::new(),
18861 shared_symbols: Vec::new(),
18862 shared_tests: Vec::new(),
18863 shared_config_files: Vec::new(),
18864 shared_semantic_refs: Vec::new(),
18865 },
18866 );
18867 }
18868 }
18869 for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
18870 if target_ids.contains(&downstream) {
18871 dependency_dag_push_edge(
18872 edges,
18873 seen,
18874 DependencyDagEdge {
18875 from: profile.id.clone(),
18876 to: downstream.clone(),
18877 kind: "explicit_before".to_string(),
18878 weight: 900,
18879 reasons: vec![format!(
18880 "{} declares it should run before #{downstream}",
18881 profile.id
18882 )],
18883 shared_files: Vec::new(),
18884 shared_symbols: Vec::new(),
18885 shared_tests: Vec::new(),
18886 shared_config_files: Vec::new(),
18887 shared_semantic_refs: Vec::new(),
18888 },
18889 );
18890 }
18891 }
18892 }
18893}
18894
18895fn dependency_dag_worker_follow_up_edges(
18896 profiles: &[DependencyDagProfile],
18897 target_ids: &BTreeSet<String>,
18898 edges: &mut Vec<DependencyDagEdge>,
18899 seen: &mut BTreeSet<(String, String, String)>,
18900) {
18901 for profile in profiles {
18902 for follow_up in &profile.worker_feedback.follow_up_ids {
18903 if target_ids.contains(follow_up) {
18904 dependency_dag_push_edge(
18905 edges,
18906 seen,
18907 DependencyDagEdge {
18908 from: profile.id.clone(),
18909 to: follow_up.clone(),
18910 kind: "worker_result_follow_up".to_string(),
18911 weight: 700,
18912 reasons: vec![format!(
18913 "worker_result for #{} references follow-up #{}",
18914 profile.id, follow_up
18915 )],
18916 shared_files: Vec::new(),
18917 shared_symbols: Vec::new(),
18918 shared_tests: Vec::new(),
18919 shared_config_files: Vec::new(),
18920 shared_semantic_refs: Vec::new(),
18921 },
18922 );
18923 }
18924 }
18925 }
18926}
18927
18928fn dependency_dag_overlap_edges(
18929 profiles: &[DependencyDagProfile],
18930 edges: &mut Vec<DependencyDagEdge>,
18931 seen: &mut BTreeSet<(String, String, String)>,
18932) {
18933 for left_idx in 0..profiles.len() {
18934 for right_idx in (left_idx + 1)..profiles.len() {
18935 let left = &profiles[left_idx];
18936 let right = &profiles[right_idx];
18937 let shared_files = sorted_intersection(&left.source_files, &right.source_files);
18938 let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
18939 let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
18940 let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
18941 let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
18942 let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
18943 let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
18944 if shared_files.is_empty()
18945 && shared_symbols.is_empty()
18946 && shared_tests.is_empty()
18947 && shared_config_files.is_empty()
18948 && shared_semantic_refs.is_empty()
18949 {
18950 continue;
18951 }
18952 let kind = if shared_files.is_empty()
18953 && shared_symbols.is_empty()
18954 && shared_tests.is_empty()
18955 && shared_config_files.is_empty()
18956 {
18957 "semantic_relation"
18958 } else {
18959 "shared_resource"
18960 };
18961 let mut reasons = Vec::new();
18962 if !shared_files.is_empty() {
18963 reasons.push(format!("shared files: {}", shared_files.join(", ")));
18964 }
18965 if !shared_symbols.is_empty() {
18966 reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
18967 }
18968 if !shared_tests.is_empty() {
18969 reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
18970 }
18971 if !shared_config_files.is_empty() {
18972 reasons.push(format!(
18973 "shared config files: {}",
18974 shared_config_files.join(", ")
18975 ));
18976 }
18977 if !shared_semantic_refs.is_empty() {
18978 reasons.push(format!(
18979 "shared semantic refs: {}",
18980 shared_semantic_refs.join(", ")
18981 ));
18982 }
18983 let weight = shared_files.len() * 100
18984 + shared_config_files.len() * 100
18985 + shared_symbols.len() * 40
18986 + shared_tests.len() * 10
18987 + shared_semantic_refs.len() * 5;
18988 dependency_dag_push_edge(
18989 edges,
18990 seen,
18991 DependencyDagEdge {
18992 from: left.id.clone(),
18993 to: right.id.clone(),
18994 kind: kind.to_string(),
18995 weight,
18996 reasons,
18997 shared_files,
18998 shared_symbols,
18999 shared_tests,
19000 shared_config_files,
19001 shared_semantic_refs,
19002 },
19003 );
19004 }
19005 }
19006}
19007
19008fn dependency_dag_topo_batches(
19009 targets: &[String],
19010 edges: &[DependencyDagEdge],
19011) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19012 let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19013 let order = targets
19014 .iter()
19015 .enumerate()
19016 .map(|(idx, id)| (id.clone(), idx))
19017 .collect::<BTreeMap<_, _>>();
19018 let mut indegree = targets
19019 .iter()
19020 .map(|id| (id.clone(), 0usize))
19021 .collect::<BTreeMap<_, _>>();
19022 let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19023 let mut seen_pairs = BTreeSet::<(String, String)>::new();
19024 for edge in edges {
19025 if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19026 continue;
19027 }
19028 if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19029 continue;
19030 }
19031 *indegree.entry(edge.to.clone()).or_default() += 1;
19032 outgoing
19033 .entry(edge.from.clone())
19034 .or_default()
19035 .push(edge.to.clone());
19036 }
19037 for values in outgoing.values_mut() {
19038 values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19039 values.dedup();
19040 }
19041
19042 let mut processed = BTreeSet::new();
19043 let mut batches = Vec::new();
19044 loop {
19045 let mut ready = targets
19046 .iter()
19047 .filter(|id| !processed.contains(*id))
19048 .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19049 .cloned()
19050 .collect::<Vec<_>>();
19051 ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19052 if ready.is_empty() {
19053 break;
19054 }
19055 for id in &ready {
19056 processed.insert(id.clone());
19057 for next in outgoing.get(id).into_iter().flatten() {
19058 if let Some(value) = indegree.get_mut(next) {
19059 *value = value.saturating_sub(1);
19060 }
19061 }
19062 }
19063 batches.push(DependencyDagTopoBatch {
19064 batch: batches.len() + 1,
19065 targets: ready,
19066 });
19067 }
19068
19069 let blocked_nodes = targets
19070 .iter()
19071 .filter(|id| !processed.contains(*id))
19072 .cloned()
19073 .collect::<Vec<_>>();
19074 let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19075 let cycle_edges = edges
19076 .iter()
19077 .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19078 .cloned()
19079 .collect::<Vec<_>>();
19080 (
19081 batches,
19082 DependencyDagCycleDiagnostics {
19083 has_cycles: !blocked_nodes.is_empty(),
19084 blocked_nodes,
19085 cycle_edges,
19086 },
19087 )
19088}
19089
19090fn dependency_dag_replay_commands(
19091 path: &Path,
19092 scope: Option<&str>,
19093 targets: &[String],
19094 depth: usize,
19095 limit: usize,
19096) -> Vec<String> {
19097 let target_args = targets
19098 .iter()
19099 .map(|target| shell_quote(target))
19100 .collect::<Vec<_>>()
19101 .join(" ");
19102 let mut command = format!(
19103 "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19104 shell_quote(path.to_string_lossy().as_ref()),
19105 scope
19106 .map(|scope| format!(" --scope {}", shell_quote(scope)))
19107 .unwrap_or_default(),
19108 depth,
19109 limit
19110 );
19111 if !target_args.is_empty() {
19112 command.push(' ');
19113 command.push_str(&target_args);
19114 }
19115 vec![command]
19116}
19117
19118fn build_dependency_dag_report(
19119 path: &Path,
19120 scope: Option<&str>,
19121 raw_targets: &[String],
19122 depth: usize,
19123 limit: usize,
19124) -> Result<DependencyDagReport> {
19125 let root = lint::resolve_project_root_or_canonical_path(path)?;
19126 write_traversal_graph_store(&root, path, scope)
19127 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19128 let graph_db = graph_substrate_db_path(&root, scope);
19129 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19130 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19131 let mut warnings = Vec::new();
19132 if let Some(recovery) = store.read_only_recovery() {
19133 warnings.push(graph_db_read_recovery_diagnostic(recovery));
19134 }
19135 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19136 if freshness.fail_closed {
19137 bail!(
19138 "dependency-dag graph projection failed closed: {}; repair: {}",
19139 freshness.diagnostics.join("; "),
19140 graph_db_repair_commands(&root, scope).join("; ")
19141 );
19142 }
19143
19144 let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19145 let graph_nodes = store.all_nodes()?;
19146 let graph_edges = store.all_edges()?;
19147 let graph_nodes_by_id = graph_nodes
19148 .into_iter()
19149 .map(|node| (node.id.clone(), node))
19150 .collect::<BTreeMap<_, _>>();
19151 let profiles = target_nodes
19152 .iter()
19153 .map(|node| {
19154 dependency_dag_node_profile(
19155 &root,
19156 &store,
19157 node,
19158 &graph_nodes_by_id,
19159 &graph_edges,
19160 depth,
19161 limit,
19162 )
19163 })
19164 .collect::<Result<Vec<_>>>()?;
19165 let targets = profiles
19166 .iter()
19167 .map(|profile| profile.id.clone())
19168 .collect::<Vec<_>>();
19169 let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19170
19171 let mut edges = Vec::new();
19172 let mut seen_edges = BTreeSet::new();
19173 dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19174 dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19175 dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19176 edges.sort_by(|left, right| {
19177 left.from
19178 .cmp(&right.from)
19179 .then(left.to.cmp(&right.to))
19180 .then(left.kind.cmp(&right.kind))
19181 });
19182 let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19183
19184 let nodes = profiles
19185 .into_iter()
19186 .map(|profile| DependencyDagNode {
19187 id: profile.id,
19188 graph_node_id: profile.graph_node_id,
19189 label: profile.label,
19190 path: profile.path,
19191 line: profile.line,
19192 detail: profile.detail,
19193 source_files: sorted_set(&profile.source_files),
19194 source_symbols: sorted_set(&profile.source_symbols),
19195 config_files: sorted_set(&profile.config_files),
19196 expected_tests: sorted_set(&profile.expected_tests),
19197 semantic_refs: profile.semantic_refs.into_values().collect(),
19198 worker_feedback: profile.worker_feedback,
19199 })
19200 .collect::<Vec<_>>();
19201 let projection_hashes = freshness
19202 .content_hash
19203 .clone()
19204 .into_iter()
19205 .collect::<Vec<_>>();
19206 let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19207 let repair_commands = graph_db_repair_commands(&root, scope);
19208 let summary = DependencyDagSummary {
19209 nodes: nodes.len(),
19210 edges: edges.len(),
19211 topo_batches: topo_batches.len(),
19212 has_cycles: cycle_diagnostics.has_cycles,
19213 };
19214
19215 Ok(DependencyDagReport {
19216 contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19217 root: root.to_string_lossy().to_string(),
19218 scope: scope.map(str::to_string),
19219 path: path.to_string_lossy().to_string(),
19220 targets,
19221 projection_freshness: freshness,
19222 projection_hashes,
19223 nodes,
19224 edges,
19225 topo_batches,
19226 cycle_diagnostics,
19227 summary,
19228 replay_commands,
19229 repair_commands,
19230 warnings,
19231 })
19232}
19233
19234fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
19235 if compact {
19236 println!(
19237 "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
19238 report.targets.len(),
19239 report.edges.len(),
19240 report.topo_batches.len(),
19241 report.cycle_diagnostics.has_cycles
19242 );
19243 } else {
19244 println!("Dependency DAG");
19245 println!(" targets: {}", report.targets.join(", "));
19246 println!(" edges: {}", report.edges.len());
19247 println!(" cycles: {}", report.cycle_diagnostics.has_cycles);
19248 }
19249 for batch in &report.topo_batches {
19250 println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
19251 }
19252 for edge in &report.edges {
19253 println!(
19254 "edge {} -> {} kind:{} weight:{}",
19255 edge.from, edge.to, edge.kind, edge.weight
19256 );
19257 for reason in &edge.reasons {
19258 println!(" reason: {reason}");
19259 }
19260 }
19261 if report.cycle_diagnostics.has_cycles {
19262 println!(
19263 "cycle blocked nodes: {}",
19264 report.cycle_diagnostics.blocked_nodes.join(", ")
19265 );
19266 }
19267 for command in &report.replay_commands {
19268 println!("replay: {command}");
19269 }
19270 for command in &report.repair_commands {
19271 println!("repair: {command}");
19272 }
19273 for warning in &report.warnings {
19274 println!("warning: {warning}");
19275 }
19276}
19277
19278fn cmd_dependency_dag(
19279 path: &Path,
19280 scope: Option<&str>,
19281 raw_targets: &[String],
19282 depth: usize,
19283 limit: usize,
19284 format: OutputFormat,
19285) -> Result<()> {
19286 let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
19287 if format.json_output {
19288 print_json_or_envelope(
19289 &report,
19290 &format,
19291 "dependency-dag",
19292 "topological-planning",
19293 ToolEnvelopeSummary {
19294 text: format!(
19295 "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
19296 report.targets.len(),
19297 report.edges.len(),
19298 report.topo_batches.len(),
19299 report.cycle_diagnostics.has_cycles
19300 ),
19301 metrics: vec![
19302 envelope_metric("targets", report.targets.len()),
19303 envelope_metric("edges", report.edges.len()),
19304 envelope_metric("topo_batches", report.topo_batches.len()),
19305 envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
19306 ],
19307 },
19308 report.cycle_diagnostics.has_cycles,
19309 report.replay_commands.clone(),
19310 )
19311 } else {
19312 print_dependency_dag_human(&report, format.compact);
19313 Ok(())
19314 }
19315}
19316
19317pub(crate) fn render_log_digest_from_input(
19318 path: &Path,
19319 input: &str,
19320 format: OutputFormat,
19321) -> Result<()> {
19322 let report = log_digest::compute(path, input)?;
19323 if format.json_output {
19324 println!(
19325 "{}",
19326 to_json_schema(
19327 &report,
19328 format.pretty,
19329 format.terse,
19330 format.ultra_terse,
19331 format.schema
19332 )?
19333 );
19334 return Ok(());
19335 }
19336
19337 if format.compact {
19338 println!(
19339 "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
19340 report.non_empty_lines,
19341 report.signal_groups,
19342 report.repeated_line_groups,
19343 report.file_ref_groups,
19344 report.symbol_ref_groups,
19345 report.stack_groups
19346 );
19347 for signal in &report.signals {
19348 let location = match (&signal.path, signal.line) {
19349 (Some(path), Some(line)) => format!("{path}:{line}"),
19350 (Some(path), None) => path.clone(),
19351 _ => "-".to_string(),
19352 };
19353 println!(
19354 "{} sev:{} count:{} sums:{} msg:{}",
19355 location,
19356 signal.severity,
19357 signal.occurrences,
19358 log_digest_summary_label(signal.summary_state),
19359 truncate_for_compact(&signal.message, 80)
19360 );
19361 }
19362 for repeated in &report.repeated_lines {
19363 println!(
19364 "repeat count:{} line:{}",
19365 repeated.occurrences,
19366 truncate_for_compact(&repeated.line, 80)
19367 );
19368 }
19369 for symbol in &report.symbol_refs {
19370 println!(
19371 "sym:{} count:{} sums:{}",
19372 symbol.symbol,
19373 symbol.occurrences,
19374 log_digest_summary_label(symbol.summary_state)
19375 );
19376 }
19377 for warning in &report.warnings {
19378 println!("warning: {warning}");
19379 }
19380 return Ok(());
19381 }
19382
19383 println!("Log digest");
19384 println!(" lines: {}", report.total_lines);
19385 println!(" non-empty lines: {}", report.non_empty_lines);
19386 println!(" signal groups: {}", report.signal_groups);
19387 println!(
19388 " repeated lines: {}",
19389 report.repeated_line_groups
19390 );
19391 println!(
19392 " repeated line instances: {}",
19393 report.repeated_line_occurrences
19394 );
19395 println!(" file refs: {}", report.file_ref_groups);
19396 println!(" symbol refs: {}", report.symbol_ref_groups);
19397 println!(" stack groups: {}", report.stack_groups);
19398
19399 if !report.signals.is_empty() {
19400 println!();
19401 println!("Signals:");
19402 for signal in &report.signals {
19403 match (&signal.path, signal.line, signal.column) {
19404 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
19405 (Some(path), Some(line), None) => println!("{path}:{line}"),
19406 (Some(path), None, _) => println!("{path}"),
19407 (None, _, _) => println!("(no file anchor)"),
19408 }
19409 println!(" severity: {}", signal.severity);
19410 println!(" occurrences: {}", signal.occurrences);
19411 println!(" message: {}", signal.message);
19412 println!(
19413 " cached summaries: {}",
19414 log_digest_summary_label(signal.summary_state)
19415 );
19416 for summary in &signal.current_summaries {
19417 println!(
19418 " - {}: {}",
19419 summary.symbol,
19420 truncate_for_compact(&summary.summary, 160)
19421 );
19422 }
19423 }
19424 }
19425
19426 if !report.repeated_lines.is_empty() {
19427 println!();
19428 println!("Repeated lines:");
19429 for repeated in &report.repeated_lines {
19430 println!(
19431 " {}x {}",
19432 repeated.occurrences,
19433 truncate_for_compact(&repeated.line, 180)
19434 );
19435 }
19436 }
19437
19438 if !report.file_refs.is_empty() {
19439 println!();
19440 println!("Anchored files:");
19441 for file_ref in &report.file_refs {
19442 match (file_ref.line, file_ref.column) {
19443 (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
19444 (Some(line), None) => println!("{}:{}", file_ref.path, line),
19445 (None, _) => println!("{}", file_ref.path),
19446 }
19447 println!(" occurrences: {}", file_ref.occurrences);
19448 println!(
19449 " cached summaries: {}",
19450 log_digest_summary_label(file_ref.summary_state)
19451 );
19452 for summary in &file_ref.current_summaries {
19453 println!(
19454 " - {}: {}",
19455 summary.symbol,
19456 truncate_for_compact(&summary.summary, 160)
19457 );
19458 }
19459 }
19460 }
19461
19462 if !report.symbol_refs.is_empty() {
19463 println!();
19464 println!("Symbol candidates:");
19465 for symbol in &report.symbol_refs {
19466 println!("{}", symbol.symbol);
19467 println!(" occurrences: {}", symbol.occurrences);
19468 println!(
19469 " cached summaries: {}",
19470 log_digest_summary_label(symbol.summary_state)
19471 );
19472 for summary in &symbol.current_summaries {
19473 println!(
19474 " - {}: {}",
19475 summary.symbol,
19476 truncate_for_compact(&summary.summary, 160)
19477 );
19478 }
19479 }
19480 }
19481
19482 if !report.stack_traces.is_empty() {
19483 println!();
19484 println!("Stack groups:");
19485 for stack in &report.stack_traces {
19486 println!(" occurrences: {}", stack.occurrences);
19487 for frame in &stack.frames {
19488 println!(" - {}", frame);
19489 }
19490 }
19491 }
19492
19493 for warning in &report.warnings {
19494 println!("warning: {warning}");
19495 }
19496 Ok(())
19497}
19498
19499pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
19500 match trend {
19501 metric_digest::MetricDigestTrend::Improved => "improved",
19502 metric_digest::MetricDigestTrend::Regressed => "regressed",
19503 metric_digest::MetricDigestTrend::Flat => "flat",
19504 metric_digest::MetricDigestTrend::Unknown => "changed",
19505 }
19506}
19507
19508pub(crate) fn metric_digest_gate_label(
19509 decision: metric_digest::CommunitySearchGateDecision,
19510) -> &'static str {
19511 match decision {
19512 metric_digest::CommunitySearchGateDecision::Pass => "pass",
19513 metric_digest::CommunitySearchGateDecision::Block => "block",
19514 }
19515}
19516
19517pub(crate) fn memgraphrag_metric_digest_gate_label(
19518 decision: metric_digest::MemGraphRagPerformanceGateDecision,
19519) -> &'static str {
19520 match decision {
19521 metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
19522 metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
19523 }
19524}
19525
19526fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
19527 let input = fs::read_to_string(fixture_path)
19528 .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
19529 let report = dci_benchmark::compute(&input)?;
19530
19531 if format.json_output {
19532 println!(
19533 "{}",
19534 to_json_schema(
19535 &report,
19536 format.pretty,
19537 format.terse,
19538 format.ultra_terse,
19539 format.schema
19540 )?
19541 );
19542 return Ok(());
19543 }
19544
19545 if format.compact {
19546 println!(
19547 "dci tasks:{} strategies:{} warnings:{}",
19548 report.tasks_loaded,
19549 report.strategies_compared,
19550 report.warnings.len()
19551 );
19552 for summary in &report.strategy_summaries {
19553 println!(
19554 "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
19555 summary.strategy,
19556 summary.rank,
19557 summary.localized,
19558 summary.task_runs,
19559 dci_benchmark::format_number(summary.localization_rate * 100.0),
19560 dci_benchmark::format_number(summary.avg_useful_hits),
19561 dci_benchmark::format_number(summary.zero_output_rate * 100.0),
19562 dci_benchmark::format_number(summary.avg_tool_calls),
19563 dci_benchmark::format_number(summary.avg_latency_ms),
19564 dci_benchmark::format_number(summary.avg_estimated_tokens),
19565 dci_benchmark::format_number(summary.avg_output_tokens)
19566 );
19567 }
19568 if let Some(gate) = &report.memory_retrieval_gate {
19569 println!(
19570 "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
19571 gate.decision,
19572 gate.baseline_strategy,
19573 dci_benchmark::format_number(gate.min_avg_useful_hits),
19574 gate.max_zero_output_failures,
19575 gate.diagnostics.len()
19576 );
19577 }
19578 for warning in &report.warnings {
19579 println!("warning: {warning}");
19580 }
19581 return Ok(());
19582 }
19583
19584 println!("DCI benchmark");
19585 if let Some(description) = &report.description {
19586 println!(" description: {}", description);
19587 }
19588 println!(" tasks loaded: {}", report.tasks_loaded);
19589 println!(" strategies compared: {}", report.strategies_compared);
19590
19591 println!();
19592 println!("Strategy summary:");
19593 for summary in &report.strategy_summaries {
19594 println!(
19595 " #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
19596 summary.rank,
19597 summary.strategy,
19598 summary.localized,
19599 summary.task_runs,
19600 summary.localization_rate * 100.0,
19601 dci_benchmark::format_number(summary.avg_useful_hits),
19602 summary.zero_output_rate * 100.0,
19603 dci_benchmark::format_number(summary.avg_tool_calls),
19604 dci_benchmark::format_number(summary.avg_latency_ms),
19605 dci_benchmark::format_number(summary.avg_estimated_tokens),
19606 dci_benchmark::format_number(summary.avg_output_tokens)
19607 );
19608 }
19609
19610 if let Some(gate) = &report.memory_retrieval_gate {
19611 println!();
19612 println!("Memory retrieval gate:");
19613 println!(" decision: {}", gate.decision);
19614 println!(
19615 " baseline: {}, min avg useful hits {}, max zero-output failures {}",
19616 gate.baseline_strategy,
19617 dci_benchmark::format_number(gate.min_avg_useful_hits),
19618 gate.max_zero_output_failures
19619 );
19620 for row in &gate.rows {
19621 println!(
19622 " {}: status {}, avg useful hits {}, zero-output failures {}",
19623 row.strategy,
19624 row.status,
19625 dci_benchmark::format_number(row.avg_useful_hits),
19626 row.zero_output_failures
19627 );
19628 }
19629 for diagnostic in &gate.diagnostics {
19630 println!(" diagnostic: {diagnostic}");
19631 }
19632 }
19633
19634 println!();
19635 println!("Task winners:");
19636 for row in &report.task_rows {
19637 let label = row
19638 .label
19639 .as_ref()
19640 .map(|value| format!(" ({value})"))
19641 .unwrap_or_default();
19642 println!(" {}{}", row.task_id, label);
19643 println!(" localized: {}", row.best_localization.join(", "));
19644 println!(" most useful hits: {}", row.most_useful_hits.join(", "));
19645 println!(
19646 " lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
19647 row.lowest_tool_calls.as_deref().unwrap_or("-"),
19648 row.lowest_latency.as_deref().unwrap_or("-"),
19649 row.lowest_token_budget.as_deref().unwrap_or("-"),
19650 row.lowest_output_tokens.as_deref().unwrap_or("-")
19651 );
19652 if !row.zero_output_failures.is_empty() {
19653 println!(" zero output: {}", row.zero_output_failures.join(", "));
19654 }
19655 }
19656
19657 for warning in &report.warnings {
19658 println!("warning: {warning}");
19659 }
19660 Ok(())
19661}
19662
19663pub(crate) fn format_compact_count(value: u64) -> String {
19664 if value >= 1_000_000 {
19665 format!("{:.1}M", value as f64 / 1_000_000.0)
19666 } else if value >= 1_000 {
19667 format!("{:.1}K", value as f64 / 1_000.0)
19668 } else {
19669 value.to_string()
19670 }
19671}
19672
19673fn cmd_digest_runner(
19674 kind: &str,
19675 path: &Path,
19676 runner: Option<&str>,
19677 shell_command: &str,
19678 format: OutputFormat,
19679) -> Result<()> {
19680 let digest_kind = DigestRunnerKind::parse(kind)?;
19681 let root = transcript_artifact_root(path)?;
19682 let execution = run_digest_runner_command(shell_command)?;
19683 let output = &execution.output;
19684 let captured = String::from_utf8_lossy(&output.stdout).into_owned();
19685 let exit_code = output.status.code().unwrap_or(-1);
19686 if format.json_output && format.envelope {
19687 let artifact_key = format!(
19688 "{}:{}:{}:{}",
19689 digest_kind.as_str(),
19690 shell_command,
19691 execution.executed_command,
19692 captured
19693 );
19694 let artifact = if captured.trim().is_empty() {
19695 None
19696 } else {
19697 let (suffix, expand) = match digest_kind {
19698 DigestRunnerKind::Test => (
19699 "test.log",
19700 format!(
19701 "tsift test-digest --path {} --input {}{} --json",
19702 shell_quote(root.to_string_lossy().as_ref()),
19703 shell_quote(
19704 root.join(".tsift/artifacts")
19705 .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
19706 .to_string_lossy()
19707 .as_ref()
19708 ),
19709 runner
19710 .map(|value| format!(" --runner {}", shell_quote(value)))
19711 .unwrap_or_default()
19712 ),
19713 ),
19714 DigestRunnerKind::Log => (
19715 "log",
19716 format!(
19717 "tsift log-digest --path {} --input {} --json",
19718 shell_quote(root.to_string_lossy().as_ref()),
19719 shell_quote(
19720 root.join(".tsift/artifacts")
19721 .join(format!("{}.log", stable_handle("tart", &artifact_key)))
19722 .to_string_lossy()
19723 .as_ref()
19724 )
19725 ),
19726 ),
19727 };
19728 Some(persist_transcript_artifact(
19729 &root,
19730 "tart",
19731 suffix,
19732 &artifact_key,
19733 &captured,
19734 expand,
19735 )?)
19736 };
19737 let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
19738
19739 match digest_kind {
19740 DigestRunnerKind::Test => {
19741 let digest_report = test_digest::compute(path, &captured, runner)?;
19742 let report = serde_json::json!({
19743 "kind": digest_kind.as_str(),
19744 "command": shell_command,
19745 "executed_command": execution.executed_command,
19746 "exit_code": exit_code,
19747 "success": output.status.success(),
19748 "filter": filter_report,
19749 "artifact": artifact,
19750 "digest": digest_report,
19751 });
19752 let mut follow_up = artifact
19753 .as_ref()
19754 .map(|entry| vec![entry.expand.clone()])
19755 .unwrap_or_default();
19756 follow_up.push(format!(
19757 "tsift rewrite --run {}",
19758 shell_quote(shell_command)
19759 ));
19760 let summary_text = if output.status.success() && digest_report.failures == 0 {
19761 format!("test run passed for {}", runner.unwrap_or("auto"))
19762 } else {
19763 format!("test run captured {} failure(s)", digest_report.failures)
19764 };
19765 print_json_or_envelope(
19766 &report,
19767 &format,
19768 "digest-runner",
19769 "test-run",
19770 ToolEnvelopeSummary {
19771 text: summary_text,
19772 metrics: vec![
19773 envelope_metric("runner", &digest_report.runner),
19774 envelope_metric("exit_code", exit_code),
19775 envelope_metric("filter", execution.filter_label()),
19776 envelope_metric("failures", digest_report.failures),
19777 envelope_metric("groups", digest_report.grouped_failures),
19778 envelope_metric(
19779 "artifact",
19780 artifact
19781 .as_ref()
19782 .map(|entry| entry.handle.as_str())
19783 .unwrap_or("-"),
19784 ),
19785 ],
19786 },
19787 false,
19788 follow_up,
19789 )?;
19790 }
19791 DigestRunnerKind::Log => {
19792 let digest_report = log_digest::compute(path, &captured)?;
19793 let report = serde_json::json!({
19794 "kind": digest_kind.as_str(),
19795 "command": shell_command,
19796 "executed_command": execution.executed_command,
19797 "exit_code": exit_code,
19798 "success": output.status.success(),
19799 "filter": filter_report,
19800 "artifact": artifact,
19801 "digest": digest_report,
19802 });
19803 let mut follow_up = artifact
19804 .as_ref()
19805 .map(|entry| vec![entry.expand.clone()])
19806 .unwrap_or_default();
19807 follow_up.push(format!(
19808 "tsift rewrite --run {}",
19809 shell_quote(shell_command)
19810 ));
19811 let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
19812 "command finished without log signals".to_string()
19813 } else {
19814 format!(
19815 "command emitted {} log signal group(s)",
19816 digest_report.signal_groups
19817 )
19818 };
19819 print_json_or_envelope(
19820 &report,
19821 &format,
19822 "digest-runner",
19823 "command-run",
19824 ToolEnvelopeSummary {
19825 text: summary_text,
19826 metrics: vec![
19827 envelope_metric("exit_code", exit_code),
19828 envelope_metric("filter", execution.filter_label()),
19829 envelope_metric("signals", digest_report.signal_groups),
19830 envelope_metric("file_refs", digest_report.file_ref_groups),
19831 envelope_metric(
19832 "artifact",
19833 artifact
19834 .as_ref()
19835 .map(|entry| entry.handle.as_str())
19836 .unwrap_or("-"),
19837 ),
19838 ],
19839 },
19840 false,
19841 follow_up,
19842 )?;
19843 }
19844 }
19845
19846 if output.status.success() {
19847 return Ok(());
19848 }
19849 if let Some(code) = output.status.code() {
19850 std::process::exit(code);
19851 }
19852 bail!("digest-wrapped command terminated by signal: {shell_command}");
19853 }
19854
19855 if captured.trim().is_empty() {
19856 let label = match digest_kind {
19857 DigestRunnerKind::Test => "test",
19858 DigestRunnerKind::Log => "log",
19859 };
19860 println!("No {label} output captured.");
19861 } else {
19862 match digest_kind {
19863 DigestRunnerKind::Test => {
19864 render_test_digest_from_input(path, &captured, runner, format)?
19865 }
19866 DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
19867 }
19868 }
19869
19870 if output.status.success() {
19871 return Ok(());
19872 }
19873 if let Some(code) = output.status.code() {
19874 std::process::exit(code);
19875 }
19876 bail!("digest-wrapped command terminated by signal: {shell_command}");
19877}
19878
19879struct DigestRunnerExecution {
19880 output: std::process::Output,
19881 executed_command: String,
19882 filter: Option<DigestRunnerFilter>,
19883}
19884
19885impl DigestRunnerExecution {
19886 fn filter_label(&self) -> &'static str {
19887 self.filter
19888 .as_ref()
19889 .map(|filter| filter.tool)
19890 .unwrap_or("none")
19891 }
19892}
19893
19894struct DigestRunnerFilter {
19895 tool: &'static str,
19896 command: String,
19897}
19898
19899impl DigestRunnerFilter {
19900 fn to_json(&self) -> serde_json::Value {
19901 serde_json::json!({
19902 "tool": self.tool,
19903 "command": self.command,
19904 })
19905 }
19906}
19907
19908fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
19909 let filter = rtk_rewrite_for_digest_runner(shell_command);
19910 let executed_command = filter
19911 .as_ref()
19912 .map(|filter| filter.command.as_str())
19913 .unwrap_or(shell_command);
19914 let output = Command::new("sh")
19915 .arg("-lc")
19916 .arg(format!("({executed_command}) 2>&1"))
19917 .stdout(Stdio::piped())
19918 .output()
19919 .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
19920
19921 Ok(DigestRunnerExecution {
19922 output,
19923 executed_command: executed_command.to_string(),
19924 filter,
19925 })
19926}
19927
19928fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
19929 if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
19930 return None;
19931 }
19932 let output = Command::new("rtk")
19933 .arg("rewrite")
19934 .arg(shell_command)
19935 .output()
19936 .ok()?;
19937 if !output.status.success() {
19938 return None;
19939 }
19940 let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
19941 if rewritten.is_empty() || rewritten == shell_command {
19942 return None;
19943 }
19944 Some(DigestRunnerFilter {
19945 tool: "rtk",
19946 command: rewritten,
19947 })
19948}
19949
19950fn find_command_on_path(command: &str) -> Option<PathBuf> {
19951 let path_var = std::env::var_os("PATH")?;
19952 std::env::split_paths(&path_var)
19953 .map(|dir| dir.join(command))
19954 .find(|candidate| candidate.is_file())
19955}
19956
19957pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
19958 if !db_path.exists() {
19959 bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
19960 }
19961 summarize::SummaryDb::open_read_only_resilient(db_path)
19962}
19963
19964fn status_index_needs_fix(report: &status::StatusReport) -> bool {
19965 !matches!(report.index, status::IndexStatus::Fresh { .. })
19966}
19967
19968fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
19969 !matches!(report.instructions, init::InstructionStatus::Current { .. })
19970}
19971
19972pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
19973 if status_instructions_need_fix(report) {
19974 eprintln!("status fix: refreshing tsift instructions");
19975 init::init(root, false, false)?;
19976 }
19977
19978 let eviction = cycle_packet_cache::cycle_packet_cache_evict(
19979 root,
19980 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
19981 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
19982 );
19983 if eviction.evicted_entries > 0 {
19984 eprintln!(
19985 "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
19986 eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
19987 );
19988 }
19989
19990 if !status_index_needs_fix(report) {
19991 return Ok(());
19992 }
19993
19994 let scopes = config::Config::submodule_dirs(root)?;
19995 if scopes.is_empty() {
19996 eprintln!("status fix: refreshing index");
19997 run_index_update(
19998 &root.join(".tsift/index.db"),
19999 root,
20000 "status --fix refreshing index".to_string(),
20001 root,
20002 None,
20003 false,
20004 false,
20005 )?;
20006 return Ok(());
20007 }
20008
20009 let cfg = config::Config::load(root)?;
20010 for scope in scopes {
20011 if !scope.source_root.exists() {
20012 eprintln!(
20013 "status fix: skipping missing submodule `{}` ({})",
20014 scope.id,
20015 scope.source_root.display()
20016 );
20017 continue;
20018 }
20019 eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20020 run_index_update(
20021 &cfg.db_path_for(root, &scope.id),
20022 &scope.source_root,
20023 format!("status --fix refreshing submodule `{}` index", scope.id),
20024 root,
20025 Some(scope.id.as_str()),
20026 false,
20027 false,
20028 )?;
20029 }
20030
20031 Ok(())
20032}
20033
20034pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20035 match &report.index {
20036 status::IndexStatus::Fresh { missing_scopes, .. }
20037 | status::IndexStatus::Stale { missing_scopes, .. }
20038 | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20039 }
20040}
20041
20042pub(crate) fn autoindex_missing_workspace_scopes(
20043 root: &Path,
20044 report: &status::StatusReport,
20045) -> Result<()> {
20046 let missing_scopes = match &report.index {
20047 status::IndexStatus::Fresh { missing_scopes, .. }
20048 | status::IndexStatus::Stale { missing_scopes, .. }
20049 | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20050 };
20051 if missing_scopes.is_empty() {
20052 return Ok(());
20053 }
20054
20055 let missing_scope_ids = missing_scopes
20056 .iter()
20057 .map(|scope| scope.scope.as_str())
20058 .collect::<std::collections::HashSet<_>>();
20059 let cfg = config::Config::load(root)?;
20060 for scope in config::Config::submodule_dirs(root)? {
20061 if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20062 continue;
20063 }
20064 let db_path = cfg.db_path_for(root, &scope.id);
20065 run_index_update(
20066 &db_path,
20067 &scope.source_root,
20068 format!(
20069 "autoindexing missing submodule `{}` during status",
20070 scope.id
20071 ),
20072 root,
20073 Some(scope.id.as_str()),
20074 false,
20075 false,
20076 )?;
20077 }
20078 Ok(())
20079}
20080
20081pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
20082 for warning in &stats.warnings {
20083 let rel_path = relativize_pathbuf(&warning.path, root);
20084 eprintln!(
20085 "warning: summarize stats {}: {}",
20086 rel_path.display(),
20087 warning.message
20088 );
20089 }
20090}
20091
20092fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
20093 Result::<(), anyhow::Error>::Err(err)
20094 .context(context)
20095 .unwrap_err()
20096}
20097
20098fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
20099 let message = err.to_string();
20100 message.contains("another tsift index writer is already active")
20101 || substrate::error_mentions_locked_db(err)
20102}
20103
20104fn add_write_lock_context(
20105 err: anyhow::Error,
20106 action: String,
20107 root: &std::path::Path,
20108 scope: Option<&str>,
20109) -> anyhow::Error {
20110 if !should_attach_lock_diagnostics(&err) {
20111 return contextualize_error(err, action);
20112 }
20113
20114 let Ok(report) = status::check_locks(root, None, scope) else {
20115 return contextualize_error(err, action);
20116 };
20117
20118 contextualize_error(
20119 err,
20120 format!(
20121 "{}\n\nlock diagnostics:\n{}",
20122 action,
20123 status::format_locks_human(&report, false).trim_end()
20124 ),
20125 )
20126}
20127
20128pub(crate) fn run_index_update(
20129 db_path: &std::path::Path,
20130 source_root: &std::path::Path,
20131 action: String,
20132 root: &std::path::Path,
20133 scope: Option<&str>,
20134 rebuild: bool,
20135 prune: bool,
20136) -> Result<index::IndexSummary> {
20137 let result = (|| {
20138 let db = index::IndexDb::open(db_path)?;
20139 if rebuild {
20140 db.rebuild(source_root)
20141 } else if prune {
20142 db.apply_changes_pruned(source_root)
20143 } else {
20144 db.apply_changes(source_root)
20145 }
20146 })();
20147
20148 let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
20149 emit_index_warnings(&summary, source_root, scope);
20150 Ok(summary)
20151}
20152
20153pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
20154 for change in &mut summary.changes {
20155 change.path = relativize_pathbuf(&change.path, root);
20156 }
20157 for warning in &mut summary.warnings {
20158 warning.path = relativize_pathbuf(&warning.path, root);
20159 }
20160}
20161
20162fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
20163 for warning in &summary.warnings {
20164 let rel_path = relativize_pathbuf(&warning.path, root);
20165 let stage = match warning.stage {
20166 index::IndexWarningStage::ReadSource => "read failed",
20167 index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
20168 index::IndexWarningStage::ExtractCallSites => "call extraction failed",
20169 index::IndexWarningStage::ExtractRoutes => "route extraction failed",
20170 };
20171 let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
20172 let lang_suffix = warning
20173 .language
20174 .as_deref()
20175 .map(|lang| format!(" [{}]", lang))
20176 .unwrap_or_default();
20177 eprintln!(
20178 "warning: {}{}{}: {}: {}",
20179 scope_prefix,
20180 rel_path.display(),
20181 lang_suffix,
20182 stage,
20183 warning.message
20184 );
20185 }
20186}
20187
20188pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
20189 let config_path = root.join(".tsift/config.toml");
20190 if !config_path.exists() {
20191 return summarize::SummarizeConfig::default();
20192 }
20193 #[derive(serde::Deserialize, Default)]
20194 struct RawConfig {
20195 #[serde(default)]
20196 summarize: Option<RawSummarize>,
20197 }
20198 #[derive(serde::Deserialize)]
20199 struct RawSummarize {
20200 model: Option<String>,
20201 max_file_tokens: Option<usize>,
20202 api_key_env: Option<String>,
20203 }
20204 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
20205 let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
20206 let defaults = summarize::SummarizeConfig::default();
20207 match raw.summarize {
20208 Some(s) => summarize::SummarizeConfig {
20209 model: s.model.unwrap_or(defaults.model),
20210 max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
20211 api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
20212 },
20213 None => defaults,
20214 }
20215}
20216
20217#[derive(Debug, Clone, PartialEq, Eq)]
20218struct ExtractSymbolContext {
20219 db_path: PathBuf,
20220 source_root: PathBuf,
20221}
20222
20223pub(crate) fn find_symbols_db_for_file(
20224 root: &Path,
20225 file_path: &Path,
20226) -> Result<Option<ExtractSymbolContext>> {
20227 let cfg = config::Config::load(root)?;
20228 let mut submodules = config::Config::submodule_dirs(root)?;
20229 submodules.sort_by(|left, right| {
20230 right
20231 .source_root
20232 .components()
20233 .count()
20234 .cmp(&left.source_root.components().count())
20235 });
20236
20237 for scope in submodules {
20238 if !file_path.starts_with(&scope.source_root) {
20239 continue;
20240 }
20241 let db_path = cfg.db_path_for(root, &scope.id);
20242 if db_path.exists() {
20243 return Ok(Some(ExtractSymbolContext {
20244 db_path,
20245 source_root: scope.source_root,
20246 }));
20247 }
20248 }
20249
20250 let single = root.join(".tsift/index.db");
20251 if single.exists() && file_path.starts_with(root) {
20252 return Ok(Some(ExtractSymbolContext {
20253 db_path: single,
20254 source_root: root.to_path_buf(),
20255 }));
20256 }
20257
20258 Ok(None)
20259}
20260
20261pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
20262 let canonical = path
20263 .canonicalize()
20264 .with_context(|| format!("canonicalizing {}", path.display()))?;
20265
20266 Ok(if canonical.is_dir() {
20267 canonical
20268 } else {
20269 canonical
20270 .parent()
20271 .map(Path::to_path_buf)
20272 .unwrap_or(canonical)
20273 })
20274}
20275
20276fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
20277 if path.exists() {
20278 return path
20279 .canonicalize()
20280 .with_context(|| format!("canonicalizing extract scope {}", path.display()));
20281 }
20282
20283 Ok(summarize::normalize_lexical_path(path))
20284}
20285
20286pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
20287 let scope = if extract_path.is_absolute() {
20288 extract_path.to_path_buf()
20289 } else {
20290 root.join(extract_path)
20291 };
20292 normalize_extract_scope_path(&scope)
20293}
20294
20295pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
20296 normalize_extract_scope_path(changed_path)
20297 .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
20298 .starts_with(extract_scope)
20299}
20300
20301pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
20302 summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
20303}
20304
20305pub(crate) fn summarize_full_extract_deleted_summary_paths(
20306 summary_db: &summarize::SummaryDb,
20307 root: &Path,
20308 extract_scope: &Path,
20309 files_to_extract: &[PathBuf],
20310) -> Result<BTreeSet<String>> {
20311 let live_paths = files_to_extract
20312 .iter()
20313 .map(|file_path| summarize_relative_file_path(root, file_path))
20314 .collect::<BTreeSet<_>>();
20315 let mut deleted = BTreeSet::new();
20316
20317 for cached_path in summary_db.cached_file_paths()? {
20318 if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
20319 continue;
20320 }
20321 if !live_paths.contains(&cached_path) {
20322 deleted.insert(cached_path);
20323 }
20324 }
20325
20326 Ok(deleted)
20327}
20328
20329#[derive(Debug, Clone)]
20330struct SearchIndexTarget {
20331 label: String,
20332 db_path: PathBuf,
20333 source_root: PathBuf,
20334 scope_name: Option<String>,
20335 reindex_cmd: String,
20336}
20337
20338fn cargo_package_index_target(
20339 root: &Path,
20340 package: multiplicity::CargoPackageInfo,
20341) -> SearchIndexTarget {
20342 SearchIndexTarget {
20343 label: format!("cargo package `{}` index", package.scope_id),
20344 db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
20345 source_root: package.package_root.clone(),
20346 scope_name: Some(package.scope_id.clone()),
20347 reindex_cmd: format!(
20348 "tsift index --submodule {} {}",
20349 package.scope_id,
20350 root.display()
20351 ),
20352 }
20353}
20354
20355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20356enum SearchIndexState {
20357 Missing,
20358 Fresh,
20359 Stale { stale_files: usize },
20360}
20361
20362fn resolve_search_index_targets(
20363 root: &Path,
20364 path_hint: &Path,
20365 scope: Option<&str>,
20366 federated: bool,
20367) -> Result<Vec<SearchIndexTarget>> {
20368 if let Some(scope_name) = scope {
20369 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
20370 let cfg = config::Config::load(root)?;
20371 return Ok(vec![SearchIndexTarget {
20372 label: format!("submodule `{}` index", scope.id),
20373 db_path: cfg.db_path_for(root, &scope.id),
20374 source_root: scope.source_root.clone(),
20375 scope_name: Some(scope.id.clone()),
20376 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20377 }]);
20378 }
20379 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
20380 return Ok(vec![cargo_package_index_target(root, package)]);
20381 }
20382 config::Config::resolve_submodule(root, scope_name)?;
20383 }
20384
20385 if federated {
20386 let cfg = config::Config::load(root)?;
20387 let mut targets = Vec::new();
20388 for scope in config::Config::submodule_dirs(root)? {
20389 if !cfg.federation_for_scope(&scope) {
20390 continue;
20391 }
20392 targets.push(SearchIndexTarget {
20393 label: format!("submodule `{}` index", scope.id),
20394 db_path: cfg.db_path_for(root, &scope.id),
20395 source_root: scope.source_root.clone(),
20396 scope_name: Some(scope.id.clone()),
20397 reindex_cmd: format!("tsift index --workspace {}", root.display()),
20398 });
20399 }
20400 return Ok(targets);
20401 }
20402
20403 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
20404 let cfg = config::Config::load(root)?;
20405 return Ok(vec![SearchIndexTarget {
20406 label: format!("submodule `{}` index", scope.id),
20407 db_path: cfg.db_path_for(root, &scope.id),
20408 source_root: scope.source_root.clone(),
20409 scope_name: Some(scope.id.clone()),
20410 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20411 }]);
20412 }
20413
20414 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
20415 return Ok(vec![cargo_package_index_target(root, package)]);
20416 }
20417
20418 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
20419 let cfg = config::Config::load(root)?;
20420 return Ok(vec![SearchIndexTarget {
20421 label: format!("submodule `{}` index", scope.id),
20422 db_path: cfg.db_path_for(root, &scope.id),
20423 source_root: scope.source_root.clone(),
20424 scope_name: Some(scope.id.clone()),
20425 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20426 }]);
20427 }
20428
20429 let scopes = config::Config::submodule_dirs(root)?;
20430 if !scopes.is_empty() {
20431 let root_db = root.join(".tsift/index.db");
20432 if !root_db.exists() {
20433 let available_scopes = scopes
20434 .iter()
20435 .map(|scope| scope.id.as_str())
20436 .collect::<Vec<_>>()
20437 .join(", ");
20438 let cfg = config::Config::load(root)?;
20439 let indexed_scopes = scopes
20440 .iter()
20441 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
20442 .map(|scope| scope.id.as_str())
20443 .collect::<Vec<_>>();
20444 let indexed_label = if indexed_scopes.is_empty() {
20445 "none".to_string()
20446 } else {
20447 indexed_scopes.join(", ")
20448 };
20449 bail!(
20450 "workspace root {} has no shared root index at {}. Default search requires `--scope <scope>` or `--federated` when the workspace uses scoped `.tsift/indexes/*/index.db` files. Available scopes: {}. Indexed scopes: {}.",
20451 root.display(),
20452 root_db.display(),
20453 available_scopes,
20454 indexed_label,
20455 );
20456 }
20457 }
20458
20459 Ok(vec![SearchIndexTarget {
20460 label: "index".to_string(),
20461 db_path: root.join(".tsift/index.db"),
20462 source_root: root.to_path_buf(),
20463 scope_name: None,
20464 reindex_cmd: format!("tsift index {}", root.display()),
20465 }])
20466}
20467
20468fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
20469 if !target.source_root.exists() || !target.db_path.exists() {
20470 return Ok(SearchIndexState::Missing);
20471 }
20472
20473 let inspection =
20474 index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
20475 let stale_files =
20476 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
20477 if stale_files == 0 {
20478 Ok(SearchIndexState::Fresh)
20479 } else {
20480 Ok(SearchIndexState::Stale { stale_files })
20481 }
20482}
20483
20484#[derive(Debug, Clone, PartialEq, Eq)]
20485struct RebuildSearchTarget {
20486 label: String,
20487 reason: RebuildSearchReason,
20488 reindex_cmd: String,
20489}
20490
20491#[derive(Debug, Clone, PartialEq, Eq)]
20492enum RebuildSearchReason {
20493 Missing,
20494 Stale { stale_files: usize },
20495}
20496
20497#[derive(Debug, Clone, PartialEq, Eq)]
20498struct DegradedSearchTarget {
20499 label: String,
20500 reason: RebuildSearchReason,
20501 reindex_cmd: String,
20502}
20503
20504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20505pub(crate) enum DegradedSearchMode {
20506 ReadOnly,
20507 Exact,
20508}
20509
20510#[derive(Debug)]
20511struct SearchPrecheck {
20512 targets: Vec<SearchIndexTarget>,
20513 degraded_targets: Vec<DegradedSearchTarget>,
20514}
20515
20516fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
20517 err.chain().any(|cause| {
20518 cause
20519 .to_string()
20520 .contains("another tsift index writer is already active")
20521 })
20522}
20523
20524fn infer_agent_doc_task_submodule(
20525 root: &Path,
20526 path_hint: &Path,
20527) -> Result<Option<config::WorkspaceScope>> {
20528 let hinted_path = if path_hint.is_absolute() {
20529 path_hint.to_path_buf()
20530 } else {
20531 root.join(path_hint)
20532 };
20533 let Ok(relative) = hinted_path.strip_prefix(root) else {
20534 return Ok(None);
20535 };
20536 let mut components = relative.components();
20537 let Some(std::path::Component::Normal(first)) = components.next() else {
20538 return Ok(None);
20539 };
20540 if first != "tasks" {
20541 return Ok(None);
20542 }
20543 let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
20544 return Ok(None);
20545 };
20546 config::Config::find_submodule(root, file_stem)
20547}
20548
20549fn degraded_search_target(
20550 target: &SearchIndexTarget,
20551 reason: RebuildSearchReason,
20552) -> DegradedSearchTarget {
20553 DegradedSearchTarget {
20554 label: target.label.clone(),
20555 reason,
20556 reindex_cmd: target.reindex_cmd.clone(),
20557 }
20558}
20559
20560fn apply_search_index_update(
20561 root: &Path,
20562 target: &SearchIndexTarget,
20563) -> Result<index::IndexSummary> {
20564 run_index_update(
20565 &target.db_path,
20566 &target.source_root,
20567 format!("autoindexing {}", target.label),
20568 root,
20569 target.scope_name.as_deref(),
20570 false,
20571 false,
20572 )
20573}
20574
20575fn collect_rebuild_search_targets(
20576 targets: &[SearchIndexTarget],
20577) -> Result<Vec<RebuildSearchTarget>> {
20578 let mut rebuild_targets = Vec::new();
20579 for target in targets {
20580 let reason = match inspect_search_index(target)? {
20581 SearchIndexState::Missing => RebuildSearchReason::Missing,
20582 SearchIndexState::Fresh => continue,
20583 SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
20584 };
20585 rebuild_targets.push(RebuildSearchTarget {
20586 label: target.label.clone(),
20587 reason,
20588 reindex_cmd: target.reindex_cmd.clone(),
20589 });
20590 }
20591 Ok(rebuild_targets)
20592}
20593
20594fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
20595 match target.reason {
20596 RebuildSearchReason::Missing => format!("{} is missing", target.label),
20597 RebuildSearchReason::Stale { stale_files } => {
20598 let file_suffix = if stale_files == 1 { "" } else { "s" };
20599 format!(
20600 "{} is stale ({} file{})",
20601 target.label, stale_files, file_suffix
20602 )
20603 }
20604 }
20605}
20606
20607fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
20608 if rebuild_targets.len() == 1 {
20609 let target = &rebuild_targets[0];
20610 return format!(
20611 "{}. Run `{}` to rebuild before retrying.",
20612 rebuild_search_target_detail(target),
20613 target.reindex_cmd
20614 );
20615 }
20616
20617 let summary: Vec<String> = rebuild_targets
20618 .iter()
20619 .take(3)
20620 .map(rebuild_search_target_detail)
20621 .collect();
20622 let overflow = rebuild_targets.len().saturating_sub(summary.len());
20623 let mut details = summary.join(", ");
20624 if overflow > 0 {
20625 details.push_str(&format!(", +{} more", overflow));
20626 }
20627 let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
20628 format!(
20629 "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
20630 rebuild_targets.len(),
20631 details,
20632 reindex_cmd
20633 )
20634}
20635
20636pub(crate) fn precheck_search_indexes(
20637 root: &Path,
20638 path_hint: &Path,
20639 scope: Option<&str>,
20640 federated: bool,
20641 autoindex: bool,
20642) -> Result<SearchPrecheck> {
20643 let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
20644 let mut stale_targets = Vec::new();
20645 let mut degraded_targets = Vec::new();
20646
20647 for target in &targets {
20648 match inspect_search_index(target)? {
20649 SearchIndexState::Missing => {
20650 if autoindex && let Err(err) = apply_search_index_update(root, target) {
20651 if is_active_writer_lock_error(&err) {
20652 degraded_targets
20653 .push(degraded_search_target(target, RebuildSearchReason::Missing));
20654 } else {
20655 return Err(err);
20656 }
20657 }
20658 }
20659 SearchIndexState::Fresh => {}
20660 SearchIndexState::Stale { stale_files } => {
20661 if autoindex {
20662 if let Err(err) = apply_search_index_update(root, target) {
20663 if is_active_writer_lock_error(&err) {
20664 degraded_targets.push(degraded_search_target(
20665 target,
20666 RebuildSearchReason::Stale { stale_files },
20667 ));
20668 } else {
20669 return Err(err);
20670 }
20671 }
20672 } else {
20673 stale_targets.push(RebuildSearchTarget {
20674 label: target.label.clone(),
20675 reason: RebuildSearchReason::Stale { stale_files },
20676 reindex_cmd: target.reindex_cmd.clone(),
20677 });
20678 }
20679 }
20680 }
20681 }
20682
20683 if stale_targets.is_empty() {
20684 return Ok(SearchPrecheck {
20685 targets,
20686 degraded_targets,
20687 });
20688 }
20689
20690 bail!(
20691 "tsift search aborted: {} \
20692 or re-run without `--no-autoindex`.",
20693 rebuild_search_targets_message(&stale_targets),
20694 );
20695}
20696
20697pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
20698 if targets.is_empty() {
20699 return None;
20700 }
20701
20702 if targets
20703 .iter()
20704 .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
20705 {
20706 Some(DegradedSearchMode::Exact)
20707 } else {
20708 Some(DegradedSearchMode::ReadOnly)
20709 }
20710}
20711
20712fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
20713 if targets.len() == 1 {
20714 let target = &targets[0];
20715 return match target.reason {
20716 RebuildSearchReason::Missing => format!("{} is missing", target.label),
20717 RebuildSearchReason::Stale { stale_files } => {
20718 let file_suffix = if stale_files == 1 { "" } else { "s" };
20719 format!(
20720 "{} is stale ({} file{})",
20721 target.label, stale_files, file_suffix
20722 )
20723 }
20724 };
20725 }
20726
20727 let missing = targets
20728 .iter()
20729 .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
20730 .count();
20731 let stale = targets.len().saturating_sub(missing);
20732 let mut parts = Vec::new();
20733 if stale > 0 {
20734 let suffix = if stale == 1 { "" } else { "es" };
20735 parts.push(format!("{stale} stale index{suffix}"));
20736 }
20737 if missing > 0 {
20738 let suffix = if missing == 1 { "" } else { "es" };
20739 parts.push(format!("{missing} missing index{suffix}"));
20740 }
20741 parts.join(", ")
20742}
20743
20744pub(crate) fn emit_degraded_search_note(
20745 targets: &[DegradedSearchTarget],
20746 mode: DegradedSearchMode,
20747) {
20748 let summary = degraded_search_targets_summary(targets);
20749 let reindex_cmd = &targets[0].reindex_cmd;
20750 match mode {
20751 DegradedSearchMode::ReadOnly => eprintln!(
20752 "note: active tsift writer detected; skipping autoindex because {}. \
20753 Continuing with read-only search and the current index snapshot; symbol hits may lag. \
20754 Retry `{}` after the active writer finishes for fresh index results.",
20755 summary, reindex_cmd
20756 ),
20757 DegradedSearchMode::Exact => eprintln!(
20758 "note: active tsift writer detected; skipping autoindex because {}. \
20759 Continuing with exact live-file search. Retry `{}` after the active writer finishes \
20760 for indexed symbol hits.",
20761 summary, reindex_cmd
20762 ),
20763 }
20764}
20765
20766fn search_timeout_message(
20767 timeout_secs: u64,
20768 strategy: &str,
20769 targets: &[SearchIndexTarget],
20770) -> Result<String> {
20771 let rebuild_targets = collect_rebuild_search_targets(targets)?;
20772 if rebuild_targets.is_empty() {
20773 return Ok(format!(
20774 "tsift search timed out after {}s (strategy: {}). \
20775 The search root looks fresh, so reindexing is unlikely to help. \
20776 Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
20777 or try a different strategy.",
20778 timeout_secs, strategy,
20779 ));
20780 }
20781
20782 Ok(format!(
20783 "tsift search timed out after {}s (strategy: {}). {}",
20784 timeout_secs,
20785 strategy,
20786 rebuild_search_targets_message(&rebuild_targets),
20787 ))
20788}
20789
20790fn is_exact_preferring_query_char(ch: char) -> bool {
20791 matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
20792}
20793
20794fn query_prefers_exact_search(query: &str) -> bool {
20795 let trimmed = query.trim();
20796 !trimmed.is_empty()
20797 && !trimmed.chars().any(char::is_whitespace)
20798 && trimmed.chars().any(|ch| ch.is_alphanumeric())
20799 && trimmed.chars().any(is_exact_preferring_query_char)
20800 && trimmed
20801 .chars()
20802 .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
20803}
20804
20805pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
20806 strategy.unwrap_or_else(|| {
20807 if query_prefers_exact_search(query) {
20808 "exact".to_string()
20809 } else {
20810 "lexical".to_string()
20811 }
20812 })
20813}
20814
20815pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
20816 let mut files = Vec::new();
20817 if path.is_file() {
20818 files.push(path.to_path_buf());
20819 return Ok(files);
20820 }
20821 let walker = ignore::WalkBuilder::new(path)
20822 .hidden(true)
20823 .git_ignore(true)
20824 .build();
20825 for entry in walker {
20826 let entry = entry?;
20827 if entry.file_type().is_some_and(|ft| ft.is_file()) {
20828 let p = entry.path();
20829 if let Some(ext) = p.extension() {
20830 let ext = ext.to_string_lossy();
20831 if matches!(
20832 ext.as_ref(),
20833 "rs" | "py"
20834 | "ts"
20835 | "tsx"
20836 | "js"
20837 | "jsx"
20838 | "kt"
20839 | "kts"
20840 | "zig"
20841 | "sh"
20842 | "bash"
20843 | "zsh"
20844 ) {
20845 files.push(p.to_path_buf());
20846 }
20847 }
20848 }
20849 }
20850 Ok(files)
20851}
20852
20853#[cfg(test)]
20854mod tests {
20855 use super::semantic_edit::{
20856 EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
20857 markdown_section_spans,
20858 };
20859 use super::*;
20860 use tsift_memory::{MemoryEventKind, MemoryStore};
20861
20862 use std::cell::RefCell;
20863 use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
20864 fn parse_cli<I, T>(itr: I) -> Cli
20865 where
20866 I: IntoIterator<Item = T> + Send + 'static,
20867 T: Into<std::ffi::OsString> + Clone + Send + 'static,
20868 {
20869 std::thread::Builder::new()
20870 .name("cli-parse".to_string())
20871 .stack_size(16 * 1024 * 1024)
20872 .spawn(move || Cli::parse_from(itr))
20873 .unwrap()
20874 .join()
20875 .unwrap()
20876 }
20877
20878 fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
20879 where
20880 I: IntoIterator<Item = T> + Send + 'static,
20881 T: Into<std::ffi::OsString> + Clone + Send + 'static,
20882 {
20883 std::thread::Builder::new()
20884 .name("cli-try-parse".to_string())
20885 .stack_size(16 * 1024 * 1024)
20886 .spawn(move || Cli::try_parse_from(itr))
20887 .unwrap()
20888 .join()
20889 .unwrap()
20890 }
20891
20892 fn build_relative_search_budget_report(
20893 query: &str,
20894 strategy: &str,
20895 root: &Path,
20896 response: &sift::SearchResponse,
20897 symbol_hits: &[index::SymbolHit],
20898 budget: ResponseBudget,
20899 filters: &SearchFacetFilters,
20900 ) -> SearchBudgetReport {
20901 build_search_budget_report(SearchBudgetReportInput {
20902 query,
20903 strategy,
20904 root,
20905 response,
20906 symbol_hits,
20907 absolute: false,
20908 budget,
20909 filters,
20910 })
20911 }
20912
20913 #[derive(Default)]
20914 struct MemoryConvexGraphClient {
20915 nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
20916 edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
20917 }
20918
20919 impl ConvexGraphClient for MemoryConvexGraphClient {
20920 fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
20921 self.nodes
20922 .borrow_mut()
20923 .insert(row.external_id.clone(), row.clone());
20924 Ok(())
20925 }
20926
20927 fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
20928 self.edges
20929 .borrow_mut()
20930 .insert(row.edge_key.clone(), row.clone());
20931 Ok(())
20932 }
20933
20934 fn delete_node_row(&self, external_id: &str) -> Result<usize> {
20935 Ok(usize::from(
20936 self.nodes.borrow_mut().remove(external_id).is_some(),
20937 ))
20938 }
20939
20940 fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
20941 Ok(usize::from(
20942 self.edges.borrow_mut().remove(edge_key).is_some(),
20943 ))
20944 }
20945
20946 fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
20947 Ok(self.nodes.borrow().get(external_id).cloned())
20948 }
20949
20950 fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
20951 Ok(self.nodes.borrow().values().cloned().collect())
20952 }
20953
20954 fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
20955 Ok(self.edges.borrow().values().cloned().collect())
20956 }
20957
20958 fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
20959 Ok(self
20960 .nodes
20961 .borrow()
20962 .values()
20963 .filter(|row| row.kind == kind)
20964 .cloned()
20965 .collect())
20966 }
20967
20968 fn outgoing_edge_rows(
20969 &self,
20970 from_external_id: &str,
20971 kind: Option<&str>,
20972 ) -> Result<Vec<ConvexEdgeRow>> {
20973 Ok(self
20974 .edges
20975 .borrow()
20976 .values()
20977 .filter(|row| row.from_external_id == from_external_id)
20978 .filter(|row| kind.is_none_or(|kind| row.kind == kind))
20979 .cloned()
20980 .collect())
20981 }
20982 }
20983
20984 fn init_git_repo(path: &Path) {
20985 let status = std::process::Command::new("git")
20986 .args(["init"])
20987 .current_dir(path)
20988 .status()
20989 .unwrap();
20990 assert!(status.success(), "git init failed");
20991
20992 let status = std::process::Command::new("git")
20993 .args(["add", "."])
20994 .current_dir(path)
20995 .status()
20996 .unwrap();
20997 assert!(status.success(), "git add failed");
20998
20999 let status = std::process::Command::new("git")
21000 .args([
21001 "-c",
21002 "user.name=tsift-tests",
21003 "-c",
21004 "user.email=tsift-tests@example.com",
21005 "commit",
21006 "--quiet",
21007 "-m",
21008 "init",
21009 ])
21010 .current_dir(path)
21011 .status()
21012 .unwrap();
21013 assert!(status.success(), "git commit failed");
21014 }
21015
21016 fn write_empty_root_index(root: &Path) {
21017 let index_dir = root.join(".tsift");
21018 fs::create_dir_all(&index_dir).unwrap();
21019 fs::write(index_dir.join("index.db"), "").unwrap();
21020 }
21021
21022 fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
21023 if let Some(parent) = path.parent() {
21024 fs::create_dir_all(parent).unwrap();
21025 }
21026 let body = std::iter::repeat_n(line, lines)
21027 .collect::<Vec<_>>()
21028 .join("\n");
21029 fs::write(path, format!("{body}\n")).unwrap();
21030 path.to_path_buf()
21031 }
21032
21033 #[test]
21036 fn token_capped_preview_returns_all_lines_when_under_cap() {
21037 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 1", "}"];
21038 let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
21039 assert!(!result.was_capped);
21040 assert_eq!(result.preview.len(), 3);
21041 assert_eq!(result.capped_end, 3);
21042 }
21043
21044 #[test]
21045 fn token_capped_preview_truncates_when_over_cap() {
21046 let lines: Vec<&str> = (0..200)
21047 .map(|_| " let x = some_very_long_expression_here();")
21048 .collect();
21049 let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
21050 assert!(result.was_capped);
21051 assert!(result.preview.len() < 200);
21052 assert!(result.capped_end < 200);
21053 }
21054
21055 #[test]
21056 fn token_capped_preview_keeps_at_least_one_line() {
21057 let long_line: String = "x".repeat(8000);
21058 let lines: Vec<&str> = vec![&long_line];
21059 let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
21060 assert!(!result.was_capped);
21061 assert_eq!(result.preview.len(), 1);
21062 }
21063
21064 #[test]
21065 fn token_capped_preview_cap_at_boundary() {
21066 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21067 let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
21068 assert!(!result.was_capped);
21069 assert_eq!(result.preview.len(), 4);
21070 }
21071
21072 #[test]
21073 fn token_capped_preview_cap_just_over_boundary() {
21074 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21075 let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
21076 assert!(result.was_capped);
21077 assert_eq!(result.preview.len(), 3);
21078 assert_eq!(result.capped_end, 3);
21079 }
21080
21081 #[test]
21082 fn token_capped_preview_empty_lines() {
21083 let lines: Vec<&str> = vec![];
21084 let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
21085 assert!(!result.was_capped);
21086 assert!(result.preview.is_empty());
21087 }
21088
21089 #[test]
21090 fn token_capped_preview_per_line_truncation_applied() {
21091 let long_line = "x".repeat(500);
21092 let lines: Vec<&str> = vec![&long_line, "short"];
21093 let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
21094 assert!(!result.was_capped);
21095 assert_eq!(result.preview.len(), 2);
21096 assert!(result.preview[0].text.len() <= 23);
21097 assert!(result.preview[0].text.ends_with("..."));
21098 }
21099
21100 #[test]
21103 fn route_search_defaults_to_haiku() {
21104 let (tier, model) = classify_task("find all uses of authenticate");
21105 assert_eq!(tier, "haiku");
21106 assert!(
21107 model.contains("haiku"),
21108 "expected haiku model, got {}",
21109 model
21110 );
21111 }
21112
21113 #[test]
21114 fn route_edit_keywords_to_sonnet() {
21115 for kw in &[
21116 "edit the file",
21117 "fix the bug",
21118 "update the config",
21119 "remove dead code",
21120 "create a new module",
21121 ] {
21122 let (tier, _) = classify_task(kw);
21123 assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
21124 }
21125 }
21126
21127 #[test]
21128 fn route_architecture_keywords_to_opus() {
21129 for kw in &[
21130 "design the API",
21131 "architecture review",
21132 "plan the migration",
21133 "analyze the system",
21134 "evaluate trade-offs",
21135 ] {
21136 let (tier, _) = classify_task(kw);
21137 assert_eq!(tier, "opus", "expected opus for {:?}", kw);
21138 }
21139 }
21140
21141 #[test]
21142 fn route_architecture_beats_edit() {
21143 let (tier, _) = classify_task("design and implement the new auth service");
21145 assert_eq!(tier, "opus");
21146 }
21147
21148 #[test]
21149 fn cli_accepts_global_compact_flag() {
21150 let cli = parse_cli(["tsift", "--compact", "status"]);
21151 assert!(cli.compact);
21152 assert!(matches!(cli.command, Some(Commands::Status { .. })));
21153 }
21154
21155 #[test]
21156 fn summarize_diff_scope_matches_relative_directory() {
21157 let root = Path::new("/repo");
21158 let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
21159
21160 assert!(summarize_diff_matches_scope(
21161 Path::new("/repo/src/feature/main.rs"),
21162 &extract_scope
21163 ));
21164 assert!(!summarize_diff_matches_scope(
21165 Path::new("/repo/src/other/main.rs"),
21166 &extract_scope
21167 ));
21168 }
21169
21170 #[test]
21171 fn summarize_diff_scope_matches_relative_file() {
21172 let root = Path::new("/repo");
21173 let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
21174
21175 assert!(summarize_diff_matches_scope(
21176 Path::new("/repo/src/feature/main.rs"),
21177 &extract_scope
21178 ));
21179 assert!(!summarize_diff_matches_scope(
21180 Path::new("/repo/src/feature/lib.rs"),
21181 &extract_scope
21182 ));
21183 }
21184
21185 #[test]
21186 fn summarize_extract_scope_walks_relative_paths_from_root() {
21187 let dir = tempfile::tempdir().unwrap();
21188 let source_dir = dir.path().join("src");
21189 std::fs::create_dir_all(&source_dir).unwrap();
21190 let main_rs = source_dir.join("main.rs");
21191 std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
21192
21193 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
21194 let files = collect_source_files(&extract_scope).unwrap();
21195
21196 assert_eq!(files, vec![main_rs]);
21197 }
21198
21199 #[test]
21200 fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
21201 let dir = tempfile::tempdir().unwrap();
21202 let nested = dir.path().join("src/nested");
21203 std::fs::create_dir_all(&nested).unwrap();
21204 std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
21205 let nested_file = nested.join("main.rs");
21206 std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
21207
21208 let extract_base = resolve_extract_base(&nested).unwrap();
21209 let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
21210 let files = collect_source_files(&extract_scope).unwrap();
21211
21212 assert_eq!(extract_scope, nested);
21213 assert_eq!(files, vec![nested_file]);
21214 }
21215
21216 #[test]
21217 fn summarize_extract_base_uses_parent_of_file_path() {
21218 let dir = tempfile::tempdir().unwrap();
21219 let nested = dir.path().join("src/nested");
21220 std::fs::create_dir_all(&nested).unwrap();
21221 let file_path = nested.join("main.rs");
21222 std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
21223
21224 let extract_base = resolve_extract_base(&file_path).unwrap();
21225
21226 assert_eq!(extract_base, nested);
21227 }
21228
21229 #[test]
21230 fn summarize_extract_scope_normalizes_dotdot_segments() {
21231 let dir = tempfile::tempdir().unwrap();
21232 let source_dir = dir.path().join("src");
21233 std::fs::create_dir_all(&source_dir).unwrap();
21234
21235 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
21236
21237 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21238 assert!(summarize_diff_matches_scope(
21239 &source_dir.join("main.rs"),
21240 &extract_scope
21241 ));
21242 }
21243
21244 #[cfg(unix)]
21245 #[test]
21246 fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
21247 use std::os::unix::fs::symlink;
21248
21249 let dir = tempfile::tempdir().unwrap();
21250 let real_root = dir.path().join("real");
21251 let source_dir = real_root.join("src");
21252 std::fs::create_dir_all(&source_dir).unwrap();
21253 let symlink_scope = dir.path().join("scope-link");
21254 symlink(&source_dir, &symlink_scope).unwrap();
21255
21256 let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
21257
21258 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21259 assert!(summarize_diff_matches_scope(
21260 &source_dir.join("lib.rs"),
21261 &extract_scope
21262 ));
21263 }
21264
21265 #[test]
21266 fn summarize_diff_extract_includes_untracked_files() {
21267 let dir = tempfile::tempdir().unwrap();
21268 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21269 init_git_repo(dir.path());
21270
21271 let source_dir = dir.path().join("src");
21272 std::fs::create_dir_all(&source_dir).unwrap();
21273 let new_file = source_dir.join("new.rs");
21274 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21275
21276 let files = summarize::git_changed_files(dir.path()).unwrap();
21277
21278 assert_eq!(files.existing, vec![new_file]);
21279 assert!(files.deleted.is_empty());
21280 }
21281
21282 #[test]
21283 fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
21284 let dir = tempfile::tempdir().unwrap();
21285 let status = std::process::Command::new("git")
21286 .args(["init"])
21287 .current_dir(dir.path())
21288 .status()
21289 .unwrap();
21290 assert!(status.success(), "git init failed");
21291
21292 let source_dir = dir.path().join("src");
21293 std::fs::create_dir_all(&source_dir).unwrap();
21294 let new_file = source_dir.join("new.rs");
21295 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21296
21297 let files = summarize::git_changed_files(dir.path()).unwrap();
21298
21299 assert_eq!(files.existing, vec![new_file]);
21300 assert!(files.deleted.is_empty());
21301 }
21302
21303 #[test]
21304 fn summarize_diff_extract_tracks_deleted_files() {
21305 let dir = tempfile::tempdir().unwrap();
21306 let source_dir = dir.path().join("src");
21307 std::fs::create_dir_all(&source_dir).unwrap();
21308 let deleted_file = source_dir.join("gone.rs");
21309 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21310 init_git_repo(dir.path());
21311
21312 std::fs::remove_file(&deleted_file).unwrap();
21313
21314 let files = summarize::git_changed_files(dir.path()).unwrap();
21315
21316 assert!(files.existing.is_empty());
21317 assert_eq!(files.deleted, vec![deleted_file]);
21318 }
21319
21320 #[test]
21321 fn summarize_diff_extract_tracks_git_renames() {
21322 let dir = tempfile::tempdir().unwrap();
21323 let source_dir = dir.path().join("src");
21324 std::fs::create_dir_all(&source_dir).unwrap();
21325 let old_file = source_dir.join("old.rs");
21326 let new_file = source_dir.join("new.rs");
21327 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21328 init_git_repo(dir.path());
21329
21330 let status = std::process::Command::new("git")
21331 .args(["mv", "src/old.rs", "src/new.rs"])
21332 .current_dir(dir.path())
21333 .status()
21334 .unwrap();
21335 assert!(status.success(), "git mv failed");
21336
21337 let files = summarize::git_changed_files(dir.path()).unwrap();
21338
21339 assert_eq!(files.existing, vec![new_file]);
21340 assert_eq!(files.deleted, vec![old_file]);
21341 }
21342
21343 #[test]
21344 fn summarize_diff_extract_deletes_removed_summary_rows() {
21345 let dir = tempfile::tempdir().unwrap();
21346 let source_dir = dir.path().join("src");
21347 std::fs::create_dir_all(&source_dir).unwrap();
21348 let deleted_file = source_dir.join("gone.rs");
21349 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21350 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21351 init_git_repo(dir.path());
21352
21353 let summary_db =
21354 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21355 summary_db
21356 .insert(&summarize::Summary {
21357 id: 0,
21358 symbol_name: "stale".to_string(),
21359 file_path: "src/gone.rs".to_string(),
21360 content_hash: "hash1".to_string(),
21361 summary: "stale summary".to_string(),
21362 entities: None,
21363 relationships: None,
21364 concept_labels: None,
21365 extracted_at: "1700000000".to_string(),
21366 model: "test".to_string(),
21367 tokens_input: Some(100),
21368 tokens_output: Some(50),
21369 })
21370 .unwrap();
21371
21372 std::fs::remove_file(&deleted_file).unwrap();
21373
21374 cmd_summarize(
21375 None,
21376 None,
21377 Some(PathBuf::from("src")),
21378 true,
21379 false,
21380 dir.path(),
21381 false,
21382 true,
21383 false,
21384 false,
21385 false,
21386 )
21387 .unwrap();
21388
21389 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21390 }
21391
21392 #[test]
21393 fn summarize_diff_extract_deletes_renamed_summary_rows() {
21394 let dir = tempfile::tempdir().unwrap();
21395 let source_dir = dir.path().join("src");
21396 std::fs::create_dir_all(&source_dir).unwrap();
21397 let old_file = source_dir.join("old.rs");
21398 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21399 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21400 init_git_repo(dir.path());
21401
21402 let summary_db =
21403 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21404 summary_db
21405 .insert(&summarize::Summary {
21406 id: 0,
21407 symbol_name: "stale".to_string(),
21408 file_path: "src/old.rs".to_string(),
21409 content_hash: "hash1".to_string(),
21410 summary: "stale summary".to_string(),
21411 entities: None,
21412 relationships: None,
21413 concept_labels: None,
21414 extracted_at: "1700000000".to_string(),
21415 model: "test".to_string(),
21416 tokens_input: Some(100),
21417 tokens_output: Some(50),
21418 })
21419 .unwrap();
21420
21421 let status = std::process::Command::new("git")
21422 .args(["mv", "src/old.rs", "src/new.rs"])
21423 .current_dir(dir.path())
21424 .status()
21425 .unwrap();
21426 assert!(status.success(), "git mv failed");
21427
21428 cmd_summarize(
21429 None,
21430 None,
21431 Some(PathBuf::from("src")),
21432 true,
21433 false,
21434 dir.path(),
21435 false,
21436 true,
21437 false,
21438 false,
21439 false,
21440 )
21441 .unwrap();
21442
21443 assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
21444 }
21445
21446 #[test]
21447 fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
21448 let dir = tempfile::tempdir().unwrap();
21449 let source_dir = dir.path().join("src");
21450 std::fs::create_dir_all(&source_dir).unwrap();
21451 let deleted_file = source_dir.join("gone.rs");
21452 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21453
21454 let summary_db =
21455 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21456 summary_db
21457 .insert(&summarize::Summary {
21458 id: 0,
21459 symbol_name: "stale".to_string(),
21460 file_path: "src/gone.rs".to_string(),
21461 content_hash: "hash1".to_string(),
21462 summary: "stale summary".to_string(),
21463 entities: None,
21464 relationships: None,
21465 concept_labels: None,
21466 extracted_at: "1700000000".to_string(),
21467 model: "test".to_string(),
21468 tokens_input: Some(100),
21469 tokens_output: Some(50),
21470 })
21471 .unwrap();
21472
21473 std::fs::remove_file(&deleted_file).unwrap();
21474
21475 cmd_summarize(
21476 None,
21477 None,
21478 Some(PathBuf::from("src")),
21479 false,
21480 false,
21481 dir.path(),
21482 false,
21483 true,
21484 false,
21485 false,
21486 false,
21487 )
21488 .unwrap();
21489
21490 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21491 }
21492
21493 #[test]
21494 fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
21495 let dir = tempfile::tempdir().unwrap();
21496 let source_dir = dir.path().join("src");
21497 std::fs::create_dir_all(&source_dir).unwrap();
21498 let file = source_dir.join("lib.rs");
21499 std::fs::write(&file, "fn helper() {}\n").unwrap();
21500
21501 let content = std::fs::read(&file).unwrap();
21502 let summary_db =
21503 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21504 summary_db
21505 .insert(&summarize::Summary {
21506 id: 0,
21507 symbol_name: "lib.rs".to_string(),
21508 file_path: "src/lib.rs".to_string(),
21509 content_hash: summarize::content_hash(&content),
21510 summary: "cached summary".to_string(),
21511 entities: None,
21512 relationships: None,
21513 concept_labels: None,
21514 extracted_at: "1700000000".to_string(),
21515 model: "test".to_string(),
21516 tokens_input: Some(100),
21517 tokens_output: Some(50),
21518 })
21519 .unwrap();
21520 drop(summary_db);
21521
21522 let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
21523 let _lock = hold_writer_lock(&lock_path);
21524
21525 let err = cmd_summarize(
21526 None,
21527 None,
21528 Some(PathBuf::from("src")),
21529 false,
21530 false,
21531 dir.path(),
21532 false,
21533 true,
21534 false,
21535 false,
21536 false,
21537 )
21538 .unwrap_err();
21539 let message = err.to_string();
21540
21541 assert!(message.contains("another tsift summarize extractor is already active"));
21542 assert!(message.contains("tsift summarize --extract"));
21543 }
21544
21545 #[test]
21546 fn summarize_stats_fails_closed_when_cache_missing() {
21547 let dir = tempfile::tempdir().unwrap();
21548 let err = cmd_summarize(
21549 None,
21550 None,
21551 None,
21552 false,
21553 true,
21554 dir.path(),
21555 false,
21556 false,
21557 false,
21558 false,
21559 false,
21560 )
21561 .unwrap_err();
21562
21563 assert!(
21564 err.to_string().contains("no summaries.db found"),
21565 "got: {err}"
21566 );
21567 assert!(!dir.path().join(".tsift/summaries.db").exists());
21568 }
21569
21570 #[test]
21571 fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21572 let dir = tempfile::tempdir().unwrap();
21573 let summary_db =
21574 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21575 summary_db
21576 .insert(&summarize::Summary {
21577 id: 0,
21578 symbol_name: "alpha_helper".to_string(),
21579 file_path: "src/lib.rs".to_string(),
21580 content_hash: "hash1".to_string(),
21581 summary: "cached summary".to_string(),
21582 entities: None,
21583 relationships: None,
21584 concept_labels: None,
21585 extracted_at: "1700000000".to_string(),
21586 model: "claude-haiku-4-5-20251001".to_string(),
21587 tokens_input: Some(100),
21588 tokens_output: Some(40),
21589 })
21590 .unwrap();
21591 drop(summary_db);
21592 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21593
21594 let result = cmd_summarize(
21595 None,
21596 None,
21597 None,
21598 false,
21599 true,
21600 dir.path(),
21601 false,
21602 false,
21603 false,
21604 false,
21605 false,
21606 );
21607
21608 assert!(result.is_ok());
21609 }
21610
21611 #[test]
21612 fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21613 let dir = tempfile::tempdir().unwrap();
21614 let summary_db =
21615 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21616 summary_db
21617 .insert(&summarize::Summary {
21618 id: 0,
21619 symbol_name: "alpha_helper".to_string(),
21620 file_path: "src/lib.rs".to_string(),
21621 content_hash: "hash1".to_string(),
21622 summary: "cached summary".to_string(),
21623 entities: None,
21624 relationships: None,
21625 concept_labels: None,
21626 extracted_at: "1700000000".to_string(),
21627 model: "claude-haiku-4-5-20251001".to_string(),
21628 tokens_input: Some(100),
21629 tokens_output: Some(40),
21630 })
21631 .unwrap();
21632 drop(summary_db);
21633 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21634
21635 let result = cmd_summarize(
21636 Some("alpha_helper".to_string()),
21637 None,
21638 None,
21639 false,
21640 false,
21641 dir.path(),
21642 false,
21643 true,
21644 false,
21645 false,
21646 false,
21647 );
21648
21649 assert!(result.is_ok());
21650 }
21651
21652 #[test]
21653 fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
21654 let dir = tempfile::tempdir().unwrap();
21655 let nested = dir.path().join("src/nested");
21656 std::fs::create_dir_all(&nested).unwrap();
21657
21658 let summary_db =
21659 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21660 summary_db
21661 .insert(&summarize::Summary {
21662 id: 0,
21663 symbol_name: "alpha_helper".to_string(),
21664 file_path: "src/lib.rs".to_string(),
21665 content_hash: "hash1".to_string(),
21666 summary: "cached summary".to_string(),
21667 entities: None,
21668 relationships: None,
21669 concept_labels: None,
21670 extracted_at: "1700000000".to_string(),
21671 model: "claude-haiku-4-5-20251001".to_string(),
21672 tokens_input: Some(100),
21673 tokens_output: Some(40),
21674 })
21675 .unwrap();
21676
21677 let result = cmd_summarize(
21678 Some("alpha_helper".to_string()),
21679 None,
21680 None,
21681 false,
21682 false,
21683 &nested,
21684 false,
21685 true,
21686 false,
21687 false,
21688 false,
21689 );
21690
21691 assert!(result.is_ok());
21692 assert!(!nested.join(".tsift/summaries.db").exists());
21693 }
21694
21695 #[test]
21696 fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
21697 let dir = tempfile::tempdir().unwrap();
21698 std::fs::write(
21699 dir.path().join(".gitmodules"),
21700 r#"[submodule "src/alpha"]
21701 path = src/alpha
21702 url = https://example.com/alpha
21703[submodule "src/beta"]
21704 path = src/beta
21705 url = https://example.com/beta
21706"#,
21707 )
21708 .unwrap();
21709
21710 let alpha_root = dir.path().join("src/alpha");
21711 let beta_root = dir.path().join("src/beta");
21712 std::fs::create_dir_all(alpha_root.join("src")).unwrap();
21713 std::fs::create_dir_all(beta_root.join("src")).unwrap();
21714 std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
21715 std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
21716 std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
21717 let beta_file = beta_root.join("src/lib.rs");
21718 std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
21719 std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
21720 std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
21721
21722 let context = find_symbols_db_for_file(dir.path(), &beta_file)
21723 .unwrap()
21724 .expect("expected matching scoped index");
21725
21726 assert_eq!(
21727 context.db_path,
21728 dir.path().join(".tsift/indexes/beta/index.db")
21729 );
21730 assert_eq!(context.source_root, beta_root);
21731 }
21732
21733 fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
21736 EditOp {
21737 file: PathBuf::from("dummy.txt"),
21738 old: old.to_string(),
21739 new: new.to_string(),
21740 replace_all,
21741 }
21742 }
21743
21744 #[test]
21745 fn edit_replaces_single_occurrence() {
21746 let content = "hello world";
21747 let op = make_op("world", "rust", false);
21748 let (result, count) = apply_edit_op(content, &op).unwrap();
21749 assert_eq!(result, "hello rust");
21750 assert_eq!(count, 1);
21751 }
21752
21753 #[test]
21754 fn edit_replace_all_replaces_every_occurrence() {
21755 let content = "foo foo foo";
21756 let op = make_op("foo", "bar", true);
21757 let (result, count) = apply_edit_op(content, &op).unwrap();
21758 assert_eq!(result, "bar bar bar");
21759 assert_eq!(count, 3);
21760 }
21761
21762 #[test]
21763 fn edit_fails_when_old_not_found() {
21764 let content = "hello world";
21765 let op = make_op("missing", "x", false);
21766 assert!(apply_edit_op(content, &op).is_err());
21767 }
21768
21769 #[test]
21770 fn edit_fails_when_ambiguous_without_replace_all() {
21771 let content = "foo foo";
21772 let op = make_op("foo", "bar", false);
21773 let err = apply_edit_op(content, &op).unwrap_err();
21774 assert!(err.to_string().contains("2 times"), "got: {}", err);
21775 }
21776
21777 #[test]
21778 fn edit_fails_when_old_equals_new() {
21779 let content = "hello";
21780 let op = make_op("hello", "hello", false);
21781 assert!(apply_edit_op(content, &op).is_err());
21782 }
21783
21784 #[test]
21785 fn edit_batch_rolls_back_when_later_swap_fails() {
21786 let dir = tempfile::tempdir().unwrap();
21787 let alpha = dir.path().join("alpha.txt");
21788 let beta = dir.path().join("beta.txt");
21789 fs::write(&alpha, "alpha old\n").unwrap();
21790 fs::write(&beta, "beta old\n").unwrap();
21791
21792 let batch = EditBatch {
21793 edits: vec![
21794 EditOp {
21795 file: alpha.clone(),
21796 old: "old".to_string(),
21797 new: "new".to_string(),
21798 replace_all: false,
21799 },
21800 EditOp {
21801 file: beta.clone(),
21802 old: "old".to_string(),
21803 new: "new".to_string(),
21804 replace_all: false,
21805 },
21806 ],
21807 };
21808
21809 let plan = build_edit_plan(&batch).unwrap();
21810 let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
21811 if commit_index == 1 {
21812 bail!("simulated swap failure");
21813 }
21814 Ok(())
21815 }) {
21816 Ok(_) => panic!("expected simulated swap failure"),
21817 Err(err) => err,
21818 };
21819
21820 assert!(err.to_string().contains("simulated swap failure"));
21821 assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
21822 assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
21823 }
21824
21825 fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
21828 let tmp = tempfile::NamedTempFile::new().unwrap();
21829 let conn = Connection::open(tmp.path()).unwrap();
21830 conn.execute_batch(
21831 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
21832 INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
21833 INSERT INTO users VALUES (2, 'Bob', NULL);
21834 CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
21835 FOREIGN KEY(user_id) REFERENCES users(id));
21836 INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
21837 INSERT INTO posts VALUES (2, 1, 'Second', NULL);
21838 INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
21839 ).unwrap();
21840 (tmp, conn)
21841 }
21842
21843 #[test]
21846 fn rewrite_rg_simple_pattern() {
21847 let result = rewrite_command("rg authenticate");
21848 assert_eq!(
21849 result,
21850 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
21851 );
21852 }
21853
21854 #[test]
21855 fn rewrite_rg_with_path() {
21856 let result = rewrite_command("rg authenticate src/");
21857 assert_eq!(
21858 result,
21859 Some(
21860 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
21861 .to_string()
21862 )
21863 );
21864 }
21865
21866 #[test]
21867 fn rewrite_rg_with_flags_ignored() {
21868 let result = rewrite_command("rg -i authenticate src/");
21869 assert_eq!(
21870 result,
21871 Some(
21872 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
21873 .to_string()
21874 )
21875 );
21876 }
21877
21878 #[test]
21879 fn rewrite_rg_with_type_flag() {
21880 let result = rewrite_command("rg -t rs authenticate");
21882 assert_eq!(
21883 result,
21884 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
21885 );
21886 }
21887
21888 #[test]
21889 fn rewrite_rg_pipe_passthrough() {
21890 let result = rewrite_command("rg authenticate | head -5");
21892 assert_eq!(result, None);
21893 }
21894
21895 #[test]
21896 fn rewrite_rg_files_passthrough() {
21897 let result = rewrite_command("rg --files src/tsift .agent-doc logs");
21898 assert_eq!(result, None);
21899 }
21900
21901 #[test]
21902 fn rewrite_find_passthrough() {
21903 let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
21904 assert_eq!(result, None);
21905 }
21906
21907 #[test]
21908 fn rewrite_grep_recursive() {
21909 let result = rewrite_command("grep -r authenticate src/");
21910 assert_eq!(
21911 result,
21912 Some(
21913 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
21914 .to_string()
21915 )
21916 );
21917 }
21918
21919 #[test]
21920 fn rewrite_grep_non_recursive_passthrough() {
21921 let result = rewrite_command("grep authenticate file.txt");
21922 assert_eq!(result, None);
21923 }
21924
21925 #[test]
21926 fn rewrite_tsift_passthrough() {
21927 let result = rewrite_command("tsift search \"foo\"");
21928 assert_eq!(result, Some("tsift search \"foo\"".to_string()));
21929 }
21930
21931 #[test]
21932 fn rewrite_run_tsift_search_disables_timeout_by_default() {
21933 let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
21934 assert_eq!(
21935 result,
21936 "tsift search hookcaps --exact --path /tmp/x --timeout 0"
21937 );
21938 }
21939
21940 #[test]
21941 fn rewrite_run_preserves_explicit_search_timeout() {
21942 let result = effective_rewrite_run_command(
21943 "tsift search hookcaps --exact --path /tmp/x --timeout 5",
21944 );
21945 assert_eq!(
21946 result,
21947 "tsift search hookcaps --exact --path /tmp/x --timeout 5"
21948 );
21949 }
21950
21951 #[test]
21952 fn rewrite_unrelated_passthrough() {
21953 let result = rewrite_command("echo cargo build");
21954 assert_eq!(result, None);
21955 }
21956
21957 #[test]
21958 fn rewrite_rg_quoted_pattern() {
21959 let result = rewrite_command("rg \"fn main\"");
21960 assert_eq!(
21961 result,
21962 Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
21963 );
21964 }
21965
21966 #[test]
21967 fn rewrite_git_diff_to_diff_digest() {
21968 let result = rewrite_command("git diff");
21969 assert_eq!(result, Some("tsift diff-digest .".to_string()));
21970 }
21971
21972 #[test]
21973 fn rewrite_git_diff_cached_to_diff_digest() {
21974 let result = rewrite_command("git diff --cached");
21975 assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
21976 }
21977
21978 #[test]
21979 fn rewrite_git_diff_with_path_to_diff_digest() {
21980 let result = rewrite_command("git diff -- src/");
21981 assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
21982 }
21983
21984 #[test]
21985 fn rewrite_git_diff_with_revision_passthrough() {
21986 let result = rewrite_command("git diff HEAD~1");
21987 assert_eq!(result, None);
21988 }
21989
21990 #[test]
21991 fn rewrite_git_show_to_revision_diff_digest() {
21992 let result = rewrite_command("git show HEAD~1");
21993 assert_eq!(
21994 result,
21995 Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
21996 );
21997 }
21998
21999 #[test]
22000 fn rewrite_git_log_patch_history_to_revision_diff_digest() {
22001 let result = rewrite_command("git log -p -1 HEAD~2");
22002 assert_eq!(
22003 result,
22004 Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
22005 );
22006 }
22007
22008 #[test]
22009 fn rewrite_cat_long_agent_doc_session_to_session_digest() {
22010 let dir = tempfile::tempdir().unwrap();
22011 let session = dir.path().join("tsift.md");
22012 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22013 for index in 0..90 {
22014 body.push_str(&format!("❯ prompt {index}?\n"));
22015 }
22016 fs::write(&session, body).unwrap();
22017
22018 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22019 assert_eq!(
22020 result,
22021 Some(format!(
22022 "tsift session-digest --path {} --input {} --source markdown",
22023 shell_quote(&resolve_digest_context_path(&session)),
22024 shell_quote(session.to_str().unwrap())
22025 ))
22026 );
22027 }
22028
22029 #[test]
22030 fn rewrite_head_long_claude_jsonl_to_session_digest() {
22031 let dir = tempfile::tempdir().unwrap();
22032 let session = dir.path().join("session.jsonl");
22033 let line =
22034 r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
22035 let body = std::iter::repeat_n(line, 120)
22036 .collect::<Vec<_>>()
22037 .join("\n");
22038 fs::write(&session, format!("{body}\n")).unwrap();
22039
22040 let result = rewrite_command(&format!(
22041 "head -n 120 {}",
22042 shell_quote(session.to_str().unwrap())
22043 ));
22044 assert_eq!(
22045 result,
22046 Some(format!(
22047 "tsift session-digest --path {} --input {} --source claude-jsonl",
22048 shell_quote(&resolve_digest_context_path(&session)),
22049 shell_quote(session.to_str().unwrap())
22050 ))
22051 );
22052 }
22053
22054 #[test]
22055 fn rewrite_head_long_codex_jsonl_to_session_digest() {
22056 let dir = tempfile::tempdir().unwrap();
22057 let session = dir.path().join("codex.jsonl");
22058 let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
22059 let body = std::iter::repeat_n(line, 120)
22060 .collect::<Vec<_>>()
22061 .join("\n");
22062 fs::write(&session, format!("{body}\n")).unwrap();
22063
22064 let result = rewrite_command(&format!(
22065 "head -n 120 {}",
22066 shell_quote(session.to_str().unwrap())
22067 ));
22068 assert_eq!(
22069 result,
22070 Some(format!(
22071 "tsift session-digest --path {} --input {} --source codex-jsonl",
22072 shell_quote(&resolve_digest_context_path(&session)),
22073 shell_quote(session.to_str().unwrap())
22074 ))
22075 );
22076 }
22077
22078 #[test]
22079 fn rewrite_small_transcript_window_passthrough() {
22080 let dir = tempfile::tempdir().unwrap();
22081 let session = dir.path().join("session.jsonl");
22082 let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
22083 let body = std::iter::repeat_n(line, 120)
22084 .collect::<Vec<_>>()
22085 .join("\n");
22086 fs::write(&session, format!("{body}\n")).unwrap();
22087
22088 let result = rewrite_command(&format!(
22089 "tail -n 20 {}",
22090 shell_quote(session.to_str().unwrap())
22091 ));
22092 assert_eq!(result, None);
22093 }
22094
22095 #[test]
22096 fn rewrite_sed_large_agent_doc_range_to_session_digest() {
22097 let dir = tempfile::tempdir().unwrap();
22098 let session = dir.path().join("tsift.md");
22099 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22100 for index in 0..120 {
22101 body.push_str(&format!("### Re: topic {index}\n"));
22102 }
22103 fs::write(&session, body).unwrap();
22104
22105 let result = rewrite_command(&format!(
22106 "sed -n '1,120p' {}",
22107 shell_quote(session.to_str().unwrap())
22108 ));
22109 assert_eq!(
22110 result,
22111 Some(format!(
22112 "tsift session-digest --path {} --input {} --source markdown",
22113 shell_quote(&resolve_digest_context_path(&session)),
22114 shell_quote(session.to_str().unwrap())
22115 ))
22116 );
22117 }
22118
22119 #[test]
22120 fn rewrite_cat_large_agent_doc_log_to_session_digest() {
22121 let dir = tempfile::tempdir().unwrap();
22122 let session = dir.path().join("tsift.log");
22123 let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
22124 let body = std::iter::repeat_n(line, 120)
22125 .collect::<Vec<_>>()
22126 .join("\n");
22127 fs::write(&session, format!("{body}\n")).unwrap();
22128
22129 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22130 assert_eq!(
22131 result,
22132 Some(format!(
22133 "tsift session-digest --path {} --input {} --source agent-doc-log",
22134 shell_quote(&resolve_digest_context_path(&session)),
22135 shell_quote(session.to_str().unwrap())
22136 ))
22137 );
22138 }
22139
22140 #[test]
22141 fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
22142 let dir = tempfile::tempdir().unwrap();
22143 fs::write(
22144 dir.path().join(".gitmodules"),
22145 r#"[submodule "src/tsift"]
22146 path = src/tsift
22147 url = https://example.com/tsift
22148"#,
22149 )
22150 .unwrap();
22151 let submodule = dir.path().join("src/tsift");
22152 fs::create_dir_all(submodule.join("tasks")).unwrap();
22153 fs::write(
22154 submodule.join(".git"),
22155 "gitdir: ../../.git/modules/src/tsift\n",
22156 )
22157 .unwrap();
22158 let session = submodule.join("tasks/plan.md");
22159 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22160 for index in 0..90 {
22161 body.push_str(&format!("❯ prompt {index}?\n"));
22162 }
22163 fs::write(&session, body).unwrap();
22164
22165 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22166
22167 assert_eq!(
22168 result,
22169 Some(format!(
22170 "tsift session-digest --path {} --input {} --source markdown",
22171 shell_quote(submodule.to_str().unwrap()),
22172 shell_quote(session.to_str().unwrap())
22173 ))
22174 );
22175 }
22176
22177 #[test]
22178 fn rewrite_regular_markdown_read_passthrough() {
22179 let dir = tempfile::tempdir().unwrap();
22180 let readme = dir.path().join("README.md");
22181 let body = std::iter::repeat_n("plain markdown", 120)
22182 .collect::<Vec<_>>()
22183 .join("\n");
22184 fs::write(&readme, format!("{body}\n")).unwrap();
22185
22186 let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
22187 assert_eq!(result, None);
22188 }
22189
22190 #[test]
22191 fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
22192 let dir = tempfile::tempdir().unwrap();
22193 write_empty_root_index(dir.path());
22194 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22195
22196 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22197
22198 assert_eq!(
22199 result,
22200 Some(format!(
22201 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
22202 shell_quote(&dir.path().to_string_lossy())
22203 ))
22204 );
22205 }
22206
22207 #[test]
22208 fn rewrite_head_small_source_window_passthrough() {
22209 let dir = tempfile::tempdir().unwrap();
22210 write_empty_root_index(dir.path());
22211 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22212
22213 let result = rewrite_command(&format!(
22214 "head -n 20 {}",
22215 shell_quote(source.to_str().unwrap())
22216 ));
22217
22218 assert_eq!(result, None);
22219 }
22220
22221 #[test]
22222 fn rewrite_sed_large_source_range_to_source_read() {
22223 let dir = tempfile::tempdir().unwrap();
22224 write_empty_root_index(dir.path());
22225 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22226
22227 let result = rewrite_command(&format!(
22228 "sed -n '40,160p' {}",
22229 shell_quote(source.to_str().unwrap())
22230 ));
22231
22232 assert_eq!(
22233 result,
22234 Some(format!(
22235 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
22236 shell_quote(&dir.path().to_string_lossy())
22237 ))
22238 );
22239 }
22240
22241 #[test]
22242 fn rewrite_tail_large_source_window_preserves_tail_anchor() {
22243 let dir = tempfile::tempdir().unwrap();
22244 write_empty_root_index(dir.path());
22245 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22246
22247 let result = rewrite_command(&format!(
22248 "tail -n 120 {}",
22249 shell_quote(source.to_str().unwrap())
22250 ));
22251
22252 assert_eq!(
22253 result,
22254 Some(format!(
22255 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
22256 shell_quote(&dir.path().to_string_lossy())
22257 ))
22258 );
22259 }
22260
22261 #[test]
22262 fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
22263 let dir = tempfile::tempdir().unwrap();
22264 write_empty_root_index(dir.path());
22265 let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
22266
22267 let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
22268
22269 assert_eq!(result, None);
22270 }
22271
22272 #[test]
22273 fn rewrite_large_source_read_passthrough_without_index() {
22274 let dir = tempfile::tempdir().unwrap();
22275 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22276
22277 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22278
22279 assert_eq!(result, None);
22280 }
22281
22282 #[test]
22283 fn rewrite_cargo_test_to_digest_runner() {
22284 let result = rewrite_command("cargo test --lib");
22285 assert_eq!(
22286 result,
22287 Some(
22288 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
22289 )
22290 );
22291 }
22292
22293 #[test]
22294 fn rewrite_pytest_to_digest_runner() {
22295 let result = rewrite_command("pytest -q tests/test_cli.py");
22296 assert_eq!(
22297 result,
22298 Some(
22299 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
22300 )
22301 );
22302 }
22303
22304 #[test]
22305 fn rewrite_python_m_pytest_to_digest_runner() {
22306 let result = rewrite_command("python -m pytest tests/test_cli.py");
22307 assert_eq!(
22308 result,
22309 Some(
22310 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
22311 )
22312 );
22313 }
22314
22315 #[test]
22316 fn rewrite_cargo_build_to_log_digest_runner() {
22317 let result = rewrite_command("cargo build --release");
22318 assert_eq!(
22319 result,
22320 Some(
22321 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
22322 )
22323 );
22324 }
22325
22326 #[test]
22327 fn rewrite_cargo_install_to_log_digest_runner() {
22328 let result = rewrite_command("cargo install --path . --force");
22329 assert_eq!(
22330 result,
22331 Some(
22332 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
22333 )
22334 );
22335 }
22336
22337 #[test]
22338 fn rewrite_metacharacter_command_passthrough() {
22339 let result = rewrite_command("cargo test | head");
22340 assert_eq!(result, None);
22341 }
22342
22343 #[test]
22344 fn rewrite_output_cap_detects_search_even_with_global_flag() {
22345 let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
22346 assert_eq!(cap.max_lines, 50);
22347 assert_eq!(cap.strip_prefix, Some("Strategy:"));
22348 }
22349
22350 #[test]
22351 fn rewrite_output_cap_skips_structured_output() {
22352 assert!(rewrite_output_cap("tsift search foo --json").is_none());
22353 assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
22354 assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
22355 }
22356
22357 #[test]
22358 fn rewrite_output_format_forwards_envelope_to_digest_runner() {
22359 let command = rewrite_command("cargo test --lib").expect("rewrite");
22360 let forwarded = apply_rewrite_output_format(
22361 &command,
22362 OutputFormat {
22363 json_output: true,
22364 compact: false,
22365 pretty: false,
22366 terse: false,
22367 ultra_terse: false,
22368 schema: false,
22369 envelope: true,
22370 },
22371 );
22372 assert_eq!(
22373 forwarded,
22374 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
22375 );
22376 }
22377
22378 #[test]
22379 fn rewrite_output_format_forwards_json_when_requested() {
22380 let command = rewrite_command("cargo build --release").expect("rewrite");
22381 let forwarded = apply_rewrite_output_format(
22382 &command,
22383 OutputFormat {
22384 json_output: true,
22385 compact: false,
22386 pretty: true,
22387 terse: false,
22388 ultra_terse: false,
22389 schema: false,
22390 envelope: false,
22391 },
22392 );
22393 assert_eq!(
22394 forwarded,
22395 "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
22396 );
22397 }
22398
22399 #[test]
22400 fn output_cap_strips_search_header_and_truncates() {
22401 let capped = apply_output_cap(
22402 b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
22403 OutputCap {
22404 max_lines: 2,
22405 strip_prefix: Some("Strategy:"),
22406 },
22407 );
22408 assert_eq!(
22409 capped,
22410 "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
22411 );
22412 }
22413
22414 #[test]
22415 fn sql_schema_overview_lists_tables() {
22416 let (_tmp, conn) = setup_test_db();
22417 let tables = schema_overview(&conn).unwrap();
22418 let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
22419 assert_eq!(names, &["posts", "users"]);
22420 }
22421
22422 #[test]
22423 fn sql_schema_overview_row_counts() {
22424 let (_tmp, conn) = setup_test_db();
22425 let tables = schema_overview(&conn).unwrap();
22426 let users = tables.iter().find(|t| t.name == "users").unwrap();
22427 let posts = tables.iter().find(|t| t.name == "posts").unwrap();
22428 assert_eq!(users.row_count, 2);
22429 assert_eq!(posts.row_count, 3);
22430 }
22431
22432 #[test]
22433 fn sql_table_columns_metadata() {
22434 let (_tmp, conn) = setup_test_db();
22435 let cols = table_columns(&conn, "users").unwrap();
22436 assert_eq!(cols.len(), 3);
22437 assert_eq!(cols[0].name, "id");
22438 assert!(cols[0].pk);
22439 assert_eq!(cols[1].name, "name");
22440 assert!(cols[1].notnull);
22441 assert_eq!(cols[2].name, "email");
22442 assert!(!cols[2].notnull);
22443 }
22444
22445 #[test]
22446 fn sql_execute_query_returns_rows() {
22447 let (_tmp, conn) = setup_test_db();
22448 let (columns, rows) =
22449 execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
22450 assert_eq!(columns, &["name", "email"]);
22451 assert_eq!(rows.len(), 2);
22452 assert_eq!(rows[0][0], serde_json::json!("Alice"));
22453 assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
22454 assert_eq!(rows[1][1], serde_json::Value::Null);
22455 }
22456
22457 #[test]
22458 fn sql_execute_query_aggregate() {
22459 let (_tmp, conn) = setup_test_db();
22460 let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
22461 assert_eq!(columns, &["cnt"]);
22462 assert_eq!(rows[0][0], serde_json::json!(3));
22463 }
22464
22465 #[test]
22466 fn sql_execute_query_join() {
22467 let (_tmp, conn) = setup_test_db();
22468 let (_cols, rows) = execute_query(
22469 &conn,
22470 "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
22471 )
22472 .unwrap();
22473 assert_eq!(rows.len(), 3);
22474 assert_eq!(rows[0][0], serde_json::json!("Alice"));
22475 assert_eq!(rows[2][0], serde_json::json!("Bob"));
22476 }
22477
22478 #[test]
22479 fn sql_open_db_read_only() {
22480 let (tmp, _conn) = setup_test_db();
22481 drop(_conn);
22482 let ro_conn = open_db(tmp.path()).unwrap();
22483 let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
22484 assert!(result.is_err(), "read-only connection should reject writes");
22485 }
22486
22487 #[test]
22488 fn sql_empty_table_schema() {
22489 let tmp = tempfile::NamedTempFile::new().unwrap();
22490 let conn = Connection::open(tmp.path()).unwrap();
22491 conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
22492 .unwrap();
22493 let tables = schema_overview(&conn).unwrap();
22494 assert_eq!(tables[0].row_count, 0);
22495 assert_eq!(tables[0].columns.len(), 2);
22496 }
22497
22498 fn setup_graph_index() -> tempfile::TempDir {
22501 let dir = tempfile::tempdir().unwrap();
22502 std::fs::write(
22503 dir.path().join("main.rs"),
22504 "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
22505 )
22506 .unwrap();
22507 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22508 db.apply_changes(dir.path()).unwrap();
22509 dir
22510 }
22511
22512 fn setup_traversal_project() -> tempfile::TempDir {
22513 let dir = setup_graph_index();
22514 let task_dir = dir.path().join("tasks/software");
22515 std::fs::create_dir_all(&task_dir).unwrap();
22516 std::fs::write(
22517 task_dir.join("tsift.md"),
22518 r#"---
22519agent_doc_session: tsift-v0.1
22520agent_doc_format: template
22521---
22522
22523## Exchange
22524
22525<!-- agent:exchange patch=append -->
22526❯ do [#kgnv]
22527Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
22528<!-- /agent:exchange -->
22529
22530<!-- agent:queue -->
22531dispatch #spec-test-build-install-commit-push
22532- do [#kgnv]
22533<!-- /agent:queue -->
22534
22535## Backlog
22536
22537<!-- agent:backlog -->
22538- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
22539<!-- /agent:backlog -->
22540"#,
22541 )
22542 .unwrap();
22543 dir
22544 }
22545
22546 fn resolve_ast_span_node<'a>(
22547 graph: &'a TraversalGraphBuild,
22548 label: &str,
22549 symbol_kind: &str,
22550 ) -> &'a TraversalNode {
22551 graph
22552 .nodes
22553 .values()
22554 .find(|node| {
22555 node.kind == "ast_span"
22556 && node.label == label
22557 && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
22558 })
22559 .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
22560 }
22561
22562 fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
22563 let dir = tempfile::tempdir().unwrap();
22564 std::fs::write(
22565 dir.path().join("rust.rs"),
22566 r#"mod fixture_nav_rust_mod {
22567 pub fn fixture_nav_rust_helper() {}
22568 pub fn fixture_nav_rust_entry() {
22569 fixture_nav_rust_helper();
22570 }
22571}
22572"#,
22573 )
22574 .unwrap();
22575 std::fs::write(
22576 dir.path().join("python.py"),
22577 r#"def fixture_nav_python_helper():
22578 return 1
22579
22580def fixture_nav_python_entry():
22581 return fixture_nav_python_helper()
22582"#,
22583 )
22584 .unwrap();
22585 std::fs::write(
22586 dir.path().join("typescript.ts"),
22587 r#"export function fixture_nav_typescript_entry(): number {
22588 return fixtureNavTsHelper();
22589}
22590
22591function fixtureNavTsHelper(): number {
22592 return 1;
22593}
22594"#,
22595 )
22596 .unwrap();
22597 std::fs::write(
22598 dir.path().join("javascript.js"),
22599 r#"function fixture_nav_javascript_entry() {
22600 return fixtureNavJsHelper();
22601}
22602
22603function fixtureNavJsHelper() {
22604 return 1;
22605}
22606"#,
22607 )
22608 .unwrap();
22609 std::fs::write(
22610 dir.path().join("kotlin.kt"),
22611 r#"fun fixture_nav_kotlin_entry(): Int {
22612 return fixtureNavKotlinHelper()
22613}
22614
22615fun fixtureNavKotlinHelper(): Int = 1
22616"#,
22617 )
22618 .unwrap();
22619 std::fs::write(
22620 dir.path().join("zig.zig"),
22621 r#"pub fn fixture_nav_zig_entry() i32 {
22622 return fixtureNavZigHelper();
22623}
22624
22625fn fixtureNavZigHelper() i32 {
22626 return 1;
22627}
22628"#,
22629 )
22630 .unwrap();
22631 std::fs::write(
22632 dir.path().join("bash.sh"),
22633 r#"#!/usr/bin/env bash
22634fixture_nav_bash_entry() {
22635 fixture_nav_bash_helper
22636}
22637
22638fixture_nav_bash_helper() {
22639 echo ok
22640}
22641
22642alias fixture_nav_bash_alias='echo alias'
22643"#,
22644 )
22645 .unwrap();
22646 std::fs::write(
22647 dir.path().join("README.md"),
22648 r#"# Fixture Guide
22649
22650## Fixture Section
22651
22652- Fixture step
22653 - Nested fixture step
22654
22655```python
22656def fixture_nav_markdown_embedded():
22657 return 1
22658```
22659"#,
22660 )
22661 .unwrap();
22662
22663 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22664 db.apply_changes(dir.path()).unwrap();
22665 dir
22666 }
22667
22668 fn assert_cli_expand_command_parses(command: &str) {
22669 let args = shell_split(command)
22670 .into_iter()
22671 .map(str::to_string)
22672 .collect::<Vec<_>>();
22673 assert!(
22674 try_parse_cli(args).is_ok(),
22675 "expand command should parse as a tsift CLI command: {command}"
22676 );
22677 }
22678
22679 fn setup_multiplicity_project() -> tempfile::TempDir {
22680 let dir = tempfile::tempdir().unwrap();
22681 std::fs::write(
22682 dir.path().join("Cargo.toml"),
22683 r#"[workspace]
22684members = ["crates/core-lib", "crates/cli-app"]
22685"#,
22686 )
22687 .unwrap();
22688 std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
22689 std::fs::write(
22690 dir.path().join("crates/core-lib/Cargo.toml"),
22691 r#"[package]
22692name = "core-lib"
22693
22694[lib]
22695name = "core_lib"
22696
22697[features]
22698default = []
22699"#,
22700 )
22701 .unwrap();
22702 std::fs::write(
22703 dir.path().join("crates/core-lib/src/lib.rs"),
22704 "pub fn run() {}\n",
22705 )
22706 .unwrap();
22707 std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
22708 std::fs::write(
22709 dir.path().join("crates/cli-app/Cargo.toml"),
22710 r#"[package]
22711name = "cli-app"
22712
22713[[bin]]
22714name = "cli-app"
22715
22716[dependencies]
22717core-lib = { path = "../core-lib" }
22718"#,
22719 )
22720 .unwrap();
22721 std::fs::write(
22722 dir.path().join("crates/cli-app/src/main.rs"),
22723 "use core_lib::run;\nfn main() { run(); }\n",
22724 )
22725 .unwrap();
22726 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22727 db.apply_changes(dir.path()).unwrap();
22728
22729 let task_dir = dir.path().join("tasks/software");
22730 std::fs::create_dir_all(&task_dir).unwrap();
22731 std::fs::write(
22732 task_dir.join("tsift.md"),
22733 r#"---
22734agent_doc_session: tsift-multiplicity
22735agent_doc_format: template
22736---
22737
22738## Backlog
22739
22740<!-- agent:backlog -->
22741- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
22742<!-- /agent:backlog -->
22743"#,
22744 )
22745 .unwrap();
22746 init_git_repo(dir.path());
22747 dir
22748 }
22749
22750 fn setup_dependency_dag_project() -> tempfile::TempDir {
22751 let dir = tempfile::tempdir().unwrap();
22752 std::fs::write(
22753 dir.path().join("main.rs"),
22754 "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
22755 )
22756 .unwrap();
22757 std::fs::write(
22758 dir.path().join("Cargo.toml"),
22759 "[package]\nname = \"dag-fixture\"\n",
22760 )
22761 .unwrap();
22762 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22763 db.apply_changes(dir.path()).unwrap();
22764
22765 let task_dir = dir.path().join("tasks/software");
22766 std::fs::create_dir_all(&task_dir).unwrap();
22767 std::fs::write(
22768 task_dir.join("tsift.md"),
22769 r#"---
22770agent_doc_session: tsift-dag
22771agent_doc_format: template
22772---
22773
22774## Exchange
22775
22776<!-- agent:exchange patch=append -->
22777Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
22778<!-- /agent:exchange -->
22779
22780## Backlog
22781
22782<!-- agent:backlog -->
22783- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
22784- [ ] [#alpha] Update shared_helper in main.rs after #prep.
22785- [ ] [#beta] Refactor shared_helper tests in main.rs.
22786- [ ] [#gamma] Follow-up review for graph navigation.
22787<!-- /agent:backlog -->
22788"#,
22789 )
22790 .unwrap();
22791 dir
22792 }
22793
22794 fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
22795 let dir = setup_graph_index();
22796 let task_dir = dir.path().join("tasks/software");
22797 std::fs::create_dir_all(&task_dir).unwrap();
22798 std::fs::write(
22799 task_dir.join("tsift.md"),
22800 r#"---
22801agent_doc_session: tsift-dag-cycle
22802agent_doc_format: template
22803---
22804
22805## Backlog
22806
22807<!-- agent:backlog -->
22808- [ ] [#left] Left side depends on #right.
22809- [ ] [#right] Right side depends on #left.
22810<!-- /agent:backlog -->
22811"#,
22812 )
22813 .unwrap();
22814 dir
22815 }
22816
22817 fn seed_traversal_semantic_summaries(dir: &Path) {
22818 let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
22819 summary_db
22820 .insert(&summarize::Summary {
22821 id: 0,
22822 symbol_name: "helper".to_string(),
22823 file_path: "main.rs".to_string(),
22824 content_hash: "hash-main".to_string(),
22825 summary: "helper builds graph navigation handles for traversal.".to_string(),
22826 entities: Some(vec![
22827 summarize::Entity {
22828 name: "helper".to_string(),
22829 kind: "function".to_string(),
22830 description: "Builds graph navigation handles.".to_string(),
22831 },
22832 summarize::Entity {
22833 name: "TraversalGraph".to_string(),
22834 kind: "type".to_string(),
22835 description: "Carries GraphStore-backed traversal rows.".to_string(),
22836 },
22837 ]),
22838 relationships: Some(vec![summarize::Relationship {
22839 from: "helper".to_string(),
22840 to: "TraversalGraph".to_string(),
22841 kind: "uses".to_string(),
22842 }]),
22843 concept_labels: Some(vec![
22844 "graph navigation".to_string(),
22845 "semantic extraction".to_string(),
22846 ]),
22847 extracted_at: "1700000000".to_string(),
22848 model: "test-model".to_string(),
22849 tokens_input: Some(10),
22850 tokens_output: Some(5),
22851 })
22852 .unwrap();
22853 }
22854
22855 fn seed_tsift_memory_graph_db(dir: &Path) {
22856 let db = dir.join(".tsift").join("memory.db");
22857 let store = MemoryStore::open_or_create(&db).unwrap();
22858 let project = dir.to_string_lossy().to_string();
22859 let observation = MemoryEvent::new(
22860 MemoryEventKind::ImportedObservation,
22861 "claude-mem:observations:1",
22862 [
22863 "Graph memory adapter",
22864 "read-only projection",
22865 "graph-db should retrieve tsift memory observations",
22866 "Project memory is queried from .tsift/memory.db",
22867 "graph memory, tsift memory, semantic query",
22868 ]
22869 .join("\n\n"),
22870 )
22871 .with_session_id("claude-session-a")
22872 .with_observed_at_unix(1_700_000_000)
22873 .with_import("claude-mem", "observations:1")
22874 .with_metadata("project", project.clone())
22875 .with_metadata("observation_type", "fact")
22876 .with_metadata("prompt_number", "7")
22877 .with_metadata("discovery_tokens", "42")
22878 .with_metadata("content_hash", "hash-observation-1");
22879 store.insert_event(&observation).unwrap();
22880
22881 let summary = MemoryEvent::new(
22882 MemoryEventKind::ImportedSessionSummary,
22883 "claude-mem:session_summaries:2",
22884 [
22885 "Query old memory from graph-db",
22886 "Read-only tsift memory SQLite projection",
22887 "Semantic graph rows can point at existing memory",
22888 "Projected source and session nodes",
22889 "Keep capture ownership inside tsift-memory",
22890 "summary note",
22891 ]
22892 .join("\n\n"),
22893 )
22894 .with_session_id("claude-session-a")
22895 .with_observed_at_unix(1_700_000_010)
22896 .with_import("claude-mem", "session_summaries:2")
22897 .with_metadata("project", project)
22898 .with_metadata("prompt_number", "8")
22899 .with_metadata("discovery_tokens", "36");
22900 store.insert_event(&summary).unwrap();
22901
22902 let prompt = MemoryEvent::new(
22903 MemoryEventKind::ImportedUserPrompt,
22904 "claude-mem:user_prompts:3",
22905 "How can graph-db query tsift memory semantic history?",
22906 )
22907 .with_session_id("claude-session-a")
22908 .with_observed_at_unix(1_700_000_020)
22909 .with_import("claude-mem", "user_prompts:3")
22910 .with_metadata("prompt_number", "9");
22911 store.insert_event(&prompt).unwrap();
22912 }
22913
22914 #[test]
22915 fn graph_callers_query() {
22916 let dir = setup_graph_index();
22917 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22918 let callers = db.callers_of("helper").unwrap();
22919 assert_eq!(callers.len(), 1);
22920 assert_eq!(callers[0].caller_name, "main");
22921 }
22922
22923 #[test]
22924 fn graph_callees_query() {
22925 let dir = setup_graph_index();
22926 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22927 let callees = db.callees_of("main").unwrap();
22928 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
22929 assert!(names.contains(&"helper"));
22930 assert!(names.contains(&"new"));
22931 }
22932
22933 #[test]
22934 fn graph_no_callers_returns_empty() {
22935 let dir = setup_graph_index();
22936 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22937 let callers = db.callers_of("nonexistent").unwrap();
22938 assert!(callers.is_empty());
22939 }
22940
22941 #[test]
22942 fn graph_cmd_autoindexes_missing_index_by_default() {
22943 let dir = tempfile::tempdir().unwrap();
22944 std::fs::write(
22945 dir.path().join("main.rs"),
22946 "fn helper() {}\nfn main() { helper(); }\n",
22947 )
22948 .unwrap();
22949 let result = cmd_graph(
22950 "helper",
22951 dir.path(),
22952 true,
22953 false,
22954 None,
22955 20,
22956 false,
22957 true,
22958 false,
22959 false,
22960 false,
22961 false,
22962 false,
22963 TagpathSearchOpts::default(),
22964 );
22965
22966 assert!(result.is_ok());
22967 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
22968 let summary = db.compute_changes(dir.path()).unwrap();
22969 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
22970 }
22971
22972 #[test]
22973 fn traversal_graph_has_stable_typed_handles() {
22974 let dir = setup_traversal_project();
22975 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
22976 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
22977
22978 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
22979 let symbol = resolve_traversal_node(&graph, "helper").unwrap();
22980 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
22981 let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
22982
22983 assert!(file.handle.starts_with("gfil-"));
22984 assert!(symbol.handle.starts_with("gsym-"));
22985 assert!(backlog.handle.starts_with("gbak-"));
22986 assert!(session.handle.starts_with("gses-"));
22987
22988 assert_eq!(
22989 symbol.handle,
22990 resolve_traversal_node(&graph_again, "helper")
22991 .unwrap()
22992 .handle
22993 );
22994 assert_eq!(
22995 backlog.handle,
22996 resolve_traversal_node(&graph_again, "#kgnv")
22997 .unwrap()
22998 .handle
22999 );
23000 }
23001
23002 #[test]
23003 fn traversal_graph_links_backlog_items_to_code_tokens() {
23004 let dir = setup_traversal_project();
23005 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23006 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23007 let helper = resolve_traversal_node(&graph, "helper").unwrap();
23008
23009 assert!(graph.edges.iter().any(|edge| {
23010 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23011 }));
23012 }
23013
23014 #[test]
23015 fn session_hinted_traversal_skips_global_call_edges() {
23016 let dir = setup_traversal_project();
23017 let session = dir.path().join("tasks/software/tsift.md");
23018 let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
23019 let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
23020 let helper = resolve_traversal_node(&bounded, "helper").unwrap();
23021
23022 assert!(bounded.edges.iter().any(|edge| {
23023 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23024 }));
23025 assert!(
23026 !bounded.edges.iter().any(|edge| edge.relation == "calls"),
23027 "session-hinted graph-db projections should not materialize unrelated global call edges"
23028 );
23029
23030 let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
23031 assert!(
23032 full.edges.iter().any(|edge| edge.relation == "calls"),
23033 "root/full projections still carry the complete indexed call graph"
23034 );
23035 }
23036
23037 #[test]
23038 fn agent_doc_task_path_infers_matching_workspace_scope() {
23039 let dir = tempfile::tempdir().unwrap();
23040 std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
23041 std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
23042 std::fs::write(
23043 dir.path().join(".gitmodules"),
23044 "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
23045 )
23046 .unwrap();
23047 let task = dir.path().join("tasks/software/tsift.md");
23048 std::fs::write(&task, "# tsift\n").unwrap();
23049
23050 let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
23051 let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
23052 let cfg = config::Config::load(dir.path()).unwrap();
23053
23054 assert_eq!(targets.len(), 1);
23055 assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
23056 assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
23057 assert!(
23058 targets[0]
23059 .db_path
23060 .ends_with(".tsift/indexes/tsift/index.db")
23061 );
23062 assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
23063 }
23064
23065 #[test]
23066 fn cargo_package_scope_selector_indexes_package_db() {
23067 let dir = setup_multiplicity_project();
23068 let targets =
23069 resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
23070
23071 assert_eq!(targets.len(), 1);
23072 assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
23073 assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
23074 assert!(
23075 targets[0]
23076 .db_path
23077 .ends_with(".tsift/indexes/cargo/core-lib/index.db")
23078 );
23079
23080 cmd_index(
23081 dir.path(),
23082 false,
23083 false,
23084 false,
23085 false,
23086 true,
23087 false,
23088 Some("core_lib"),
23089 false,
23090 true,
23091 false,
23092 false,
23093 false,
23094 false,
23095 )
23096 .unwrap();
23097 assert!(targets[0].db_path.exists());
23098 }
23099
23100 #[test]
23101 fn path_inference_prefers_nested_cargo_package_without_submodule() {
23102 let dir = setup_multiplicity_project();
23103 let source = dir.path().join("crates/cli-app/src/main.rs");
23104 let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
23105
23106 assert_eq!(targets.len(), 1);
23107 assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
23108 assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
23109 }
23110
23111 #[test]
23112 fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
23113 let dir = setup_multiplicity_project();
23114 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23115 let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
23116 let core = resolve_traversal_node(&graph, "core-lib").unwrap();
23117 let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
23118 let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
23119
23120 assert_eq!(workspace.kind, "cargo_workspace");
23121 assert_eq!(core.kind, "cargo_package");
23122 assert_eq!(
23123 core.properties.get("features"),
23124 Some(&"default".to_string())
23125 );
23126 assert!(graph.edges.iter().any(|edge| {
23127 edge.from == workspace.handle
23128 && edge.to == core.handle
23129 && edge.relation == "contains_package"
23130 }));
23131 assert!(graph.edges.iter().any(|edge| {
23132 edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
23133 }));
23134 assert!(graph.edges.iter().any(|edge| {
23135 edge.from == cli.handle
23136 && edge.to == core.handle
23137 && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
23138 }));
23139 }
23140
23141 #[test]
23142 fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
23143 let dir = setup_multiplicity_project();
23144 let session = dir.path().join("tasks/software/tsift.md");
23145 let report =
23146 build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
23147 .unwrap();
23148
23149 assert!(report.per_target_fail_closed.is_empty());
23150 let candidate = report
23151 .candidates
23152 .iter()
23153 .find(|candidate| candidate.target == "corepkg")
23154 .unwrap();
23155 assert!(
23156 candidate
23157 .owned_files
23158 .iter()
23159 .any(|file| file == "crates/core-lib/Cargo.toml"),
23160 "{:?}",
23161 candidate.owned_files
23162 );
23163 }
23164
23165 #[test]
23166 fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
23167 let dir = setup_traversal_project();
23168 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23169 let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
23170 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23171
23172 assert_eq!(job.kind, "job_packet");
23173 assert!(job.handle.starts_with("gjob-"));
23174 assert!(graph.edges.iter().any(|edge| {
23175 edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
23176 }));
23177
23178 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23179 let jobs = store.nodes_by_kind("job_packet").unwrap();
23180 assert!(
23181 jobs.iter()
23182 .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23183 "expected queued job packet in graph store, got {jobs:?}"
23184 );
23185 }
23186
23187 #[test]
23188 fn traversal_graph_includes_routes_and_handler_edges() {
23189 let dir = tempfile::tempdir().unwrap();
23190 std::fs::write(
23191 dir.path().join("api.py"),
23192 r#"@router.get("/items")
23193def list_items():
23194 return []
23195"#,
23196 )
23197 .unwrap();
23198 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23199 db.apply_changes(dir.path()).unwrap();
23200
23201 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23202 let route = resolve_traversal_node(&graph, "/items").unwrap();
23203 let handler = resolve_traversal_node(&graph, "list_items").unwrap();
23204
23205 assert_eq!(route.kind, "route");
23206 assert!(graph.edges.iter().any(|edge| {
23207 edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
23208 }));
23209 }
23210
23211 #[test]
23212 fn traversal_graph_projects_rust_ast_navigation_edges() {
23213 let dir = tempfile::tempdir().unwrap();
23214 std::fs::write(
23215 dir.path().join("main.rs"),
23216 r#"mod api {
23217 pub fn helper() {}
23218 pub fn handler() { helper(); }
23219}
23220
23221fn main() { api::handler(); }
23222"#,
23223 )
23224 .unwrap();
23225 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23226 db.apply_changes(dir.path()).unwrap();
23227
23228 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23229 let api = resolve_ast_span_node(&graph, "api", "mod");
23230 let helper = resolve_ast_span_node(&graph, "helper", "function");
23231 let handler = resolve_ast_span_node(&graph, "handler", "function");
23232
23233 assert_eq!(helper.kind, "ast_span");
23234 assert!(helper.handle.starts_with("span-"));
23235 assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
23236 assert!(graph.edges.iter().any(|edge| {
23237 edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
23238 }));
23239 assert!(graph.edges.iter().any(|edge| {
23240 edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
23241 }));
23242 assert!(graph.edges.iter().any(|edge| {
23243 edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
23244 }));
23245 assert!(graph.edges.iter().any(|edge| {
23246 edge.from == helper.handle
23247 && edge.to == handler.handle
23248 && edge.relation == "next_sibling"
23249 }));
23250 assert!(graph.edges.iter().any(|edge| {
23251 edge.from == handler.handle
23252 && edge.to == helper.handle
23253 && edge.relation == "previous_sibling"
23254 }));
23255 assert!(graph.edges.iter().any(|edge| {
23256 edge.from == helper.handle
23257 && edge.to == api.handle
23258 && edge.relation == "enclosing_module"
23259 }));
23260 assert!(graph.edges.iter().any(|edge| {
23261 edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
23262 }));
23263
23264 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23265 let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
23266 assert!(
23267 ast_nodes.iter().any(|node| node.id == helper.handle
23268 && node.properties.get("symbol_kind") == Some(&"function".to_string())),
23269 "expected helper AST span in graph store, got {ast_nodes:?}"
23270 );
23271 assert!(
23272 store
23273 .outgoing_edges(&helper.handle, Some("parent"))
23274 .unwrap()
23275 .iter()
23276 .any(|edge| edge.to_id == api.handle),
23277 "expected persisted AST parent edge"
23278 );
23279 }
23280
23281 #[test]
23282 fn traversal_graph_projects_markdown_section_block_edges() {
23283 let dir = tempfile::tempdir().unwrap();
23284 std::fs::write(
23285 dir.path().join("README.md"),
23286 "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
23287 )
23288 .unwrap();
23289 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23290 db.apply_changes(dir.path()).unwrap();
23291
23292 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23293 let guide = resolve_ast_span_node(&graph, "Guide", "heading");
23294 let code = resolve_ast_span_node(&graph, "rust", "code_block");
23295 let embedded = resolve_ast_span_node(&graph, "demo", "function");
23296 let list_item = graph
23297 .nodes
23298 .values()
23299 .find(|node| {
23300 node.kind == "ast_span"
23301 && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
23302 && node.properties.get("section_handle") == Some(&guide.handle)
23303 })
23304 .expect("missing Markdown list item AST span");
23305
23306 assert_eq!(
23307 code.properties.get("markdown_block_kind"),
23308 Some(&"fenced_code_block".to_string())
23309 );
23310 assert_eq!(
23311 guide.properties.get("heading_level"),
23312 Some(&"1".to_string())
23313 );
23314 assert_eq!(
23315 embedded.properties.get("embedded"),
23316 Some(&"true".to_string())
23317 );
23318 assert_eq!(
23319 embedded.properties.get("language"),
23320 Some(&"rust".to_string())
23321 );
23322 assert_eq!(
23323 embedded.properties.get("markdown_block_handle"),
23324 Some(&code.handle)
23325 );
23326 assert!(graph.edges.iter().any(|edge| {
23327 edge.from == guide.handle
23328 && edge.to == code.handle
23329 && edge.relation == "contains_markdown_block"
23330 }));
23331 assert!(graph.edges.iter().any(|edge| {
23332 edge.from == code.handle
23333 && edge.to == guide.handle
23334 && edge.relation == "enclosing_section"
23335 }));
23336 assert!(graph.edges.iter().any(|edge| {
23337 edge.from == guide.handle
23338 && edge.to == list_item.handle
23339 && edge.relation == "contains_markdown_block"
23340 }));
23341 assert!(graph.edges.iter().any(|edge| {
23342 edge.from == code.handle
23343 && edge.to == embedded.handle
23344 && edge.relation == "contains_embedded_symbol"
23345 }));
23346 assert!(graph.edges.iter().any(|edge| {
23347 edge.from == embedded.handle
23348 && edge.to == code.handle
23349 && edge.relation == "embedded_in_fence"
23350 }));
23351 assert!(graph.edges.iter().any(|edge| {
23352 edge.from == guide.handle
23353 && edge.to == embedded.handle
23354 && edge.relation == "contains_embedded_code"
23355 }));
23356
23357 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23358 assert!(
23359 store
23360 .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
23361 .unwrap()
23362 .iter()
23363 .any(|edge| edge.to_id == code.handle),
23364 "expected persisted Markdown section/block edge"
23365 );
23366 assert!(
23367 store
23368 .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
23369 .unwrap()
23370 .iter()
23371 .any(|edge| edge.to_id == embedded.handle),
23372 "expected persisted Markdown fence/embedded symbol edge"
23373 );
23374 }
23375
23376 #[test]
23377 fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
23378 let dir = setup_multilingual_ast_navigation_project();
23379 let db =
23380 index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
23381 let symbols = db.all_symbols().unwrap();
23382 let expected_symbols = [
23383 ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
23384 (
23385 "python",
23386 "fixture_nav_python_entry",
23387 "function",
23388 "python.py",
23389 ),
23390 (
23391 "typescript",
23392 "fixture_nav_typescript_entry",
23393 "function",
23394 "typescript.ts",
23395 ),
23396 (
23397 "javascript",
23398 "fixture_nav_javascript_entry",
23399 "function",
23400 "javascript.js",
23401 ),
23402 (
23403 "kotlin",
23404 "fixture_nav_kotlin_entry",
23405 "function",
23406 "kotlin.kt",
23407 ),
23408 ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
23409 ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
23410 ("markdown", "Fixture Section", "heading", "README.md"),
23411 ("markdown", "Fixture step", "list_item", "README.md"),
23412 ("markdown", "python", "code_block", "README.md"),
23413 ];
23414
23415 for (language, name, kind, file) in expected_symbols {
23416 let symbol = symbols
23417 .iter()
23418 .find(|symbol| {
23419 symbol.language == language
23420 && symbol.name == name
23421 && symbol.kind == kind
23422 && symbol.file.ends_with(file)
23423 })
23424 .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
23425 assert!(
23426 symbol.start_byte.is_some() && symbol.end_byte.is_some(),
23427 "{language} {name} should carry AST byte spans"
23428 );
23429 }
23430
23431 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23432 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23433 let expected_ast_nodes = [
23434 ("fixture_nav_rust_entry", "function", "rust"),
23435 ("fixture_nav_python_entry", "function", "python"),
23436 ("fixture_nav_typescript_entry", "function", "typescript"),
23437 ("fixture_nav_javascript_entry", "function", "javascript"),
23438 ("fixture_nav_kotlin_entry", "function", "kotlin"),
23439 ("fixture_nav_zig_entry", "function", "zig"),
23440 ("fixture_nav_bash_entry", "function", "bash"),
23441 ("Fixture Section", "heading", "markdown"),
23442 ("Fixture step", "list_item", "markdown"),
23443 ("python", "code_block", "markdown"),
23444 ("fixture_nav_markdown_embedded", "function", "python"),
23445 ];
23446
23447 for (name, kind, language) in expected_ast_nodes {
23448 let node = resolve_ast_span_node(&graph, name, kind);
23449 let repeated = resolve_ast_span_node(&graph_again, name, kind);
23450 assert!(
23451 node.handle.starts_with("span-"),
23452 "{name} handle: {}",
23453 node.handle
23454 );
23455 assert_eq!(
23456 node.handle, repeated.handle,
23457 "{language} {name} handle drifted"
23458 );
23459 assert_eq!(
23460 node.properties.get("language"),
23461 Some(&language.to_string()),
23462 "{name} should keep its language label"
23463 );
23464 }
23465
23466 let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
23467 let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
23468 let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
23469 assert!(graph.edges.iter().any(|edge| {
23470 edge.from == markdown_section.handle
23471 && edge.to == markdown_code.handle
23472 && edge.relation == "contains_markdown_block"
23473 }));
23474 assert!(graph.edges.iter().any(|edge| {
23475 edge.from == markdown_code.handle
23476 && edge.to == embedded.handle
23477 && edge.relation == "contains_embedded_symbol"
23478 }));
23479 assert!(
23480 graph.nodes.len() <= 80,
23481 "multilingual AST fixture should stay bounded, got {} nodes",
23482 graph.nodes.len()
23483 );
23484 assert!(
23485 graph.edges.len() <= 180,
23486 "multilingual AST fixture should stay bounded, got {} edges",
23487 graph.edges.len()
23488 );
23489
23490 let response = empty_search_response(dir.path(), "lexical");
23491 let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
23492 let report = build_relative_search_budget_report(
23493 "fixture_nav_python_entry",
23494 "lexical",
23495 dir.path(),
23496 &response,
23497 &symbol_hits,
23498 ResponseBudget::new(Some(8), Some(120)),
23499 &SearchFacetFilters::default(),
23500 );
23501 let report_again = build_relative_search_budget_report(
23502 "fixture_nav_python_entry",
23503 "lexical",
23504 dir.path(),
23505 &response,
23506 &symbol_hits,
23507 ResponseBudget::new(Some(8), Some(120)),
23508 &SearchFacetFilters::default(),
23509 );
23510
23511 let top = report
23512 .ranked
23513 .first()
23514 .expect("ranked preview should not be empty");
23515 assert_eq!(top.source, "symbol_span");
23516 assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
23517 assert!(top.handle.starts_with("srnk-"));
23518 assert_eq!(top.handle, report_again.ranked[0].handle);
23519 assert!(
23520 top.reasons.iter().any(|reason| reason == "ast_span"),
23521 "expected AST span ranking reason, got {:?}",
23522 top.reasons
23523 );
23524 assert!(report.ranked.len() <= 8);
23525 assert!(report.symbols.len() <= 8);
23526
23527 let symbol = report
23528 .symbols
23529 .iter()
23530 .find(|symbol| symbol.name == "fixture_nav_python_entry")
23531 .expect("missing search preview symbol");
23532 assert_cli_expand_command_parses(&symbol.expand);
23533 let ast = symbol
23534 .ast
23535 .as_ref()
23536 .expect("search symbol should expose AST");
23537 assert_cli_expand_command_parses(&ast.expand.source_window);
23538 assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
23539 assert_cli_expand_command_parses(&ast.expand.symbol_read);
23540
23541 let markdown_hits = db.symbol_search("python", 20).unwrap();
23542 let markdown_report = build_relative_search_budget_report(
23543 "python",
23544 "lexical",
23545 dir.path(),
23546 &response,
23547 &markdown_hits,
23548 ResponseBudget::new(Some(8), Some(120)),
23549 &SearchFacetFilters::default(),
23550 );
23551 let markdown_symbol = markdown_report
23552 .symbols
23553 .iter()
23554 .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
23555 .expect("missing Markdown code-block symbol");
23556 let markdown_ast = markdown_symbol
23557 .ast
23558 .as_ref()
23559 .expect("Markdown code block should expose AST");
23560 assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
23561 assert_eq!(
23562 markdown_ast
23563 .span
23564 .markdown
23565 .as_ref()
23566 .unwrap()
23567 .embedded_symbols[0]
23568 .name,
23569 "fixture_nav_markdown_embedded"
23570 );
23571 }
23572
23573 #[test]
23574 fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
23575 let edges = vec![
23576 TraversalEdge {
23577 from: "origin".to_string(),
23578 to: "aaa_low".to_string(),
23579 relation: "unknown".to_string(),
23580 label: None,
23581 weight: 1,
23582 },
23583 TraversalEdge {
23584 from: "origin".to_string(),
23585 to: "zzz_high".to_string(),
23586 relation: "mentions".to_string(),
23587 label: None,
23588 weight: 1,
23589 },
23590 ];
23591
23592 let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
23593
23594 assert!(handles.contains("origin"));
23595 assert!(handles.contains("zzz_high"), "{handles:?}");
23596 assert!(!handles.contains("aaa_low"), "{handles:?}");
23597 }
23598
23599 #[test]
23600 fn traversal_materializes_provider_neutral_sqlite_graph() {
23601 let dir = setup_traversal_project();
23602 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23603 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23604
23605 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23606 let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
23607 assert!(
23608 backlog_nodes.iter().any(|node| node.id == backlog.handle
23609 && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23610 "expected materialized backlog node, got {backlog_nodes:?}"
23611 );
23612 assert!(
23613 store
23614 .all_nodes()
23615 .unwrap()
23616 .iter()
23617 .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
23618 && node.properties.get("projection_version")
23619 == Some(&GRAPH_PROJECTION_VERSION.to_string())),
23620 "expected projection metadata node"
23621 );
23622 let source_handles = store.nodes_by_kind("source_handle").unwrap();
23623 assert!(
23624 source_handles
23625 .iter()
23626 .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
23627 "expected bounded source_handle rows, got {source_handles:?}"
23628 );
23629 let worker_context = store.nodes_by_kind("worker_context").unwrap();
23630 assert!(
23631 worker_context
23632 .iter()
23633 .any(|node| node.properties.get("target")
23634 == Some(&"tasks/software/tsift.md".to_string())),
23635 "expected bounded worker_context rows, got {worker_context:?}"
23636 );
23637 let worker_results = store.nodes_by_kind("worker_result").unwrap();
23638 assert!(
23639 worker_results.iter().any(|node| {
23640 node.properties.get("ref_id") == Some(&"kgnv".to_string())
23641 && node.properties.get("status") == Some(&"completed".to_string())
23642 && node.properties.get("touched_files") == Some(&"main.rs".to_string())
23643 && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
23644 }),
23645 "expected worker_result rows, got {worker_results:?}"
23646 );
23647 }
23648
23649 #[test]
23650 fn traversal_projection_materializes_cached_semantic_rows() {
23651 let dir = setup_traversal_project();
23652 seed_traversal_semantic_summaries(dir.path());
23653 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23654 let helper = resolve_traversal_node(&graph, "helper").unwrap();
23655 let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
23656 let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
23657
23658 assert_eq!(concept.kind, "semantic_concept");
23659 assert_eq!(entity.kind, "semantic_entity");
23660 assert!(concept.handle.starts_with("gcon-"));
23661 assert!(entity.handle.starts_with("gent-"));
23662
23663 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23664 assert!(
23665 store
23666 .nodes_by_kind("semantic_concept")
23667 .unwrap()
23668 .iter()
23669 .any(|node| node.label == "semantic extraction"
23670 && node.properties.contains_key("embedding")),
23671 "expected persisted concept embeddings"
23672 );
23673 assert!(
23674 store
23675 .outgoing_edges(&helper.handle, Some("mentions_concept"))
23676 .unwrap()
23677 .iter()
23678 .any(|edge| edge.to_id == concept.handle),
23679 "expected helper symbol to link to cached summary concept"
23680 );
23681 assert!(
23682 store
23683 .outgoing_edges(
23684 &semantic_entity_handle("helper", "function"),
23685 Some("semantic_relation")
23686 )
23687 .unwrap()
23688 .iter()
23689 .any(|edge| edge.to_id == entity.handle
23690 && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
23691 "expected LLM relationship rows projected into GraphStore"
23692 );
23693 }
23694
23695 #[test]
23696 fn traversal_projection_materializes_tsift_memory_rows() {
23697 let dir = setup_traversal_project();
23698 seed_tsift_memory_graph_db(dir.path());
23699 let memory_db = dir.path().join(".tsift").join("memory.db");
23700 let store = MemoryStore::open_or_create(&memory_db).unwrap();
23701 for summary in ["first closeout", "second closeout"] {
23702 let event = MemoryEvent::new(
23703 MemoryEventKind::ResponseSummary,
23704 "tasks/software/tsift.md",
23705 summary,
23706 )
23707 .with_session_id("tasks/software/tsift.md")
23708 .with_observed_at_unix(1_700_000_100);
23709 store.insert_event(&event).unwrap();
23710 }
23711 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23712 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23713
23714 let native_sources = store
23715 .nodes_by_kind("source_handle")
23716 .unwrap()
23717 .into_iter()
23718 .filter(|node| {
23719 node.properties.get("provider") == Some(&"tsift-memory".to_string())
23720 && node.properties.get("source_ref")
23721 == Some(&"tasks/software/tsift.md".to_string())
23722 })
23723 .collect::<Vec<_>>();
23724 assert_eq!(
23725 native_sources.len(),
23726 2,
23727 "same-source native memory events must get distinct source handles"
23728 );
23729
23730 let source = store
23731 .nodes_by_kind("source_handle")
23732 .unwrap()
23733 .into_iter()
23734 .find(|node| {
23735 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23736 })
23737 .expect("expected tsift-memory source handle");
23738 let session = store
23739 .nodes_by_kind("memory_session")
23740 .unwrap()
23741 .into_iter()
23742 .find(|node| {
23743 node.properties.get("provider") == Some(&"tsift-memory".to_string())
23744 && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
23745 })
23746 .expect("expected tsift-memory session node");
23747 let event = store
23748 .nodes_by_kind("memory_event")
23749 .unwrap()
23750 .into_iter()
23751 .find(|node| {
23752 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23753 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
23754 && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
23755 })
23756 .expect("expected tsift-memory event node");
23757 let concept = store
23758 .nodes_by_kind("semantic_concept")
23759 .unwrap()
23760 .into_iter()
23761 .find(|node| {
23762 node.properties.get("provider") == Some(&"tsift-memory".to_string())
23763 && node.label.contains("Graph memory adapter")
23764 && node.properties.contains_key("embedding")
23765 })
23766 .expect("expected tsift-memory semantic concept");
23767
23768 assert!(
23769 store
23770 .outgoing_edges(&session.id, Some("records_memory_source"))
23771 .unwrap()
23772 .iter()
23773 .any(|edge| edge.to_id == source.id),
23774 "expected session to link to source handle"
23775 );
23776 assert!(
23777 store
23778 .outgoing_edges(&session.id, Some("records_memory_event"))
23779 .unwrap()
23780 .iter()
23781 .any(|edge| edge.to_id == event.id),
23782 "expected session to link to memory event"
23783 );
23784 assert!(
23785 store
23786 .outgoing_edges(&event.id, Some("projects_source"))
23787 .unwrap()
23788 .iter()
23789 .any(|edge| edge.to_id == source.id),
23790 "expected memory event to project source handle"
23791 );
23792 assert!(
23793 store
23794 .outgoing_edges(&source.id, Some("mentions_concept"))
23795 .unwrap()
23796 .iter()
23797 .any(|edge| edge.to_id == concept.id),
23798 "expected source handle to seed semantic concept"
23799 );
23800
23801 let related = semantic_related_report_from_store(
23802 dir.path(),
23803 None,
23804 "tsift memory graph adapter",
23805 5,
23806 SemanticRelatedKind::Concept,
23807 &store,
23808 )
23809 .unwrap();
23810 assert!(
23811 related
23812 .items
23813 .iter()
23814 .any(|item| item.handle == concept.id && item.score > 0.0),
23815 "expected semantic query to retrieve tsift-memory concept, got {:?}",
23816 related.items
23817 );
23818
23819 let graph_related = graph_db_report_from_store(
23820 dir.path(),
23821 None,
23822 "sqlite",
23823 GraphDbQuery::Related {
23824 query: "tsift memory graph adapter".to_string(),
23825 kind: SemanticRelatedKind::Concept,
23826 depth: 1,
23827 seed_limit: 5,
23828 limit: 20,
23829 },
23830 &store,
23831 sqlite_graph_freshness(&store, "root").unwrap(),
23832 Vec::new(),
23833 )
23834 .unwrap();
23835 assert_eq!(
23836 graph_related
23837 .readiness
23838 .as_ref()
23839 .map(|readiness| readiness.status.as_str()),
23840 Some("ready"),
23841 "tsift-memory semantic rows should satisfy graph-db related readiness"
23842 );
23843 assert!(
23844 graph_related.nodes.iter().any(|node| {
23845 node.kind == "semantic_concept"
23846 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
23847 }),
23848 "expected related graph output to include tsift-memory semantic rows"
23849 );
23850 }
23851
23852 #[test]
23853 fn semantic_related_query_uses_persisted_graph_embeddings() {
23854 let dir = setup_traversal_project();
23855 seed_traversal_semantic_summaries(dir.path());
23856 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23857 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23858 let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
23859 .unwrap()
23860 .query_row(
23861 "SELECT COUNT(*) FROM graph_node_semantic_vectors",
23862 [],
23863 |row| row.get(0),
23864 )
23865 .unwrap();
23866 assert!(semantic_vector_rows > 0);
23867
23868 let report = semantic_related_report_from_store(
23869 dir.path(),
23870 None,
23871 "graph navigation",
23872 5,
23873 SemanticRelatedKind::Concept,
23874 &store,
23875 )
23876 .unwrap();
23877
23878 assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
23879 assert!(
23880 report
23881 .items
23882 .iter()
23883 .any(|item| item.label == "graph navigation"
23884 && item.kind == "semantic_concept"
23885 && item.score > 0.9),
23886 "expected nearest concept match from graph embeddings, got {:?}",
23887 report.items
23888 );
23889 }
23890
23891 #[test]
23892 fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
23893 let dir = setup_traversal_project();
23894 seed_traversal_semantic_summaries(dir.path());
23895 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23896 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23897
23898 let report = graph_db_report_from_store(
23899 dir.path(),
23900 None,
23901 "sqlite",
23902 GraphDbQuery::Related {
23903 query: "graph navigation".to_string(),
23904 kind: SemanticRelatedKind::All,
23905 depth: 1,
23906 seed_limit: 2,
23907 limit: 20,
23908 },
23909 &store,
23910 sqlite_graph_freshness(&store, "root").unwrap(),
23911 Vec::new(),
23912 )
23913 .unwrap();
23914
23915 let knowledge = report.knowledge_retrieval.as_ref().unwrap();
23916 assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
23917 assert_eq!(knowledge.seed_kind, "all");
23918 assert_eq!(knowledge.depth, 1);
23919 assert_eq!(
23920 report
23921 .readiness
23922 .as_ref()
23923 .map(|readiness| readiness.status.as_str()),
23924 Some("ready")
23925 );
23926 assert!(
23927 knowledge
23928 .diagnostics
23929 .iter()
23930 .any(|diagnostic| diagnostic.contains("incident"))
23931 );
23932 assert!(
23933 report
23934 .semantic_related
23935 .iter()
23936 .any(|item| item.label == "graph navigation"
23937 && item.kind == "semantic_concept"
23938 && item.score > 0.9),
23939 "expected natural-language query to seed the graph navigation concept, got {:?}",
23940 report.semantic_related
23941 );
23942 assert!(
23943 report
23944 .nodes
23945 .iter()
23946 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
23947 );
23948 assert!(
23949 report
23950 .nodes
23951 .iter()
23952 .any(|node| node.kind == "symbol" && node.label == "helper"),
23953 "incident expansion from semantic seed should recover source symbols, got {:?}",
23954 report
23955 .nodes
23956 .iter()
23957 .map(|node| (&node.kind, &node.label))
23958 .collect::<Vec<_>>()
23959 );
23960 assert!(
23961 report
23962 .edges
23963 .iter()
23964 .any(|edge| edge.kind == "mentions_concept")
23965 );
23966 assert!(
23967 report.output_budget.as_ref().is_some_and(|budget| budget
23968 .diagnostics
23969 .iter()
23970 .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
23971 "expected related output budget diagnostics, got {:?}",
23972 report.output_budget
23973 );
23974 }
23975
23976 #[test]
23977 fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
23978 let dir = setup_graph_index();
23979 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23980 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23981
23982 let report = graph_db_report_from_store(
23983 dir.path(),
23984 None,
23985 "sqlite",
23986 GraphDbQuery::Related {
23987 query: "graph navigation".to_string(),
23988 kind: SemanticRelatedKind::All,
23989 depth: 1,
23990 seed_limit: 2,
23991 limit: 20,
23992 },
23993 &store,
23994 sqlite_graph_freshness(&store, "root").unwrap(),
23995 Vec::new(),
23996 )
23997 .unwrap();
23998
23999 let readiness = report.readiness.as_ref().unwrap();
24000 assert_eq!(readiness.status, "blocked");
24001 assert_eq!(readiness.reason, "summary_cache_empty");
24002 assert!(readiness.fail_closed);
24003 assert_eq!(
24004 readiness.next_commands,
24005 vec![
24006 "tsift summarize --extract .".to_string(),
24007 graph_db_refresh_command(dir.path(), None)
24008 ]
24009 );
24010 assert!(
24011 report
24012 .knowledge_retrieval
24013 .as_ref()
24014 .unwrap()
24015 .diagnostics
24016 .iter()
24017 .any(|diagnostic| diagnostic.contains("summary cache empty")
24018 && diagnostic.contains("graph-db materialized code/session rows")),
24019 "expected related diagnostics to carry readiness gate, got {:?}",
24020 report.knowledge_retrieval.as_ref().unwrap().diagnostics
24021 );
24022 }
24023
24024 #[test]
24025 fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
24026 let mut nodes = vec![
24027 SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
24028 SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
24029 ];
24030 let mut edges = vec![SubstrateGraphEdge::new(
24031 "zzz_high",
24032 "seed",
24033 "mentions_concept",
24034 )];
24035 for idx in 0..24 {
24036 let id = format!("aaa_low_{idx:02}");
24037 nodes.push(SubstrateGraphNode::new(
24038 id.clone(),
24039 "note",
24040 format!("low {idx}"),
24041 ));
24042 edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
24043 }
24044 let mut store = SqliteGraphStore::in_memory().unwrap();
24045 store
24046 .replace_projection(&GraphProjection { nodes, edges })
24047 .unwrap();
24048
24049 let subgraph =
24050 graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
24051
24052 assert_eq!(subgraph.nodes.len(), 3);
24053 assert_eq!(subgraph.nodes[0].id, "seed");
24054 assert_eq!(
24055 subgraph.nodes[1].id, "zzz_high",
24056 "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
24057 subgraph.nodes
24058 );
24059 assert!(subgraph.truncated);
24060 assert!(
24061 subgraph
24062 .diagnostics
24063 .iter()
24064 .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
24065 "{:?}",
24066 subgraph.diagnostics
24067 );
24068 assert!(
24069 subgraph
24070 .diagnostics
24071 .iter()
24072 .any(|diagnostic| diagnostic.contains("skipped")),
24073 "{:?}",
24074 subgraph.diagnostics
24075 );
24076 }
24077
24078 #[test]
24079 fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
24080 let dir = setup_traversal_project();
24081 seed_traversal_semantic_summaries(dir.path());
24082 init_git_repo(dir.path());
24083 let session = dir.path().join("tasks/software/tsift.md");
24084 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24085 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24086 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24087 let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24088 root: dir.path(),
24089 scope: None,
24090 backend: "sqlite",
24091 target: "kgnv",
24092 depth: 4,
24093 limit: 8,
24094 cursor: None,
24095 store: &store,
24096 freshness,
24097 warnings: Vec::new(),
24098 })
24099 .unwrap();
24100 assert!(
24101 evidence
24102 .semantic_related
24103 .iter()
24104 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
24105 "expected semantic evidence rows, got {:?}",
24106 evidence
24107 .semantic_related
24108 .iter()
24109 .map(|node| (&node.kind, &node.label))
24110 .collect::<Vec<_>>()
24111 );
24112 assert!(
24113 evidence
24114 .output_budget
24115 .as_ref()
24116 .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
24117 diagnostic.contains("semantic_match")
24118 && diagnostic.contains("source_handle_coverage")
24119 })),
24120 "expected evidence output budget diagnostics, got {:?}",
24121 evidence.output_budget
24122 );
24123
24124 let cached_diff = diff_digest::compute(
24125 dir.path(),
24126 diff_digest::DiffDigestOptions {
24127 cached: true,
24128 revision: None,
24129 max_parsed_files: None,
24130 },
24131 )
24132 .unwrap();
24133 let impact_report = impact::compute(
24134 dir.path(),
24135 impact::ImpactOptions {
24136 cached: true,
24137 revision: None,
24138 scope: None,
24139 limit: 10,
24140 },
24141 )
24142 .unwrap();
24143 let graph_nodes = store.all_nodes().unwrap();
24144 let graph_index = conflict_matrix_graph_index(&graph_nodes);
24145 let semantic_candidate = conflict_matrix_candidate_from_evidence(
24146 dir.path(),
24147 &evidence,
24148 &graph_index,
24149 &cached_diff,
24150 &impact_report,
24151 );
24152 assert!(semantic_candidate.semantic_dispatch_score > 0);
24153 assert!(
24154 semantic_candidate
24155 .semantic_dispatch_reasons
24156 .iter()
24157 .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
24158 "expected semantic ranking explanations, got {:?}",
24159 semantic_candidate.semantic_dispatch_reasons
24160 );
24161 assert!(
24162 semantic_candidate
24163 .semantic_related
24164 .iter()
24165 .any(|item| item.label == "graph navigation")
24166 );
24167
24168 let mut plain_candidate = semantic_candidate.clone();
24169 plain_candidate.target = "plain".to_string();
24170 plain_candidate.semantic_related.clear();
24171 plain_candidate.semantic_dispatch_score = 0;
24172 plain_candidate.semantic_dispatch_reasons.clear();
24173 let mut ranked = [plain_candidate, semantic_candidate];
24174 ranked.sort_by(|left, right| {
24175 left.risk
24176 .cmp(&right.risk)
24177 .then_with(|| left.risk_score.cmp(&right.risk_score))
24178 .then_with(|| {
24179 right
24180 .semantic_dispatch_score
24181 .cmp(&left.semantic_dispatch_score)
24182 })
24183 .then_with(|| left.target.cmp(&right.target))
24184 });
24185 assert_eq!(ranked[0].target, "kgnv");
24186 }
24187
24188 #[test]
24189 fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
24190 let dir = setup_dependency_dag_project();
24191 let session = dir.path().join("tasks/software/tsift.md");
24192 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24193
24194 assert_eq!(report.contract_version, "dependency-dag-v1");
24195 assert_eq!(
24196 report.targets,
24197 vec![
24198 "prep".to_string(),
24199 "alpha".to_string(),
24200 "beta".to_string(),
24201 "gamma".to_string()
24202 ]
24203 );
24204 assert!(report.edges.iter().any(|edge| {
24205 edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
24206 }));
24207 assert!(report.edges.iter().any(|edge| {
24208 edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
24209 }));
24210 assert!(report.edges.iter().any(|edge| {
24211 edge.from == "alpha"
24212 && edge.to == "beta"
24213 && edge.kind == "shared_resource"
24214 && edge.shared_files.contains(&"main.rs".to_string())
24215 && edge.shared_symbols.contains(&"shared_helper".to_string())
24216 }));
24217 assert!(
24218 !report.cycle_diagnostics.has_cycles,
24219 "{:?}",
24220 report.cycle_diagnostics
24221 );
24222 assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
24223 assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
24224 assert!(
24225 report.replay_commands[0].contains("dependency-dag"),
24226 "{:?}",
24227 report.replay_commands
24228 );
24229
24230 cmd_dependency_dag(
24231 &session,
24232 None,
24233 &["alpha".to_string(), "beta".to_string()],
24234 4,
24235 12,
24236 OutputFormat {
24237 json_output: true,
24238 compact: false,
24239 pretty: false,
24240 terse: false,
24241 ultra_terse: false,
24242 schema: false,
24243 envelope: false,
24244 },
24245 )
24246 .unwrap();
24247 }
24248
24249 #[test]
24250 fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
24251 let dir = setup_dependency_dag_cycle_project();
24252 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24253
24254 assert!(report.cycle_diagnostics.has_cycles);
24255 assert_eq!(
24256 report.cycle_diagnostics.blocked_nodes,
24257 vec!["left".to_string(), "right".to_string()]
24258 );
24259 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24260 edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
24261 }));
24262 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24263 edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
24264 }));
24265 }
24266
24267 #[test]
24268 fn traversal_projection_queries_match_sqlite_and_convex_stores() {
24269 let dir = setup_traversal_project();
24270 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24271 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
24272
24273 let mut sqlite = SqliteGraphStore::in_memory().unwrap();
24274 sqlite.replace_projection(&projection).unwrap();
24275 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24276 projection.upsert_into(&convex).unwrap();
24277
24278 let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
24279 let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
24280 assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
24281 assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
24282
24283 let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
24284 let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
24285 assert!(convex_graph.edges.iter().any(|edge| {
24286 edge.from == sqlite_backlog.handle
24287 && edge.to == convex_helper.handle
24288 && edge.relation == "mentions"
24289 }));
24290 }
24291
24292 #[test]
24293 fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
24294 let dir = setup_traversal_project();
24295 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24296 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24297 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24298 assert_eq!(freshness.status, "current");
24299
24300 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24301 let report = graph_db_report_from_store(
24302 dir.path(),
24303 None,
24304 "sqlite",
24305 GraphDbQuery::Neighborhood {
24306 id: backlog.handle.clone(),
24307 depth: 1,
24308 edge_kind: Some("mentions".to_string()),
24309 cursor: None,
24310 limit: None,
24311 property_filters: Vec::new(),
24312 },
24313 &store,
24314 freshness,
24315 Vec::new(),
24316 )
24317 .unwrap();
24318 assert!(
24319 report
24320 .edges
24321 .iter()
24322 .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
24323 "expected backlog mention edge, got {:?}",
24324 report.edges
24325 );
24326 assert!(
24327 report.ranked_neighbors.iter().any(|neighbor| {
24328 neighbor.depth == Some(1)
24329 && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
24330 && neighbor.node_id != backlog.handle
24331 && neighbor.handle_coverage_pct >= 95.0
24332 && neighbor.duplicate_name_precision >= 0.99
24333 }),
24334 "expected ranked neighborhood neighbors with quality scores, got {:?}",
24335 report.ranked_neighbors
24336 );
24337 assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
24338 let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
24339 assert!(!ranking_gate.ranked_output_default);
24340 assert_eq!(ranking_gate.default_order, "stable_node_id");
24341 assert!(
24342 ranking_gate
24343 .diagnostics
24344 .iter()
24345 .any(|diagnostic| diagnostic.contains("score-capped")),
24346 "{ranking_gate:?}"
24347 );
24348 assert!(
24349 ranking_gate
24350 .required_metrics
24351 .iter()
24352 .any(|metric| metric == "handle_coverage_pct")
24353 );
24354 assert!(
24355 ranking_gate
24356 .required_metrics
24357 .iter()
24358 .any(|metric| metric == "duplicate_name_precision")
24359 );
24360 assert!(
24361 report
24362 .page
24363 .as_ref()
24364 .unwrap()
24365 .diagnostics
24366 .iter()
24367 .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
24368 "expected SQLite neighborhood query plan diagnostics, got {:?}",
24369 report.page.as_ref().unwrap().diagnostics
24370 );
24371 let edges_report = graph_db_report_from_store(
24372 dir.path(),
24373 None,
24374 "sqlite",
24375 GraphDbQuery::Edges {
24376 edge_kind: Some("mentions".to_string()),
24377 cursor: None,
24378 limit: Some(2),
24379 property_filters: Vec::new(),
24380 },
24381 &store,
24382 sqlite_graph_freshness(&store, "root").unwrap(),
24383 Vec::new(),
24384 )
24385 .unwrap();
24386 let edge_id = edges_report
24387 .edges
24388 .first()
24389 .map(|edge| edge.id.clone())
24390 .expect("expected at least one paged mentions edge");
24391 assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
24392 assert_eq!(
24393 edges_report.page.as_ref().unwrap().returned_edges,
24394 edges_report.edges.len()
24395 );
24396
24397 let edge_report = graph_db_report_from_store(
24398 dir.path(),
24399 None,
24400 "sqlite",
24401 GraphDbQuery::Edge {
24402 id: edge_id.clone(),
24403 },
24404 &store,
24405 sqlite_graph_freshness(&store, "root").unwrap(),
24406 Vec::new(),
24407 )
24408 .unwrap();
24409 assert_eq!(
24410 edge_report
24411 .edge
24412 .as_ref()
24413 .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
24414 Some(edge_id.clone())
24415 );
24416
24417 let incident_report = graph_db_report_from_store(
24418 dir.path(),
24419 None,
24420 "sqlite",
24421 GraphDbQuery::Incident {
24422 id: backlog.handle.clone(),
24423 edge_kind: Some("mentions".to_string()),
24424 cursor: None,
24425 limit: Some(1),
24426 property_filters: Vec::new(),
24427 },
24428 &store,
24429 sqlite_graph_freshness(&store, "root").unwrap(),
24430 Vec::new(),
24431 )
24432 .unwrap();
24433 assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
24434 assert!(
24435 incident_report
24436 .edges
24437 .iter()
24438 .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
24439 "{:?}",
24440 incident_report.edges
24441 );
24442
24443 let schema_report = graph_db_report_from_store(
24444 dir.path(),
24445 None,
24446 "sqlite",
24447 GraphDbQuery::Schema,
24448 &store,
24449 sqlite_graph_freshness(&store, "root").unwrap(),
24450 Vec::new(),
24451 )
24452 .unwrap();
24453 assert!(
24454 schema_report
24455 .schema
24456 .unwrap()
24457 .operations
24458 .iter()
24459 .any(|operation| operation.command.starts_with("neighborhood"))
24460 );
24461 }
24462
24463 #[test]
24464 fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
24465 let mut nodes = vec![SubstrateGraphNode::new(
24466 "origin",
24467 "backlog",
24468 "#budgeted-neighborhood",
24469 )];
24470 let mut edges = Vec::new();
24471 for idx in 0..32 {
24472 let id = format!("src-{idx:02}");
24473 nodes.push(
24474 SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
24475 .with_property("source_ref", format!("fixture:{idx}"))
24476 .with_property("detail", "x".repeat(600)),
24477 );
24478 edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
24479 }
24480 let store = SqliteGraphStore::in_memory().unwrap();
24481 GraphProjection { nodes, edges }
24482 .upsert_into(&store)
24483 .unwrap();
24484
24485 let report = graph_db_report_from_store(
24486 Path::new("."),
24487 None,
24488 "fixture",
24489 GraphDbQuery::Neighborhood {
24490 id: "origin".to_string(),
24491 depth: 1,
24492 edge_kind: None,
24493 cursor: None,
24494 limit: None,
24495 property_filters: Vec::new(),
24496 },
24497 &store,
24498 current_graph_db_freshness(),
24499 Vec::new(),
24500 )
24501 .unwrap();
24502 let budget = report.output_budget.as_ref().unwrap();
24503 assert!(budget.selected_nodes < budget.candidate_nodes);
24504 assert!(
24505 budget.dropped_by_budget.iter().any(|drop| {
24506 drop.item == "node"
24507 && drop.kind == "source_handle"
24508 && drop.reason == "per_kind_quota"
24509 }),
24510 "expected source_handle budget drops, got {:?}",
24511 budget.dropped_by_budget
24512 );
24513 assert!(report.page.as_ref().unwrap().truncated);
24514 assert!(
24515 report
24516 .page
24517 .as_ref()
24518 .unwrap()
24519 .diagnostics
24520 .iter()
24521 .any(|diagnostic| diagnostic.contains("budget ranking signals")),
24522 "{:?}",
24523 report.page
24524 );
24525 }
24526
24527 #[test]
24528 fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
24529 let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
24530 let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
24531 for idx in 0..8 {
24532 let id = format!("far-{idx:02}");
24533 nodes.push(SubstrateGraphNode::new(
24534 id.clone(),
24535 "note",
24536 format!("aaa deeper row {idx}"),
24537 ));
24538 depth_by_id.insert(id, 6);
24539 }
24540
24541 let origin_ids = vec!["target".to_string()];
24542 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
24543 &origin_ids,
24544 &BTreeMap::new(),
24545 nodes,
24546 Vec::new(),
24547 Some(3),
24548 Some(&depth_by_id),
24549 None,
24550 );
24551
24552 assert!(
24553 budgeted.nodes.iter().any(|node| node.id == "near"),
24554 "expected the shallow evidence row to outrank deeper rows, got {:?}",
24555 budgeted
24556 .nodes
24557 .iter()
24558 .map(|node| (&node.id, &node.label))
24559 .collect::<Vec<_>>()
24560 );
24561 assert!(
24562 budgeted.report.dropped_by_budget.iter().any(|drop| {
24563 drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
24564 }),
24565 "expected node quota drops, got {:?}",
24566 budgeted.report.dropped_by_budget
24567 );
24568 assert!(
24569 budgeted
24570 .report
24571 .diagnostics
24572 .iter()
24573 .any(|diagnostic| diagnostic.contains("depth")),
24574 "{:?}",
24575 budgeted.report.diagnostics
24576 );
24577 }
24578
24579 #[test]
24580 fn evidence_pagination_returns_next_cursor_when_truncated() {
24581 let mut nodes = vec![SubstrateGraphNode::new(
24582 "target".to_string(),
24583 "backlog_item",
24584 "target item".to_string(),
24585 )];
24586 let mut depth_by_id = BTreeMap::new();
24587 depth_by_id.insert("target".to_string(), 0);
24588 for idx in 0..20 {
24589 let id = format!("ev-{idx}");
24590 nodes.push(
24591 SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
24592 .with_property("detail", "x".repeat(400)),
24593 );
24594 depth_by_id.insert(id, 1);
24595 }
24596 let origin_ids = vec!["target".to_string()];
24597 let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
24598 &origin_ids,
24599 &BTreeMap::new(),
24600 nodes.clone(),
24601 Vec::new(),
24602 Some(3),
24603 Some(&depth_by_id),
24604 None,
24605 );
24606 assert!(
24607 first_page.truncated,
24608 "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
24609 first_page.nodes.len(),
24610 first_page.report.candidate_nodes
24611 );
24612 assert!(
24613 first_page.next_cursor.is_some(),
24614 "expected next_cursor when truncated"
24615 );
24616 let cursor = first_page.next_cursor.unwrap();
24617 assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
24618 let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
24619 let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
24620 &origin_ids,
24621 &BTreeMap::new(),
24622 nodes.clone(),
24623 Vec::new(),
24624 Some(3),
24625 Some(&depth_by_id),
24626 Some(&cursor),
24627 );
24628 let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
24629 let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
24630 assert!(
24631 overlap.is_empty(),
24632 "pages should not overlap, but found shared ids: {overlap:?}"
24633 );
24634 assert!(
24635 second_page
24636 .report
24637 .diagnostics
24638 .iter()
24639 .any(|d| d.contains("cursor skipped")),
24640 "expected cursor skip diagnostic, got {:?}",
24641 second_page.report.diagnostics
24642 );
24643 }
24644
24645 #[test]
24646 fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
24647 let mut nodes = vec![SubstrateGraphNode::new(
24648 "target".to_string(),
24649 "backlog_item",
24650 "target item".to_string(),
24651 )];
24652 let mut depth_by_id = BTreeMap::new();
24653 depth_by_id.insert("target".to_string(), 0);
24654 for idx in 0..3 {
24655 let id = format!("ev-{idx}");
24656 nodes.push(SubstrateGraphNode::new(
24657 id.clone(),
24658 "source_handle",
24659 format!("evidence row {idx}"),
24660 ));
24661 depth_by_id.insert(id, 1);
24662 }
24663 let origin_ids = vec!["target".to_string()];
24664 let result = graph_db_apply_output_budget_with_depths_and_cursor(
24665 &origin_ids,
24666 &BTreeMap::new(),
24667 nodes,
24668 Vec::new(),
24669 None,
24670 Some(&depth_by_id),
24671 None,
24672 );
24673 assert!(
24674 !result.truncated,
24675 "expected no truncation with small candidate set and default budget"
24676 );
24677 assert!(
24678 result.next_cursor.is_none(),
24679 "expected no next_cursor when not truncated"
24680 );
24681 }
24682
24683 #[test]
24684 fn evidence_pagination_invalid_cursor_returns_first_page() {
24685 let mut nodes = vec![SubstrateGraphNode::new(
24686 "target".to_string(),
24687 "backlog_item",
24688 "target item".to_string(),
24689 )];
24690 let mut depth_by_id = BTreeMap::new();
24691 depth_by_id.insert("target".to_string(), 0);
24692 for idx in 0..5 {
24693 let id = format!("ev-{idx}");
24694 nodes.push(SubstrateGraphNode::new(
24695 id.clone(),
24696 "source_handle",
24697 format!("evidence row {idx}"),
24698 ));
24699 depth_by_id.insert(id, 1);
24700 }
24701 let origin_ids = vec!["target".to_string()];
24702 let result = graph_db_apply_output_budget_with_depths_and_cursor(
24703 &origin_ids,
24704 &BTreeMap::new(),
24705 nodes.clone(),
24706 Vec::new(),
24707 None,
24708 Some(&depth_by_id),
24709 Some("nonexistent-id"),
24710 );
24711 assert!(
24712 result
24713 .report
24714 .diagnostics
24715 .iter()
24716 .any(|d| d.contains("cursor skipped 0")),
24717 "invalid cursor should skip 0 candidates, got {:?}",
24718 result.report.diagnostics
24719 );
24720 }
24721
24722 #[test]
24723 fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
24724 let dir = setup_traversal_project();
24725 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24726 let graph_db = dir.path().join(".tsift/graph.db");
24727 let _lock = hold_rollback_journal_lock(&graph_db);
24728
24729 let report =
24730 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24731 .unwrap();
24732
24733 assert_eq!(report.status, "current");
24734 assert_eq!(
24735 report.recovery,
24736 Some(index::ReadOnlyRecovery::SnapshotFallback)
24737 );
24738 assert!(
24739 report
24740 .warnings
24741 .iter()
24742 .any(|warning| warning.contains("rollback-journal lock")),
24743 "expected rollback-journal recovery warning, got {:?}",
24744 report.warnings
24745 );
24746 }
24747
24748 #[test]
24749 fn graph_db_status_copies_wal_sidecars_when_locked() {
24750 let dir = setup_traversal_project();
24751 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24752 let graph_db = dir.path().join(".tsift/graph.db");
24753 let _lock = hold_wal_database_lock(&graph_db);
24754
24755 let report =
24756 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24757 .unwrap();
24758
24759 assert_eq!(report.status, "current");
24760 assert_eq!(
24761 report.recovery,
24762 Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
24763 );
24764 assert!(
24765 report
24766 .warnings
24767 .iter()
24768 .any(|warning| warning.contains("WAL-aware snapshot fallback")),
24769 "expected WAL recovery warning, got {:?}",
24770 report.warnings
24771 );
24772 }
24773
24774 #[test]
24775 fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
24776 let dir = setup_traversal_project();
24777 let session = dir.path().join("tasks/software/tsift.md");
24778 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24779 let graph_db = dir.path().join(".tsift/graph.db");
24780 let _lock = hold_rollback_journal_lock(&graph_db);
24781
24782 let result = cmd_graph_db(
24783 &session,
24784 None,
24785 GraphDbBackend::Sqlite,
24786 None,
24787 GraphDbQuery::Evidence {
24788 target: "kgnv".to_string(),
24789 depth: 3,
24790 limit: 8,
24791 cursor: None,
24792 },
24793 OutputFormat {
24794 json_output: false,
24795 compact: true,
24796 pretty: false,
24797 terse: false,
24798 ultra_terse: false,
24799 schema: false,
24800 envelope: false,
24801 },
24802 );
24803
24804 assert!(result.is_ok());
24805 }
24806
24807 fn current_graph_db_freshness() -> GraphDbFreshnessReport {
24808 GraphDbFreshnessReport {
24809 status: "current".to_string(),
24810 fail_closed: false,
24811 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
24812 content_hash: Some("fixture".to_string()),
24813 source_watermark: None,
24814 diagnostics: Vec::new(),
24815 }
24816 }
24817
24818 #[test]
24819 fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
24820 let dir = setup_traversal_project();
24821 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24822 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24823 let stale = GraphDbFreshnessReport {
24824 status: "stale".to_string(),
24825 fail_closed: true,
24826 projection_version: Some("old-v0".to_string()),
24827 content_hash: None,
24828 source_watermark: None,
24829 diagnostics: vec!["projection content hash is missing".to_string()],
24830 };
24831
24832 let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24833 root: dir.path(),
24834 scope: None,
24835 backend: "sqlite",
24836 target: "kgnv",
24837 depth: 3,
24838 limit: 8,
24839 cursor: None,
24840 store: &store,
24841 freshness: stale,
24842 warnings: Vec::new(),
24843 }) {
24844 Ok(_) => panic!("stale graph freshness should fail closed"),
24845 Err(err) => err,
24846 };
24847 let message = err.to_string();
24848 assert!(message.contains("failed closed"), "{message}");
24849 assert!(message.contains("graph-db --path"), "{message}");
24850 assert!(message.contains("refresh --json"), "{message}");
24851 }
24852
24853 fn paged_graph_ids(
24854 store: &impl GraphStore,
24855 cursor: Option<&str>,
24856 ) -> (Vec<String>, GraphDbPageReport) {
24857 let report = graph_db_report_from_store(
24858 Path::new("."),
24859 None,
24860 "fixture",
24861 GraphDbQuery::Kind {
24862 kind: "backlog".to_string(),
24863 cursor: cursor.map(str::to_string),
24864 limit: Some(2),
24865 property_filters: vec!["phase=open".to_string()],
24866 },
24867 store,
24868 current_graph_db_freshness(),
24869 Vec::new(),
24870 )
24871 .unwrap();
24872 (
24873 report.nodes.iter().map(|node| node.id.clone()).collect(),
24874 report.page.unwrap(),
24875 )
24876 }
24877
24878 #[test]
24879 fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
24880 let nodes = (0..5)
24881 .map(|idx| {
24882 let phase = if idx == 1 { "closed" } else { "open" };
24883 SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
24884 .with_property("phase", phase)
24885 })
24886 .collect::<Vec<_>>();
24887 let projection = GraphProjection {
24888 nodes,
24889 edges: Vec::new(),
24890 };
24891 let sqlite = SqliteGraphStore::in_memory().unwrap();
24892 projection.upsert_into(&sqlite).unwrap();
24893 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24894 projection.upsert_into(&convex).unwrap();
24895
24896 let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
24897 let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
24898 assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
24899 assert_eq!(sqlite_first_ids, convex_first_ids);
24900 assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
24901 assert!(sqlite_first_page.truncated);
24902 assert_eq!(
24903 sqlite_first_page.returned_nodes,
24904 convex_first_page.returned_nodes
24905 );
24906 assert_eq!(
24907 sqlite_first_page.property_filters,
24908 convex_first_page.property_filters
24909 );
24910 assert!(
24911 sqlite_first_page
24912 .diagnostics
24913 .iter()
24914 .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
24915 "expected SQLite kind query plan diagnostics, got {:?}",
24916 sqlite_first_page.diagnostics
24917 );
24918
24919 let cursor = sqlite_first_page.next_cursor.as_deref();
24920 let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
24921 let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
24922 assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
24923 assert_eq!(sqlite_next_ids, convex_next_ids);
24924 assert_eq!(sqlite_next_page.next_cursor, None);
24925 assert!(!sqlite_next_page.truncated);
24926 assert_eq!(
24927 sqlite_next_page.returned_nodes,
24928 convex_next_page.returned_nodes
24929 );
24930 assert_eq!(
24931 sqlite_next_page.property_filters,
24932 convex_next_page.property_filters
24933 );
24934 }
24935
24936 #[test]
24937 fn traversal_shortest_path_crosses_artifacts_and_symbols() {
24938 let dir = setup_traversal_project();
24939 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24940 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24941 let main = resolve_traversal_node(&graph, "main").unwrap();
24942
24943 let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
24944 assert_eq!(path.first(), Some(&backlog.handle));
24945 assert_eq!(path.last(), Some(&main.handle));
24946 assert!(
24947 path.len() >= 3,
24948 "expected backlog -> symbol -> main, got {path:?}"
24949 );
24950 }
24951
24952 #[test]
24953 fn traversal_report_recommends_next_bugfix_nodes() {
24954 let dir = setup_traversal_project();
24955 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24956 let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
24957
24958 assert_eq!(report.mode, "neighborhood");
24959 assert!(
24960 report
24961 .recommendations
24962 .iter()
24963 .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
24964 "expected helper recommendation, got {:?}",
24965 report.recommendations
24966 );
24967 assert!(
24968 !report.exploration.source_windows.is_empty(),
24969 "expected exploration source windows"
24970 );
24971 assert!(
24972 report
24973 .exploration
24974 .no_reread_guidance
24975 .contains("avoid whole-file reads")
24976 );
24977 }
24978
24979 #[test]
24980 fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
24981 let dir = setup_traversal_project();
24982 std::thread::sleep(std::time::Duration::from_millis(50));
24983 std::fs::write(
24984 dir.path().join("main.rs"),
24985 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
24986 )
24987 .unwrap();
24988
24989 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24990
24991 assert!(
24992 graph
24993 .warnings
24994 .iter()
24995 .any(|warning| warning.contains("index refreshed")
24996 && warning.contains("graph traversal packet")),
24997 "expected refresh diagnostic, got {:?}",
24998 graph.warnings
24999 );
25000 assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
25001
25002 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
25003 let summary = db.compute_changes(dir.path()).unwrap();
25004 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
25005 }
25006
25007 #[test]
25008 fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
25009 let dir = setup_traversal_project();
25010 let db_path = dir.path().join(".tsift/index.db");
25011 let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
25012 std::thread::sleep(std::time::Duration::from_millis(50));
25013 std::fs::write(
25014 dir.path().join("main.rs"),
25015 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25016 )
25017 .unwrap();
25018
25019 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25020 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
25021
25022 assert!(
25023 graph
25024 .warnings
25025 .iter()
25026 .any(|warning| warning.contains("falling back to raw source file nodes")),
25027 "expected raw-source fallback diagnostic, got {:?}",
25028 graph.warnings
25029 );
25030 assert!(
25031 file.detail
25032 .as_deref()
25033 .is_some_and(|detail| detail.contains("raw source fallback")),
25034 "expected raw-source detail, got {:?}",
25035 file.detail
25036 );
25037 assert!(
25038 file.expand.contains("source-read"),
25039 "expected source-read fallback command, got {}",
25040 file.expand
25041 );
25042 assert!(
25043 resolve_traversal_node(&graph, "helper").is_none(),
25044 "stale symbol evidence should be skipped when refresh is blocked"
25045 );
25046 }
25047
25048 #[test]
25049 fn traversal_cmd_supports_json_and_html_outputs() {
25050 let dir = setup_traversal_project();
25051 cmd_traverse(
25052 Some("#kgnv"),
25053 Some("main"),
25054 dir.path(),
25055 None,
25056 1,
25057 50,
25058 TraverseFormat::Json,
25059 false,
25060 false,
25061 false,
25062 None,
25063 )
25064 .unwrap();
25065 cmd_traverse(
25066 None,
25067 None,
25068 dir.path(),
25069 None,
25070 1,
25071 50,
25072 TraverseFormat::Html,
25073 false,
25074 false,
25075 false,
25076 None,
25077 )
25078 .unwrap();
25079 }
25080
25081 #[test]
25082 fn traversal_html_renders_inline_graph_visualization() {
25083 let dir = setup_traversal_project();
25084 seed_traversal_semantic_summaries(dir.path());
25085 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25086 let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
25087 let html = traversal_report_html(&report).unwrap();
25088
25089 assert!(html.contains("id=\"graph-canvas\""));
25090 assert!(html.contains("semantic_concept"));
25091 assert!(html.contains("graph navigation"));
25092 assert!(html.contains("JSON.parse"));
25093 }
25094
25095 #[test]
25096 fn compact_helpers_trim_scores_and_snippets() {
25097 assert_eq!(format_score(0.12345, true), "0.12");
25098 assert_eq!(format_score(0.12345, false), "0.1235");
25099 let snippet = compact_snippet(" first line with useful context\nsecond");
25100 assert_eq!(snippet.as_deref(), Some("first line with useful context"));
25101 }
25102
25103 #[test]
25104 fn compact_members_caps_list() {
25105 let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
25106 .iter()
25107 .map(|n| graph::CommunityMember::new(*n))
25108 .collect();
25109 assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
25110 }
25111
25112 #[test]
25113 fn abbreviate_kind_maps_common_kinds() {
25114 assert_eq!(abbreviate_kind("function"), "fn");
25115 assert_eq!(abbreviate_kind("method"), "meth");
25116 assert_eq!(abbreviate_kind("class"), "cls");
25117 assert_eq!(abbreviate_kind("interface"), "iface");
25118 assert_eq!(abbreviate_kind("type_alias"), "type");
25119 assert_eq!(abbreviate_kind("data_class"), "data_cls");
25120 assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
25121 assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
25122 assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
25123 assert_eq!(abbreviate_kind("object"), "obj");
25124 assert_eq!(abbreviate_kind("heading"), "h");
25125 assert_eq!(abbreviate_kind("code_block"), "code");
25126 assert_eq!(abbreviate_kind("struct"), "struct");
25128 assert_eq!(abbreviate_kind("trait"), "trait");
25129 assert_eq!(abbreviate_kind("enum"), "enum");
25130 assert_eq!(abbreviate_kind("const"), "const");
25131 assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
25132 }
25133
25134 #[test]
25135 fn abbreviate_match_type_maps_search_types() {
25136 assert_eq!(abbreviate_match_type("exact_name"), "exact");
25137 assert_eq!(abbreviate_match_type("partial_tags"), "partial");
25138 assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
25139 assert_eq!(abbreviate_match_type("other_type"), "other_type");
25140 }
25141
25142 #[test]
25143 fn explain_compact_groups_edges_by_file() {
25144 let edges = vec![
25145 index::StoredEdge {
25146 caller_file: "src/main.rs".to_string(),
25147 caller_name: "main".to_string(),
25148 caller_line: 1,
25149 callee_name: "helper".to_string(),
25150 call_site_line: 2,
25151 tagpath_handle: None,
25152 },
25153 index::StoredEdge {
25154 caller_file: "src/main.rs".to_string(),
25155 caller_name: "main".to_string(),
25156 caller_line: 1,
25157 callee_name: "render".to_string(),
25158 call_site_line: 3,
25159 tagpath_handle: None,
25160 },
25161 ];
25162 let lines = format_edge_groups(&edges, false);
25163 assert_eq!(lines, vec![" src/main.rs (2): helper, render"]);
25164 }
25165
25166 #[test]
25167 fn search_hit_groups_preserve_file_counts_and_samples() {
25168 let dir = tempfile::tempdir().unwrap();
25169 let root = dir.path();
25170 let main_rs = root.join("src/main.rs");
25171 fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
25172 fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
25173 let freshness = exact_search_file_timestamp(&main_rs);
25174 let hits = vec![
25175 sift::SearchHit {
25176 artifact_id: "a".to_string(),
25177 artifact_kind: sift::ContextArtifactKind::File,
25178 path: main_rs.display().to_string(),
25179 rank: 1,
25180 score: 10.0,
25181 confidence: sift::ScoreConfidence::High,
25182 location: Some("line 3".to_string()),
25183 snippet: "claudescore-3 anchor".to_string(),
25184 provenance: sift::ArtifactProvenance {
25185 adapter: sift::AcquisitionAdapterKind::FileSystem,
25186 source: "ripgrep -F".to_string(),
25187 synthetic: false,
25188 },
25189 freshness: freshness.clone(),
25190 budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
25191 },
25192 sift::SearchHit {
25193 artifact_id: "b".to_string(),
25194 artifact_kind: sift::ContextArtifactKind::File,
25195 path: main_rs.display().to_string(),
25196 rank: 2,
25197 score: 9.0,
25198 confidence: sift::ScoreConfidence::High,
25199 location: Some("line 7".to_string()),
25200 snippet: "claudescore-3 follow-up".to_string(),
25201 provenance: sift::ArtifactProvenance {
25202 adapter: sift::AcquisitionAdapterKind::FileSystem,
25203 source: "ripgrep -F".to_string(),
25204 synthetic: false,
25205 },
25206 freshness: freshness.clone(),
25207 budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
25208 },
25209 sift::SearchHit {
25210 artifact_id: "c".to_string(),
25211 artifact_kind: sift::ContextArtifactKind::File,
25212 path: main_rs.display().to_string(),
25213 rank: 3,
25214 score: 8.0,
25215 confidence: sift::ScoreConfidence::High,
25216 location: Some("line 9".to_string()),
25217 snippet: "claudescore-3 tail".to_string(),
25218 provenance: sift::ArtifactProvenance {
25219 adapter: sift::AcquisitionAdapterKind::FileSystem,
25220 source: "ripgrep -F".to_string(),
25221 synthetic: false,
25222 },
25223 freshness,
25224 budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
25225 },
25226 ];
25227
25228 let groups = group_search_hits(&hits, root, false);
25229 assert_eq!(groups.len(), 1);
25230 assert_eq!(groups[0].path, "src/main.rs");
25231 assert_eq!(groups[0].hits, 3);
25232 assert_eq!(
25233 groups[0].samples,
25234 vec![
25235 "line 3: claudescore-3 anchor".to_string(),
25236 "line 7: claudescore-3 follow-up".to_string()
25237 ]
25238 );
25239 assert!(should_collapse_search_hits(&hits, root, false));
25240 }
25241
25242 #[test]
25243 fn dense_edge_groups_trigger_collapse() {
25244 let edges = vec![
25245 index::StoredEdge {
25246 caller_file: "src/main.rs".to_string(),
25247 caller_name: "main".to_string(),
25248 caller_line: 1,
25249 callee_name: "helper".to_string(),
25250 call_site_line: 2,
25251 tagpath_handle: None,
25252 },
25253 index::StoredEdge {
25254 caller_file: "src/main.rs".to_string(),
25255 caller_name: "beta".to_string(),
25256 caller_line: 5,
25257 callee_name: "helper".to_string(),
25258 call_site_line: 6,
25259 tagpath_handle: None,
25260 },
25261 index::StoredEdge {
25262 caller_file: "src/main.rs".to_string(),
25263 caller_name: "gamma".to_string(),
25264 caller_line: 9,
25265 callee_name: "helper".to_string(),
25266 call_site_line: 10,
25267 tagpath_handle: None,
25268 },
25269 ];
25270 assert!(should_collapse_edge_groups(&edges));
25271 }
25272
25273 fn setup_workspace() -> tempfile::TempDir {
25276 let dir = tempfile::tempdir().unwrap();
25277 let root = dir.path();
25278 std::fs::write(
25279 root.join(".gitmodules"),
25280 r#"[submodule "src/alpha"]
25281 path = src/alpha
25282 url = https://example.com/alpha
25283[submodule "src/beta"]
25284 path = src/beta
25285 url = https://example.com/beta
25286"#,
25287 )
25288 .unwrap();
25289 let alpha = root.join("src/alpha");
25290 let beta = root.join("src/beta");
25291 std::fs::create_dir_all(&alpha).unwrap();
25292 std::fs::create_dir_all(&beta).unwrap();
25293 std::fs::write(
25294 alpha.join("lib.rs"),
25295 "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
25296 )
25297 .unwrap();
25298 std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
25299 dir
25300 }
25301
25302 fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
25303 let dir = tempfile::tempdir().unwrap();
25304 let root = dir.path();
25305 std::fs::write(
25306 root.join(".gitmodules"),
25307 r#"[submodule "pkg/app/foo"]
25308 path = pkg/app/foo
25309 url = https://example.com/pkg-app-foo
25310[submodule "vendor/foo"]
25311 path = vendor/foo
25312 url = https://example.com/vendor-foo
25313"#,
25314 )
25315 .unwrap();
25316 let pkg_foo = root.join("pkg/app/foo");
25317 let vendor_foo = root.join("vendor/foo");
25318 std::fs::create_dir_all(&pkg_foo).unwrap();
25319 std::fs::create_dir_all(&vendor_foo).unwrap();
25320 std::fs::write(
25321 pkg_foo.join("lib.rs"),
25322 "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
25323 )
25324 .unwrap();
25325 std::fs::write(
25326 vendor_foo.join("lib.rs"),
25327 "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
25328 )
25329 .unwrap();
25330 dir
25331 }
25332
25333 #[test]
25334 fn workspace_index_creates_per_submodule_dbs() {
25335 let dir = setup_workspace();
25336 cmd_index(
25337 dir.path(),
25338 false,
25339 false,
25340 false,
25341 false,
25342 false,
25343 true,
25344 None,
25345 false,
25346 false,
25347 false,
25348 false,
25349 false,
25350 false,
25351 )
25352 .unwrap();
25353 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25354 assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
25355 }
25356
25357 #[test]
25358 fn workspace_index_single_submodule() {
25359 let dir = setup_workspace();
25360 cmd_index(
25361 dir.path(),
25362 false,
25363 false,
25364 false,
25365 false,
25366 false,
25367 false,
25368 Some("alpha"),
25369 false,
25370 false,
25371 false,
25372 false,
25373 false,
25374 false,
25375 )
25376 .unwrap();
25377 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25378 assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
25379 }
25380
25381 #[test]
25382 fn workspace_index_single_submodule_errors_on_unknown_scope() {
25383 let dir = setup_workspace();
25384
25385 let err = cmd_index(
25386 dir.path(),
25387 false,
25388 false,
25389 false,
25390 false,
25391 false,
25392 false,
25393 Some("missing"),
25394 false,
25395 false,
25396 false,
25397 false,
25398 false,
25399 false,
25400 )
25401 .unwrap_err();
25402
25403 let msg = err.to_string();
25404 assert!(msg.contains("unknown scope `missing`"));
25405 assert!(msg.contains("Available scopes: alpha, beta"));
25406 assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
25407 }
25408
25409 #[test]
25410 fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
25411 let dir = setup_workspace_with_duplicate_leaf_names();
25412 cmd_index(
25413 dir.path(),
25414 false,
25415 false,
25416 false,
25417 false,
25418 false,
25419 true,
25420 None,
25421 false,
25422 false,
25423 false,
25424 false,
25425 false,
25426 false,
25427 )
25428 .unwrap();
25429
25430 assert!(
25431 dir.path()
25432 .join(".tsift/indexes/pkg/app/foo/index.db")
25433 .exists()
25434 );
25435 assert!(
25436 dir.path()
25437 .join(".tsift/indexes/vendor/foo/index.db")
25438 .exists()
25439 );
25440 }
25441
25442 #[test]
25443 fn federated_search_across_submodules() {
25444 let dir = setup_workspace();
25445 cmd_index(
25446 dir.path(),
25447 false,
25448 false,
25449 false,
25450 false,
25451 false,
25452 true,
25453 None,
25454 false,
25455 false,
25456 false,
25457 false,
25458 false,
25459 false,
25460 )
25461 .unwrap();
25462 let (hits, _diag) = federated_symbol_search(
25463 dir.path(),
25464 "alpha_helper",
25465 10,
25466 &TagpathSearchOpts {
25467 no_tagpath: true,
25468 strict: false,
25469 },
25470 )
25471 .unwrap();
25472 assert!(
25473 !hits.is_empty(),
25474 "should find alpha_helper via federated search"
25475 );
25476 }
25477
25478 #[test]
25479 fn federated_search_respects_isolation() {
25480 let dir = setup_workspace();
25481 let tsift_dir = dir.path().join(".tsift");
25482 std::fs::create_dir_all(&tsift_dir).unwrap();
25483 std::fs::write(
25484 tsift_dir.join("config.toml"),
25485 r#"
25486[overrides.alpha]
25487tier = "isolated"
25488"#,
25489 )
25490 .unwrap();
25491 cmd_index(
25492 dir.path(),
25493 false,
25494 false,
25495 false,
25496 false,
25497 false,
25498 true,
25499 None,
25500 false,
25501 false,
25502 false,
25503 false,
25504 false,
25505 false,
25506 )
25507 .unwrap();
25508 let (hits, _diag) = federated_symbol_search(
25509 dir.path(),
25510 "alpha_helper",
25511 10,
25512 &TagpathSearchOpts {
25513 no_tagpath: true,
25514 strict: false,
25515 },
25516 )
25517 .unwrap();
25518 assert!(
25519 hits.is_empty(),
25520 "isolated submodule should not appear in federated search"
25521 );
25522 }
25523
25524 #[test]
25525 fn federated_lexical_search_respects_isolation() {
25526 let dir = setup_workspace();
25527 let tsift_dir = dir.path().join(".tsift");
25528 std::fs::create_dir_all(&tsift_dir).unwrap();
25529 std::fs::write(
25530 tsift_dir.join("config.toml"),
25531 r#"
25532[overrides.alpha]
25533tier = "isolated"
25534"#,
25535 )
25536 .unwrap();
25537 cmd_index(
25538 dir.path(),
25539 false,
25540 false,
25541 false,
25542 false,
25543 false,
25544 true,
25545 None,
25546 false,
25547 false,
25548 false,
25549 false,
25550 false,
25551 false,
25552 )
25553 .unwrap();
25554
25555 let response = federated_sift_search(
25556 dir.path(),
25557 &dir.path().join(".tsift/search-cache"),
25558 "fn",
25559 10,
25560 0,
25561 "lexical",
25562 )
25563 .unwrap();
25564
25565 assert!(
25566 !response.hits.is_empty(),
25567 "shared scopes should still contribute lexical hits"
25568 );
25569 assert!(
25570 response
25571 .hits
25572 .iter()
25573 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25574 "isolated scope should not leak lexical hits: {:?}",
25575 response.hits
25576 );
25577 }
25578
25579 #[test]
25580 fn federated_lexical_search_respects_private_tier() {
25581 let dir = setup_workspace();
25582 let tsift_dir = dir.path().join(".tsift");
25583 std::fs::create_dir_all(&tsift_dir).unwrap();
25584 std::fs::write(
25585 tsift_dir.join("config.toml"),
25586 r#"
25587[overrides.alpha]
25588tier = "private"
25589"#,
25590 )
25591 .unwrap();
25592 cmd_index(
25593 dir.path(),
25594 false,
25595 false,
25596 false,
25597 false,
25598 false,
25599 true,
25600 None,
25601 false,
25602 false,
25603 false,
25604 false,
25605 false,
25606 false,
25607 )
25608 .unwrap();
25609
25610 let response = federated_sift_search(
25611 dir.path(),
25612 &dir.path().join(".tsift/search-cache"),
25613 "fn",
25614 10,
25615 0,
25616 "lexical",
25617 )
25618 .unwrap();
25619
25620 assert!(
25621 !response.hits.is_empty(),
25622 "shared scopes should still contribute lexical hits"
25623 );
25624 assert!(
25625 response
25626 .hits
25627 .iter()
25628 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25629 "private scope should not leak lexical hits: {:?}",
25630 response.hits
25631 );
25632 }
25633
25634 #[test]
25635 fn scoped_search_finds_submodule_symbols() {
25636 let dir = setup_workspace();
25637 cmd_index(
25638 dir.path(),
25639 false,
25640 false,
25641 false,
25642 false,
25643 false,
25644 true,
25645 None,
25646 false,
25647 false,
25648 false,
25649 false,
25650 false,
25651 false,
25652 )
25653 .unwrap();
25654 let cfg = config::Config::load(dir.path()).unwrap();
25655 let db_path = cfg.db_path_for(dir.path(), "alpha");
25656 let db = index::IndexDb::open(&db_path).unwrap();
25657 let hits = db.symbol_search("alpha_main", 10).unwrap();
25658 assert!(!hits.is_empty());
25659 assert_eq!(hits[0].name, "alpha_main");
25660 }
25661
25662 #[test]
25663 fn scoped_search_cmd_errors_on_unknown_scope() {
25664 let dir = setup_workspace();
25665
25666 let err = cmd_search(
25667 "alpha_main".to_string(),
25668 Some(dir.path().to_path_buf()),
25669 5,
25670 Some("lexical".to_string()),
25671 Some("missing".to_string()),
25672 false,
25673 false,
25674 false,
25675 0,
25676 false,
25677 false,
25678 false,
25679 false,
25680 false,
25681 false,
25682 false,
25683 )
25684 .unwrap_err();
25685
25686 let msg = err.to_string();
25687 assert!(msg.contains("unknown scope `missing`"));
25688 assert!(msg.contains("Available scopes: alpha, beta"));
25689 }
25690
25691 #[test]
25692 fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
25693 let dir = setup_workspace_with_duplicate_leaf_names();
25694 cmd_index(
25695 dir.path(),
25696 false,
25697 false,
25698 false,
25699 false,
25700 false,
25701 true,
25702 None,
25703 false,
25704 false,
25705 false,
25706 false,
25707 false,
25708 false,
25709 )
25710 .unwrap();
25711
25712 let err = cmd_search(
25713 "vendor_only".to_string(),
25714 Some(dir.path().to_path_buf()),
25715 5,
25716 Some("lexical".to_string()),
25717 Some("foo".to_string()),
25718 false,
25719 false,
25720 false,
25721 0,
25722 false,
25723 false,
25724 false,
25725 false,
25726 false,
25727 false,
25728 false,
25729 )
25730 .unwrap_err();
25731
25732 let msg = err.to_string();
25733 assert!(msg.contains("ambiguous scope `foo`"));
25734 assert!(msg.contains("pkg/app/foo"));
25735 assert!(msg.contains("vendor/foo"));
25736 }
25737
25738 #[test]
25739 fn scoped_graph_query() {
25740 let dir = setup_workspace();
25741 cmd_index(
25742 dir.path(),
25743 false,
25744 false,
25745 false,
25746 false,
25747 false,
25748 true,
25749 None,
25750 false,
25751 false,
25752 false,
25753 false,
25754 false,
25755 false,
25756 )
25757 .unwrap();
25758 let cfg = config::Config::load(dir.path()).unwrap();
25759 let db_path = cfg.db_path_for(dir.path(), "alpha");
25760 let db = index::IndexDb::open(&db_path).unwrap();
25761 let callees = db.callees_of("alpha_main").unwrap();
25762 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
25763 assert!(names.contains(&"alpha_helper"));
25764 }
25765
25766 fn assert_workspace_query_requires_scope(err: anyhow::Error) {
25767 let msg = err.to_string();
25768 assert!(msg.contains("require `--scope <scope>`"), "{msg}");
25769 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
25770 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
25771 assert!(
25772 !msg.contains("no index found at"),
25773 "workspace query should fail with scope guidance, got: {msg}"
25774 );
25775 }
25776
25777 fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
25778 let msg = err.to_string();
25779 assert!(
25780 msg.contains("requires `--scope <scope>` or `--federated`"),
25781 "{msg}"
25782 );
25783 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
25784 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
25785 assert!(
25786 !msg.contains("autoindexing index"),
25787 "workspace search should fail before creating a shared root index: {msg}"
25788 );
25789 }
25790
25791 #[test]
25792 fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
25793 let dir = setup_workspace();
25794 cmd_index(
25795 dir.path(),
25796 false,
25797 false,
25798 false,
25799 false,
25800 false,
25801 true,
25802 None,
25803 false,
25804 false,
25805 false,
25806 false,
25807 false,
25808 false,
25809 )
25810 .unwrap();
25811
25812 let err = cmd_graph(
25813 "alpha_main",
25814 dir.path(),
25815 false,
25816 false,
25817 None,
25818 20,
25819 false,
25820 false,
25821 false,
25822 false,
25823 false,
25824 false,
25825 false,
25826 TagpathSearchOpts::default(),
25827 )
25828 .unwrap_err();
25829
25830 assert_workspace_query_requires_scope(err);
25831 }
25832
25833 #[test]
25834 fn graph_cmd_infers_scope_from_nested_workspace_path() {
25835 let dir = setup_workspace();
25836 cmd_index(
25837 dir.path(),
25838 false,
25839 false,
25840 false,
25841 false,
25842 false,
25843 true,
25844 None,
25845 false,
25846 false,
25847 false,
25848 false,
25849 false,
25850 false,
25851 )
25852 .unwrap();
25853 let nested = dir.path().join("src/alpha/nested");
25854 std::fs::create_dir_all(&nested).unwrap();
25855
25856 let result = cmd_graph(
25857 "alpha_main",
25858 &nested,
25859 false,
25860 false,
25861 None,
25862 20,
25863 false,
25864 false,
25865 false,
25866 false,
25867 false,
25868 false,
25869 false,
25870 TagpathSearchOpts::default(),
25871 );
25872
25873 assert!(result.is_ok());
25874 }
25875
25876 #[test]
25877 fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
25878 let dir = setup_workspace();
25879 cmd_index(
25880 dir.path(),
25881 false,
25882 false,
25883 false,
25884 false,
25885 false,
25886 true,
25887 None,
25888 false,
25889 false,
25890 false,
25891 false,
25892 false,
25893 false,
25894 )
25895 .unwrap();
25896
25897 let err = cmd_communities(
25898 dir.path(),
25899 None,
25900 1,
25901 10,
25902 false,
25903 false,
25904 false,
25905 false,
25906 false,
25907 false,
25908 TagpathSearchOpts::default(),
25909 )
25910 .unwrap_err();
25911
25912 assert_workspace_query_requires_scope(err);
25913 }
25914
25915 #[test]
25916 fn communities_cmd_infers_scope_from_nested_workspace_path() {
25917 let dir = setup_workspace();
25918 cmd_index(
25919 dir.path(),
25920 false,
25921 false,
25922 false,
25923 false,
25924 false,
25925 true,
25926 None,
25927 false,
25928 false,
25929 false,
25930 false,
25931 false,
25932 false,
25933 )
25934 .unwrap();
25935 let nested = dir.path().join("src/alpha/nested");
25936 std::fs::create_dir_all(&nested).unwrap();
25937
25938 let result = cmd_communities(
25939 &nested,
25940 None,
25941 1,
25942 10,
25943 false,
25944 false,
25945 false,
25946 false,
25947 false,
25948 false,
25949 TagpathSearchOpts::default(),
25950 );
25951
25952 assert!(result.is_ok());
25953 }
25954
25955 #[test]
25956 fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
25957 let dir = setup_workspace();
25958 cmd_index(
25959 dir.path(),
25960 false,
25961 false,
25962 false,
25963 false,
25964 false,
25965 true,
25966 None,
25967 false,
25968 false,
25969 false,
25970 false,
25971 false,
25972 false,
25973 )
25974 .unwrap();
25975
25976 let err = cmd_path(
25977 "alpha_main",
25978 "alpha_helper",
25979 dir.path(),
25980 None,
25981 false,
25982 false,
25983 false,
25984 false,
25985 false,
25986 TagpathSearchOpts::default(),
25987 )
25988 .unwrap_err();
25989
25990 assert_workspace_query_requires_scope(err);
25991 }
25992
25993 #[test]
25994 fn path_cmd_infers_scope_from_nested_workspace_path() {
25995 let dir = setup_workspace();
25996 cmd_index(
25997 dir.path(),
25998 false,
25999 false,
26000 false,
26001 false,
26002 false,
26003 true,
26004 None,
26005 false,
26006 false,
26007 false,
26008 false,
26009 false,
26010 false,
26011 )
26012 .unwrap();
26013 let nested = dir.path().join("src/alpha/nested");
26014 std::fs::create_dir_all(&nested).unwrap();
26015
26016 let result = cmd_path(
26017 "alpha_main",
26018 "alpha_helper",
26019 &nested,
26020 None,
26021 false,
26022 false,
26023 false,
26024 false,
26025 false,
26026 TagpathSearchOpts::default(),
26027 );
26028
26029 assert!(result.is_ok());
26030 }
26031
26032 #[test]
26033 fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26034 let dir = setup_graph_index();
26035 let db_path = dir.path().join(".tsift/index.db");
26036 let _lock = hold_rollback_journal_lock(&db_path);
26037
26038 let result = cmd_path(
26039 "main",
26040 "helper",
26041 dir.path(),
26042 None,
26043 false,
26044 false,
26045 false,
26046 false,
26047 false,
26048 TagpathSearchOpts::default(),
26049 );
26050
26051 assert!(result.is_ok());
26052 }
26053
26054 #[test]
26055 fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
26056 let dir = setup_workspace();
26057 cmd_index(
26058 dir.path(),
26059 false,
26060 false,
26061 false,
26062 false,
26063 false,
26064 true,
26065 None,
26066 false,
26067 false,
26068 false,
26069 false,
26070 false,
26071 false,
26072 )
26073 .unwrap();
26074
26075 let err = cmd_explain(
26076 "alpha_main",
26077 dir.path(),
26078 None,
26079 15,
26080 false,
26081 false,
26082 false,
26083 false,
26084 false,
26085 false,
26086 false,
26087 false,
26088 )
26089 .unwrap_err();
26090
26091 assert_workspace_query_requires_scope(err);
26092 }
26093
26094 #[test]
26095 fn explain_cmd_infers_scope_from_nested_workspace_path() {
26096 let dir = setup_workspace();
26097 cmd_index(
26098 dir.path(),
26099 false,
26100 false,
26101 false,
26102 false,
26103 false,
26104 true,
26105 None,
26106 false,
26107 false,
26108 false,
26109 false,
26110 false,
26111 false,
26112 )
26113 .unwrap();
26114 let nested = dir.path().join("src/alpha/nested");
26115 std::fs::create_dir_all(&nested).unwrap();
26116
26117 let result = cmd_explain(
26118 "alpha_main",
26119 &nested,
26120 None,
26121 15,
26122 false,
26123 false,
26124 false,
26125 false,
26126 false,
26127 false,
26128 false,
26129 false,
26130 );
26131
26132 assert!(result.is_ok());
26133 }
26134
26135 #[test]
26136 fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26137 let dir = setup_graph_index();
26138 let db_path = dir.path().join(".tsift/index.db");
26139 let _lock = hold_rollback_journal_lock(&db_path);
26140
26141 let result = cmd_explain(
26142 "main",
26143 dir.path(),
26144 None,
26145 15,
26146 false,
26147 false,
26148 false,
26149 false,
26150 false,
26151 false,
26152 false,
26153 false,
26154 );
26155
26156 assert!(result.is_ok());
26157 }
26158
26159 #[test]
26162 fn community_detection_groups_related() {
26163 let dir = setup_graph_index();
26164 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26165 let edges = db.all_edges().unwrap();
26166 let result = graph::detect_communities(&edges);
26167 assert!(result.node_count > 0);
26168 assert!(!result.communities.is_empty());
26169 }
26170
26171 #[test]
26172 fn community_cmd_autoindexes_missing_index_by_default() {
26173 let dir = tempfile::tempdir().unwrap();
26174 let result = cmd_communities(
26175 dir.path(),
26176 None,
26177 2,
26178 10,
26179 false,
26180 false,
26181 false,
26182 false,
26183 false,
26184 false,
26185 TagpathSearchOpts::default(),
26186 );
26187
26188 assert!(result.is_ok());
26189 assert!(dir.path().join(".tsift/index.db").exists());
26190 }
26191
26192 #[test]
26195 fn path_finds_connected_symbols() {
26196 let dir = setup_graph_index();
26197 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26198 let edges = db.all_edges().unwrap();
26199 let result = graph::shortest_path(&edges, "main", "helper");
26200 assert!(result.is_some());
26201 let path = result.unwrap();
26202 assert_eq!(path.hops, 1);
26203 }
26204
26205 #[test]
26206 fn path_returns_none_for_unknown() {
26207 let dir = setup_graph_index();
26208 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26209 let edges = db.all_edges().unwrap();
26210 assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
26211 }
26212
26213 #[test]
26214 fn path_cmd_autoindexes_missing_index_by_default() {
26215 let dir = tempfile::tempdir().unwrap();
26216 let result = cmd_path(
26217 "a",
26218 "b",
26219 dir.path(),
26220 None,
26221 false,
26222 false,
26223 false,
26224 false,
26225 false,
26226 TagpathSearchOpts::default(),
26227 );
26228
26229 assert!(result.is_ok());
26230 assert!(dir.path().join(".tsift/index.db").exists());
26231 }
26232
26233 #[test]
26236 fn explain_shows_symbol_info() {
26237 let dir = setup_graph_index();
26238 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26239 let symbols = db.symbol_info("main").unwrap();
26240 assert!(!symbols.is_empty());
26241 assert_eq!(symbols[0].name, "main");
26242 assert_eq!(symbols[0].kind, "function");
26243 }
26244
26245 #[test]
26246 fn explain_cmd_autoindexes_missing_index_by_default() {
26247 let dir = tempfile::tempdir().unwrap();
26248 let result = cmd_explain(
26249 "main",
26250 dir.path(),
26251 None,
26252 15,
26253 false,
26254 false,
26255 false,
26256 false,
26257 false,
26258 false,
26259 false,
26260 false,
26261 );
26262
26263 assert!(result.is_ok());
26264 assert!(dir.path().join(".tsift/index.db").exists());
26265 }
26266
26267 fn hold_write_lock(db_path: &std::path::Path) -> Connection {
26268 let conn = Connection::open(db_path).unwrap();
26269 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
26270 conn
26271 }
26272
26273 fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
26274 use fs4::fs_std::FileExt;
26275 use std::io::Write;
26276
26277 let mut file = std::fs::OpenOptions::new()
26278 .read(true)
26279 .write(true)
26280 .create(true)
26281 .truncate(false)
26282 .open(lock_path)
26283 .unwrap();
26284 assert!(file.try_lock_exclusive().unwrap());
26285 writeln!(file, "{}", std::process::id()).unwrap();
26286 file
26287 }
26288
26289 fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
26290 let conn = Connection::open(db_path).unwrap();
26291 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26292 .unwrap();
26293 std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
26294 conn
26295 }
26296
26297 fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
26298 let conn = Connection::open(db_path).unwrap();
26299 conn.execute_batch(
26300 "PRAGMA journal_mode=WAL;
26301 PRAGMA wal_autocheckpoint=0;
26302 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
26303 INSERT INTO wal_lock_probe DEFAULT VALUES;
26304 PRAGMA locking_mode=EXCLUSIVE;
26305 BEGIN EXCLUSIVE;",
26306 )
26307 .unwrap();
26308 assert!(substrate::wal_sidecar_path(db_path).exists());
26309 conn
26310 }
26311
26312 #[test]
26313 fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
26314 let dir = setup_graph_index();
26315 let db_path = dir.path().join(".tsift/index.db");
26316 let _lock = hold_wal_database_lock(&db_path);
26317
26318 let err = cmd_index(
26319 dir.path(),
26320 false,
26321 false,
26322 false,
26323 false,
26324 false,
26325 false,
26326 None,
26327 false,
26328 false,
26329 false,
26330 false,
26331 false,
26332 false,
26333 )
26334 .unwrap_err();
26335
26336 let msg = err.to_string();
26337 assert!(msg.contains("indexing"));
26338 assert!(msg.contains("lock diagnostics:"));
26339 assert!(msg.contains("lock: absent"));
26340 assert!(msg.contains("wal: present") || msg.contains("shm: present"));
26341 assert!(msg.contains("wedged writer holding live WAL sidecars"));
26342 assert!(msg.contains("snapshot fallback"));
26343 }
26344
26345 #[test]
26346 fn search_cmd_succeeds_while_writer_lock_is_held() {
26347 let dir = setup_graph_index();
26348 let db_path = dir.path().join(".tsift/index.db");
26349 let _lock = hold_write_lock(&db_path);
26350
26351 let result = cmd_search(
26352 "main".to_string(),
26353 Some(dir.path().to_path_buf()),
26354 5,
26355 Some("lexical".to_string()),
26356 None,
26357 false,
26358 false,
26359 false,
26360 0,
26361 true,
26362 false,
26363 false,
26364 false,
26365 false,
26366 false,
26367 false,
26368 );
26369
26370 assert!(result.is_ok());
26371 }
26372
26373 #[test]
26374 fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
26375 let dir = setup_graph_index();
26376 let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
26377
26378 let result = cmd_search(
26379 "main".to_string(),
26380 Some(dir.path().to_path_buf()),
26381 5,
26382 Some("lexical".to_string()),
26383 None,
26384 false,
26385 false,
26386 false,
26387 0,
26388 true,
26389 false,
26390 false,
26391 false,
26392 false,
26393 false,
26394 false,
26395 );
26396
26397 assert!(result.is_ok());
26398 }
26399
26400 #[test]
26401 fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
26402 let dir = setup_graph_index();
26403 let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
26404
26405 let result = cmd_search(
26406 "main".to_string(),
26407 Some(dir.path().to_path_buf()),
26408 5,
26409 Some("lexical".to_string()),
26410 None,
26411 false,
26412 false,
26413 false,
26414 0,
26415 true,
26416 false,
26417 false,
26418 false,
26419 false,
26420 false,
26421 false,
26422 );
26423
26424 assert!(result.is_ok());
26425 }
26426
26427 #[test]
26428 fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
26429 let dir = setup_graph_index();
26430 std::thread::sleep(std::time::Duration::from_millis(50));
26431 std::fs::write(
26432 dir.path().join("main.rs"),
26433 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26434 )
26435 .unwrap();
26436
26437 let err = cmd_search(
26438 "helper".to_string(),
26439 Some(dir.path().to_path_buf()),
26440 5,
26441 Some("lexical".to_string()),
26442 None,
26443 false,
26444 false,
26445 false,
26446 0,
26447 false,
26448 false,
26449 false,
26450 false,
26451 false,
26452 false,
26453 false,
26454 )
26455 .unwrap_err();
26456
26457 assert!(err.to_string().contains("search aborted"));
26458 assert!(err.to_string().contains("index is stale"));
26459 assert!(err.to_string().contains("--no-autoindex"));
26460 }
26461
26462 #[test]
26463 fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
26464 let dir = setup_graph_index();
26465 std::thread::sleep(std::time::Duration::from_millis(50));
26466 std::fs::write(
26467 dir.path().join("main.rs"),
26468 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26469 )
26470 .unwrap();
26471 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26472
26473 let err = cmd_search(
26474 "helper".to_string(),
26475 Some(dir.path().to_path_buf()),
26476 5,
26477 Some("lexical".to_string()),
26478 None,
26479 false,
26480 false,
26481 false,
26482 0,
26483 false,
26484 false,
26485 false,
26486 false,
26487 false,
26488 false,
26489 false,
26490 )
26491 .unwrap_err();
26492
26493 assert!(err.to_string().contains("search aborted"));
26494 assert!(err.to_string().contains("index is stale"));
26495 assert!(!err.to_string().contains("database is locked"));
26496 }
26497
26498 #[test]
26499 fn search_cmd_autoindexes_stale_index_by_default() {
26500 let dir = setup_graph_index();
26501 std::thread::sleep(std::time::Duration::from_millis(50));
26502 std::fs::write(
26503 dir.path().join("main.rs"),
26504 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26505 )
26506 .unwrap();
26507
26508 let result = cmd_search(
26509 "helper".to_string(),
26510 Some(dir.path().to_path_buf()),
26511 5,
26512 Some("lexical".to_string()),
26513 None,
26514 false,
26515 false,
26516 true,
26517 0,
26518 false,
26519 false,
26520 false,
26521 false,
26522 false,
26523 false,
26524 false,
26525 );
26526
26527 assert!(result.is_ok());
26528
26529 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26530 let summary = db.compute_changes(dir.path()).unwrap();
26531 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26532 }
26533
26534 #[test]
26535 fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
26536 let dir = setup_graph_index();
26537 std::thread::sleep(std::time::Duration::from_millis(50));
26538 std::fs::write(
26539 dir.path().join("main.rs"),
26540 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26541 )
26542 .unwrap();
26543 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
26544
26545 let result = cmd_search(
26546 "helper".to_string(),
26547 Some(dir.path().to_path_buf()),
26548 5,
26549 Some("lexical".to_string()),
26550 None,
26551 false,
26552 false,
26553 true,
26554 0,
26555 false,
26556 false,
26557 false,
26558 false,
26559 false,
26560 false,
26561 false,
26562 );
26563
26564 assert!(result.is_ok());
26565
26566 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26567 let summary = db.compute_changes(dir.path()).unwrap();
26568 assert_eq!(summary.modified, 1);
26569 }
26570
26571 #[test]
26572 fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
26573 let dir = setup_graph_index();
26574 std::thread::sleep(std::time::Duration::from_millis(50));
26575 std::fs::write(
26576 dir.path().join("main.rs"),
26577 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26578 )
26579 .unwrap();
26580 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26581
26582 let err = cmd_search(
26583 "helper".to_string(),
26584 Some(dir.path().to_path_buf()),
26585 5,
26586 Some("lexical".to_string()),
26587 None,
26588 false,
26589 false,
26590 true,
26591 0,
26592 false,
26593 false,
26594 false,
26595 false,
26596 false,
26597 false,
26598 false,
26599 )
26600 .unwrap_err();
26601
26602 let msg = err.to_string();
26603 assert!(msg.contains("autoindexing index"));
26604 assert!(msg.contains("lock diagnostics:"));
26605 assert!(msg.contains("journal: present"));
26606 assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
26607 }
26608
26609 #[test]
26610 fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
26611 let dir = setup_graph_index();
26612 let nested = dir.path().join("src/nested");
26613 std::fs::create_dir_all(&nested).unwrap();
26614
26615 let result = cmd_search(
26616 "helper".to_string(),
26617 Some(nested.clone()),
26618 5,
26619 Some("lexical".to_string()),
26620 None,
26621 false,
26622 false,
26623 true,
26624 0,
26625 false,
26626 false,
26627 false,
26628 false,
26629 false,
26630 false,
26631 false,
26632 );
26633
26634 assert!(result.is_ok());
26635 assert!(!nested.join(".tsift/index.db").exists());
26636 }
26637
26638 #[test]
26639 fn exact_search_returns_literal_matches() {
26640 let dir = tempfile::tempdir().unwrap();
26641 std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
26642
26643 let response = run_exact_search_with_timeout(dir.path(), "claudescore-3", 5, 0).unwrap();
26644
26645 assert_eq!(response.strategy, "exact");
26646 assert_eq!(response.hits.len(), 1);
26647 assert!(response.hits[0].path.ends_with("notes.txt"));
26648 assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
26649 assert!(response.hits[0].snippet.contains("claudescore-3"));
26650 }
26651
26652 #[test]
26653 fn exact_search_skips_stale_index_precheck() {
26654 let dir = setup_graph_index();
26655 std::thread::sleep(std::time::Duration::from_millis(50));
26656 std::fs::write(
26657 dir.path().join("main.rs"),
26658 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
26659 )
26660 .unwrap();
26661
26662 let result = cmd_search(
26663 "println!(\"updated\")".to_string(),
26664 Some(dir.path().to_path_buf()),
26665 5,
26666 Some("exact".to_string()),
26667 None,
26668 false,
26669 false,
26670 false,
26671 0,
26672 false,
26673 false,
26674 false,
26675 false,
26676 false,
26677 false,
26678 false,
26679 );
26680
26681 assert!(result.is_ok());
26682 }
26683
26684 #[test]
26685 fn workspace_exact_search_does_not_require_shared_root_index() {
26686 let dir = setup_workspace();
26687 cmd_index(
26688 dir.path(),
26689 false,
26690 false,
26691 false,
26692 false,
26693 false,
26694 true,
26695 None,
26696 false,
26697 false,
26698 false,
26699 false,
26700 false,
26701 false,
26702 )
26703 .unwrap();
26704
26705 let result = cmd_search(
26706 "alpha_helper".to_string(),
26707 Some(dir.path().to_path_buf()),
26708 5,
26709 Some("exact".to_string()),
26710 None,
26711 false,
26712 false,
26713 false,
26714 0,
26715 false,
26716 false,
26717 false,
26718 false,
26719 false,
26720 false,
26721 false,
26722 );
26723
26724 assert!(result.is_ok());
26725 assert!(!dir.path().join(".tsift/index.db").exists());
26726 }
26727
26728 #[test]
26729 fn identifier_like_query_prefers_exact_search() {
26730 assert!(query_prefers_exact_search("claudescore-3"));
26731 assert!(query_prefers_exact_search("alpha_helper"));
26732 assert!(query_prefers_exact_search("src/main.rs"));
26733 assert!(query_prefers_exact_search("crate::module"));
26734 assert!(!query_prefers_exact_search("authenticate"));
26735 assert!(!query_prefers_exact_search("fn main"));
26736 assert!(!query_prefers_exact_search("."));
26737 }
26738
26739 #[test]
26740 fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
26741 assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
26742 assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
26743 assert_eq!(
26744 resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
26745 "hybrid"
26746 );
26747 }
26748
26749 #[test]
26750 fn workspace_identifier_like_search_auto_uses_exact_backend() {
26751 let dir = setup_workspace();
26752 cmd_index(
26753 dir.path(),
26754 false,
26755 false,
26756 false,
26757 false,
26758 false,
26759 true,
26760 None,
26761 false,
26762 false,
26763 false,
26764 false,
26765 false,
26766 false,
26767 )
26768 .unwrap();
26769
26770 let result = cmd_search(
26771 "alpha_helper".to_string(),
26772 Some(dir.path().to_path_buf()),
26773 5,
26774 None,
26775 None,
26776 false,
26777 false,
26778 false,
26779 0,
26780 false,
26781 false,
26782 false,
26783 false,
26784 false,
26785 false,
26786 false,
26787 );
26788
26789 assert!(result.is_ok());
26790 assert!(!dir.path().join(".tsift/index.db").exists());
26791 }
26792
26793 #[test]
26794 fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
26795 let dir = setup_graph_index();
26796 let nested = dir.path().join("src/nested");
26797 std::fs::create_dir_all(&nested).unwrap();
26798 std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
26799
26800 let result = cmd_index(
26801 &nested, false, false, false, false, false, false, None, false, false, false, false,
26802 false, false,
26803 );
26804
26805 assert!(result.is_ok());
26806 assert!(dir.path().join(".tsift/index.db").exists());
26807 assert!(!nested.join(".tsift/index.db").exists());
26808 }
26809
26810 #[test]
26811 fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
26812 let dir = setup_workspace();
26813 let nested = dir.path().join("docs/nested");
26814 std::fs::create_dir_all(&nested).unwrap();
26815
26816 let result = cmd_index(
26817 &nested, false, false, false, false, false, true, None, false, false, false, false,
26818 false, false,
26819 );
26820
26821 let cfg = config::Config::load(dir.path()).unwrap();
26822
26823 assert!(result.is_ok());
26824 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
26825 assert!(cfg.db_path_for(dir.path(), "beta").exists());
26826 }
26827
26828 #[test]
26829 fn status_cmd_autoindexes_missing_workspace_scopes() {
26830 let dir = setup_workspace();
26831 let cfg = config::Config::load(dir.path()).unwrap();
26832 let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
26833 let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
26834 let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
26835 alpha_db.apply_changes(&alpha.source_root).unwrap();
26836
26837 let beta_db_path = cfg.db_path_for(dir.path(), "beta");
26838 assert!(!beta_db_path.exists());
26839
26840 cmd_status(
26841 dir.path(),
26842 StatusCommandOptions {
26843 fix: false,
26844 no_fix: false,
26845 json_output: true,
26846 compact: false,
26847 pretty: false,
26848 terse: false,
26849 schema: false,
26850 },
26851 )
26852 .unwrap();
26853
26854 assert!(beta_db_path.exists());
26855 let report = status::check_status(dir.path()).unwrap();
26856 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
26857 }
26858
26859 #[test]
26860 fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
26861 let dir = setup_workspace();
26862 let cfg = config::Config::load(dir.path()).unwrap();
26863
26864 cmd_status(
26865 dir.path(),
26866 StatusCommandOptions {
26867 fix: false,
26868 no_fix: false,
26869 json_output: true,
26870 compact: false,
26871 pretty: false,
26872 terse: false,
26873 schema: false,
26874 },
26875 )
26876 .unwrap();
26877
26878 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
26879 assert!(cfg.db_path_for(dir.path(), "beta").exists());
26880 let report = status::check_status(dir.path()).unwrap();
26881 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
26882 }
26883
26884 #[test]
26885 fn status_cmd_fix_refreshes_stale_index() {
26886 let dir = setup_graph_index();
26887 std::thread::sleep(std::time::Duration::from_millis(50));
26888 std::fs::write(
26889 dir.path().join("main.rs"),
26890 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
26891 )
26892 .unwrap();
26893
26894 let report = status::check_status(dir.path()).unwrap();
26895 assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
26896
26897 cmd_status(
26898 dir.path(),
26899 StatusCommandOptions {
26900 fix: false,
26901 no_fix: false,
26902 json_output: true,
26903 compact: false,
26904 pretty: false,
26905 terse: false,
26906 schema: false,
26907 },
26908 )
26909 .unwrap();
26910
26911 let report = status::check_status(dir.path()).unwrap();
26912 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
26913 }
26914
26915 #[test]
26916 fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
26917 let dir = setup_graph_index();
26918 let db_path = dir.path().join(".tsift/index.db");
26919 let _lock = hold_wal_database_lock(&db_path);
26920
26921 cmd_status(
26922 dir.path(),
26923 StatusCommandOptions {
26924 fix: false,
26925 no_fix: false,
26926 json_output: true,
26927 compact: false,
26928 pretty: false,
26929 terse: false,
26930 schema: false,
26931 },
26932 )
26933 .unwrap();
26934
26935 let report = status::check_status(dir.path()).unwrap();
26936 assert!(matches!(
26937 report.index,
26938 status::IndexStatus::Fresh {
26939 recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
26940 ..
26941 }
26942 ));
26943 let locks = status::check_locks(dir.path(), None, None).unwrap();
26944 assert!(matches!(
26945 locks.writer_lock,
26946 status::WriterLockStatus::Absent { .. }
26947 ));
26948 assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
26949 assert!(
26950 locks
26951 .recommended_action
26952 .contains("wedged writer holding live WAL sidecars")
26953 );
26954 }
26955
26956 #[test]
26957 fn locks_report_uses_ancestor_project_root_for_nested_paths() {
26958 let dir = setup_graph_index();
26959 let nested = dir.path().join("src/nested");
26960 std::fs::create_dir_all(&nested).unwrap();
26961
26962 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
26963 let report = status::check_locks(&root, Some(&nested), None).unwrap();
26964
26965 assert_eq!(report.source_root, dir.path());
26966 assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
26967 }
26968
26969 #[test]
26970 fn workspace_locks_report_infers_scope_from_nested_path() {
26971 let dir = setup_workspace();
26972 cmd_index(
26973 dir.path(),
26974 false,
26975 false,
26976 false,
26977 false,
26978 false,
26979 true,
26980 None,
26981 false,
26982 false,
26983 false,
26984 false,
26985 false,
26986 false,
26987 )
26988 .unwrap();
26989 let nested = dir.path().join("src/alpha/nested");
26990 std::fs::create_dir_all(&nested).unwrap();
26991
26992 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
26993 let report = status::check_locks(&root, Some(&nested), None).unwrap();
26994 let cfg = config::Config::load(dir.path()).unwrap();
26995
26996 assert_eq!(report.label, "submodule `alpha` index");
26997 assert_eq!(report.source_root, dir.path().join("src/alpha"));
26998 assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
26999 assert_eq!(
27000 report.reindex_command,
27001 format!("tsift index --submodule alpha {}", dir.path().display())
27002 );
27003 }
27004
27005 #[test]
27006 fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
27007 let dir = setup_workspace();
27008 cmd_index(
27009 dir.path(),
27010 false,
27011 false,
27012 false,
27013 false,
27014 false,
27015 true,
27016 None,
27017 false,
27018 false,
27019 false,
27020 false,
27021 false,
27022 false,
27023 )
27024 .unwrap();
27025
27026 let alpha = dir.path().join("src/alpha/lib.rs");
27027 std::thread::sleep(std::time::Duration::from_millis(50));
27028 std::fs::write(
27029 &alpha,
27030 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27031 )
27032 .unwrap();
27033
27034 let result = cmd_search(
27035 "alpha_helper".to_string(),
27036 Some(dir.path().to_path_buf()),
27037 5,
27038 Some("lexical".to_string()),
27039 Some("alpha".to_string()),
27040 false,
27041 false,
27042 true,
27043 0,
27044 false,
27045 false,
27046 false,
27047 false,
27048 false,
27049 false,
27050 false,
27051 );
27052
27053 assert!(result.is_ok());
27054
27055 let cfg = config::Config::load(dir.path()).unwrap();
27056 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27057 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27058 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27059 }
27060
27061 #[test]
27062 fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27063 let dir = setup_workspace();
27064 cmd_index(
27065 dir.path(),
27066 false,
27067 false,
27068 false,
27069 false,
27070 false,
27071 true,
27072 None,
27073 false,
27074 false,
27075 false,
27076 false,
27077 false,
27078 false,
27079 )
27080 .unwrap();
27081
27082 let alpha = dir.path().join("src/alpha/lib.rs");
27083 std::thread::sleep(std::time::Duration::from_millis(50));
27084 std::fs::write(
27085 &alpha,
27086 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27087 )
27088 .unwrap();
27089
27090 let cfg = config::Config::load(dir.path()).unwrap();
27091 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27092
27093 let err = cmd_search(
27094 "alpha_helper".to_string(),
27095 Some(dir.path().to_path_buf()),
27096 5,
27097 Some("lexical".to_string()),
27098 Some("alpha".to_string()),
27099 false,
27100 false,
27101 false,
27102 0,
27103 false,
27104 false,
27105 false,
27106 false,
27107 false,
27108 false,
27109 false,
27110 )
27111 .unwrap_err();
27112
27113 assert!(err.to_string().contains("search aborted"));
27114 assert!(err.to_string().contains("submodule `alpha` index"));
27115 assert!(!err.to_string().contains("database is locked"));
27116 }
27117
27118 #[test]
27119 fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
27120 let dir = setup_workspace();
27121 cmd_index(
27122 dir.path(),
27123 false,
27124 false,
27125 false,
27126 false,
27127 false,
27128 true,
27129 None,
27130 false,
27131 false,
27132 false,
27133 false,
27134 false,
27135 false,
27136 )
27137 .unwrap();
27138
27139 let alpha = dir.path().join("src/alpha/lib.rs");
27140 std::thread::sleep(std::time::Duration::from_millis(50));
27141 std::fs::write(
27142 &alpha,
27143 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27144 )
27145 .unwrap();
27146
27147 let result = cmd_search(
27148 "alpha_helper".to_string(),
27149 Some(dir.path().to_path_buf()),
27150 5,
27151 Some("lexical".to_string()),
27152 None,
27153 true,
27154 false,
27155 true,
27156 0,
27157 false,
27158 false,
27159 false,
27160 false,
27161 false,
27162 false,
27163 false,
27164 );
27165
27166 assert!(result.is_ok());
27167
27168 let cfg = config::Config::load(dir.path()).unwrap();
27169 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27170 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27171 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27172 }
27173
27174 #[test]
27175 fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27176 let dir = setup_workspace();
27177 cmd_index(
27178 dir.path(),
27179 false,
27180 false,
27181 false,
27182 false,
27183 false,
27184 true,
27185 None,
27186 false,
27187 false,
27188 false,
27189 false,
27190 false,
27191 false,
27192 )
27193 .unwrap();
27194
27195 let alpha = dir.path().join("src/alpha/lib.rs");
27196 std::thread::sleep(std::time::Duration::from_millis(50));
27197 std::fs::write(
27198 &alpha,
27199 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27200 )
27201 .unwrap();
27202
27203 let cfg = config::Config::load(dir.path()).unwrap();
27204 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27205
27206 let err = cmd_search(
27207 "alpha_helper".to_string(),
27208 Some(dir.path().to_path_buf()),
27209 5,
27210 Some("lexical".to_string()),
27211 None,
27212 true,
27213 false,
27214 false,
27215 30,
27216 false,
27217 false,
27218 false,
27219 false,
27220 false,
27221 false,
27222 false,
27223 )
27224 .unwrap_err();
27225
27226 assert!(err.to_string().contains("stale"));
27227 assert!(err.to_string().contains("submodule `alpha` index"));
27228 assert!(!err.to_string().contains("database is locked"));
27229 }
27230
27231 #[test]
27232 fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
27233 let dir = setup_workspace();
27234 cmd_index(
27235 dir.path(),
27236 false,
27237 false,
27238 false,
27239 false,
27240 false,
27241 true,
27242 None,
27243 false,
27244 false,
27245 false,
27246 false,
27247 false,
27248 false,
27249 )
27250 .unwrap();
27251
27252 let err = cmd_search(
27253 "alpha_helper".to_string(),
27254 Some(dir.path().to_path_buf()),
27255 5,
27256 Some("lexical".to_string()),
27257 None,
27258 false,
27259 false,
27260 true,
27261 0,
27262 false,
27263 false,
27264 false,
27265 false,
27266 false,
27267 false,
27268 false,
27269 )
27270 .unwrap_err();
27271
27272 assert_workspace_search_requires_explicit_target(err);
27273 assert!(!dir.path().join(".tsift/index.db").exists());
27274 }
27275
27276 #[test]
27277 fn workspace_search_cmd_infers_scope_from_nested_path() {
27278 let dir = setup_workspace();
27279 cmd_index(
27280 dir.path(),
27281 false,
27282 false,
27283 false,
27284 false,
27285 false,
27286 true,
27287 None,
27288 false,
27289 false,
27290 false,
27291 false,
27292 false,
27293 false,
27294 )
27295 .unwrap();
27296 let nested = dir.path().join("src/alpha/nested");
27297 std::fs::create_dir_all(&nested).unwrap();
27298
27299 let result = cmd_search(
27300 "alpha_helper".to_string(),
27301 Some(nested),
27302 5,
27303 Some("lexical".to_string()),
27304 None,
27305 false,
27306 false,
27307 false,
27308 0,
27309 false,
27310 false,
27311 false,
27312 false,
27313 false,
27314 false,
27315 false,
27316 );
27317
27318 assert!(result.is_ok());
27319 }
27320
27321 #[test]
27322 fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
27323 let dir = setup_workspace_with_duplicate_leaf_names();
27324 cmd_index(
27325 dir.path(),
27326 false,
27327 false,
27328 false,
27329 false,
27330 false,
27331 true,
27332 None,
27333 false,
27334 false,
27335 false,
27336 false,
27337 false,
27338 false,
27339 )
27340 .unwrap();
27341 let nested = dir.path().join("vendor/foo/nested");
27342 std::fs::create_dir_all(&nested).unwrap();
27343
27344 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27345 let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
27346 let cfg = config::Config::load(dir.path()).unwrap();
27347
27348 assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
27349 }
27350
27351 #[test]
27352 fn graph_cmd_succeeds_while_writer_lock_is_held() {
27353 let dir = setup_graph_index();
27354 let db_path = dir.path().join(".tsift/index.db");
27355 let _lock = hold_write_lock(&db_path);
27356
27357 let result = cmd_graph(
27358 "main",
27359 dir.path(),
27360 false,
27361 false,
27362 None,
27363 20,
27364 false,
27365 true,
27366 false,
27367 false,
27368 false,
27369 false,
27370 false,
27371 TagpathSearchOpts::default(),
27372 );
27373
27374 assert!(result.is_ok());
27375 }
27376
27377 #[test]
27378 fn graph_cmd_autoindexes_stale_index_by_default() {
27379 let dir = setup_graph_index();
27380 std::thread::sleep(std::time::Duration::from_millis(50));
27381 std::fs::write(
27382 dir.path().join("main.rs"),
27383 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27384 )
27385 .unwrap();
27386
27387 let result = cmd_graph(
27388 "helper",
27389 dir.path(),
27390 true,
27391 false,
27392 None,
27393 20,
27394 false,
27395 true,
27396 false,
27397 false,
27398 false,
27399 false,
27400 false,
27401 TagpathSearchOpts::default(),
27402 );
27403
27404 assert!(result.is_ok());
27405 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27406 let summary = db.compute_changes(dir.path()).unwrap();
27407 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27408 }
27409
27410 #[test]
27411 fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27412 let dir = setup_graph_index();
27413 let db_path = dir.path().join(".tsift/index.db");
27414 let _lock = hold_rollback_journal_lock(&db_path);
27415
27416 let result = cmd_graph(
27417 "main",
27418 dir.path(),
27419 false,
27420 false,
27421 None,
27422 20,
27423 false,
27424 true,
27425 false,
27426 false,
27427 false,
27428 false,
27429 false,
27430 TagpathSearchOpts::default(),
27431 );
27432
27433 assert!(result.is_ok());
27434 }
27435
27436 #[test]
27437 fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
27438 let dir = setup_graph_index();
27439 let nested = dir.path().join("src/nested");
27440 std::fs::create_dir_all(&nested).unwrap();
27441
27442 let result = cmd_graph(
27443 "helper",
27444 &nested,
27445 true,
27446 false,
27447 None,
27448 20,
27449 false,
27450 false,
27451 false,
27452 false,
27453 false,
27454 false,
27455 false,
27456 TagpathSearchOpts::default(),
27457 );
27458
27459 assert!(result.is_ok());
27460 }
27461
27462 #[test]
27463 fn communities_cmd_succeeds_while_writer_lock_is_held() {
27464 let dir = setup_graph_index();
27465 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27466
27467 let result = cmd_communities(
27468 dir.path(),
27469 None,
27470 1,
27471 10,
27472 false,
27473 false,
27474 false,
27475 false,
27476 false,
27477 false,
27478 TagpathSearchOpts::default(),
27479 );
27480
27481 assert!(result.is_ok());
27482 }
27483
27484 #[test]
27485 fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27486 let dir = setup_graph_index();
27487 let db_path = dir.path().join(".tsift/index.db");
27488 let _lock = hold_rollback_journal_lock(&db_path);
27489
27490 let result = cmd_communities(
27491 dir.path(),
27492 None,
27493 1,
27494 10,
27495 false,
27496 false,
27497 false,
27498 false,
27499 false,
27500 false,
27501 TagpathSearchOpts::default(),
27502 );
27503
27504 assert!(result.is_ok());
27505 }
27506
27507 #[test]
27508 fn lint_finds_entities_from_project_root_index_db() {
27509 let dir = tempfile::tempdir().unwrap();
27510 std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
27511 std::fs::write(
27512 dir.path().join("README.md"),
27513 "alpha_helper should be backticked.\n",
27514 )
27515 .unwrap();
27516 cmd_index(
27517 dir.path(),
27518 false,
27519 false,
27520 false,
27521 false,
27522 false,
27523 false,
27524 None,
27525 false,
27526 false,
27527 false,
27528 false,
27529 false,
27530 false,
27531 )
27532 .unwrap();
27533
27534 let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
27535 .unwrap()
27536 .unwrap();
27537 let entities = lint::collect_entities_from_index_path(&root).unwrap();
27538 let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
27539
27540 assert!(
27541 result
27542 .annotations
27543 .iter()
27544 .any(|ann| ann.text == "alpha_helper")
27545 );
27546 }
27547
27548 #[test]
27551 fn search_direct_runs_ok() {
27552 let dir = tempfile::tempdir().unwrap();
27553 let search_dir = dir.path().to_path_buf();
27554 let cache_dir = search_dir.join(".tsift/search-cache");
27555 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27556 let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical");
27557 assert!(result.is_ok(), "direct search should succeed");
27558 assert!(
27559 cache_dir.exists(),
27560 "search should create the configured cache dir"
27561 );
27562 }
27563
27564 #[test]
27565 fn search_timeout_zero_disables_timeout() {
27566 let dir = tempfile::tempdir().unwrap();
27567 let search_dir = dir.path().to_path_buf();
27568 let cache_dir = search_dir.join(".tsift/search-cache");
27569 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27570 let result = run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[]);
27571 assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
27572 assert!(
27573 cache_dir.exists(),
27574 "timeout=0 should keep using the stable search cache dir"
27575 );
27576 }
27577
27578 #[test]
27579 fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
27580 let dir = tempfile::tempdir().unwrap();
27581 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
27582 cmd_index(
27583 dir.path(),
27584 false,
27585 false,
27586 false,
27587 false,
27588 false,
27589 false,
27590 None,
27591 false,
27592 false,
27593 false,
27594 false,
27595 false,
27596 false,
27597 )
27598 .unwrap();
27599 let db_path = dir.path().join(".tsift/index.db");
27600 std::fs::remove_file(&db_path).unwrap();
27601 let search_target = SearchIndexTarget {
27602 label: "index".to_string(),
27603 db_path,
27604 source_root: dir.path().to_path_buf(),
27605 scope_name: None,
27606 reindex_cmd: format!("tsift index {}", dir.path().display()),
27607 };
27608
27609 let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
27610
27611 assert!(message.contains("timed out after 1s"));
27612 assert!(message.contains("index is missing"));
27613 assert!(message.contains("Run `tsift index"));
27614 assert!(!message.contains("search root looks fresh"));
27615 }
27616
27617 #[test]
27618 fn search_worker_output_path_uses_json_suffix() {
27619 let path = next_search_worker_output_path();
27620 assert!(path.extension().is_some_and(|ext| ext == "json"));
27621 }
27622
27623 #[test]
27626 fn index_quiet_suppresses_file_list() {
27627 let dir = setup_graph_index();
27628 let result = cmd_index(
27629 dir.path(),
27630 false,
27631 true,
27632 false,
27633 false,
27634 true,
27635 false,
27636 None,
27637 false,
27638 false,
27639 false,
27640 false,
27641 false,
27642 false,
27643 );
27644 assert!(result.is_ok());
27645 }
27646
27647 #[test]
27648 fn index_exit_code_implies_quiet() {
27649 let dir = setup_graph_index();
27650 let result = cmd_index(
27651 dir.path(),
27652 false,
27653 true,
27654 false,
27655 false,
27656 false,
27657 false,
27658 None,
27659 false,
27660 false,
27661 false,
27662 false,
27663 false,
27664 false,
27665 );
27666 assert!(result.is_ok());
27667 }
27668
27669 #[test]
27670 fn index_quiet_json_omits_changes() {
27671 let dir = setup_graph_index();
27672 let result = cmd_index(
27673 dir.path(),
27674 false,
27675 true,
27676 false,
27677 false,
27678 true,
27679 false,
27680 None,
27681 true,
27682 false,
27683 false,
27684 false,
27685 false,
27686 false,
27687 );
27688 assert!(result.is_ok());
27689 }
27690
27691 #[test]
27692 fn cli_workflow_defaults_to_search_topic() {
27693 let cli = parse_cli(["tsift", "workflow"]);
27694 match cli.command {
27695 Some(Commands::Workflow { topic, json }) => {
27696 assert_eq!(topic, "search");
27697 assert!(!json);
27698 }
27699 _ => panic!("expected Workflow command"),
27700 }
27701 }
27702
27703 #[test]
27704 fn search_workflow_recipe_preserves_handles_across_expansions() {
27705 let recipe = workflow::search_workflow_recipe();
27706 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
27707 assert_eq!(
27708 step_names,
27709 vec![
27710 "exact-anchor",
27711 "semantic-search",
27712 "explain-symbol",
27713 "summarize-selection",
27714 "digest-expansion"
27715 ]
27716 );
27717 assert!(
27718 recipe
27719 .handle_contract
27720 .iter()
27721 .any(|item| item.contains("originating command"))
27722 );
27723 assert!(
27724 recipe.steps[1]
27725 .preserves
27726 .iter()
27727 .any(|item| item.contains("sfam-*"))
27728 );
27729 assert!(
27730 recipe.steps[2]
27731 .preserves
27732 .iter()
27733 .any(|item| item.contains("ecall-*"))
27734 );
27735 assert!(
27736 recipe.steps[4]
27737 .preserves
27738 .iter()
27739 .any(|item| item.contains("artifact handles"))
27740 );
27741 }
27742
27743 #[test]
27746 fn to_json_compact_default() {
27747 let val = serde_json::json!({"a": 1, "b": [2, 3]});
27748 let compact = to_json(&val, false, false).unwrap();
27749 assert!(!compact.contains('\n'));
27750 assert!(
27751 compact.contains("\"a\":1")
27752 || compact.contains("\"a\": 1")
27753 || compact.contains("\"a\":")
27754 );
27755 }
27756
27757 #[test]
27758 fn to_json_pretty_indents() {
27759 let val = serde_json::json!({"a": 1, "b": [2, 3]});
27760 let pretty = to_json(&val, true, false).unwrap();
27761 assert!(pretty.contains('\n'));
27762 assert!(pretty.contains(" "));
27763 }
27764
27765 #[test]
27766 fn to_json_compact_is_shorter() {
27767 let val =
27768 serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
27769 let compact = to_json(&val, false, false).unwrap();
27770 let pretty = to_json(&val, true, false).unwrap();
27771 assert!(compact.len() < pretty.len());
27772 }
27773
27774 #[test]
27775 fn terse_renames_keys() {
27776 let val =
27777 serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
27778 let result = to_json(&val, false, true).unwrap();
27779 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27780 assert!(parsed["_s"].is_object());
27781 let d = &parsed["d"];
27782 assert_eq!(d["cf"], "a.rs");
27783 assert_eq!(d["cn"], "main");
27784 assert_eq!(d["csl"], 10);
27785 }
27786
27787 #[test]
27788 fn terse_schema_only_includes_used_keys() {
27789 let val = serde_json::json!({"name": "test", "score": 0.5});
27790 let result = to_json(&val, false, true).unwrap();
27791 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27792 let schema = parsed["_s"].as_object().unwrap();
27793 assert_eq!(schema["n"], "name");
27794 assert_eq!(schema["sc"], "score");
27795 assert!(!schema.contains_key("cf"));
27796 }
27797
27798 #[test]
27799 fn terse_nested_arrays() {
27800 let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
27801 let result = to_json(&val, false, true).unwrap();
27802 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27803 let d = &parsed["d"];
27804 assert_eq!(d["crs"][0]["cn"], "a");
27805 assert_eq!(d["crs"][0]["cf"], "b.rs");
27806 }
27807
27808 #[test]
27809 fn terse_preserves_unknown_keys() {
27810 let val = serde_json::json!({"custom_field": "value", "name": "test"});
27811 let result = to_json(&val, false, true).unwrap();
27812 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27813 let d = &parsed["d"];
27814 assert_eq!(d["custom_field"], "value");
27815 assert_eq!(d["n"], "test");
27816 }
27817
27818 #[test]
27821 fn ultra_terse_strips_properties_from_graph_nodes() {
27822 let val = serde_json::json!({
27823 "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
27824 });
27825 let result = to_json_schema(&val, false, true, true, false).unwrap();
27826 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27827 let node = &parsed["d"]["nodes"][0];
27828 assert_eq!(node["id"], "fn:main");
27829 assert_eq!(node["k"], "fn");
27830 assert_eq!(node["n"], "main");
27831 assert!(node.get("properties").is_none());
27832 }
27833
27834 #[test]
27835 fn ultra_terse_strips_properties_from_graph_edges() {
27836 let val = serde_json::json!({
27837 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
27838 });
27839 let result = to_json_schema(&val, false, true, true, false).unwrap();
27840 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27841 let edge = &parsed["d"]["edges"][0];
27842 assert_eq!(edge["from_id"], "a");
27843 assert_eq!(edge["to_id"], "b");
27844 assert_eq!(edge["k"], "c");
27845 assert!(edge.get("properties").is_none());
27846 }
27847
27848 #[test]
27849 fn ultra_terse_abbreviates_edge_kinds() {
27850 let val = serde_json::json!({
27851 "edges": [
27852 {"from_id": "a", "to_id": "b", "kind": "defines"},
27853 {"from_id": "a", "to_id": "c", "kind": "contains"},
27854 {"from_id": "a", "to_id": "d", "kind": "imports"},
27855 {"from_id": "a", "to_id": "e", "kind": "mentions"},
27856 {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
27857 {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
27858 {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
27859 {"from_id": "a", "to_id": "i", "kind": "uses"},
27860 {"from_id": "a", "to_id": "j", "kind": "parent"},
27861 {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
27862 ]
27863 });
27864 let result = to_json_schema(&val, false, true, true, false).unwrap();
27865 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27866 let edges = &parsed["d"]["edges"].as_array().unwrap();
27867 assert_eq!(edges[0]["k"], "d");
27868 assert_eq!(edges[1]["k"], "ct");
27869 assert_eq!(edges[2]["k"], "i");
27870 assert_eq!(edges[3]["k"], "m");
27871 assert_eq!(edges[4]["k"], "sr");
27872 assert_eq!(edges[5]["k"], "bt");
27873 assert_eq!(edges[6]["k"], "sctx");
27874 assert_eq!(edges[7]["k"], "u");
27875 assert_eq!(edges[8]["k"], "p");
27876 assert_eq!(edges[9]["k"], "unknown_edge");
27877 }
27878
27879 #[test]
27880 fn ultra_terse_strips_provenance_freshness_from_edges() {
27881 let val = serde_json::json!({
27882 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
27883 });
27884 let result = to_json_schema(&val, false, true, true, false).unwrap();
27885 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27886 let edge = &parsed["d"]["edges"][0];
27887 assert!(edge.get("provenance").is_none());
27888 assert!(edge.get("freshness").is_none());
27889 assert_eq!(edge["k"], "c");
27890 }
27891
27892 #[test]
27893 fn ultra_terse_truncates_snippets() {
27894 let long_snippet = "x".repeat(120);
27895 let val = serde_json::json!({"snippet": long_snippet});
27896 let result = to_json_schema(&val, false, true, true, false).unwrap();
27897 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27898 let snipped = parsed["d"]["sn"].as_str().unwrap();
27899 assert_eq!(snipped.len(), 80);
27900 assert!(snipped.ends_with("..."));
27901 }
27902
27903 #[test]
27904 fn ultra_terse_truncates_abbreviated_snippet_key() {
27905 let long_snippet = "y".repeat(100);
27906 let val = serde_json::json!({"snippet": long_snippet});
27907 let result = to_json_schema(&val, false, true, true, false).unwrap();
27908 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27909 let snipped = parsed["d"]["sn"].as_str().unwrap();
27910 assert_eq!(snipped.len(), 80);
27911 assert!(snipped.ends_with("..."));
27912 }
27913
27914 #[test]
27915 fn ultra_terse_compacts_coverage_snapshot() {
27916 let val = serde_json::json!({
27917 "mode": "incremental",
27918 "total_sector_count": 10,
27919 "dirty_sector_count": 2,
27920 "active_rebuild": Some("rebuild-1"),
27921 "completed_dirty_sector_count": 1,
27922 "mounted_sector_count": 8,
27923 "rebuilding_sector_count": 1,
27924 "resumed_sector_count": 3,
27925 "reused_sector_count": 5
27926 });
27927 let result = to_json_schema(&val, false, true, true, false).unwrap();
27928 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27929 let d = &parsed["d"];
27930 assert_eq!(d["mode"], "incremental");
27931 assert_eq!(d["total_sector_count"], 10);
27932 assert_eq!(d["dirty_sector_count"], 2);
27933 assert!(d.get("active_rebuild").is_none());
27934 assert!(d.get("completed_dirty_sector_count").is_none());
27935 assert!(d.get("mounted_sector_count").is_none());
27936 assert!(d.get("rebuilding_sector_count").is_none());
27937 assert!(d.get("resumed_sector_count").is_none());
27938 assert!(d.get("reused_sector_count").is_none());
27939 }
27940
27941 #[test]
27942 fn ultra_terse_short_snippet_unchanged() {
27943 let val = serde_json::json!({"snippet": "short text"});
27944 let result = to_json_schema(&val, false, true, true, false).unwrap();
27945 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27946 assert_eq!(parsed["d"]["sn"], "short text");
27947 }
27948
27949 #[test]
27950 fn ultra_terse_non_graph_object_properties_preserved() {
27951 let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
27952 let result = to_json_schema(&val, false, true, true, false).unwrap();
27953 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27954 assert!(parsed["d"]["config"]["properties"].is_object());
27955 }
27956
27957 #[test]
27960 fn schema_converts_homogeneous_arrays() {
27961 let val = serde_json::json!({"symbols": [
27962 {"name": "foo", "kind": "fn", "line": 10},
27963 {"name": "bar", "kind": "fn", "line": 20}
27964 ]});
27965 let result = to_json_schema(&val, false, false, false, true).unwrap();
27966 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27967 let syms = &parsed["symbols"];
27968 let columns = syms["_c"]
27969 .as_array()
27970 .unwrap()
27971 .iter()
27972 .map(|value| value.as_str().unwrap())
27973 .collect::<Vec<_>>();
27974 let row0 = syms["_r"][0].as_array().unwrap();
27975 let row1 = syms["_r"][1].as_array().unwrap();
27976 let name_index = columns.iter().position(|column| *column == "name").unwrap();
27977 let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
27978 let line_index = columns.iter().position(|column| *column == "line").unwrap();
27979 assert_eq!(row0[name_index], "foo");
27980 assert_eq!(row0[kind_index], "fn");
27981 assert_eq!(row0[line_index], 10);
27982 assert_eq!(row1[name_index], "bar");
27983 assert_eq!(row1[kind_index], "fn");
27984 assert_eq!(row1[line_index], 20);
27985 }
27986
27987 #[test]
27988 fn schema_skips_short_arrays() {
27989 let val = serde_json::json!({"items": [{"name": "only"}]});
27990 let result = to_json_schema(&val, false, false, false, true).unwrap();
27991 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27992 assert!(parsed["items"].is_array());
27993 assert_eq!(parsed["items"][0]["name"], "only");
27994 }
27995
27996 #[test]
27997 fn schema_skips_heterogeneous_arrays() {
27998 let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
27999 let result = to_json_schema(&val, false, false, false, true).unwrap();
28000 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28001 assert!(parsed["items"].is_array());
28002 assert_eq!(parsed["items"][0]["a"], 1);
28003 }
28004
28005 #[test]
28006 fn schema_with_terse_combines() {
28007 let val = serde_json::json!({"callers": [
28008 {"caller_name": "a", "caller_file": "x.rs"},
28009 {"caller_name": "b", "caller_file": "y.rs"}
28010 ]});
28011 let result = to_json_schema(&val, false, true, false, true).unwrap();
28012 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28013 assert!(parsed["_s"].is_object());
28014 let d = &parsed["d"];
28015 let crs = &d["crs"];
28016 assert!(crs["_c"].is_array());
28017 assert!(crs["_r"].is_array());
28018 let columns = crs["_c"]
28019 .as_array()
28020 .unwrap()
28021 .iter()
28022 .map(|value| value.as_str().unwrap())
28023 .collect::<Vec<_>>();
28024 let row = crs["_r"][0].as_array().unwrap();
28025 let name_index = columns.iter().position(|column| *column == "cn").unwrap();
28026 let file_index = columns.iter().position(|column| *column == "cf").unwrap();
28027 assert_eq!(row[name_index], "a");
28028 assert_eq!(row[file_index], "x.rs");
28029 }
28030
28031 #[test]
28032 fn schema_preserves_non_object_arrays() {
28033 let val = serde_json::json!({"tags": ["a", "b", "c"]});
28034 let result = to_json_schema(&val, false, false, false, true).unwrap();
28035 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28036 assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
28037 }
28038
28039 #[test]
28040 fn cli_accepts_global_schema_flag() {
28041 let cli = parse_cli(["tsift", "--schema", "search", "test"]);
28042 assert!(cli.schema);
28043 assert!(matches!(cli.command, Some(Commands::Search { .. })));
28044 }
28045
28046 #[test]
28047 fn cli_accepts_global_envelope_flag() {
28048 let cli = parse_cli([
28049 "tsift",
28050 "--envelope",
28051 "context-pack",
28052 "tasks/software/tsift.md",
28053 ]);
28054 assert!(cli.envelope);
28055 assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
28056 }
28057
28058 #[test]
28059 fn cli_accepts_locks_command() {
28060 let cli = parse_cli(["tsift", "locks"]);
28061 assert!(matches!(cli.command, Some(Commands::Locks { .. })));
28062 }
28063
28064 #[test]
28065 fn cli_parses_memory_budget_guard_command() {
28066 let cli = parse_cli([
28067 "tsift",
28068 "memory",
28069 "budget-guard",
28070 "--file",
28071 "tool.log",
28072 "--budget-tokens",
28073 "1000",
28074 "--json",
28075 ]);
28076 match cli.command {
28077 Some(Commands::Memory {
28078 command:
28079 crate::cli::MemoryCommand::BudgetGuard {
28080 file,
28081 budget_tokens,
28082 json,
28083 ..
28084 },
28085 }) => {
28086 assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
28087 assert_eq!(budget_tokens, 1000);
28088 assert!(json);
28089 }
28090 _ => panic!("expected memory budget-guard command"),
28091 }
28092 }
28093
28094 #[test]
28095 fn cli_parses_memory_capture_agent_doc_closeout_command() {
28096 let cli = parse_cli([
28097 "tsift",
28098 "memory",
28099 "capture-agent-doc-closeout",
28100 ".",
28101 "--session-path",
28102 "tasks/software/tsift.md",
28103 "--prompt-target",
28104 "do [#tsiftmemhooks]",
28105 "--response-summary",
28106 "wired closeout capture",
28107 "--commit-hash",
28108 "abc123",
28109 "--session-check-status",
28110 "clean",
28111 "--json",
28112 ]);
28113 match cli.command {
28114 Some(Commands::Memory {
28115 command:
28116 crate::cli::MemoryCommand::CaptureAgentDocCloseout {
28117 path,
28118 session_path,
28119 prompt_target,
28120 response_summary,
28121 commit_hash,
28122 session_check_status,
28123 json,
28124 },
28125 }) => {
28126 assert_eq!(path, std::path::PathBuf::from("."));
28127 assert_eq!(
28128 session_path,
28129 std::path::PathBuf::from("tasks/software/tsift.md")
28130 );
28131 assert_eq!(prompt_target, "do [#tsiftmemhooks]");
28132 assert_eq!(response_summary, "wired closeout capture");
28133 assert_eq!(commit_hash.as_deref(), Some("abc123"));
28134 assert_eq!(session_check_status, "clean");
28135 assert!(json);
28136 }
28137 _ => panic!("expected memory capture-agent-doc-closeout command"),
28138 }
28139 }
28140
28141 #[test]
28142 fn cli_parses_memory_project_graph_read_policy() {
28143 let cli = parse_cli([
28144 "tsift",
28145 "memory",
28146 "project-graph",
28147 ".",
28148 "--read-policy",
28149 "query-relevant",
28150 "--query",
28151 "semantic memory",
28152 "--limit",
28153 "7",
28154 "--json",
28155 ]);
28156 match cli.command {
28157 Some(Commands::Memory {
28158 command:
28159 crate::cli::MemoryCommand::ProjectGraph {
28160 read_policy,
28161 query,
28162 limit,
28163 json,
28164 ..
28165 },
28166 }) => {
28167 assert_eq!(
28168 read_policy,
28169 crate::cli::MemoryProjectReadPolicy::QueryRelevant
28170 );
28171 assert_eq!(query.as_deref(), Some("semantic memory"));
28172 assert_eq!(limit, 7);
28173 assert!(json);
28174 }
28175 _ => panic!("expected memory project-graph command"),
28176 }
28177 }
28178
28179 #[test]
28180 fn cli_locks_accepts_scope_flag() {
28181 let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
28182 match cli.command {
28183 Some(Commands::Locks { scope, .. }) => {
28184 assert_eq!(scope.as_deref(), Some("alpha"));
28185 }
28186 _ => panic!("expected Locks command"),
28187 }
28188 }
28189
28190 #[test]
28191 fn cli_search_accepts_autoindex_flag() {
28192 let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
28193 match cli.command {
28194 Some(Commands::Search {
28195 autoindex,
28196 no_autoindex,
28197 ..
28198 }) => {
28199 assert!(autoindex);
28200 assert!(!no_autoindex);
28201 }
28202 _ => panic!("expected Search command"),
28203 }
28204 }
28205
28206 #[test]
28207 fn cli_search_accepts_exact_flag() {
28208 let cli = parse_cli(["tsift", "search", "test", "--exact"]);
28209 match cli.command {
28210 Some(Commands::Search {
28211 exact, strategy, ..
28212 }) => {
28213 assert!(exact);
28214 assert!(strategy.is_none());
28215 }
28216 _ => panic!("expected Search command"),
28217 }
28218 }
28219
28220 #[test]
28221 fn cli_parses_diff_digest_command() {
28222 let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
28223 match cli.command {
28224 Some(Commands::DiffDigest {
28225 json,
28226 path,
28227 cached,
28228 revision,
28229 max_parsed_files,
28230 }) => {
28231 assert!(json);
28232 assert_eq!(path, PathBuf::from("."));
28233 assert!(!cached);
28234 assert!(revision.is_none());
28235 assert_eq!(max_parsed_files, 25);
28236 }
28237 _ => panic!("expected DiffDigest command"),
28238 }
28239 }
28240
28241 #[test]
28242 fn cli_rejects_conflicting_diff_digest_modes() {
28243 match try_parse_cli([
28244 "tsift",
28245 "diff-digest",
28246 "--cached",
28247 "--revision",
28248 "HEAD",
28249 ".",
28250 ]) {
28251 Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
28252 Err(err) => {
28253 assert!(err.to_string().contains("--cached"));
28254 assert!(err.to_string().contains("--revision"));
28255 }
28256 }
28257 }
28258
28259 #[test]
28260 fn cli_parses_test_digest_command() {
28261 let cli = parse_cli([
28262 "tsift",
28263 "test-digest",
28264 "--path",
28265 ".",
28266 "--input",
28267 "target/test.log",
28268 "--runner",
28269 "cargo",
28270 "--json",
28271 ]);
28272 match cli.command {
28273 Some(Commands::TestDigest {
28274 json,
28275 path,
28276 input,
28277 runner,
28278 }) => {
28279 assert!(json);
28280 assert_eq!(path, PathBuf::from("."));
28281 assert_eq!(input, Some(PathBuf::from("target/test.log")));
28282 assert_eq!(runner.as_deref(), Some("cargo"));
28283 }
28284 _ => panic!("expected TestDigest command"),
28285 }
28286 }
28287
28288 #[test]
28289 fn cli_parses_log_digest_command() {
28290 let cli = parse_cli([
28291 "tsift",
28292 "log-digest",
28293 "--path",
28294 ".",
28295 "--input",
28296 "target/build.log",
28297 "--json",
28298 ]);
28299 match cli.command {
28300 Some(Commands::LogDigest { json, path, input }) => {
28301 assert!(json);
28302 assert_eq!(path, PathBuf::from("."));
28303 assert_eq!(input, Some(PathBuf::from("target/build.log")));
28304 }
28305 _ => panic!("expected LogDigest command"),
28306 }
28307 }
28308
28309 #[test]
28310 fn cli_parses_metric_digest_command() {
28311 let cli = parse_cli([
28312 "tsift",
28313 "metric-digest",
28314 "--input",
28315 "target/runs.json",
28316 "--baseline",
28317 "target/prior.json",
28318 "--metric",
28319 "session_mae",
28320 "--lower-is-better",
28321 "session_mae",
28322 "--history",
28323 "4",
28324 "--top",
28325 "2",
28326 "--json",
28327 ]);
28328 match cli.command {
28329 Some(Commands::MetricDigest {
28330 input,
28331 baseline,
28332 metrics,
28333 lower_is_better,
28334 history,
28335 top,
28336 json,
28337 ..
28338 }) => {
28339 assert!(json);
28340 assert_eq!(input, Some(PathBuf::from("target/runs.json")));
28341 assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
28342 assert_eq!(metrics, vec!["session_mae"]);
28343 assert_eq!(lower_is_better, vec!["session_mae"]);
28344 assert_eq!(history, 4);
28345 assert_eq!(top, 2);
28346 }
28347 _ => panic!("expected MetricDigest command"),
28348 }
28349 }
28350
28351 #[test]
28352 fn cli_parses_dci_benchmark_command() {
28353 let cli = parse_cli([
28354 "tsift",
28355 "dci-benchmark",
28356 "--fixture",
28357 "fixtures/dci-search-benchmark.json",
28358 "--json",
28359 ]);
28360 match cli.command {
28361 Some(Commands::DciBenchmark { fixture, json }) => {
28362 assert!(json);
28363 assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
28364 }
28365 _ => panic!("expected DciBenchmark command"),
28366 }
28367 }
28368
28369 #[test]
28370 fn cli_parses_session_digest_command() {
28371 let cli = parse_cli([
28372 "tsift",
28373 "session-digest",
28374 "--path",
28375 ".",
28376 "--input",
28377 "target/session.md",
28378 "--source",
28379 "markdown",
28380 "--json",
28381 ]);
28382 match cli.command {
28383 Some(Commands::SessionDigest {
28384 json,
28385 path,
28386 input,
28387 source,
28388 }) => {
28389 assert!(json);
28390 assert_eq!(path, PathBuf::from("."));
28391 assert_eq!(input, Some(PathBuf::from("target/session.md")));
28392 assert_eq!(source.as_deref(), Some("markdown"));
28393 }
28394 _ => panic!("expected SessionDigest command"),
28395 }
28396 }
28397
28398 #[test]
28399 fn cli_parses_session_cost_command() {
28400 let cli = parse_cli([
28401 "tsift",
28402 "session-cost",
28403 "--input",
28404 "target/session.jsonl",
28405 "--source",
28406 "codex-jsonl",
28407 "--json",
28408 ]);
28409 match cli.command {
28410 Some(Commands::SessionCost {
28411 json,
28412 input,
28413 fixture,
28414 fail_under,
28415 source,
28416 }) => {
28417 assert!(json);
28418 assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
28419 assert_eq!(fixture, None);
28420 assert!(!fail_under);
28421 assert_eq!(source.as_deref(), Some("codex-jsonl"));
28422 }
28423 _ => panic!("expected SessionCost command"),
28424 }
28425
28426 let cli = parse_cli([
28427 "tsift",
28428 "session-cost",
28429 "--fixture",
28430 "fixtures/real-session-prompt-cache-effectiveness.json",
28431 "--fail-under",
28432 "--json",
28433 ]);
28434 match cli.command {
28435 Some(Commands::SessionCost {
28436 json,
28437 input,
28438 fixture,
28439 fail_under,
28440 source,
28441 }) => {
28442 assert!(json);
28443 assert_eq!(input, None);
28444 assert_eq!(
28445 fixture,
28446 Some(PathBuf::from(
28447 "fixtures/real-session-prompt-cache-effectiveness.json"
28448 ))
28449 );
28450 assert!(fail_under);
28451 assert_eq!(source, None);
28452 }
28453 _ => panic!("expected SessionCost command"),
28454 }
28455 }
28456
28457 #[test]
28458 fn cli_parses_session_review_command() {
28459 let cli = parse_cli([
28460 "tsift",
28461 "session-review",
28462 "tasks/software/tsift.md",
28463 "--next-context",
28464 "--json",
28465 ]);
28466 match cli.command {
28467 Some(Commands::SessionReview {
28468 json,
28469 next_context,
28470 path,
28471 ..
28472 }) => {
28473 assert!(json);
28474 assert!(next_context);
28475 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28476 }
28477 _ => panic!("expected SessionReview command"),
28478 }
28479 }
28480
28481 #[test]
28482 fn cli_search_accepts_budget_flags() {
28483 let cli = parse_cli([
28484 "tsift",
28485 "search",
28486 "alpha_helper",
28487 "--max-items",
28488 "3",
28489 "--max-bytes",
28490 "96",
28491 ]);
28492 match cli.command {
28493 Some(Commands::Search {
28494 max_items,
28495 max_bytes,
28496 ..
28497 }) => {
28498 assert_eq!(max_items, Some(3));
28499 assert_eq!(max_bytes, Some(96));
28500 }
28501 _ => panic!("expected Search command"),
28502 }
28503 }
28504
28505 #[test]
28506 fn cli_search_accepts_budget_preset() {
28507 let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
28508 match cli.command {
28509 Some(Commands::Search { budget, .. }) => {
28510 assert_eq!(budget, Some(ResponseBudgetPreset::Small));
28511 }
28512 _ => panic!("expected Search command"),
28513 }
28514 }
28515
28516 #[test]
28517 fn cli_search_accepts_ast_facet_filters() {
28518 let cli = parse_cli([
28519 "tsift",
28520 "search",
28521 "setup",
28522 "--lang",
28523 "markdown",
28524 "--kind",
28525 "list_item",
28526 "--node-kind",
28527 "list_item",
28528 "--section",
28529 "Install",
28530 "--parent",
28531 "Run setup.",
28532 "--child",
28533 "Confirm setup.",
28534 "--fence-language",
28535 "rust",
28536 "--list-depth",
28537 "1",
28538 "--heading-level",
28539 "2",
28540 ]);
28541 match cli.command {
28542 Some(Commands::Search {
28543 lang,
28544 kind,
28545 node_kind,
28546 section,
28547 parent,
28548 child,
28549 fence_language,
28550 list_depth,
28551 heading_level,
28552 ..
28553 }) => {
28554 assert_eq!(lang, vec!["markdown"]);
28555 assert_eq!(kind, vec!["list_item"]);
28556 assert_eq!(node_kind, vec!["list_item"]);
28557 assert_eq!(section, vec!["Install"]);
28558 assert_eq!(parent, vec!["Run setup."]);
28559 assert_eq!(child, vec!["Confirm setup."]);
28560 assert_eq!(fence_language, vec!["rust"]);
28561 assert_eq!(list_depth, vec![1]);
28562 assert_eq!(heading_level, vec![2]);
28563 }
28564 _ => panic!("expected Search command"),
28565 }
28566 }
28567
28568 #[test]
28569 fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
28570 let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
28571 assert_eq!(small.preview_items(), 3);
28572 assert_eq!(small.preview_bytes(), 120);
28573 assert_eq!(small.follow_up_items(), 4);
28574
28575 let overridden =
28576 ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
28577 assert_eq!(overridden.preview_items(), 7);
28578 assert_eq!(overridden.preview_bytes(), 120);
28579 assert_eq!(overridden.follow_up_items(), 7);
28580
28581 let envelope_default = ResponseBudget::from_cli(None, None, None, true);
28582 assert!(envelope_default.is_active());
28583 }
28584
28585 #[test]
28586 fn cli_explain_accepts_budget_flags() {
28587 let cli = parse_cli([
28588 "tsift",
28589 "explain",
28590 "alpha_helper",
28591 "--max-items",
28592 "2",
28593 "--max-bytes",
28594 "80",
28595 ]);
28596 match cli.command {
28597 Some(Commands::Explain {
28598 max_items,
28599 max_bytes,
28600 ..
28601 }) => {
28602 assert_eq!(max_items, Some(2));
28603 assert_eq!(max_bytes, Some(80));
28604 }
28605 _ => panic!("expected Explain command"),
28606 }
28607 }
28608
28609 #[test]
28610 fn cli_session_review_accepts_budget_flags() {
28611 let cli = parse_cli([
28612 "tsift",
28613 "session-review",
28614 "tasks/software/tsift.md",
28615 "--max-items",
28616 "4",
28617 "--max-bytes",
28618 "120",
28619 ]);
28620 match cli.command {
28621 Some(Commands::SessionReview {
28622 max_items,
28623 max_bytes,
28624 ..
28625 }) => {
28626 assert_eq!(max_items, Some(4));
28627 assert_eq!(max_bytes, Some(120));
28628 }
28629 _ => panic!("expected SessionReview command"),
28630 }
28631 }
28632
28633 #[test]
28634 fn cli_parses_context_pack_command() {
28635 let cli = parse_cli([
28636 "tsift",
28637 "context-pack",
28638 "tasks/software/tsift.md",
28639 "--test-input",
28640 "target/test.log",
28641 "--runner",
28642 "cargo",
28643 "--log-input",
28644 "target/build.log",
28645 "--max-items",
28646 "3",
28647 "--max-bytes",
28648 "96",
28649 "--json",
28650 ]);
28651 match cli.command {
28652 Some(Commands::ContextPack {
28653 path,
28654 test_input,
28655 runner,
28656 log_input,
28657 json,
28658 max_items,
28659 max_bytes,
28660 budget,
28661 convex_snapshot,
28662 }) => {
28663 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28664 assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
28665 assert_eq!(runner.as_deref(), Some("cargo"));
28666 assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
28667 assert!(json);
28668 assert_eq!(max_items, Some(3));
28669 assert_eq!(max_bytes, Some(96));
28670 assert!(budget.is_none());
28671 assert!(convex_snapshot.is_none());
28672 }
28673 _ => panic!("expected ContextPack command"),
28674 }
28675 }
28676
28677 #[test]
28678 fn cli_parses_token_savings_command() {
28679 let cli = parse_cli([
28680 "tsift",
28681 "token-savings",
28682 "--fixture",
28683 "fixtures/tsift-token-savings.json",
28684 "--fail-under",
28685 "--json",
28686 ]);
28687 match cli.command {
28688 Some(Commands::TokenSavings {
28689 fixture,
28690 fail_under,
28691 json,
28692 }) => {
28693 assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
28694 assert!(fail_under);
28695 assert!(json);
28696 }
28697 _ => panic!("expected TokenSavings command"),
28698 }
28699 }
28700
28701 #[test]
28702 fn token_savings_report_records_fixture_thresholds() {
28703 let raw_symbols = [
28704 "validate_user",
28705 "validateUser",
28706 "ValidateUser",
28707 "validate-user",
28708 "VALIDATE_USER",
28709 "Validate_User",
28710 "raw_symbol",
28711 "rawSymbol",
28712 "RawSymbol",
28713 "raw-symbol",
28714 "RAW_SYMBOL",
28715 "Raw_Symbol",
28716 ]
28717 .iter()
28718 .enumerate()
28719 .map(|(idx, identifier)| TokenSavingsRawSymbol {
28720 identifier: (*identifier).to_string(),
28721 file: format!("src/example_{idx}.rs"),
28722 line: (idx + 1) as u64,
28723 context: "function".to_string(),
28724 })
28725 .collect();
28726 let fixture = TokenSavingsFixture {
28727 schema_version: 1,
28728 description: "fixture".to_string(),
28729 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28730 cases: vec![TokenSavingsFixtureCase {
28731 name: "search-preview".to_string(),
28732 surface: "search".to_string(),
28733 minimum_savings_percent: 40.0,
28734 raw_symbols,
28735 tagpath_families: vec![
28736 TokenSavingsFamily {
28737 canonical: "validate_user".to_string(),
28738 count: 6,
28739 aliases: BTreeMap::new(),
28740 },
28741 TokenSavingsFamily {
28742 canonical: "raw_symbol".to_string(),
28743 count: 6,
28744 aliases: BTreeMap::new(),
28745 },
28746 ],
28747 context_pack_inputs: None,
28748 session_review_inputs: None,
28749 source_read_inputs: None,
28750 markdown_projection_inputs: None,
28751 }],
28752 };
28753
28754 let report = build_token_savings_report(&fixture).unwrap();
28755
28756 assert!(report.pass);
28757 assert_eq!(report.cases[0].raw_symbol_count, 12);
28758 assert_eq!(report.cases[0].family_count, 2);
28759 assert_eq!(report.cases[0].status, "pass");
28760 assert!(report.cases[0].byte_delta > 0);
28761 assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
28762 assert!(report.cases[0].savings_percent >= 40.0);
28763 }
28764
28765 #[test]
28766 fn token_savings_source_read_inputs_preserve_required_anchors() {
28767 let fixture = TokenSavingsFixture {
28768 schema_version: 1,
28769 description: "fixture".to_string(),
28770 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28771 cases: vec![TokenSavingsFixtureCase {
28772 name: "source-read".to_string(),
28773 surface: "source-read".to_string(),
28774 minimum_savings_percent: 40.0,
28775 raw_symbols: Vec::new(),
28776 tagpath_families: Vec::new(),
28777 context_pack_inputs: None,
28778 session_review_inputs: None,
28779 source_read_inputs: Some(TokenSavingsSourceReadInputs {
28780 reads: vec![TokenSavingsSourceReadInput {
28781 command: "sed -n '40,160p' src/main.rs".to_string(),
28782 file: "src/main.rs".to_string(),
28783 raw_start: 40,
28784 raw_lines: 121,
28785 raw_excerpt: "line 40\n".repeat(121),
28786 envelope_start: 40,
28787 envelope_lines: 121,
28788 required_line_anchors: vec![40, 120, 160],
28789 }],
28790 }),
28791 markdown_projection_inputs: None,
28792 }],
28793 };
28794
28795 let report = build_token_savings_report(&fixture).unwrap();
28796
28797 assert!(report.pass);
28798 assert_eq!(report.cases[0].surface, "source-read");
28799 assert!(report.cases[0].savings_percent >= 40.0);
28800 }
28801
28802 #[test]
28803 fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
28804 let fixture = TokenSavingsFixture {
28805 schema_version: 1,
28806 description: "fixture".to_string(),
28807 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28808 cases: vec![TokenSavingsFixtureCase {
28809 name: "source-read".to_string(),
28810 surface: "source-read".to_string(),
28811 minimum_savings_percent: 40.0,
28812 raw_symbols: Vec::new(),
28813 tagpath_families: Vec::new(),
28814 context_pack_inputs: None,
28815 session_review_inputs: None,
28816 source_read_inputs: Some(TokenSavingsSourceReadInputs {
28817 reads: vec![TokenSavingsSourceReadInput {
28818 command: "cat src/main.rs".to_string(),
28819 file: "src/main.rs".to_string(),
28820 raw_start: 1,
28821 raw_lines: 200,
28822 raw_excerpt: "line\n".repeat(200),
28823 envelope_start: 1,
28824 envelope_lines: 80,
28825 required_line_anchors: vec![120],
28826 }],
28827 }),
28828 markdown_projection_inputs: None,
28829 }],
28830 };
28831
28832 let err = match build_token_savings_report(&fixture) {
28833 Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
28834 Err(err) => err,
28835 };
28836
28837 assert!(err.to_string().contains("hides required line anchor 120"));
28838 }
28839
28840 #[test]
28841 fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
28842 let fixture = TokenSavingsFixture {
28843 schema_version: 1,
28844 description: "fixture".to_string(),
28845 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28846 cases: vec![TokenSavingsFixtureCase {
28847 name: "markdown-projection".to_string(),
28848 surface: "context-pack".to_string(),
28849 minimum_savings_percent: 40.0,
28850 raw_symbols: Vec::new(),
28851 tagpath_families: Vec::new(),
28852 context_pack_inputs: None,
28853 session_review_inputs: None,
28854 source_read_inputs: None,
28855 markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
28856 documents: vec![TokenSavingsMarkdownProjectionInput {
28857 command: "context-pack markdown body".to_string(),
28858 file: "tasks/software/tsift.md".to_string(),
28859 raw_markdown: "# Heading\n\n".repeat(120),
28860 outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
28861 selected_nodes: vec!["mdast-selected".to_string()],
28862 expand:
28863 "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
28864 .to_string(),
28865 }],
28866 }),
28867 }],
28868 };
28869
28870 let report = build_token_savings_report(&fixture).unwrap();
28871
28872 assert!(report.pass);
28873 assert_eq!(report.cases[0].surface, "context-pack");
28874 assert!(report.cases[0].savings_percent >= 40.0);
28875 }
28876
28877 #[test]
28878 fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
28879 let mut content = String::from("# Cache Root\n\n");
28880 for idx in 0..96 {
28881 content.push_str(&format!(
28882 "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
28883 ));
28884 }
28885
28886 let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
28887 assert!(!first.cache_hit);
28888 assert!(first.nodes.len() > 200);
28889
28890 let sections = markdown_section_spans(&content).unwrap();
28891 let list_items = markdown_block_spans(&content, "list_item").unwrap();
28892 let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
28893 let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
28894
28895 assert!(second.cache_hit);
28896 assert_eq!(second.nodes.len(), first.nodes.len());
28897 assert_eq!(sections.len(), 97);
28898 assert_eq!(list_items.len(), 96);
28899 assert_eq!(code_blocks.len(), 96);
28900 let first_code = first
28901 .nodes
28902 .iter()
28903 .find(|node| node.kind == "code_block")
28904 .expect("expected a Markdown code block");
28905 let first_code_node = markdown_ast_node(
28906 Path::new("/repo"),
28907 "semantic-edit",
28908 first_code,
28909 content.as_bytes(),
28910 &first.nodes,
28911 8,
28912 );
28913 assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
28914 assert_eq!(
28915 first_code_node.metadata.embedded_symbols[0].name,
28916 "sample_0"
28917 );
28918 assert_eq!(
28919 first_code_node.metadata.embedded_symbols[0].language,
28920 "rust"
28921 );
28922 }
28923
28924 #[test]
28925 fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
28926 let response = empty_search_response(Path::new("/repo"), "lexical");
28927 let symbol_hits = vec![index::SymbolHit {
28928 name: "alpha_helper_with_a_long_name".to_string(),
28929 kind: "function".to_string(),
28930 language: "rust".to_string(),
28931 file: "/repo/src/lib.rs".to_string(),
28932 line: 12,
28933 end_line: None,
28934 node_kind: None,
28935 start_byte: None,
28936 end_byte: None,
28937 body_start_byte: None,
28938 body_end_byte: None,
28939 tags: None,
28940 score: 0.98,
28941 match_type: "exact_name".to_string(),
28942 tagpath_handle: None,
28943 }];
28944
28945 let report = build_relative_search_budget_report(
28946 "alpha_helper_with_a_long_name",
28947 "lexical",
28948 Path::new("/repo"),
28949 &response,
28950 &symbol_hits,
28951 ResponseBudget::new(Some(1), Some(12)),
28952 &SearchFacetFilters::default(),
28953 );
28954
28955 assert_eq!(report.symbols.len(), 1);
28956 assert!(report.symbols[0].handle.starts_with("sfam-"));
28957 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
28958 assert_eq!(report.symbols[0].name, "alpha_hel...");
28959 assert_eq!(report.symbols[0].file, "src/lib.rs");
28960 assert!(report.symbols[0].expand.contains("tsift search"));
28961 }
28962
28963 #[test]
28964 fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
28965 let dir = tempfile::tempdir().unwrap();
28966 let src_dir = dir.path().join("src");
28967 fs::create_dir_all(&src_dir).unwrap();
28968 let source = "fn alpha_helper() {\n beta();\n}\n";
28969 let file = src_dir.join("lib.rs");
28970 fs::write(&file, source).unwrap();
28971 let body_start = source.find("{\n").unwrap() + 1;
28972 let body_end = source.rfind("\n}").unwrap() + 1;
28973
28974 let response = empty_search_response(dir.path(), "lexical");
28975 let symbol_hits = vec![index::SymbolHit {
28976 name: "alpha_helper".to_string(),
28977 kind: "function".to_string(),
28978 language: "rust".to_string(),
28979 file: file.to_string_lossy().to_string(),
28980 line: 0,
28981 end_line: Some(2),
28982 node_kind: Some("function_item".to_string()),
28983 start_byte: Some(0),
28984 end_byte: Some(i64::try_from(source.len()).unwrap()),
28985 body_start_byte: Some(i64::try_from(body_start).unwrap()),
28986 body_end_byte: Some(i64::try_from(body_end).unwrap()),
28987 tags: Some("alpha,helper".to_string()),
28988 score: 0.98,
28989 match_type: "exact_name".to_string(),
28990 tagpath_handle: None,
28991 }];
28992
28993 let report = build_relative_search_budget_report(
28994 "alpha helper",
28995 "lexical",
28996 dir.path(),
28997 &response,
28998 &symbol_hits,
28999 ResponseBudget::new(Some(5), Some(96)),
29000 &SearchFacetFilters::default(),
29001 );
29002
29003 let symbol = &report.symbols[0];
29004 assert_eq!(symbol.language, "rust");
29005 assert_eq!(symbol.end_line, Some(2));
29006 let ast = symbol
29007 .ast
29008 .as_ref()
29009 .expect("search symbol preview should expose an AST span artifact");
29010 assert_eq!(ast.artifact_kind, "ast_span");
29011 assert!(ast.span.handle.starts_with("span-"));
29012 assert_eq!(ast.span.node_kind, "function_item");
29013 assert_eq!(ast.span.start_byte, 0);
29014 assert_eq!(ast.span.end_byte, source.len());
29015 assert_eq!(ast.span.body_start_byte, Some(body_start));
29016 assert_eq!(ast.span.body_end_byte, Some(body_end));
29017 assert!(ast.expand.source_window.contains("source-read"));
29018 assert!(
29019 ast.expand
29020 .source_body
29021 .as_ref()
29022 .unwrap()
29023 .contains("source-read")
29024 );
29025 assert!(ast.expand.symbol_read.contains("symbol-read"));
29026 assert!(ast.expand.markdown_ast.is_none());
29027 }
29028
29029 #[test]
29030 fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
29031 let dir = tempfile::tempdir().unwrap();
29032 let source = "# Guide\n\n## Install\n\n- Run setup.\n";
29033 let file = dir.path().join("README.md");
29034 fs::write(&file, source).unwrap();
29035 let heading_start = source.find("## Install").unwrap();
29036 let heading_end = source.len();
29037
29038 let response = empty_search_response(dir.path(), "lexical");
29039 let symbol_hits = vec![index::SymbolHit {
29040 name: "Install".to_string(),
29041 kind: "heading".to_string(),
29042 language: "markdown".to_string(),
29043 file: file.to_string_lossy().to_string(),
29044 line: 2,
29045 end_line: Some(4),
29046 node_kind: Some("atx_heading".to_string()),
29047 start_byte: Some(i64::try_from(heading_start).unwrap()),
29048 end_byte: Some(i64::try_from(heading_end).unwrap()),
29049 body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
29050 body_end_byte: Some(i64::try_from(heading_end).unwrap()),
29051 tags: Some("install".to_string()),
29052 score: 1.0,
29053 match_type: "exact_name".to_string(),
29054 tagpath_handle: None,
29055 }];
29056
29057 let report = build_relative_search_budget_report(
29058 "Install",
29059 "lexical",
29060 dir.path(),
29061 &response,
29062 &symbol_hits,
29063 ResponseBudget::new(Some(5), Some(96)),
29064 &SearchFacetFilters::default(),
29065 );
29066
29067 let ast = report.symbols[0]
29068 .ast
29069 .as_ref()
29070 .expect("Markdown search symbol should expose an AST span artifact");
29071 assert_eq!(ast.span.node_kind, "atx_heading");
29072 assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
29073 let markdown_ast = ast
29074 .expand
29075 .markdown_ast
29076 .as_ref()
29077 .expect("Markdown symbols should include markdown-ast expansion");
29078 assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
29079 assert!(markdown_ast.contains("--node"), "{markdown_ast}");
29080 assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
29081 assert!(ast.expand.source_window.contains("source-read"));
29082 assert!(ast.expand.symbol_read.contains("symbol-read"));
29083 }
29084
29085 #[test]
29086 fn search_budget_report_exposes_markdown_embedded_code_symbols() {
29087 let dir = tempfile::tempdir().unwrap();
29088 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29089 let file = dir.path().join("README.md");
29090 fs::write(&file, source).unwrap();
29091 let fence_start = source.find("```rust").unwrap();
29092 let body_start = source.find("fn sample").unwrap();
29093 let body_end = body_start + "fn sample() {}\n".len();
29094
29095 let response = empty_search_response(dir.path(), "lexical");
29096 let symbol_hits = vec![index::SymbolHit {
29097 name: "rust".to_string(),
29098 kind: "code_block".to_string(),
29099 language: "markdown".to_string(),
29100 file: file.to_string_lossy().to_string(),
29101 line: 2,
29102 end_line: Some(4),
29103 node_kind: Some("fenced_code_block".to_string()),
29104 start_byte: Some(i64::try_from(fence_start).unwrap()),
29105 end_byte: Some(i64::try_from(source.len()).unwrap()),
29106 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29107 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29108 tags: Some("rust".to_string()),
29109 score: 1.0,
29110 match_type: "exact_name".to_string(),
29111 tagpath_handle: None,
29112 }];
29113
29114 let report = build_relative_search_budget_report(
29115 "rust",
29116 "lexical",
29117 dir.path(),
29118 &response,
29119 &symbol_hits,
29120 ResponseBudget::new(Some(5), Some(96)),
29121 &SearchFacetFilters::default(),
29122 );
29123
29124 let embedded = &report.symbols[0]
29125 .ast
29126 .as_ref()
29127 .unwrap()
29128 .span
29129 .markdown
29130 .as_ref()
29131 .unwrap()
29132 .embedded_symbols;
29133 assert_eq!(embedded.len(), 1);
29134 assert_eq!(embedded[0].name, "sample");
29135 assert_eq!(embedded[0].kind, "function");
29136 assert_eq!(embedded[0].language, "rust");
29137 assert_eq!(embedded[0].node_kind, "function_item");
29138 assert!(embedded[0].handle.starts_with("span-"));
29139 assert_eq!(embedded[0].start_byte, body_start);
29140 assert_eq!(embedded[0].start_line, 4);
29141 }
29142
29143 fn test_lexical_search_hit(
29144 path: &Path,
29145 rank: usize,
29146 score: f64,
29147 snippet: &str,
29148 ) -> sift::SearchHit {
29149 sift::SearchHit {
29150 artifact_id: format!("hit-{rank}"),
29151 artifact_kind: sift::ContextArtifactKind::File,
29152 budget: sift::ArtifactBudget::from_text(snippet, 1),
29153 confidence: sift::ScoreConfidence::High,
29154 freshness: sift::ArtifactFreshness {
29155 modified_unix_secs: None,
29156 observed_unix_secs: 0,
29157 },
29158 location: Some("line 1".to_string()),
29159 path: path.to_string_lossy().to_string(),
29160 provenance: sift::ArtifactProvenance {
29161 adapter: sift::AcquisitionAdapterKind::FileSystem,
29162 source: "test lexical hit".to_string(),
29163 synthetic: false,
29164 },
29165 rank,
29166 score,
29167 snippet: snippet.to_string(),
29168 }
29169 }
29170
29171 fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
29172 summarize::Summary {
29173 id: 0,
29174 symbol_name: symbol_name.to_string(),
29175 file_path: file_path.to_string(),
29176 content_hash: "hash".to_string(),
29177 summary: summary.to_string(),
29178 entities: None,
29179 relationships: None,
29180 concept_labels: None,
29181 extracted_at: "2026-06-02T00:00:00Z".to_string(),
29182 model: "test".to_string(),
29183 tokens_input: None,
29184 tokens_output: None,
29185 }
29186 }
29187
29188 #[test]
29189 fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
29190 let dir = tempfile::tempdir().unwrap();
29191 let src_dir = dir.path().join("src");
29192 fs::create_dir_all(&src_dir).unwrap();
29193 let source = "fn alpha_helper() {}\n";
29194 let file = src_dir.join("lib.rs");
29195 let broad_file = dir.path().join("README.md");
29196 fs::write(&file, source).unwrap();
29197 fs::write(
29198 &broad_file,
29199 "alpha helper alpha helper alpha helper in prose\n",
29200 )
29201 .unwrap();
29202
29203 let mut response = empty_search_response(dir.path(), "lexical");
29204 response.hits.push(test_lexical_search_hit(
29205 &broad_file,
29206 1,
29207 240.0,
29208 "alpha helper alpha helper alpha helper in prose",
29209 ));
29210 let symbol_hits = vec![index::SymbolHit {
29211 name: "alpha_helper".to_string(),
29212 kind: "function".to_string(),
29213 language: "rust".to_string(),
29214 file: file.to_string_lossy().to_string(),
29215 line: 0,
29216 end_line: Some(0),
29217 node_kind: Some("function_item".to_string()),
29218 start_byte: Some(0),
29219 end_byte: Some(i64::try_from(source.len()).unwrap()),
29220 body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29221 body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29222 tags: Some("alpha,helper".to_string()),
29223 score: 0.8,
29224 match_type: "all_tags".to_string(),
29225 tagpath_handle: None,
29226 }];
29227
29228 let report = build_relative_search_budget_report(
29229 "alpha helper",
29230 "lexical",
29231 dir.path(),
29232 &response,
29233 &symbol_hits,
29234 ResponseBudget::new(Some(5), Some(128)),
29235 &SearchFacetFilters::default(),
29236 );
29237
29238 assert_eq!(report.ranked[0].source, "symbol_span");
29239 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
29240 assert!(report.ranked[0].score > report.ranked[1].score);
29241 assert_eq!(report.ranked[1].source, "lexical_file");
29242 }
29243
29244 #[test]
29245 fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
29246 let dir = tempfile::tempdir().unwrap();
29247 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29248 let file = dir.path().join("README.md");
29249 fs::write(&file, source).unwrap();
29250 let summary_db =
29251 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
29252 summary_db
29253 .insert(&test_summary(
29254 "rust",
29255 "README.md",
29256 "Rust fence contains a sample function.",
29257 ))
29258 .unwrap();
29259
29260 let fence_start = source.find("```rust").unwrap();
29261 let body_start = source.find("fn sample").unwrap();
29262 let body_end = body_start + "fn sample() {}\n".len();
29263 let response = empty_search_response(dir.path(), "lexical");
29264 let symbol_hits = vec![index::SymbolHit {
29265 name: "rust".to_string(),
29266 kind: "code_block".to_string(),
29267 language: "markdown".to_string(),
29268 file: file.to_string_lossy().to_string(),
29269 line: 2,
29270 end_line: Some(4),
29271 node_kind: Some("fenced_code_block".to_string()),
29272 start_byte: Some(i64::try_from(fence_start).unwrap()),
29273 end_byte: Some(i64::try_from(source.len()).unwrap()),
29274 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29275 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29276 tags: Some("rust".to_string()),
29277 score: 1.0,
29278 match_type: "exact_name".to_string(),
29279 tagpath_handle: None,
29280 }];
29281
29282 let report = build_relative_search_budget_report(
29283 "rust",
29284 "lexical",
29285 dir.path(),
29286 &response,
29287 &symbol_hits,
29288 ResponseBudget::new(Some(5), Some(128)),
29289 &SearchFacetFilters::default(),
29290 );
29291
29292 let symbol = &report.symbols[0];
29293 assert_eq!(symbol.summary_refs, 1);
29294 assert_eq!(symbol.graph_neighbors, 1);
29295 assert!(
29296 report.ranked[0]
29297 .reasons
29298 .iter()
29299 .any(|reason| reason == "summary_refs:1")
29300 );
29301 assert!(
29302 report.ranked[0]
29303 .reasons
29304 .iter()
29305 .any(|reason| reason == "graph_neighbors:1")
29306 );
29307 }
29308
29309 fn markdown_search_facet_fixture() -> tempfile::TempDir {
29310 let dir = tempfile::tempdir().unwrap();
29311 let source = r#"# Guide
29312
29313## Install
29314
29315- Run setup.
29316 - Confirm setup.
29317
29318```rust
29319fn sample() {}
29320```
29321"#;
29322 fs::write(dir.path().join("README.md"), source).unwrap();
29323 let index_dir = dir.path().join(".tsift");
29324 fs::create_dir_all(&index_dir).unwrap();
29325 run_index_update(
29326 &index_dir.join("index.db"),
29327 dir.path(),
29328 "indexing markdown search facet fixture".to_string(),
29329 dir.path(),
29330 None,
29331 false,
29332 false,
29333 )
29334 .unwrap();
29335 dir
29336 }
29337
29338 fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
29339 let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
29340 db.symbol_search(query, 20).unwrap()
29341 }
29342
29343 #[test]
29344 fn search_facet_filters_match_scalar_symbol_fields() {
29345 let dir = tempfile::tempdir().unwrap();
29346 let hits = vec![
29347 index::SymbolHit {
29348 name: "alpha_helper".to_string(),
29349 kind: "function".to_string(),
29350 language: "rust".to_string(),
29351 file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
29352 line: 0,
29353 end_line: None,
29354 node_kind: Some("function_item".to_string()),
29355 start_byte: None,
29356 end_byte: None,
29357 body_start_byte: None,
29358 body_end_byte: None,
29359 tags: None,
29360 score: 1.0,
29361 match_type: "exact_name".to_string(),
29362 tagpath_handle: None,
29363 },
29364 index::SymbolHit {
29365 name: "Install".to_string(),
29366 kind: "heading".to_string(),
29367 language: "markdown".to_string(),
29368 file: dir.path().join("README.md").to_string_lossy().to_string(),
29369 line: 0,
29370 end_line: None,
29371 node_kind: Some("atx_heading".to_string()),
29372 start_byte: None,
29373 end_byte: None,
29374 body_start_byte: None,
29375 body_end_byte: None,
29376 tags: None,
29377 score: 0.9,
29378 match_type: "exact_name".to_string(),
29379 tagpath_handle: None,
29380 },
29381 ];
29382
29383 let filtered = apply_search_facet_filters(
29384 dir.path(),
29385 hits,
29386 &SearchFacetFilters {
29387 languages: vec!["rust".to_string()],
29388 kinds: vec!["function".to_string()],
29389 node_kinds: vec!["function_item".to_string()],
29390 ..SearchFacetFilters::default()
29391 },
29392 );
29393
29394 assert_eq!(filtered.len(), 1);
29395 assert_eq!(filtered[0].name, "alpha_helper");
29396 }
29397
29398 #[test]
29399 fn search_facet_filters_match_markdown_sections_and_block_metadata() {
29400 let dir = markdown_search_facet_fixture();
29401
29402 let nested_list = apply_search_facet_filters(
29403 dir.path(),
29404 markdown_search_facet_hits(dir.path(), "setup"),
29405 &SearchFacetFilters {
29406 sections: vec!["Install".to_string()],
29407 parents: vec!["Run setup.".to_string()],
29408 list_depths: vec![1],
29409 ..SearchFacetFilters::default()
29410 },
29411 );
29412 assert_eq!(nested_list.len(), 1);
29413 assert_eq!(nested_list[0].name, "Confirm setup.");
29414
29415 let parent_list = apply_search_facet_filters(
29416 dir.path(),
29417 markdown_search_facet_hits(dir.path(), "setup"),
29418 &SearchFacetFilters {
29419 children: vec!["Confirm setup.".to_string()],
29420 ..SearchFacetFilters::default()
29421 },
29422 );
29423 assert_eq!(parent_list.len(), 1);
29424 assert_eq!(parent_list[0].name, "Run setup.");
29425
29426 let heading = apply_search_facet_filters(
29427 dir.path(),
29428 markdown_search_facet_hits(dir.path(), "Install"),
29429 &SearchFacetFilters {
29430 heading_levels: vec![2],
29431 node_kinds: vec!["atx_heading".to_string()],
29432 ..SearchFacetFilters::default()
29433 },
29434 );
29435 assert_eq!(heading.len(), 1);
29436 assert_eq!(heading[0].name, "Install");
29437
29438 let fence = apply_search_facet_filters(
29439 dir.path(),
29440 markdown_search_facet_hits(dir.path(), "rust"),
29441 &SearchFacetFilters {
29442 fence_languages: vec!["rust".to_string()],
29443 kinds: vec!["code_block".to_string()],
29444 ..SearchFacetFilters::default()
29445 },
29446 );
29447 assert_eq!(fence.len(), 1);
29448 assert_eq!(fence[0].kind, "code_block");
29449
29450 let embedded_child = apply_search_facet_filters(
29451 dir.path(),
29452 markdown_search_facet_hits(dir.path(), "rust"),
29453 &SearchFacetFilters {
29454 children: vec!["sample".to_string()],
29455 kinds: vec!["code_block".to_string()],
29456 ..SearchFacetFilters::default()
29457 },
29458 );
29459 assert_eq!(embedded_child.len(), 1);
29460 assert_eq!(embedded_child[0].name, "rust");
29461 }
29462
29463 #[test]
29464 fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
29465 let response = empty_search_response(Path::new("/repo"), "lexical");
29466 let symbol_hits = vec![
29467 index::SymbolHit {
29468 name: "alpha_helper".to_string(),
29469 kind: "function".to_string(),
29470 language: "rust".to_string(),
29471 file: "/repo/src/lib.rs".to_string(),
29472 line: 12,
29473 end_line: None,
29474 node_kind: None,
29475 start_byte: None,
29476 end_byte: None,
29477 body_start_byte: None,
29478 body_end_byte: None,
29479 tags: Some("alpha,helper".to_string()),
29480 score: 0.98,
29481 match_type: "exact_name".to_string(),
29482 tagpath_handle: None,
29483 },
29484 index::SymbolHit {
29485 name: "alphaHelper".to_string(),
29486 kind: "method".to_string(),
29487 language: "rust".to_string(),
29488 file: "/repo/src/main.rs".to_string(),
29489 line: 34,
29490 end_line: None,
29491 node_kind: None,
29492 start_byte: None,
29493 end_byte: None,
29494 body_start_byte: None,
29495 body_end_byte: None,
29496 tags: Some("alpha,helper".to_string()),
29497 score: 0.93,
29498 match_type: "tag_overlap".to_string(),
29499 tagpath_handle: None,
29500 },
29501 index::SymbolHit {
29502 name: "alpha_helper".to_string(),
29503 kind: "function".to_string(),
29504 language: "rust".to_string(),
29505 file: "/repo/src/worker.rs".to_string(),
29506 line: 56,
29507 end_line: None,
29508 node_kind: None,
29509 start_byte: None,
29510 end_byte: None,
29511 body_start_byte: None,
29512 body_end_byte: None,
29513 tags: Some("alpha,helper".to_string()),
29514 score: 0.91,
29515 match_type: "tag_overlap".to_string(),
29516 tagpath_handle: None,
29517 },
29518 ];
29519
29520 let report = build_relative_search_budget_report(
29521 "alpha helper",
29522 "lexical",
29523 Path::new("/repo"),
29524 &response,
29525 &symbol_hits,
29526 ResponseBudget::new(Some(5), Some(48)),
29527 &SearchFacetFilters::default(),
29528 );
29529
29530 assert_eq!(report.symbol_total, 1);
29531 assert_eq!(report.raw_symbol_total, 3);
29532 assert_eq!(report.symbols.len(), 1);
29533 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
29534 assert_eq!(report.symbols[0].match_count, 3);
29535 assert_eq!(report.symbols[0].surface_count, 2);
29536 assert_eq!(report.symbols[0].file_count, 3);
29537 assert_eq!(
29538 report.symbols[0].surface_examples,
29539 vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
29540 );
29541 assert!(report.symbols[0].name.contains("(+1 variant)"));
29542 assert!(report.symbols[0].file.contains("(+2 files)"));
29543 assert!(report.symbols[0].expand.contains("tsift search"));
29544 assert!(report.symbols[0].expand.contains("alpha helper"));
29545 }
29546
29547 #[test]
29548 fn search_budget_report_carries_active_filters() {
29549 let response = empty_search_response(Path::new("/repo"), "lexical");
29550 let symbol_hits = vec![index::SymbolHit {
29551 name: "alpha_helper".to_string(),
29552 kind: "function".to_string(),
29553 language: "rust".to_string(),
29554 file: "/repo/src/lib.rs".to_string(),
29555 line: 12,
29556 end_line: None,
29557 node_kind: Some("function_item".to_string()),
29558 start_byte: None,
29559 end_byte: None,
29560 body_start_byte: None,
29561 body_end_byte: None,
29562 tags: Some("alpha,helper".to_string()),
29563 score: 0.98,
29564 match_type: "exact_name".to_string(),
29565 tagpath_handle: None,
29566 }];
29567 let filters = SearchFacetFilters {
29568 languages: vec!["rust".to_string()],
29569 kinds: vec!["function".to_string()],
29570 node_kinds: vec!["function_item".to_string()],
29571 ..SearchFacetFilters::default()
29572 };
29573
29574 let report = build_relative_search_budget_report(
29575 "alpha helper",
29576 "lexical",
29577 Path::new("/repo"),
29578 &response,
29579 &symbol_hits,
29580 ResponseBudget::new(Some(5), Some(48)),
29581 &filters,
29582 );
29583
29584 assert_eq!(report.filters, filters);
29585 assert_eq!(
29586 search_facet_filters_summary(&report.filters),
29587 "lang=rust kind=function node-kind=function_item"
29588 );
29589 }
29590
29591 #[test]
29592 fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
29593 let mut response = empty_search_response(Path::new("/repo"), "lexical");
29594 response.indexed_artifacts = 450;
29595 let symbol_hits = vec![
29596 index::SymbolHit {
29597 name: "alpha_helper".to_string(),
29598 kind: "function".to_string(),
29599 language: "rust".to_string(),
29600 file: "/repo/src/lib.rs".to_string(),
29601 line: 12,
29602 end_line: None,
29603 node_kind: None,
29604 start_byte: None,
29605 end_byte: None,
29606 body_start_byte: None,
29607 body_end_byte: None,
29608 tags: Some("alpha,helper".to_string()),
29609 score: 0.98,
29610 match_type: "exact_name".to_string(),
29611 tagpath_handle: None,
29612 },
29613 index::SymbolHit {
29614 name: "beta_helper".to_string(),
29615 kind: "function".to_string(),
29616 language: "rust".to_string(),
29617 file: "/repo/src/beta.rs".to_string(),
29618 line: 21,
29619 end_line: None,
29620 node_kind: None,
29621 start_byte: None,
29622 end_byte: None,
29623 body_start_byte: None,
29624 body_end_byte: None,
29625 tags: Some("beta,helper".to_string()),
29626 score: 0.92,
29627 match_type: "tag_overlap".to_string(),
29628 tagpath_handle: None,
29629 },
29630 ];
29631
29632 let report = build_relative_search_budget_report(
29633 "helper",
29634 "lexical",
29635 Path::new("/repo"),
29636 &response,
29637 &symbol_hits,
29638 ResponseBudget::new(Some(1), Some(64)),
29639 &SearchFacetFilters::default(),
29640 );
29641
29642 let guard = report
29643 .scale_guard
29644 .as_ref()
29645 .expect("broad previews should emit a scale guard");
29646 assert_eq!(guard.level, "high-hit");
29647 assert_eq!(guard.signals.indexed_artifacts, 450);
29648 assert_eq!(guard.signals.raw_symbol_matches, 2);
29649 assert!(
29650 guard
29651 .narrow_commands
29652 .iter()
29653 .any(|command| command.contains("--exact"))
29654 );
29655 assert!(
29656 guard
29657 .narrow_commands
29658 .iter()
29659 .any(|command| command.contains("alpha helper"))
29660 );
29661 assert!(
29662 guard
29663 .narrow_commands
29664 .last()
29665 .unwrap()
29666 .contains("workflow search")
29667 );
29668 }
29669
29670 #[test]
29671 fn explain_budget_report_limits_edges_and_members() {
29672 let symbols = vec![index::StoredSymbol {
29673 name: "alpha_helper".to_string(),
29674 kind: "function".to_string(),
29675 language: "rust".to_string(),
29676 signature: None,
29677 file: "src/lib.rs".to_string(),
29678 line: 10,
29679 end_line: None,
29680 node_kind: None,
29681 start_byte: None,
29682 end_byte: None,
29683 body_start_byte: None,
29684 body_end_byte: None,
29685 parent_module: None,
29686 visibility: None,
29687 tags: None,
29688 tagpath_handle: None,
29689 }];
29690 let callers = vec![
29691 index::StoredEdge {
29692 caller_file: "src/main.rs".to_string(),
29693 caller_name: "main".to_string(),
29694 caller_line: 1,
29695 callee_name: "alpha_helper".to_string(),
29696 call_site_line: 3,
29697 tagpath_handle: None,
29698 },
29699 index::StoredEdge {
29700 caller_file: "src/worker.rs".to_string(),
29701 caller_name: "worker".to_string(),
29702 caller_line: 5,
29703 callee_name: "alpha_helper".to_string(),
29704 call_site_line: 8,
29705 tagpath_handle: None,
29706 },
29707 ];
29708 let community = graph::Community {
29709 id: 1,
29710 members: vec![
29711 graph::CommunityMember::new("alpha_helper"),
29712 graph::CommunityMember::new("main"),
29713 graph::CommunityMember::new("worker"),
29714 ],
29715 modularity_contribution: 0.5,
29716 };
29717
29718 let report = build_explain_budget_report(
29719 "alpha_helper",
29720 Path::new("/repo"),
29721 &symbols,
29722 &callers,
29723 2,
29724 false,
29725 &[],
29726 0,
29727 false,
29728 Some(&community),
29729 ResponseBudget::new(Some(1), Some(24)),
29730 );
29731
29732 assert_eq!(report.definitions.len(), 1);
29733 assert_eq!(report.callers.len(), 1);
29734 assert!(report.truncated);
29735 assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
29736 assert_eq!(
29737 report.definitions[0].tag_alias.as_deref(),
29738 Some("alpha/helper")
29739 );
29740 assert!(report.callers[0].handle.starts_with("ecall-"));
29741 assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
29742 }
29743
29744 #[test]
29745 fn session_review_next_context_budget_limits_lists() {
29746 let report = session_review::SessionReviewReport {
29747 root: "/repo".to_string(),
29748 target: "tasks/software/tsift.md".to_string(),
29749 target_kind: "file".to_string(),
29750 sessions_considered: 1,
29751 sessions_matched: 1,
29752 claude_sessions: 1,
29753 codex_sessions: 0,
29754 agent_doc_logs: 0,
29755 prompt_target_count: 2,
29756 command_groups: 0,
29757 file_groups: 2,
29758 symbol_groups: 1,
29759 failure_groups: 1,
29760 runtime_event_groups: 0,
29761 restart_churn_groups: 0,
29762 closeout_groups: 0,
29763 usage_samples: 1,
29764 prompt_tokens: 120,
29765 cached_input_tokens: 80,
29766 cache_creation_input_tokens: 0,
29767 output_tokens: 40,
29768 reasoning_output_tokens: 0,
29769 total_tokens: 240,
29770 cached_input_ratio: Some(40.0),
29771 largest_turn_total_tokens: 240,
29772 aggregate_cost: session_review::SessionReviewCostSummary {
29773 scope: "bounded_matched_sessions".to_string(),
29774 sessions: 1,
29775 usage_samples: 1,
29776 prompt_tokens: 120,
29777 cached_input_tokens: 80,
29778 cache_creation_input_tokens: 0,
29779 output_tokens: 40,
29780 reasoning_output_tokens: 0,
29781 total_tokens: 240,
29782 cached_input_ratio: Some(40.0),
29783 largest_turn_total_tokens: 240,
29784 },
29785 latest_session_cost: Some(session_review::SessionReviewCostSummary {
29786 scope: "latest_matched_session".to_string(),
29787 sessions: 1,
29788 usage_samples: 1,
29789 prompt_tokens: 120,
29790 cached_input_tokens: 80,
29791 cache_creation_input_tokens: 0,
29792 output_tokens: 40,
29793 reasoning_output_tokens: 0,
29794 total_tokens: 240,
29795 cached_input_ratio: Some(66.67),
29796 largest_turn_total_tokens: 240,
29797 }),
29798 guardrails: vec![
29799 session_cost::SessionCostGuardrail {
29800 kind: "cache_resend".to_string(),
29801 severity: "warn".to_string(),
29802 message: "cached input ratio was high".to_string(),
29803 guidance: "compact or restart the session".to_string(),
29804 },
29805 session_cost::SessionCostGuardrail {
29806 kind: "prompt_budget".to_string(),
29807 severity: "warn".to_string(),
29808 message: "largest prompt turn reached 999999 tokens".to_string(),
29809 guidance: "compact the session before another large turn".to_string(),
29810 },
29811 session_cost::SessionCostGuardrail {
29812 kind: "restart_loop".to_string(),
29813 severity: "warn".to_string(),
29814 message: "restart churn detected".to_string(),
29815 guidance: "restart cleanly".to_string(),
29816 },
29817 session_cost::SessionCostGuardrail {
29818 kind: "noop_closeout".to_string(),
29819 severity: "warn".to_string(),
29820 message: "commit_already_current appeared 8 times".to_string(),
29821 guidance: "avoid reopening without new edits".to_string(),
29822 },
29823 ],
29824 loop_clusters: vec![],
29825 file_read_diagnostics: vec![],
29826 prompt_targets: vec![
29827 session_review::SessionReviewPromptTarget {
29828 text: "do one".to_string(),
29829 occurrences: 1,
29830 },
29831 session_review::SessionReviewPromptTarget {
29832 text: "do two".to_string(),
29833 occurrences: 1,
29834 },
29835 ],
29836 commands: vec![],
29837 touched_files: vec![],
29838 touched_symbols: vec![],
29839 failures: vec![],
29840 runtime_events: vec![],
29841 restart_churn: vec![],
29842 closeout: vec![],
29843 largest_turns: vec![],
29844 sessions: vec![session_review::SessionReviewSession {
29845 source: "claude_jsonl".to_string(),
29846 path: "/tmp/session.jsonl".to_string(),
29847 matched_by: vec!["path".to_string()],
29848 modified_unix_secs: None,
29849 prompt_target_count: 2,
29850 command_groups: 0,
29851 file_groups: 2,
29852 symbol_groups: 1,
29853 failure_groups: 1,
29854 runtime_event_groups: 0,
29855 restart_churn_groups: 0,
29856 closeout_groups: 0,
29857 usage_samples: 1,
29858 prompt_tokens: 120,
29859 cached_input_tokens: 80,
29860 cache_creation_input_tokens: 0,
29861 output_tokens: 40,
29862 reasoning_output_tokens: 0,
29863 total_tokens: 240,
29864 largest_turn_total_tokens: 240,
29865 }],
29866 next_context: session_review::SessionReviewNextContext {
29867 target: "tasks/software/tsift.md".to_string(),
29868 active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
29869 last_verification: session_review::SessionReviewVerificationState {
29870 status: "green".to_string(),
29871 detail: "cargo test".to_string(),
29872 },
29873 touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
29874 touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
29875 unresolved_failures: vec![session_review::SessionReviewFailure {
29876 kind: "timeout".to_string(),
29877 message: "search timed out".to_string(),
29878 occurrences: 1,
29879 command: None,
29880 session_path: None,
29881 }],
29882 next_digest_commands: vec![
29883 "tsift session-review --next-context tasks/software/tsift.md".to_string(),
29884 "tsift diff-digest .".to_string(),
29885 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
29886 "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
29887 ],
29888 },
29889 warnings: vec![],
29890 };
29891
29892 let budget_report = build_session_review_next_context_budget_report(
29893 &report,
29894 ResponseBudget::new(Some(1), Some(12)),
29895 None,
29896 );
29897
29898 assert!(budget_report.truncated);
29899 assert_eq!(budget_report.prompt_targets, vec!["do one"]);
29900 assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
29901 assert!(
29902 budget_report.touched_symbol_refs[0]
29903 .handle
29904 .starts_with("ncsym-")
29905 );
29906 assert_eq!(
29907 budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
29908 Some("alpha/helper")
29909 );
29910 assert!(
29911 budget_report.unresolved_failures[0]
29912 .handle
29913 .starts_with("snf-")
29914 );
29915 assert_eq!(budget_report.next_digest_commands.len(), 4);
29916 assert_eq!(
29917 budget_report.next_digest_commands[2],
29918 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
29919 );
29920 assert_eq!(budget_report.next_token_actions.len(), 1);
29921 assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
29922
29923 let full_action_report = build_session_review_next_context_budget_report(
29924 &report,
29925 ResponseBudget::new(Some(4), Some(120)),
29926 None,
29927 );
29928 assert_eq!(
29929 full_action_report
29930 .next_token_actions
29931 .iter()
29932 .map(|action| action.kind.as_str())
29933 .collect::<Vec<_>>(),
29934 vec![
29935 "prompt_budget",
29936 "cache_resend",
29937 "restart_loop",
29938 "noop_closeout"
29939 ]
29940 );
29941 assert_eq!(
29942 full_action_report.next_token_actions[0]
29943 .compact_command
29944 .as_deref(),
29945 Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
29946 );
29947 assert_eq!(
29948 full_action_report.next_token_actions[0]
29949 .restart_command
29950 .as_deref(),
29951 Some("agent-doc start \"tasks/software/tsift.md\"")
29952 );
29953 assert!(
29954 full_action_report.next_token_actions[0]
29955 .digest_commands
29956 .iter()
29957 .any(|command| command
29958 == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
29959 );
29960 }
29961
29962 #[test]
29963 fn context_pack_diff_preview_limits_files_and_symbols() {
29964 let report = diff_digest::DiffDigestReport {
29965 root: "/repo".to_string(),
29966 mode: diff_digest::DiffDigestMode::WorkingTree,
29967 revision: None,
29968 files_changed: 2,
29969 files_with_current_summaries: 1,
29970 symbols_touched: 3,
29971 call_edges_added: 1,
29972 call_edges_removed: 0,
29973 files: vec![
29974 diff_digest::DiffDigestFile {
29975 path: "src/lib.rs".to_string(),
29976 status: diff_digest::DiffDigestFileStatus::Modified,
29977 touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
29978 summary_state: diff_digest::DiffDigestSummaryState::Current,
29979 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
29980 symbol: "alpha_helper".to_string(),
29981 summary: "alpha helper handles the main alpha workflow".to_string(),
29982 }],
29983 added_call_edges: vec!["alpha->beta".to_string()],
29984 removed_call_edges: vec![],
29985 warnings: vec!["stale parse".to_string()],
29986 },
29987 diff_digest::DiffDigestFile {
29988 path: "src/main.rs".to_string(),
29989 status: diff_digest::DiffDigestFileStatus::Added,
29990 touched_symbols: vec!["main".to_string()],
29991 summary_state: diff_digest::DiffDigestSummaryState::Missing,
29992 current_summaries: vec![],
29993 added_call_edges: vec![],
29994 removed_call_edges: vec![],
29995 warnings: vec![],
29996 },
29997 ],
29998 };
29999
30000 let preview =
30001 build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
30002
30003 assert!(preview.truncated);
30004 assert_eq!(preview.files.len(), 1);
30005 assert_eq!(preview.files[0].path, "src/lib.rs");
30006 assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
30007 assert!(
30008 preview.files[0].touched_symbol_refs[0]
30009 .handle
30010 .starts_with("cdsym-")
30011 );
30012 assert_eq!(
30013 preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
30014 Some("alpha/he...")
30015 );
30016 assert!(
30017 preview.files[0].summary_refs[0]
30018 .handle
30019 .starts_with("cdsum-")
30020 );
30021 assert_eq!(
30022 preview.files[0].summary_refs[0].tag_alias.as_deref(),
30023 Some("alpha/he...")
30024 );
30025 assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
30026 assert_eq!(
30027 preview.files[0].summary_refs[0].expand,
30028 "tsift summarize --file \"src/lib.rs\""
30029 );
30030 assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
30031 }
30032
30033 #[test]
30034 fn context_pack_status_reminders_include_stale_index_state() {
30035 let dir = setup_graph_index();
30036 std::thread::sleep(std::time::Duration::from_millis(50));
30037 std::fs::write(
30038 dir.path().join("main.rs"),
30039 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
30040 )
30041 .unwrap();
30042
30043 let reminders = context_pack_status_reminders(dir.path());
30044
30045 assert_eq!(reminders.len(), 1);
30046 assert!(reminders[0].contains("index stale"));
30047 assert!(reminders[0].contains("tsift index ."));
30048 }
30049
30050 #[test]
30057 fn build_context_pack_reuses_inspect_within_scope() {
30058 let dir = setup_graph_index();
30059 init_git_repo(dir.path());
30060 let _guard = index::InspectScopeGuard::new();
30061 let _ = build_context_pack_report(
30062 dir.path(),
30063 None,
30064 None,
30065 None,
30066 ResponseBudget::new(Some(2), Some(96)),
30067 )
30068 .unwrap();
30069 let (hits, misses) = index::inspect_scope_stats();
30070 assert!(
30071 hits >= 1,
30072 "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
30073 );
30074 assert!(
30075 misses >= 1,
30076 "expected at least one initial inspect miss (hits={hits}, misses={misses})"
30077 );
30078 }
30079
30080 #[test]
30085 fn inspect_read_only_outside_scope_does_not_cache() {
30086 let dir = setup_graph_index();
30087 let db_path = dir.path().join(".tsift/index.db");
30088 let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30089 let (hits, misses) = index::inspect_scope_stats();
30090 assert_eq!(
30091 (hits, misses),
30092 (0, 0),
30093 "no scope guard => no hits/misses recorded"
30094 );
30095 let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30096 let (hits, _) = index::inspect_scope_stats();
30097 assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
30098 }
30099
30100 #[test]
30101 fn context_pack_refreshes_stale_index_before_handoff() {
30102 let dir = setup_graph_index();
30103 init_git_repo(dir.path());
30104 std::thread::sleep(std::time::Duration::from_millis(50));
30105 std::fs::write(
30106 dir.path().join("main.rs"),
30107 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
30108 )
30109 .unwrap();
30110
30111 let report = build_context_pack_report(
30112 dir.path(),
30113 None,
30114 None,
30115 None,
30116 ResponseBudget::new(Some(2), Some(96)),
30117 )
30118 .unwrap();
30119
30120 assert!(
30121 report
30122 .status_reminders
30123 .iter()
30124 .any(|reminder| reminder.contains("index refreshed")
30125 && reminder.contains("context-pack handoff")),
30126 "expected context-pack refresh diagnostic, got {:?}",
30127 report.status_reminders
30128 );
30129 assert!(
30130 !report
30131 .status_reminders
30132 .iter()
30133 .any(|reminder| reminder.contains("index stale")),
30134 "stale reminder should be gone after refresh: {:?}",
30135 report.status_reminders
30136 );
30137
30138 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
30139 let summary = db.compute_changes(dir.path()).unwrap();
30140 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
30141 }
30142
30143 #[test]
30144 fn context_pack_materializes_source_handles_into_graph_store() {
30145 let dir = tempfile::tempdir().unwrap();
30146 let packet = ExplorationPacket {
30147 budget: exploration_budget_for_counts(2, 1),
30148 relationship_map: vec![ExplorationRelation {
30149 from: "file:main.rs".to_string(),
30150 relation: "touches_symbol".to_string(),
30151 to: "symbol:helper".to_string(),
30152 label: Some("modified diff".to_string()),
30153 }],
30154 source_windows: vec![ExplorationSourceWindow {
30155 handle: "xwin-test".to_string(),
30156 file: "main.rs".to_string(),
30157 start: 1,
30158 end: 32,
30159 reason: "changed file".to_string(),
30160 expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
30161 }],
30162 worker_context: vec![ExplorationWorkerContext {
30163 handle: "xwrk-test".to_string(),
30164 target: "tasks/software/tsift.md".to_string(),
30165 summary: "do #kgnv".to_string(),
30166 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
30167 .to_string(),
30168 }],
30169 no_reread_guidance: "use windows".to_string(),
30170 };
30171
30172 let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
30173 assert_eq!(packet.source_windows[0].handle, "xwin-test");
30174
30175 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
30176 let source_handles = store.nodes_by_kind("source_handle").unwrap();
30177 assert_eq!(source_handles.len(), 1);
30178 assert_eq!(
30179 source_handles[0].properties.get("file"),
30180 Some(&"main.rs".to_string())
30181 );
30182 assert_eq!(
30183 store
30184 .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
30185 .unwrap()
30186 .len(),
30187 1
30188 );
30189 let worker_context = store.nodes_by_kind("worker_context").unwrap();
30190 assert_eq!(worker_context.len(), 1);
30191 assert_eq!(
30192 store
30193 .outgoing_edges("xwrk-test", Some("scopes_source"))
30194 .unwrap()
30195 .len(),
30196 1
30197 );
30198 }
30199
30200 #[test]
30201 fn context_pack_records_graph_orchestration_observability() {
30202 let dir = setup_traversal_project();
30203 init_git_repo(dir.path());
30204 let session = dir.path().join("tasks/software/tsift.md");
30205 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
30206
30207 let report = build_context_pack_report(
30208 &session,
30209 None,
30210 None,
30211 None,
30212 ResponseBudget::new(Some(4), Some(160)),
30213 )
30214 .unwrap();
30215
30216 assert_eq!(
30217 report.graph_orchestration.contract_version,
30218 CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
30219 );
30220 assert_eq!(
30221 report
30222 .graph_orchestration
30223 .projection_freshness
30224 .status
30225 .as_str(),
30226 "current"
30227 );
30228 assert!(!report.graph_orchestration.projection_hashes.is_empty());
30229 assert_eq!(report.graph_orchestration.readiness.status, "blocked");
30230 assert_eq!(
30231 report.graph_orchestration.readiness.reason,
30232 "summary_cache_empty"
30233 );
30234 assert!(report.graph_orchestration.readiness.fail_closed);
30235 assert!(
30236 report
30237 .graph_orchestration
30238 .readiness
30239 .next_commands
30240 .iter()
30241 .any(|command| command == "tsift summarize --extract ."),
30242 "{:?}",
30243 report.graph_orchestration.readiness.next_commands
30244 );
30245 assert!(
30246 report
30247 .graph_orchestration
30248 .evidence_packet_ids
30249 .iter()
30250 .all(|id| !id.starts_with("gevd-")),
30251 "evidence packet ids should be empty when readiness is blocked: {:?}",
30252 report.graph_orchestration.evidence_packet_ids
30253 );
30254 assert!(
30255 report
30256 .graph_orchestration
30257 .conflict_matrix_decisions
30258 .iter()
30259 .any(|decision| decision.contains("readiness blocked")),
30260 "conflict-matrix decisions should reference readiness block: {:?}",
30261 report.graph_orchestration.conflict_matrix_decisions
30262 );
30263 assert!(
30264 !report
30265 .graph_orchestration
30266 .follow_up_commands
30267 .iter()
30268 .any(|command| command.contains("conflict-matrix")),
30269 "conflict-matrix command should not appear when readiness is blocked: {:?}",
30270 report.graph_orchestration.follow_up_commands
30271 );
30272 assert!(
30273 report
30274 .graph_orchestration
30275 .follow_up_commands
30276 .iter()
30277 .any(|command| command == "tsift summarize --extract ."),
30278 "{:?}",
30279 report.graph_orchestration.follow_up_commands
30280 );
30281 assert!(
30282 !report
30283 .graph_orchestration
30284 .worker_ownership_blocks
30285 .is_empty()
30286 );
30287 }
30288
30289 #[test]
30290 fn convex_sync_report_chunks_upserts_and_tombstones() {
30291 let dir = setup_traversal_project();
30292 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
30293 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
30294 let mut snapshot = projection.to_convex_rows();
30295 snapshot.nodes.push(ConvexNodeRow {
30296 external_id: "stale-node".to_string(),
30297 kind: "backlog".to_string(),
30298 label: "stale".to_string(),
30299 properties: BTreeMap::new(),
30300 provenance: Vec::new(),
30301 freshness: None,
30302 });
30303 snapshot.edges.clear();
30304 snapshot.edges.push(ConvexEdgeRow {
30305 edge_key: "stale-edge".to_string(),
30306 from_external_id: "stale-node".to_string(),
30307 to_external_id: "stale-node".to_string(),
30308 kind: "mentions".to_string(),
30309 properties: BTreeMap::new(),
30310 provenance: Vec::new(),
30311 freshness: None,
30312 });
30313 let snapshot_path = dir.path().join("convex-snapshot.json");
30314 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30315
30316 let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
30317
30318 assert_eq!(report.freshness.status, "stale");
30319 assert!(report.freshness.fail_closed);
30320 assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
30321 assert!(
30322 report.edge_upserts.len() > 1,
30323 "snapshot without edges should upsert local edges"
30324 );
30325 assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
30326 assert_eq!(
30327 report.chunks.first().map(|chunk| chunk.operation.as_str()),
30328 Some("delete_edges"),
30329 "edge tombstones should be planned before node tombstones"
30330 );
30331 assert!(
30332 report
30333 .chunks
30334 .iter()
30335 .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
30336 "expected chunked edge upserts, got {:?}",
30337 report.chunks
30338 );
30339 }
30340
30341 #[test]
30342 fn convex_snapshot_validation_fails_closed_when_stale() {
30343 let dir = setup_traversal_project();
30344 build_traversal_graph(dir.path(), dir.path(), None).unwrap();
30345 let snapshot = ConvexProjectionRows::default();
30346 let snapshot_path = dir.path().join("empty-convex-snapshot.json");
30347 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30348
30349 let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
30350 assert!(
30351 err.to_string()
30352 .contains("Convex graph projection is not current"),
30353 "{err}"
30354 );
30355 }
30356
30357 #[test]
30358 fn convex_sync_report_marks_live_apply_mode_without_network() {
30359 let dir = setup_traversal_project();
30360 let report =
30361 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30362
30363 assert!(!report.dry_run);
30364 assert!(
30365 !report
30366 .diagnostics
30367 .iter()
30368 .any(|diagnostic| diagnostic.contains("dry-run only")),
30369 "apply-mode report should not claim dry-run diagnostics"
30370 );
30371 assert!(
30372 report
30373 .chunks
30374 .iter()
30375 .any(|chunk| chunk.operation == "upsert_nodes"),
30376 "live apply mode should still expose chunked idempotent operations"
30377 );
30378 }
30379
30380 #[test]
30381 fn convex_sync_apply_round_trips_with_http_backend() {
30382 use std::net::TcpListener;
30383 use std::sync::{Arc, Mutex};
30384
30385 let dir = setup_traversal_project();
30386 let report =
30387 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30388 let expected_chunks = report.chunks.len();
30389 assert!(expected_chunks > 0);
30390
30391 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
30392 let endpoint = format!("http://{}", listener.local_addr().unwrap());
30393 let operations = Arc::new(Mutex::new(Vec::<String>::new()));
30394 let server_operations = Arc::clone(&operations);
30395 let server = std::thread::spawn(move || {
30396 for _ in 0..expected_chunks {
30397 let (mut stream, _) = listener.accept().unwrap();
30398 let mut reader = BufReader::new(stream.try_clone().unwrap());
30399 let mut request_line = String::new();
30400 reader.read_line(&mut request_line).unwrap();
30401 assert!(request_line.starts_with("POST "));
30402
30403 let mut content_length = 0usize;
30404 loop {
30405 let mut line = String::new();
30406 reader.read_line(&mut line).unwrap();
30407 if line == "\r\n" {
30408 break;
30409 }
30410 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
30411 content_length = value.trim().parse().unwrap();
30412 }
30413 }
30414
30415 let mut body = vec![0u8; content_length];
30416 reader.read_exact(&mut body).unwrap();
30417 let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
30418 server_operations
30419 .lock()
30420 .unwrap()
30421 .push(request["operation"].as_str().unwrap().to_string());
30422
30423 let response = br#"{"status":"ok","message":"accepted"}"#;
30424 write!(
30425 stream,
30426 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
30427 response.len()
30428 )
30429 .unwrap();
30430 stream.write_all(response).unwrap();
30431 }
30432 });
30433
30434 cmd_convex_sync(
30435 ConvexSyncOptions {
30436 path: dir.path(),
30437 scope: None,
30438 snapshot: None,
30439 chunk_size: 100,
30440 remote_snapshot: false,
30441 apply: true,
30442 endpoint: Some(&endpoint),
30443 auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
30444 },
30445 OutputFormat {
30446 json_output: false,
30447 compact: true,
30448 pretty: false,
30449 terse: false,
30450 ultra_terse: false,
30451 schema: false,
30452 envelope: false,
30453 },
30454 )
30455 .unwrap();
30456 server.join().unwrap();
30457
30458 let operations = operations.lock().unwrap().clone();
30459 assert!(operations.contains(&"upsert_nodes".to_string()));
30460 assert!(operations.contains(&"upsert_edges".to_string()));
30461 }
30462
30463 #[test]
30464 fn context_pack_diff_preview_attaches_tag_ontology_refs() {
30465 let root = tempfile::tempdir().unwrap();
30466 fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
30467 fs::write(
30468 root.path().join(".naming/tags/alpha.md"),
30469 "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
30470 )
30471 .unwrap();
30472 let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
30473 let report = diff_digest::DiffDigestReport {
30474 root: root.path().display().to_string(),
30475 mode: diff_digest::DiffDigestMode::WorkingTree,
30476 revision: None,
30477 files_changed: 1,
30478 files_with_current_summaries: 1,
30479 symbols_touched: 1,
30480 call_edges_added: 0,
30481 call_edges_removed: 0,
30482 files: vec![diff_digest::DiffDigestFile {
30483 path: "src/lib.rs".to_string(),
30484 status: diff_digest::DiffDigestFileStatus::Modified,
30485 touched_symbols: vec!["alpha_helper".to_string()],
30486 summary_state: diff_digest::DiffDigestSummaryState::Current,
30487 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30488 symbol: "alpha_helper".to_string(),
30489 summary: "alpha helper summary".to_string(),
30490 }],
30491 added_call_edges: vec![],
30492 removed_call_edges: vec![],
30493 warnings: vec![],
30494 }],
30495 };
30496
30497 let preview = build_context_pack_diff_preview(
30498 &report,
30499 ResponseBudget::new(Some(1), Some(80)),
30500 Some(&ontology),
30501 );
30502
30503 let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
30504 assert!(symbol_ref.handle.starts_with("tont-"));
30505 assert_eq!(symbol_ref.tag, "alpha");
30506 assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
30507 assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
30508 assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
30509 assert_eq!(
30510 preview.files[0].summary_refs[0].ontology_refs[0].path,
30511 ".naming/tags/alpha.md"
30512 );
30513 }
30514
30515 #[test]
30516 fn context_pack_test_preview_limits_failure_groups() {
30517 let report = test_digest::TestDigestReport {
30518 root: "/repo".to_string(),
30519 runner: "cargo".to_string(),
30520 failures: 2,
30521 grouped_failures: 2,
30522 counts: test_digest::TestDigestCounts {
30523 passed: Some(8),
30524 failed: Some(2),
30525 skipped: Some(1),
30526 },
30527 failure_groups: vec![
30528 test_digest::TestDigestFailure {
30529 tests: vec!["suite::alpha_failure".to_string()],
30530 message: "assertion failed".to_string(),
30531 path: Some("src/lib.rs".to_string()),
30532 line: Some(42),
30533 column: None,
30534 occurrences: 1,
30535 summary_state: test_digest::TestDigestSummaryState::Current,
30536 current_summaries: vec![test_digest::TestDigestSummarySnippet {
30537 symbol: "alpha_failure".to_string(),
30538 summary: "failure summary for alpha test".to_string(),
30539 }],
30540 },
30541 test_digest::TestDigestFailure {
30542 tests: vec!["suite::beta_failure".to_string()],
30543 message: "panic".to_string(),
30544 path: Some("src/main.rs".to_string()),
30545 line: Some(7),
30546 column: None,
30547 occurrences: 1,
30548 summary_state: test_digest::TestDigestSummaryState::Missing,
30549 current_summaries: vec![],
30550 },
30551 ],
30552 warnings: vec!["warning text".to_string()],
30553 };
30554
30555 let preview =
30556 build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30557
30558 assert!(preview.truncated);
30559 assert_eq!(preview.failure_groups.len(), 1);
30560 assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
30561 assert_eq!(preview.failure_groups[0].message, "assertion f...");
30562 assert!(
30563 preview.failure_groups[0].summary_refs[0]
30564 .handle
30565 .starts_with("ctsum-")
30566 );
30567 assert_eq!(
30568 preview.failure_groups[0].summary_refs[0].expand,
30569 "tsift summarize --file \"src/lib.rs\""
30570 );
30571 assert_eq!(preview.warnings, vec!["warning text"]);
30572 }
30573
30574 #[test]
30575 fn context_pack_log_preview_limits_signals_and_refs() {
30576 let report = log_digest::LogDigestReport {
30577 root: "/repo".to_string(),
30578 total_lines: 12,
30579 non_empty_lines: 10,
30580 signal_groups: 2,
30581 repeated_line_groups: 2,
30582 repeated_line_occurrences: 3,
30583 file_ref_groups: 2,
30584 symbol_ref_groups: 2,
30585 stack_groups: 1,
30586 signals: vec![
30587 log_digest::LogDigestSignal {
30588 severity: "error".to_string(),
30589 message: "src/lib.rs:42 boom".to_string(),
30590 path: Some("src/lib.rs".to_string()),
30591 line: Some(42),
30592 column: None,
30593 occurrences: 2,
30594 summary_state: log_digest::LogDigestSummaryState::Current,
30595 current_summaries: vec![log_digest::LogDigestSummarySnippet {
30596 symbol: "alpha_helper".to_string(),
30597 summary: "alpha helper cached log summary".to_string(),
30598 }],
30599 },
30600 log_digest::LogDigestSignal {
30601 severity: "warn".to_string(),
30602 message: "slow path".to_string(),
30603 path: None,
30604 line: None,
30605 column: None,
30606 occurrences: 1,
30607 summary_state: log_digest::LogDigestSummaryState::Unavailable,
30608 current_summaries: vec![],
30609 },
30610 ],
30611 repeated_lines: vec![
30612 log_digest::LogDigestRepeatedLine {
30613 line: "retrying work item alpha".to_string(),
30614 occurrences: 3,
30615 },
30616 log_digest::LogDigestRepeatedLine {
30617 line: "retrying work item beta".to_string(),
30618 occurrences: 2,
30619 },
30620 ],
30621 file_refs: vec![
30622 log_digest::LogDigestFileRef {
30623 path: "src/lib.rs".to_string(),
30624 line: Some(42),
30625 column: None,
30626 occurrences: 2,
30627 summary_state: log_digest::LogDigestSummaryState::Current,
30628 current_summaries: vec![log_digest::LogDigestSummarySnippet {
30629 symbol: "alpha_helper".to_string(),
30630 summary: "alpha helper cached file summary".to_string(),
30631 }],
30632 },
30633 log_digest::LogDigestFileRef {
30634 path: "src/main.rs".to_string(),
30635 line: Some(7),
30636 column: None,
30637 occurrences: 1,
30638 summary_state: log_digest::LogDigestSummaryState::Missing,
30639 current_summaries: vec![],
30640 },
30641 ],
30642 symbol_refs: vec![
30643 log_digest::LogDigestSymbolRef {
30644 symbol: "alpha_helper".to_string(),
30645 occurrences: 2,
30646 summary_state: log_digest::LogDigestSummaryState::Current,
30647 current_summaries: vec![log_digest::LogDigestSummarySnippet {
30648 symbol: "alpha_helper".to_string(),
30649 summary: "alpha helper cached symbol summary".to_string(),
30650 }],
30651 },
30652 log_digest::LogDigestSymbolRef {
30653 symbol: "beta_helper".to_string(),
30654 occurrences: 1,
30655 summary_state: log_digest::LogDigestSummaryState::Missing,
30656 current_summaries: vec![],
30657 },
30658 ],
30659 stack_traces: vec![log_digest::LogDigestStackGroup {
30660 frames: vec!["frame one".to_string()],
30661 occurrences: 1,
30662 }],
30663 warnings: vec!["warning text".to_string()],
30664 };
30665
30666 let preview =
30667 build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30668
30669 assert!(preview.truncated);
30670 assert_eq!(preview.signals.len(), 1);
30671 assert_eq!(preview.signals[0].message, "src/lib.rs:...");
30672 assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
30673 assert_eq!(preview.file_refs.len(), 1);
30674 assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
30675 assert!(
30676 preview.signals[0].summary_refs[0]
30677 .handle
30678 .starts_with("clsum-")
30679 );
30680 assert!(
30681 preview.file_refs[0].summary_refs[0]
30682 .handle
30683 .starts_with("clfsum-")
30684 );
30685 assert!(
30686 preview.symbol_refs[0].summary_refs[0]
30687 .handle
30688 .starts_with("clssum-")
30689 );
30690 assert_eq!(
30691 preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
30692 Some("alpha/helper")
30693 );
30694 assert_eq!(
30695 preview.symbol_refs[0].summary_refs[0].expand,
30696 "tsift summarize \"alpha_helper\""
30697 );
30698 assert_eq!(preview.warnings, vec!["warning text"]);
30699 }
30700
30701 #[test]
30702 fn cli_search_rejects_exact_with_strategy_flag() {
30703 let cli = try_parse_cli([
30704 "tsift",
30705 "search",
30706 "test",
30707 "--exact",
30708 "--strategy",
30709 "lexical",
30710 ]);
30711 assert!(cli.is_err());
30712 }
30713
30714 #[test]
30715 fn cli_search_autoindexes_by_default() {
30716 let cli = parse_cli(["tsift", "search", "test"]);
30717 match cli.command {
30718 Some(Commands::Search {
30719 autoindex,
30720 no_autoindex,
30721 ..
30722 }) => {
30723 assert!(!autoindex);
30724 assert!(!no_autoindex);
30725 assert!(autoindex || !no_autoindex);
30726 }
30727 _ => panic!("expected Search command"),
30728 }
30729 }
30730
30731 #[test]
30732 fn cli_search_accepts_no_autoindex_flag() {
30733 let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
30734 match cli.command {
30735 Some(Commands::Search {
30736 autoindex,
30737 no_autoindex,
30738 ..
30739 }) => {
30740 assert!(!autoindex);
30741 assert!(no_autoindex);
30742 }
30743 _ => panic!("expected Search command"),
30744 }
30745 }
30746
30747 #[test]
30748 fn cli_search_rejects_conflicting_autoindex_flags() {
30749 let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
30750 assert!(cli.is_err());
30751 }
30752
30753 #[test]
30756 fn cli_accepts_global_absolute_flag() {
30757 let cli = parse_cli(["tsift", "--absolute", "status"]);
30758 assert!(cli.absolute);
30759 assert!(matches!(cli.command, Some(Commands::Status { .. })));
30760 }
30761
30762 #[test]
30763 fn cli_accepts_global_tabular_flag() {
30764 let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
30765 assert!(cli.tabular);
30766 assert!(matches!(cli.command, Some(Commands::Search { .. })));
30767 }
30768
30769 #[test]
30770 fn cli_tabular_with_graph() {
30771 let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
30772 assert!(cli.tabular);
30773 assert!(matches!(cli.command, Some(Commands::Graph { .. })));
30774 }
30775
30776 #[test]
30777 fn cli_tabular_with_communities() {
30778 let cli = parse_cli(["tsift", "--tabular", "communities"]);
30779 assert!(cli.tabular);
30780 assert!(matches!(cli.command, Some(Commands::Communities { .. })));
30781 }
30782
30783 #[test]
30784 fn cli_tabular_with_explain() {
30785 let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
30786 assert!(cli.tabular);
30787 assert!(matches!(cli.command, Some(Commands::Explain { .. })));
30788 }
30789
30790 #[test]
30791 fn cli_traverse_accepts_path_target_and_html_format() {
30792 let cli = parse_cli([
30793 "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
30794 ]);
30795 match cli.command {
30796 Some(Commands::Traverse {
30797 node,
30798 to,
30799 path,
30800 format,
30801 ..
30802 }) => {
30803 assert_eq!(node.as_deref(), Some("#kgnv"));
30804 assert_eq!(to.as_deref(), Some("main"));
30805 assert_eq!(path, PathBuf::from("."));
30806 assert_eq!(format, TraverseFormat::Html);
30807 }
30808 _ => panic!("expected Traverse command"),
30809 }
30810 }
30811
30812 #[test]
30813 fn cli_parses_semantic_related_command() {
30814 let cli = parse_cli([
30815 "tsift",
30816 "semantic",
30817 "graph navigation",
30818 "--path",
30819 ".",
30820 "--kind",
30821 "all",
30822 "--limit",
30823 "3",
30824 "--json",
30825 ]);
30826 match cli.command {
30827 Some(Commands::Semantic {
30828 query,
30829 path,
30830 kind,
30831 limit,
30832 json,
30833 ..
30834 }) => {
30835 assert_eq!(query, "graph navigation");
30836 assert_eq!(path, PathBuf::from("."));
30837 assert_eq!(kind, SemanticRelatedKind::All);
30838 assert_eq!(limit, 3);
30839 assert!(json);
30840 }
30841 _ => panic!("expected Semantic command"),
30842 }
30843 }
30844
30845 #[test]
30846 fn cli_parses_convex_sync_command() {
30847 let cli = parse_cli([
30848 "tsift",
30849 "convex-sync",
30850 ".",
30851 "--snapshot",
30852 "rows.json",
30853 "--chunk-size",
30854 "25",
30855 "--json",
30856 ]);
30857 match cli.command {
30858 Some(Commands::ConvexSync {
30859 path,
30860 snapshot,
30861 chunk_size,
30862 json,
30863 ..
30864 }) => {
30865 assert_eq!(path, PathBuf::from("."));
30866 assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
30867 assert_eq!(chunk_size, 25);
30868 assert!(json);
30869 }
30870 _ => panic!("expected ConvexSync command"),
30871 }
30872 }
30873
30874 #[test]
30875 fn cli_parses_convex_sync_live_flags() {
30876 let cli = parse_cli([
30877 "tsift",
30878 "convex-sync",
30879 ".",
30880 "--remote-snapshot",
30881 "--apply",
30882 "--endpoint",
30883 "https://example.test/convex-graph",
30884 "--auth-token-env",
30885 "TSIFT_TEST_TOKEN",
30886 ]);
30887 match cli.command {
30888 Some(Commands::ConvexSync {
30889 remote_snapshot,
30890 apply,
30891 endpoint,
30892 auth_token_env,
30893 ..
30894 }) => {
30895 assert!(remote_snapshot);
30896 assert!(apply);
30897 assert_eq!(
30898 endpoint.as_deref(),
30899 Some("https://example.test/convex-graph")
30900 );
30901 assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
30902 }
30903 _ => panic!("expected ConvexSync command"),
30904 }
30905 }
30906
30907 #[test]
30908 fn cli_parses_graph_db_query() {
30909 let cli = parse_cli([
30910 "tsift",
30911 "graph-db",
30912 "--backend",
30913 "convex-snapshot",
30914 "--convex-snapshot",
30915 "rows.json",
30916 "--json",
30917 "neighborhood",
30918 "gbak-kgnv",
30919 "--depth",
30920 "2",
30921 "--edge-kind",
30922 "mentions",
30923 "--property",
30924 "path=tasks/software/tsift.md",
30925 "--cursor",
30926 "gbak-old",
30927 "--limit",
30928 "10",
30929 ]);
30930 match cli.command {
30931 Some(Commands::GraphDb {
30932 backend,
30933 convex_snapshot,
30934 json,
30935 query,
30936 ..
30937 }) => {
30938 assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
30939 assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
30940 assert!(json);
30941 match query {
30942 GraphDbQuery::Neighborhood {
30943 id,
30944 depth,
30945 edge_kind,
30946 cursor,
30947 limit,
30948 property_filters,
30949 } => {
30950 assert_eq!(id, "gbak-kgnv");
30951 assert_eq!(depth, 2);
30952 assert_eq!(edge_kind.as_deref(), Some("mentions"));
30953 assert_eq!(cursor.as_deref(), Some("gbak-old"));
30954 assert_eq!(limit, Some(10));
30955 assert_eq!(
30956 property_filters,
30957 vec!["path=tasks/software/tsift.md".to_string()]
30958 );
30959 }
30960 _ => panic!("expected graph-db neighborhood query"),
30961 }
30962 }
30963 _ => panic!("expected GraphDb command"),
30964 }
30965 }
30966
30967 #[test]
30968 fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
30969 let cli = parse_cli([
30970 "tsift",
30971 "graph-db",
30972 "--json",
30973 "backend-eval",
30974 "--candidate",
30975 "surrealdb",
30976 "--target",
30977 "gval",
30978 "--full-projection",
30979 ]);
30980 match cli.command {
30981 Some(Commands::GraphDb { json, query, .. }) => {
30982 assert!(json);
30983 match query {
30984 GraphDbQuery::BackendEval {
30985 candidates,
30986 targets,
30987 full_projection,
30988 } => {
30989 assert_eq!(candidates, vec!["surrealdb".to_string()]);
30990 assert_eq!(targets, vec!["gval".to_string()]);
30991 assert!(full_projection);
30992 }
30993 _ => panic!("expected graph-db backend-eval query"),
30994 }
30995 }
30996 _ => panic!("expected GraphDb command"),
30997 }
30998 }
30999
31000 #[test]
31001 fn cli_parses_graph_db_tokensave_backend() {
31002 let cli = parse_cli([
31003 "tsift",
31004 "graph-db",
31005 "--backend",
31006 "tokensave",
31007 "--json",
31008 "node",
31009 "fn:main",
31010 ]);
31011 match cli.command {
31012 Some(Commands::GraphDb {
31013 backend,
31014 json,
31015 query,
31016 ..
31017 }) => {
31018 assert_eq!(backend, GraphDbBackend::Tokensave);
31019 assert!(json);
31020 match query {
31021 GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
31022 _ => panic!("expected graph-db node query"),
31023 }
31024 }
31025 _ => panic!("expected GraphDb command"),
31026 }
31027 }
31028
31029 #[test]
31030 fn cli_parses_analyze_command() {
31031 let cli = parse_cli([
31032 "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
31033 "--limit", "7", "--json",
31034 ]);
31035 match cli.command {
31036 Some(Commands::Analyze {
31037 path,
31038 scope,
31039 entry_points,
31040 limit,
31041 json,
31042 }) => {
31043 assert_eq!(path, PathBuf::from("."));
31044 assert_eq!(scope.as_deref(), Some("core"));
31045 assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
31046 assert_eq!(limit, 7);
31047 assert!(json);
31048 }
31049 _ => panic!("expected Analyze command"),
31050 }
31051 }
31052
31053 #[test]
31054 fn cli_parses_graph_db_related_query() {
31055 let cli = parse_cli([
31056 "tsift",
31057 "graph-db",
31058 "--json",
31059 "related",
31060 "voice avatar memory retrieval",
31061 "--kind",
31062 "all",
31063 "--depth",
31064 "3",
31065 "--seed-limit",
31066 "4",
31067 "--limit",
31068 "12",
31069 ]);
31070 match cli.command {
31071 Some(Commands::GraphDb { json, query, .. }) => {
31072 assert!(json);
31073 match query {
31074 GraphDbQuery::Related {
31075 query,
31076 kind,
31077 depth,
31078 seed_limit,
31079 limit,
31080 } => {
31081 assert_eq!(query, "voice avatar memory retrieval");
31082 assert_eq!(kind, SemanticRelatedKind::All);
31083 assert_eq!(depth, 3);
31084 assert_eq!(seed_limit, 4);
31085 assert_eq!(limit, 12);
31086 }
31087 _ => panic!("expected graph-db related query"),
31088 }
31089 }
31090 _ => panic!("expected GraphDb command"),
31091 }
31092 }
31093
31094 #[test]
31095 fn cli_parses_graph_db_compact_query() {
31096 let cli = parse_cli([
31097 "tsift",
31098 "graph-db",
31099 "--path",
31100 ".",
31101 "compact",
31102 "--apply",
31103 "--prune-tombstones",
31104 "--confirmed-convex-reconciled",
31105 ]);
31106 match cli.command {
31107 Some(Commands::GraphDb { query, .. }) => match query {
31108 GraphDbQuery::Compact {
31109 apply,
31110 prune_tombstones,
31111 confirmed_convex_reconciled,
31112 } => {
31113 assert!(apply);
31114 assert!(prune_tombstones);
31115 assert!(confirmed_convex_reconciled);
31116 }
31117 _ => panic!("expected graph-db compact query"),
31118 },
31119 _ => panic!("expected GraphDb command"),
31120 }
31121 }
31122
31123 #[test]
31124 fn cli_parses_impact_command() {
31125 let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
31126 match cli.command {
31127 Some(Commands::Impact {
31128 path,
31129 cached,
31130 limit,
31131 ..
31132 }) => {
31133 assert_eq!(path, PathBuf::from("."));
31134 assert!(cached);
31135 assert_eq!(limit, 5);
31136 }
31137 _ => panic!("expected Impact command"),
31138 }
31139 }
31140
31141 #[test]
31142 fn cli_parses_conflict_matrix_command() {
31143 let cli = parse_cli([
31144 "tsift",
31145 "conflict-matrix",
31146 "--path",
31147 "tasks/software/tsift.md",
31148 "--depth",
31149 "4",
31150 "--limit",
31151 "12",
31152 "--impact-limit",
31153 "6",
31154 "--json",
31155 "pwcm",
31156 "#g6kf",
31157 ]);
31158 match cli.command {
31159 Some(Commands::ConflictMatrix {
31160 targets,
31161 path,
31162 depth,
31163 limit,
31164 impact_limit,
31165 json,
31166 ..
31167 }) => {
31168 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31169 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31170 assert_eq!(depth, 4);
31171 assert_eq!(limit, 12);
31172 assert_eq!(impact_limit, 6);
31173 assert!(json);
31174 }
31175 _ => panic!("expected ConflictMatrix command"),
31176 }
31177 }
31178
31179 #[test]
31180 fn cli_parses_dispatch_trace_command() {
31181 let cli = parse_cli([
31182 "tsift",
31183 "dispatch-trace",
31184 "--path",
31185 "tasks/software/tsift.md",
31186 "--format",
31187 "html",
31188 "--depth",
31189 "4",
31190 "pwcm",
31191 "#g6kf",
31192 ]);
31193 match cli.command {
31194 Some(Commands::DispatchTrace {
31195 targets,
31196 path,
31197 format,
31198 depth,
31199 ..
31200 }) => {
31201 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31202 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31203 assert_eq!(format, DispatchTraceFormat::Html);
31204 assert_eq!(depth, 4);
31205 }
31206 _ => panic!("expected DispatchTrace command"),
31207 }
31208 }
31209
31210 #[test]
31211 fn cli_parses_dependency_dag_command() {
31212 let cli = parse_cli([
31213 "tsift",
31214 "dependency-dag",
31215 "--path",
31216 "tasks/software/tsift.md",
31217 "--depth",
31218 "5",
31219 "--limit",
31220 "20",
31221 "--json",
31222 "alpha",
31223 "#beta",
31224 ]);
31225 match cli.command {
31226 Some(Commands::DependencyDag {
31227 targets,
31228 path,
31229 depth,
31230 limit,
31231 json,
31232 ..
31233 }) => {
31234 assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
31235 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31236 assert_eq!(depth, 5);
31237 assert_eq!(limit, 20);
31238 assert!(json);
31239 }
31240 _ => panic!("expected DependencyDag command"),
31241 }
31242 }
31243
31244 #[test]
31245 fn relativize_strips_root_prefix() {
31246 let root = std::path::Path::new("/home/user/project");
31247 assert_eq!(
31248 relativize("/home/user/project/src/main.rs", root),
31249 "src/main.rs"
31250 );
31251 }
31252
31253 #[test]
31254 fn relativize_leaves_non_matching_path() {
31255 let root = std::path::Path::new("/home/user/project");
31256 assert_eq!(
31257 relativize("/other/path/file.rs", root),
31258 "/other/path/file.rs"
31259 );
31260 }
31261
31262 #[test]
31263 fn relativize_leaves_already_relative() {
31264 let root = std::path::Path::new("/home/user/project");
31265 assert_eq!(relativize("src/main.rs", root), "src/main.rs");
31266 }
31267
31268 #[test]
31269 fn relativize_pathbuf_strips_prefix() {
31270 let root = std::path::Path::new("/home/user/project");
31271 let path = std::path::Path::new("/home/user/project/src/lib.rs");
31272 assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
31273 }
31274
31275 #[test]
31276 fn relativize_edges_strips_caller_file() {
31277 let root = std::path::Path::new("/tmp/proj");
31278 let mut edges = vec![index::StoredEdge {
31279 caller_file: "/tmp/proj/src/main.rs".to_string(),
31280 caller_name: "main".to_string(),
31281 caller_line: 1,
31282 callee_name: "helper".to_string(),
31283 call_site_line: 5,
31284 tagpath_handle: None,
31285 }];
31286 relativize_edges(&mut edges, root);
31287 assert_eq!(edges[0].caller_file, "src/main.rs");
31288 }
31289
31290 #[test]
31291 fn relativize_json_paths_strips_known_keys() {
31292 let root = std::path::Path::new("/tmp/proj");
31293 let mut val = serde_json::json!({
31294 "file": "/tmp/proj/src/main.rs",
31295 "path": "/tmp/proj/test.rs",
31296 "name": "/tmp/proj/not-a-path",
31297 "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
31298 });
31299 relativize_json_paths(&mut val, root);
31300 assert_eq!(val["file"], "src/main.rs");
31301 assert_eq!(val["path"], "test.rs");
31302 assert_eq!(val["name"], "/tmp/proj/not-a-path");
31303 assert_eq!(val["hits"][0]["path"], "nested.rs");
31304 }
31305
31306 #[test]
31309 fn cli_graph_accepts_limit_flag() {
31310 let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
31311 match cli.command {
31312 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
31313 _ => panic!("expected Graph command"),
31314 }
31315 }
31316
31317 #[test]
31318 fn cli_graph_default_limit_is_20() {
31319 let cli = parse_cli(["tsift", "graph", "main"]);
31320 match cli.command {
31321 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
31322 _ => panic!("expected Graph command"),
31323 }
31324 }
31325
31326 #[test]
31327 fn cli_communities_accepts_limit_flag() {
31328 let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
31329 match cli.command {
31330 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
31331 _ => panic!("expected Communities command"),
31332 }
31333 }
31334
31335 #[test]
31336 fn cli_communities_default_limit_is_10() {
31337 let cli = parse_cli(["tsift", "communities"]);
31338 match cli.command {
31339 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
31340 _ => panic!("expected Communities command"),
31341 }
31342 }
31343
31344 #[test]
31345 fn cli_explain_accepts_limit_flag() {
31346 let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
31347 match cli.command {
31348 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
31349 _ => panic!("expected Explain command"),
31350 }
31351 }
31352
31353 #[test]
31354 fn cli_explain_default_limit_is_15() {
31355 let cli = parse_cli(["tsift", "explain", "main"]);
31356 match cli.command {
31357 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
31358 _ => panic!("expected Explain command"),
31359 }
31360 }
31361
31362 #[test]
31363 fn cli_limit_zero_means_unlimited() {
31364 let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
31365 match cli.command {
31366 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
31367 _ => panic!("expected Graph command"),
31368 }
31369 }
31370
31371 #[test]
31372 fn graph_cmd_limit_runs_ok() {
31373 let dir = setup_graph_index();
31374 let result = cmd_graph(
31375 "main",
31376 dir.path(),
31377 false,
31378 false,
31379 None,
31380 1,
31381 false,
31382 false,
31383 false,
31384 false,
31385 false,
31386 false,
31387 false,
31388 TagpathSearchOpts::default(),
31389 );
31390 assert!(result.is_ok());
31391 }
31392
31393 #[test]
31394 fn graph_cmd_unlimited_runs_ok() {
31395 let dir = setup_graph_index();
31396 let result = cmd_graph(
31397 "main",
31398 dir.path(),
31399 false,
31400 false,
31401 None,
31402 0,
31403 false,
31404 false,
31405 false,
31406 false,
31407 false,
31408 false,
31409 false,
31410 TagpathSearchOpts::default(),
31411 );
31412 assert!(result.is_ok());
31413 }
31414
31415 #[test]
31416 fn graph_cmd_tabular_runs_ok() {
31417 let dir = setup_graph_index();
31418 let result = cmd_graph(
31419 "main",
31420 dir.path(),
31421 false,
31422 false,
31423 None,
31424 20,
31425 false,
31426 false,
31427 false,
31428 false,
31429 false,
31430 true,
31431 false,
31432 TagpathSearchOpts::default(),
31433 );
31434 assert!(result.is_ok());
31435 }
31436
31437 #[test]
31438 fn communities_cmd_tabular_runs_ok() {
31439 let dir = setup_graph_index();
31440 let result = cmd_communities(
31441 dir.path(),
31442 None,
31443 1,
31444 10,
31445 false,
31446 false,
31447 false,
31448 false,
31449 true,
31450 false,
31451 TagpathSearchOpts::default(),
31452 );
31453 assert!(result.is_ok());
31454 }
31455
31456 #[test]
31457 fn explain_cmd_tabular_runs_ok() {
31458 let dir = setup_graph_index();
31459 let result = cmd_explain(
31460 "main",
31461 dir.path(),
31462 None,
31463 15,
31464 false,
31465 false,
31466 false,
31467 false,
31468 false,
31469 true,
31470 false,
31471 false,
31472 );
31473 assert!(result.is_ok());
31474 }
31475
31476 #[test]
31477 fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
31478 let cases = [
31483 ".agent-doc",
31484 ".agent-doc/snapshots/abc.md",
31485 ".agent-doc/baselines/abc.md",
31486 ".agent-doc/archives/2026.md",
31487 ".agent-doc/runtime/run.jsonl",
31488 "src/foo/.agent-doc",
31489 "src/foo/.agent-doc/snapshots/x.md",
31490 "./.agent-doc/snapshots/x.md",
31491 ];
31492 for path in cases {
31493 assert!(
31494 traversal_relative_path_is_generated_artifact(path),
31495 "expected `{path}` to be excluded from source watermark"
31496 );
31497 }
31498 for path in [
31500 "src/main.rs",
31501 "tests/perf_gate.rs",
31502 "fixtures/x.json",
31503 "agent-doc/src/lib.rs", "src/.agent-doc-helper.rs",
31505 ] {
31506 assert!(
31507 !traversal_relative_path_is_generated_artifact(path),
31508 "expected `{path}` to be included in source watermark"
31509 );
31510 }
31511 }
31512
31513 #[test]
31514 fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
31515 let cases = [
31523 ".tsift",
31524 ".tsift/index.db",
31525 ".tsift/indexes/foo/index.db",
31526 ".tsift/conflict-matrix-cache/inputs/abc.json",
31527 ".tsift/summaries.db",
31528 "src/foo/.tsift",
31529 "src/foo/.tsift/graph.db",
31530 "./.tsift/index.db",
31531 "target",
31532 "target/debug/build/x",
31533 "target/release/tsift",
31534 "src/foo/target/debug/x",
31535 "./target/release/x",
31536 ];
31537 for path in cases {
31538 assert!(
31539 traversal_relative_path_is_generated_artifact(path),
31540 "expected `{path}` to be excluded from source watermark"
31541 );
31542 }
31543 for path in [
31545 "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
31546 "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
31547 "src/tsift-extras/lib.rs",
31548 "tsift/README.md",
31549 "src/targeting.rs",
31550 "src/.tsiftrc",
31551 "src/agent-doc-helper.rs",
31552 ] {
31553 assert!(
31554 !traversal_relative_path_is_generated_artifact(path),
31555 "expected `{path}` to be included in source watermark"
31556 );
31557 }
31558 }
31559
31560 #[test]
31561 fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
31562 let dir = tempfile::tempdir().unwrap();
31571 let root = dir.path();
31572 std::fs::create_dir_all(root.join("src")).unwrap();
31573 std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
31574 let hint = root.join("README.md");
31575 std::fs::write(&hint, "# stable\n").unwrap();
31576 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31578 std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
31579 std::fs::create_dir_all(root.join("target/debug")).unwrap();
31580 std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
31581
31582 let first = traversal_source_watermark(root, &hint, None, true)
31583 .expect("first watermark call must succeed")
31584 .expect("first watermark must produce a hash for hinted markdown");
31585 let second = traversal_source_watermark(root, &hint, None, true)
31586 .expect("second watermark call must succeed")
31587 .expect("second watermark must produce a hash for hinted markdown");
31588 assert_eq!(
31589 first, second,
31590 "watermark must be identical across back-to-back invocations on a quiescent root"
31591 );
31592
31593 std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
31595 std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
31596 let third = traversal_source_watermark(root, &hint, None, true)
31597 .expect("third watermark call must succeed")
31598 .expect("third watermark must produce a hash for hinted markdown");
31599 assert_eq!(
31600 first, third,
31601 "watermark must ignore mutations under .tsift/ and target/"
31602 );
31603
31604 std::thread::sleep(std::time::Duration::from_millis(20));
31609 std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
31610 let fourth = traversal_source_watermark(root, &hint, None, true)
31611 .expect("fourth watermark call must succeed")
31612 .expect("fourth watermark must produce a hash for hinted markdown");
31613 assert_ne!(
31614 first, fourth,
31615 "watermark must invalidate when the hinted markdown file changes"
31616 );
31617 }
31618
31619 #[test]
31620 fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
31621 let dir = tempfile::tempdir().unwrap();
31625 let root = dir.path();
31626 std::fs::write(root.join("README.md"), "# stable\n").unwrap();
31627 let summaries_db_path = root.join(".tsift/summaries.db");
31628 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31629 let mut summary = summarize::Summary {
31630 id: 0,
31631 symbol_name: "main".to_string(),
31632 file_path: "src/main.rs".to_string(),
31633 content_hash: "hash-main".to_string(),
31634 summary: "main wires the CLI".to_string(),
31635 entities: Some(vec![summarize::Entity {
31636 name: "Cli".to_string(),
31637 kind: "type".to_string(),
31638 description: "Command-line interface".to_string(),
31639 }]),
31640 relationships: None,
31641 concept_labels: Some(vec!["cli".to_string()]),
31642 extracted_at: "1700000000".to_string(),
31643 model: "test-model".to_string(),
31644 tokens_input: Some(10),
31645 tokens_output: Some(5),
31646 };
31647 summary_db.insert(&summary).unwrap();
31648 drop(summary_db);
31649
31650 let hint = root.join("README.md");
31651 let first = traversal_source_watermark(root, &hint, None, true)
31652 .expect("first watermark call must succeed")
31653 .expect("first watermark must produce a hash");
31654
31655 std::thread::sleep(std::time::Duration::from_millis(20));
31656 let conn = Connection::open(&summaries_db_path).unwrap();
31657 conn.pragma_update(None, "user_version", 1).unwrap();
31658 conn.pragma_update(None, "user_version", 0).unwrap();
31659 drop(conn);
31660
31661 let second = traversal_source_watermark(root, &hint, None, true)
31662 .expect("second watermark call must succeed")
31663 .expect("second watermark must produce a hash");
31664 assert_eq!(
31665 first, second,
31666 "metadata-only summaries.db churn must not invalidate the source watermark"
31667 );
31668
31669 summary.entities = Some(vec![summarize::Entity {
31670 name: "GraphCache".to_string(),
31671 kind: "type".to_string(),
31672 description: "Stable full-projection cache input".to_string(),
31673 }]);
31674 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31675 summary_db.delete_by_file("src/main.rs").unwrap();
31676 summary_db.insert(&summary).unwrap();
31677 drop(summary_db);
31678
31679 let third = traversal_source_watermark(root, &hint, None, true)
31680 .expect("third watermark call must succeed")
31681 .expect("third watermark must produce a hash");
31682 assert_ne!(
31683 first, third,
31684 "semantic summary row changes must invalidate the source watermark"
31685 );
31686 }
31687
31688 #[test]
31689 fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
31690 let dir = tempfile::tempdir().unwrap();
31694 let root = dir.path();
31695 std::fs::create_dir_all(root.join("src")).unwrap();
31696 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31697 let source = root.join("src/lib.rs");
31698 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
31699 std::fs::write(&source, source_body).unwrap();
31700 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31701 db.rebuild(root).unwrap();
31702 drop(db);
31703
31704 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31705 .unwrap()
31706 .value;
31707 std::thread::sleep(std::time::Duration::from_millis(20));
31708 std::fs::write(&source, source_body).unwrap();
31709 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31710 db.apply_changes(root).unwrap();
31711 drop(db);
31712
31713 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31714 .unwrap()
31715 .value;
31716 assert_eq!(
31717 first, second,
31718 "mtime-only source index churn must not invalidate the full-projection cache"
31719 );
31720 }
31721
31722 #[test]
31723 fn full_projection_source_watermark_ignores_session_markdown_churn() {
31724 let dir = tempfile::tempdir().unwrap();
31729 let root = dir.path();
31730 std::fs::create_dir_all(root.join("src")).unwrap();
31731 std::fs::create_dir_all(root.join("tasks/software")).unwrap();
31732 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31733 std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
31734 let task_doc = root.join("tasks/software/tsift.md");
31735 std::fs::write(
31736 &task_doc,
31737 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
31738 )
31739 .unwrap();
31740 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31741 db.rebuild(root).unwrap();
31742 drop(db);
31743
31744 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31745 .unwrap()
31746 .value;
31747 std::fs::write(
31748 &task_doc,
31749 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
31750 )
31751 .unwrap();
31752 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31753 .unwrap()
31754 .value;
31755 assert_eq!(
31756 first, second,
31757 "session markdown churn must not invalidate the full-projection code/summary cache"
31758 );
31759 }
31760
31761 #[test]
31762 fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
31763 let dir = tempfile::tempdir().unwrap();
31767 let root = dir.path();
31768 std::fs::create_dir_all(root.join("src")).unwrap();
31769 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31770 let source = root.join("src/lib.rs");
31771 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
31772 std::fs::write(&source, source_body).unwrap();
31773 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31774 db.rebuild(root).unwrap();
31775 drop(db);
31776
31777 let (_projection, _warnings, _phases, first_stats) =
31778 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
31779 assert!(
31780 !first_stats.hit,
31781 "the first full-projection run should populate the cache"
31782 );
31783
31784 std::thread::sleep(std::time::Duration::from_millis(20));
31785 std::fs::write(&source, source_body).unwrap();
31786 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31787 db.apply_changes(root).unwrap();
31788 drop(db);
31789
31790 let (_projection, _warnings, phases, second_stats) =
31791 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
31792 assert!(second_stats.hit, "mtime-only churn should still cache-hit");
31793 let source_graph_build = phases
31794 .iter()
31795 .find(|phase| phase.name == "full_projection.source_graph_build")
31796 .expect("cache hit must report source_graph_build");
31797 let projection_rows = phases
31798 .iter()
31799 .find(|phase| phase.name == "full_projection.projection_rows")
31800 .expect("cache hit must report projection_rows");
31801 assert_eq!(source_graph_build.duration_micros, 0);
31802 assert_eq!(projection_rows.duration_micros, 0);
31803 }
31804
31805 #[test]
31806 fn build_token_capped_preview_within_cap() {
31807 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 2", "}"];
31808 let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
31809 assert!(!capped.was_capped);
31810 assert_eq!(capped.preview.len(), 3);
31811 assert_eq!(capped.capped_end, 3);
31812 }
31813
31814 #[test]
31815 fn build_token_capped_preview_truncates_long_body() {
31816 let owned: Vec<String> = (0..200)
31817 .map(|i| format!(" let line_{i} = {i};"))
31818 .collect();
31819 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
31820 let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
31821 assert!(capped.was_capped);
31822 assert!(capped.preview.len() < 200);
31823 assert!(capped.capped_end < 200);
31824 assert!(!capped.preview.is_empty());
31825 }
31826
31827 #[test]
31828 fn build_token_capped_preview_respects_start_offset() {
31829 let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
31830 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
31831 let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
31832 assert!(capped.was_capped);
31833 assert!(capped.capped_end >= 50);
31834 assert!(capped.capped_end < 100);
31835 assert_eq!(capped.preview[0].line, 50);
31836 }
31837
31838 #[test]
31839 fn response_budget_body_token_cap_defaults() {
31840 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
31841 assert_eq!(budget.body_token_cap(), 1500);
31842
31843 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
31844 assert_eq!(budget.body_token_cap(), 500);
31845
31846 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
31847 assert_eq!(budget.body_token_cap(), 3000);
31848 }
31849
31850 #[test]
31851 fn build_token_capped_preview_empty_input() {
31852 let lines: Vec<&str> = vec![];
31853 let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
31854 assert!(!capped.was_capped);
31855 assert!(capped.preview.is_empty());
31856 }
31857
31858 #[test]
31859 fn build_token_capped_preview_single_long_line_fits() {
31860 let lines: Vec<&str> = vec!["short"];
31861 let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
31862 assert!(!capped.was_capped);
31863 assert_eq!(capped.preview.len(), 1);
31864 assert_eq!(capped.capped_end, 1);
31865 }
31866
31867 #[test]
31868 fn edge_index_replaces_from_id_to_id_with_positions() {
31869 let input = serde_json::json!({
31870 "nodes": [
31871 {"id": "symbol:src/lib.rs:foo"},
31872 {"id": "symbol:src/lib.rs:bar"},
31873 {"id": "symbol:src/lib.rs:baz"}
31874 ],
31875 "edges": [
31876 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
31877 {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
31878 ]
31879 });
31880 let result = edge_index_transform(input);
31881 let edges = result.get("edges").unwrap().as_array().unwrap();
31882 assert_eq!(edges.len(), 2);
31883 assert_eq!(edges[0]["from"], 0);
31884 assert_eq!(edges[0]["to"], 1);
31885 assert_eq!(edges[1]["from"], 1);
31886 assert_eq!(edges[1]["to"], 2);
31887 assert!(edges[0].get("from_id").is_none());
31888 assert!(edges[0].get("to_id").is_none());
31889 }
31890
31891 #[test]
31892 fn edge_index_preserves_unresolved_ids_as_strings() {
31893 let input = serde_json::json!({
31894 "nodes": [{"id": "symbol:src/lib.rs:foo"}],
31895 "edges": [
31896 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
31897 ]
31898 });
31899 let result = edge_index_transform(input);
31900 let edge = &result["edges"][0];
31901 assert_eq!(edge["from"], 0);
31902 assert_eq!(edge["to_id"], "symbol:other.rs:missing");
31903 }
31904
31905 #[test]
31906 fn edge_index_noop_without_nodes_and_edges() {
31907 let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
31908 let result = edge_index_transform(input);
31909 assert_eq!(result["report"]["entries"][0]["from_id"], "a");
31910 }
31911}
31912
31913#[derive(Serialize)]
31916struct TableInfo {
31917 name: String,
31918 columns: Vec<ColumnInfo>,
31919 row_count: i64,
31920}
31921
31922#[derive(Serialize)]
31923struct ColumnInfo {
31924 name: String,
31925 #[serde(rename = "type")]
31926 col_type: String,
31927 notnull: bool,
31928 pk: bool,
31929 #[serde(skip_serializing_if = "Option::is_none")]
31930 default_value: Option<String>,
31931}
31932
31933pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
31935 let conn = Connection::open_with_flags(
31936 path,
31937 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
31938 )
31939 .with_context(|| format!("opening database: {}", path.display()))?;
31940 Ok(conn)
31941}
31942
31943pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
31945 let mut stmt = conn.prepare(
31946 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
31947 )?;
31948 let table_names: Vec<String> = stmt
31949 .query_map([], |row| row.get(0))?
31950 .collect::<std::result::Result<Vec<_>, _>>()?;
31951
31952 let mut tables = Vec::new();
31953 for tbl in table_names {
31954 let columns = table_columns(conn, &tbl)?;
31955 let row_count: i64 =
31956 conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
31957 row.get(0)
31958 })?;
31959 tables.push(TableInfo {
31960 name: tbl,
31961 columns,
31962 row_count,
31963 });
31964 }
31965 Ok(tables)
31966}
31967
31968pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
31970 let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
31971 let cols = stmt
31972 .query_map([], |row| {
31973 Ok(ColumnInfo {
31974 name: row.get(1)?,
31975 col_type: row.get::<_, String>(2).unwrap_or_default(),
31976 notnull: row.get::<_, bool>(3).unwrap_or(false),
31977 pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
31978 default_value: row.get(4)?,
31979 })
31980 })?
31981 .collect::<std::result::Result<Vec<_>, _>>()?;
31982 Ok(cols)
31983}
31984
31985pub(crate) fn execute_query(
31987 conn: &Connection,
31988 sql: &str,
31989) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
31990 let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
31991 let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
31992 let col_count = col_names.len();
31993
31994 let mut rows = Vec::new();
31995 let mut query_rows = stmt.query([])?;
31996 while let Some(row) = query_rows.next()? {
31997 let mut vals = Vec::with_capacity(col_count);
31998 for i in 0..col_count {
31999 let val = match row.get_ref(i)? {
32000 rusqlite::types::ValueRef::Null => serde_json::Value::Null,
32001 rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
32002 rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
32003 rusqlite::types::ValueRef::Text(s) => {
32004 serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
32005 }
32006 rusqlite::types::ValueRef::Blob(b) => {
32007 serde_json::Value::String(format!("<blob {} bytes>", b.len()))
32008 }
32009 };
32010 vals.push(val);
32011 }
32012 rows.push(vals);
32013 }
32014 Ok((col_names, rows))
32015}
32016
32017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32018enum DigestRunnerKind {
32019 Test,
32020 Log,
32021}
32022
32023impl DigestRunnerKind {
32024 fn parse(raw: &str) -> Result<Self> {
32025 match raw.trim().to_ascii_lowercase().as_str() {
32026 "test" => Ok(Self::Test),
32027 "log" => Ok(Self::Log),
32028 other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
32029 }
32030 }
32031
32032 fn as_str(self) -> &'static str {
32033 match self {
32034 Self::Test => "test",
32035 Self::Log => "log",
32036 }
32037 }
32038}
32039
32040pub(crate) fn shell_split(s: &str) -> Vec<&str> {
32042 let mut parts = Vec::new();
32043 let mut i = 0;
32044 let bytes = s.as_bytes();
32045 while i < bytes.len() {
32046 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
32048 i += 1;
32049 }
32050 if i >= bytes.len() {
32051 break;
32052 }
32053 let start = i;
32054 if bytes[i] == b'"' || bytes[i] == b'\'' {
32055 let quote = bytes[i];
32056 i += 1;
32057 while i < bytes.len() && bytes[i] != quote {
32058 i += 1;
32059 }
32060 if i < bytes.len() {
32061 i += 1; }
32063 } else {
32064 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
32065 i += 1;
32066 }
32067 }
32068 parts.push(&s[start..i]);
32069 }
32070 parts
32071}
32072
32073pub(crate) fn shell_quote(s: &str) -> String {
32075 let unquoted =
32077 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
32078 &s[1..s.len() - 1]
32079 } else {
32080 s
32081 };
32082
32083 if unquoted
32084 .chars()
32085 .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
32086 {
32087 format!("\"{}\"", unquoted)
32088 } else {
32089 format!(
32090 "\"{}\"",
32091 unquoted.replace('\\', "\\\\").replace('"', "\\\"")
32092 )
32093 }
32094}
32095
32096fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
32097 sift::SearchCoverageSnapshot {
32098 mode: sift::SearchCoverageMode::Sealed,
32099 total_sector_count: 0,
32100 mounted_sector_count: 0,
32101 reused_sector_count: 0,
32102 dirty_sector_count: 0,
32103 completed_dirty_sector_count: 0,
32104 rebuilding_sector_count: 0,
32105 resumed_sector_count: 0,
32106 active_rebuild: None,
32107 }
32108}
32109
32110fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
32111 let total_sector_count = responses
32112 .iter()
32113 .map(|response| response.coverage.total_sector_count)
32114 .sum();
32115 let mounted_sector_count = responses
32116 .iter()
32117 .map(|response| response.coverage.mounted_sector_count)
32118 .sum();
32119 let reused_sector_count = responses
32120 .iter()
32121 .map(|response| response.coverage.reused_sector_count)
32122 .sum();
32123 let dirty_sector_count = responses
32124 .iter()
32125 .map(|response| response.coverage.dirty_sector_count)
32126 .sum();
32127 let completed_dirty_sector_count = responses
32128 .iter()
32129 .map(|response| response.coverage.completed_dirty_sector_count)
32130 .sum();
32131 let rebuilding_sector_count = responses
32132 .iter()
32133 .map(|response| response.coverage.rebuilding_sector_count)
32134 .sum();
32135 let resumed_sector_count = responses
32136 .iter()
32137 .map(|response| response.coverage.resumed_sector_count)
32138 .sum();
32139
32140 let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
32141 sift::SearchCoverageMode::Sealed
32142 } else if completed_dirty_sector_count > 0
32143 || rebuilding_sector_count > 0
32144 || resumed_sector_count > 0
32145 {
32146 sift::SearchCoverageMode::Converging
32147 } else {
32148 sift::SearchCoverageMode::Frontier
32149 };
32150
32151 sift::SearchCoverageSnapshot {
32152 mode,
32153 total_sector_count,
32154 mounted_sector_count,
32155 reused_sector_count,
32156 dirty_sector_count,
32157 completed_dirty_sector_count,
32158 rebuilding_sector_count,
32159 resumed_sector_count,
32160 active_rebuild: responses
32161 .iter()
32162 .find_map(|response| response.coverage.active_rebuild.clone()),
32163 }
32164}
32165
32166fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
32167 sift::SearchResponse {
32168 strategy: strategy.to_string(),
32169 root: root.display().to_string(),
32170 indexed_artifacts: 0,
32171 skipped_artifacts: 0,
32172 coverage: empty_search_coverage(),
32173 hits: Vec::new(),
32174 }
32175}
32176
32177fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
32178 for hit in &mut response.hits {
32179 let path = Path::new(&hit.path);
32180 if path.is_relative() {
32181 hit.path = search_root.join(path).display().to_string();
32182 }
32183 }
32184}
32185
32186fn merge_search_responses(
32187 root: &Path,
32188 strategy: &str,
32189 limit: usize,
32190 responses: Vec<sift::SearchResponse>,
32191) -> sift::SearchResponse {
32192 let indexed_artifacts = responses
32193 .iter()
32194 .map(|response| response.indexed_artifacts)
32195 .sum();
32196 let skipped_artifacts = responses
32197 .iter()
32198 .map(|response| response.skipped_artifacts)
32199 .sum();
32200 let coverage = if responses.is_empty() {
32201 empty_search_coverage()
32202 } else {
32203 aggregate_search_coverage(&responses)
32204 };
32205 let mut hits: Vec<sift::SearchHit> = responses
32206 .into_iter()
32207 .flat_map(|response| response.hits)
32208 .collect();
32209 hits.sort_by(|left, right| {
32210 right
32211 .score
32212 .partial_cmp(&left.score)
32213 .unwrap_or(Ordering::Equal)
32214 .then_with(|| left.path.cmp(&right.path))
32215 .then_with(|| left.location.cmp(&right.location))
32216 });
32217 hits.truncate(limit);
32218 for (rank, hit) in hits.iter_mut().enumerate() {
32219 hit.rank = rank + 1;
32220 }
32221
32222 sift::SearchResponse {
32223 strategy: strategy.to_string(),
32224 root: root.display().to_string(),
32225 indexed_artifacts,
32226 skipped_artifacts,
32227 coverage,
32228 hits,
32229 }
32230}
32231
32232pub(crate) fn federated_sift_search(
32233 root: &Path,
32234 cache_dir: &Path,
32235 query: &str,
32236 limit: usize,
32237 timeout_secs: u64,
32238 strategy: &str,
32239) -> Result<sift::SearchResponse> {
32240 let targets = resolve_search_index_targets(root, root, None, true)?;
32241 if targets.is_empty() {
32242 if config::Config::submodule_dirs(root)?.is_empty() {
32243 return run_search_with_timeout(
32244 root,
32245 cache_dir,
32246 query,
32247 limit,
32248 timeout_secs,
32249 strategy,
32250 &[],
32251 );
32252 }
32253 return Ok(empty_search_response(root, strategy));
32254 }
32255
32256 let mut responses = Vec::with_capacity(targets.len());
32257 for target in &targets {
32258 let mut response = run_search_with_timeout(
32259 &target.source_root,
32260 cache_dir,
32261 query,
32262 limit,
32263 timeout_secs,
32264 strategy,
32265 std::slice::from_ref(target),
32266 )?;
32267 absolutize_search_hit_paths(&mut response, &target.source_root);
32268 response.root = root.display().to_string();
32269 responses.push(response);
32270 }
32271
32272 Ok(merge_search_responses(root, strategy, limit, responses))
32273}
32274
32275pub(crate) fn federated_symbol_search(
32283 root: &std::path::Path,
32284 query: &str,
32285 limit: usize,
32286 tagpath_opts: &TagpathSearchOpts,
32287) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
32288 let cfg = config::Config::load(root)?;
32289 let submodules = config::Config::submodule_dirs(root)?;
32290 let mut all_hits: Vec<index::SymbolHit> = Vec::new();
32291 let mut combined = TagpathAnnotationDiagnostic::default();
32292 for scope in &submodules {
32293 if !cfg.federation_for_scope(scope) {
32294 continue;
32295 }
32296 let db_path = cfg.db_path_for(root, &scope.id);
32297 if !db_path.exists() {
32298 continue;
32299 }
32300 let db = index::IndexDb::open_read_only(&db_path)?;
32301 let mut hits = db.symbol_search(query, limit)?;
32302 let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
32303 combined.loaded |= diag.loaded;
32304 if diag.stale && !combined.stale {
32305 combined.stale = true;
32306 combined.reason = diag.reason;
32307 }
32308 all_hits.append(&mut hits);
32309 }
32310 all_hits.sort_by(|a, b| {
32311 b.score
32312 .partial_cmp(&a.score)
32313 .unwrap_or(std::cmp::Ordering::Equal)
32314 });
32315 all_hits.truncate(limit);
32316 Ok((all_hits, combined))
32317}
32318
32319#[derive(Debug, Deserialize)]
32320#[serde(tag = "type", rename_all = "lowercase")]
32321enum RipgrepJsonEvent {
32322 Match {
32323 data: RipgrepMatchData,
32324 },
32325 #[serde(other)]
32326 Other,
32327}
32328
32329#[derive(Debug, Deserialize)]
32330struct RipgrepMatchData {
32331 path: RipgrepTextField,
32332 lines: RipgrepTextField,
32333 line_number: Option<usize>,
32334}
32335
32336#[derive(Debug, Deserialize)]
32337struct RipgrepTextField {
32338 text: Option<String>,
32339}
32340
32341pub(crate) fn federated_exact_search(
32342 root: &Path,
32343 query: &str,
32344 limit: usize,
32345 timeout_secs: u64,
32346) -> Result<sift::SearchResponse> {
32347 let cfg = config::Config::load(root)?;
32348 let mut responses = Vec::new();
32349 for scope in config::Config::submodule_dirs(root)? {
32350 if !cfg.federation_for_scope(&scope) {
32351 continue;
32352 }
32353 let mut response =
32354 run_exact_search_with_timeout(&scope.source_root, query, limit, timeout_secs)?;
32355 absolutize_search_hit_paths(&mut response, &scope.source_root);
32356 response.root = root.display().to_string();
32357 responses.push(response);
32358 }
32359
32360 Ok(merge_search_responses(root, "exact", limit, responses))
32361}
32362
32363pub(crate) fn run_sift_search(
32364 search_path: &Path,
32365 cache_dir: &Path,
32366 query: &str,
32367 limit: usize,
32368 strategy: &str,
32369) -> Result<sift::SearchResponse> {
32370 let engine = Sift::builder().with_cache_dir(cache_dir).build();
32371 let options = SearchOptions::default()
32372 .with_limit(limit)
32373 .with_strategy(strategy.to_string());
32374 let input = SearchInput::new(search_path, query).with_options(options);
32375 engine.search(input).context("sift search failed")
32376}
32377
32378fn exact_search_timeout_message(timeout_secs: u64) -> String {
32379 format!(
32380 "tsift search timed out after {}s (strategy: exact). \
32381 Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
32382 timeout_secs
32383 )
32384}
32385
32386fn exact_search_command(search_path: &Path, query: &str) -> Command {
32387 let mut command = Command::new("rg");
32388 command
32389 .arg("--json")
32390 .arg("--fixed-strings")
32391 .arg("--line-number")
32392 .arg("--hidden")
32393 .arg("--")
32394 .arg(query)
32395 .arg(search_path);
32396 command
32397}
32398
32399fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
32400 let observed_unix_secs = SystemTime::now()
32401 .duration_since(UNIX_EPOCH)
32402 .unwrap_or_default()
32403 .as_secs() as i64;
32404 let modified_unix_secs = fs::metadata(path)
32405 .ok()
32406 .and_then(|metadata| metadata.modified().ok())
32407 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
32408 .map(|duration| duration.as_secs() as i64);
32409 sift::ArtifactFreshness {
32410 observed_unix_secs,
32411 modified_unix_secs,
32412 }
32413}
32414
32415fn parse_exact_search_output(
32416 search_path: &Path,
32417 limit: usize,
32418 raw: &str,
32419) -> Result<sift::SearchResponse> {
32420 if limit == 0 {
32421 return Ok(sift::SearchResponse {
32422 strategy: "exact".to_string(),
32423 root: search_path.display().to_string(),
32424 indexed_artifacts: 0,
32425 skipped_artifacts: 0,
32426 coverage: empty_search_coverage(),
32427 hits: Vec::new(),
32428 });
32429 }
32430
32431 let mut hits = Vec::new();
32432 for line in raw.lines() {
32433 let event: RipgrepJsonEvent =
32434 serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
32435 let RipgrepJsonEvent::Match { data } = event else {
32436 continue;
32437 };
32438 let Some(path_text) = data.path.text else {
32439 continue;
32440 };
32441 let Some(lines_text) = data.lines.text else {
32442 continue;
32443 };
32444 let path = PathBuf::from(path_text);
32445 let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
32446 let rank = hits.len() + 1;
32447 hits.push(sift::SearchHit {
32448 artifact_id: format!(
32449 "exact:{}:{}:{}",
32450 path.display(),
32451 data.line_number.unwrap_or(0),
32452 rank
32453 ),
32454 artifact_kind: sift::ContextArtifactKind::File,
32455 path: path.display().to_string(),
32456 rank,
32457 score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
32458 confidence: sift::ScoreConfidence::High,
32459 location: data.line_number.map(|line| format!("line {}", line)),
32460 snippet: snippet.clone(),
32461 provenance: sift::ArtifactProvenance {
32462 adapter: sift::AcquisitionAdapterKind::FileSystem,
32463 source: "ripgrep -F".to_string(),
32464 synthetic: false,
32465 },
32466 freshness: exact_search_file_timestamp(&path),
32467 budget: sift::ArtifactBudget::from_text(&snippet, 1),
32468 });
32469 if hits.len() >= limit {
32470 break;
32471 }
32472 }
32473
32474 Ok(sift::SearchResponse {
32475 strategy: "exact".to_string(),
32476 root: search_path.display().to_string(),
32477 indexed_artifacts: hits.len(),
32478 skipped_artifacts: 0,
32479 coverage: empty_search_coverage(),
32480 hits,
32481 })
32482}
32483
32484fn exact_search_response_from_process(
32485 search_path: &Path,
32486 limit: usize,
32487 status: std::process::ExitStatus,
32488 stdout: &[u8],
32489 stderr: &[u8],
32490) -> Result<sift::SearchResponse> {
32491 if !status.success() && status.code() != Some(1) {
32492 let message = String::from_utf8_lossy(stderr);
32493 let trimmed = message.trim();
32494 if trimmed.is_empty() {
32495 bail!("ripgrep exact search exited with status {}", status);
32496 }
32497 bail!("{}", trimmed);
32498 }
32499
32500 let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
32501 parse_exact_search_output(search_path, limit, &raw)
32502}
32503
32504fn run_exact_search(search_path: &Path, query: &str, limit: usize) -> Result<sift::SearchResponse> {
32505 let output = exact_search_command(search_path, query)
32506 .output()
32507 .context("running exact search with ripgrep")?;
32508 exact_search_response_from_process(
32509 search_path,
32510 limit,
32511 output.status,
32512 &output.stdout,
32513 &output.stderr,
32514 )
32515}
32516
32517pub(crate) fn run_exact_search_with_timeout(
32518 search_path: &Path,
32519 query: &str,
32520 limit: usize,
32521 timeout_secs: u64,
32522) -> Result<sift::SearchResponse> {
32523 if timeout_secs == 0 {
32524 return run_exact_search(search_path, query, limit);
32525 }
32526
32527 let mut child = exact_search_command(search_path, query)
32528 .stdin(Stdio::null())
32529 .stdout(Stdio::piped())
32530 .stderr(Stdio::piped())
32531 .spawn()
32532 .context("spawning timed exact search worker")?;
32533
32534 let timeout = Duration::from_secs(timeout_secs);
32535 let status = wait_for_child_exit(&mut child, timeout)
32536 .context("waiting for timed exact search worker")?;
32537 if status.is_none() {
32538 let _ = child.kill();
32539 let _ = child.wait();
32540 bail!("{}", exact_search_timeout_message(timeout_secs));
32541 }
32542
32543 let status = status.unwrap();
32544 let stdout = read_child_stdout(&mut child)?;
32545 let stderr = read_child_stderr(&mut child)?;
32546 exact_search_response_from_process(
32547 search_path,
32548 limit,
32549 status,
32550 stdout.as_bytes(),
32551 stderr.as_bytes(),
32552 )
32553}
32554
32555pub(crate) fn run_search_with_timeout(
32556 search_path: &Path,
32557 cache_dir: &Path,
32558 query: &str,
32559 limit: usize,
32560 timeout_secs: u64,
32561 strategy: &str,
32562 search_targets: &[SearchIndexTarget],
32563) -> Result<sift::SearchResponse> {
32564 if timeout_secs == 0 {
32565 return run_sift_search(search_path, cache_dir, query, limit, strategy);
32566 }
32567
32568 let output_path = next_search_worker_output_path();
32569 let mut child = Command::new(
32570 std::env::current_exe().context("resolving tsift executable for timed search")?,
32571 )
32572 .arg("__search-worker")
32573 .arg("--path")
32574 .arg(search_path)
32575 .arg("--cache-dir")
32576 .arg(cache_dir)
32577 .arg("--query")
32578 .arg(query)
32579 .arg("--limit")
32580 .arg(limit.to_string())
32581 .arg("--strategy")
32582 .arg(strategy)
32583 .arg("--output")
32584 .arg(&output_path)
32585 .stdin(Stdio::null())
32586 .stdout(Stdio::null())
32587 .stderr(Stdio::piped())
32588 .spawn()
32589 .context("spawning timed sift search worker")?;
32590
32591 let timeout = Duration::from_secs(timeout_secs);
32592 let status =
32593 wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
32594 if status.is_none() {
32595 let _ = child.kill();
32596 let _ = child.wait();
32597 let _ = fs::remove_file(&output_path);
32598 bail!(
32599 "{}",
32600 search_timeout_message(timeout_secs, strategy, search_targets)?
32601 );
32602 }
32603
32604 let status = status.unwrap();
32605 let stderr = read_child_stderr(&mut child)?;
32606 if !status.success() {
32607 let _ = fs::remove_file(&output_path);
32608 let message = stderr.trim();
32609 if message.is_empty() {
32610 bail!("sift search worker exited with status {}", status);
32611 }
32612 bail!("{}", message);
32613 }
32614
32615 let raw = fs::read_to_string(&output_path)
32616 .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
32617 let _ = fs::remove_file(&output_path);
32618 serde_json::from_str(&raw).context("parsing search worker output")
32619}
32620
32621fn next_search_worker_output_path() -> PathBuf {
32622 let stamp = SystemTime::now()
32623 .duration_since(UNIX_EPOCH)
32624 .unwrap_or_default()
32625 .as_nanos();
32626 std::env::temp_dir().join(format!(
32627 "tsift-search-{}-{}.json",
32628 std::process::id(),
32629 stamp
32630 ))
32631}
32632
32633fn wait_for_child_exit(
32634 child: &mut std::process::Child,
32635 timeout: Duration,
32636) -> Result<Option<std::process::ExitStatus>> {
32637 let started = Instant::now();
32638 loop {
32639 if let Some(status) = child.try_wait()? {
32640 return Ok(Some(status));
32641 }
32642 if started.elapsed() >= timeout {
32643 return Ok(None);
32644 }
32645 let remaining = timeout.saturating_sub(started.elapsed());
32646 std::thread::sleep(remaining.min(Duration::from_millis(10)));
32647 }
32648}
32649
32650fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
32651 let mut stderr = String::new();
32652 if let Some(mut pipe) = child.stderr.take() {
32653 pipe.read_to_string(&mut stderr)
32654 .context("reading search worker stderr")?;
32655 }
32656 Ok(stderr)
32657}
32658
32659fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
32660 let mut stdout = String::new();
32661 if let Some(mut pipe) = child.stdout.take() {
32662 pipe.read_to_string(&mut stdout)
32663 .context("reading search worker stdout")?;
32664 }
32665 Ok(stdout)
32666}
32667
32668pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
32669 if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
32670 fs::write(&path, std::process::id().to_string())
32671 .with_context(|| format!("writing search worker pid file: {path}"))?;
32672 }
32673 if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
32674 let delay_ms = ms
32675 .parse::<u64>()
32676 .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
32677 std::thread::sleep(Duration::from_millis(delay_ms));
32678 }
32679 Ok(())
32680}
32681
32682#[cfg(test)]
32683thread_local! {
32684 static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
32685}
32686
32687#[cfg(test)]
32688enum SearchPostPrecheckLockMode {
32689 RollbackJournal,
32690 Wal,
32691}
32692
32693#[cfg(test)]
32694struct SearchPostPrecheckLockHook {
32695 db_path: PathBuf,
32696 mode: SearchPostPrecheckLockMode,
32697}
32698
32699#[cfg(test)]
32700struct SearchPostPrecheckLockGuard;
32701
32702#[cfg(test)]
32703impl Drop for SearchPostPrecheckLockGuard {
32704 fn drop(&mut self) {
32705 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32706 hook.borrow_mut().take();
32707 });
32708 }
32709}
32710
32711#[cfg(test)]
32712fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32713 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
32714}
32715
32716#[cfg(test)]
32717fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32718 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
32719}
32720
32721#[cfg(test)]
32722fn install_search_post_precheck_lock_hook(
32723 db_path: PathBuf,
32724 mode: SearchPostPrecheckLockMode,
32725) -> SearchPostPrecheckLockGuard {
32726 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32727 assert!(
32728 hook.borrow().is_none(),
32729 "search post-precheck lock hook already installed"
32730 );
32731 *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
32732 });
32733 SearchPostPrecheckLockGuard
32734}
32735
32736#[cfg(test)]
32737pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
32738 let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
32739 return Ok(());
32740 };
32741 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
32742 std::thread::spawn(move || {
32743 let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
32744 match hook.mode {
32745 SearchPostPrecheckLockMode::RollbackJournal => {
32746 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
32747 .expect("acquiring rollback-journal hook lock");
32748 fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
32749 .expect("writing rollback journal marker");
32750 }
32751 SearchPostPrecheckLockMode::Wal => {
32752 conn.execute_batch(
32753 "PRAGMA journal_mode=WAL;
32754 PRAGMA wal_autocheckpoint=0;
32755 CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
32756 INSERT INTO search_wal_lock_probe DEFAULT VALUES;
32757 PRAGMA locking_mode=EXCLUSIVE;
32758 BEGIN EXCLUSIVE;",
32759 )
32760 .expect("acquiring WAL hook lock");
32761 assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
32762 }
32763 }
32764 ready_tx.send(()).expect("signaling search lock hook");
32765 std::thread::sleep(Duration::from_millis(200));
32766 drop(conn);
32767 let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
32768 });
32769 ready_rx
32770 .recv_timeout(Duration::from_secs(1))
32771 .context("waiting for search post-precheck lock hook")?;
32772 Ok(())
32773}
32774
32775#[cfg(not(test))]
32776pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
32777 Ok(())
32778}