1mod cli;
2mod commands;
3mod community_detection;
4mod conflict_matrix;
5mod context_pack;
6mod output;
7mod rewrite;
8mod search_budget;
9mod semantic_edit;
10mod session_review_budget;
11mod token_savings;
12mod workflow;
13
14pub use rewrite::rewrite_command;
15pub(crate) use rewrite::{apply_rewrite_output_format, execute_rewritten_command, no_rewrite_message};
16pub(crate) use community_detection::{
17 CommunityDetectionReport, annotate_community_members_with_context,
18 community_tagpath_cache_part, community_tagpath_cache_part_for_loaded,
19 detect_communities_cached, file_communities_from_callers,
20 graph_effectiveness_blocked, graph_effectiveness_ready,
21 resolve_tagpath_handle_for_callee_edge, update_community_annotation_diagnostics,
22};
23#[allow(unused_imports)]
24pub(crate) use conflict_matrix::{
25 ConflictMatrixCandidate, ConflictMatrixGraphPreparedInputs,
26 ConflictMatrixPreparedInputs, ConflictMatrixReport,
27 ConflictMatrixSemanticRef, ConflictMatrixSharedPreparationSummary,
28 ConflictMatrixWorkerFeedback, ConflictMatrixWorkerPromptPacket,
29 build_conflict_matrix_report, build_conflict_matrix_report_from_prepared_graph,
30 cmd_conflict_matrix, collect_conflict_matrix_evidence_packets,
31 conflict_matrix_candidate_from_evidence, conflict_matrix_graph_index,
32 conflict_matrix_semantic_ref, conflict_matrix_shared_preparation_summary,
33 conflict_matrix_source_handle, conflict_matrix_target_scoped_graph_snapshot,
34 conflict_matrix_worker_feedback,
35 conflict_risk_label, extract_conflict_target_refs, hash_bytes_hex,
36 is_planner_config_path, normalize_conflict_target,
37 prepare_conflict_matrix_graph_orchestration,
38 prepare_conflict_matrix_inputs, resolve_conflict_matrix_targets,
39 sorted_intersection, sorted_set,
40};
41#[allow(unused_imports)]
42pub(crate) use context_pack::{
43 ContextPackReport, ContextPackSummaryRefPreview,
44 build_context_pack_diff_preview, build_context_pack_log_preview,
45 build_context_pack_report, build_context_pack_report_with_profile,
46 build_context_pack_test_preview, context_pack_status_reminders,
47 exploration_ref_id, materialize_context_pack_exploration_packet,
48 print_context_pack_human,
49};
50pub(crate) use search_budget::{
51 SearchBudgetReportInput,
52 apply_search_facet_filters, build_search_budget_follow_up, build_search_budget_report,
53 print_search_budget_human,
54};
55#[allow(unused_imports)]
56pub(crate) use session_review_budget::{
57 SessionReviewBudgetFailurePreview, SessionReviewBudgetReport,
58 SessionReviewNextContextBudgetReport, SessionReviewNextTokenAction,
59 build_session_review_budget_report, build_session_review_next_context_budget_report,
60 print_session_review_budget_human, print_session_review_next_context_budget_human,
61};
62#[cfg(test)]
63use search_budget::{SearchBudgetReport, search_facet_filters_summary};
64pub(crate) use semantic_edit::{
65 AstSpanPreview, EditBatch, EditResult, EditStatus,
66 MarkdownEmbeddedSymbol, MarkdownSpanMetadata, MetricDigestOptions,
67 SemanticEditVerifyOptions, apply_edit_plan_atomically, build_edit_plan, cmd_edit_intents,
68};
69
70#[cfg(test)]
71use rewrite::{apply_output_cap, effective_rewrite_run_command, resolve_digest_context_path, rewrite_output_cap, OutputCap};
72#[cfg(test)]
73use std::io::{BufRead as _, BufReader};
74#[cfg(test)]
75use token_savings::{
76 TokenSavingsFamily, TokenSavingsFixture, TokenSavingsFixtureCase,
77 TokenSavingsMarkdownProjectionInput, TokenSavingsMarkdownProjectionInputs,
78 TokenSavingsRawSymbol, TokenSavingsSourceReadInput, TokenSavingsSourceReadInputs,
79 build_token_savings_report,
80};
81
82use anyhow::{Context, Result, bail};
83use clap::Parser;
84use cli::{Cli, Commands, DispatchTraceFormat, GraphDbQuery, SemanticRelatedKind, SourceReadStyle};
85#[cfg(test)]
86use cli::{GraphDbBackend, TraverseFormat};
87use commands::digests::{
88 cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
89 cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
90};
91#[cfg(test)]
92use commands::graph::cmd_explain;
93use commands::graph::{
94 cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
95};
96#[cfg(test)]
97use commands::index_search::cmd_search;
98use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
99use commands::infra::{
100 StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
101 cmd_rewrite, cmd_route, cmd_sql, cmd_status,
102};
103use commands::memory::cmd_memory;
104use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
105use commands::summarize::cmd_summarize;
106use flate2::{Compression, read::GzDecoder, write::GzEncoder};
107use output::tagpath::{
108 TagpathAnnotationDiagnostic, TagpathSearchOpts,
109 annotate_communities_with_tagpath, annotate_hits_with_tagpath,
110 annotate_path_nodes_with_tagpath, annotate_stored_edges_with_tagpath,
111 annotate_stored_symbols_with_tagpath,
112};
113#[cfg(test)]
114use output::ResponseBudgetPreset;
115use output::{
116 OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric,
117 ToolEnvelopeSummary, TranscriptArtifactRef,
118};
119use rusqlite::{Connection, OptionalExtension};
120use serde::{Deserialize, Serialize};
121use sift::{SearchInput, SearchOptions, Sift};
122#[cfg(test)]
123use std::cell::RefCell;
124use std::cmp::Ordering;
125use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
126use std::env;
127use std::fs;
128use std::io::{Read as _, Write as _};
129use std::path::{Path, PathBuf};
130use std::process::{Command, Stdio};
131use std::sync::{Mutex, OnceLock};
132use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
133use substrate::{
134 ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
135 GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
136 GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
137 SqliteGraphStore, SqliteProjectionRefresh,
138 TerseGraphNode as SubstrateTerseGraphNode, TerseGraphEdge as SubstrateTerseGraphEdge,
139};
140use tsift_core::{NeighborhoodScoring, RankedNeighborhoodOptions};
141use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
142#[cfg(test)]
143use tsift_agent_doc::session_cost;
144#[cfg(test)]
145use tsift_agent_doc::session_review;
146use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
147use tsift_graph as graph;
148use tsift_index::{config, index, init, multiplicity, walk};
149use tsift_memory::{MemoryEvent, default_memory_db_path, read_memory_events};
150use tsift_quality::{cycle_packet_cache, dci_benchmark, lint, perf_gate, token_gate};
151use tsift_resolution as resolution;
152use tsift_search::{impact, sift};
153use tsift_sqlite as substrate;
154use tsift_status::status;
155use tsift_summarize::summarize;
156#[cfg(feature = "backend-surrealdb")]
157use tsift_surrealdb::SurrealdbGraphStore;
158use tsift_tokensave::TokensaveDb;
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
161pub(crate) enum GraphDbExperimentalBackend {
162 DuckdbDuckpgq,
163 Falkordb,
164 Ladybug,
165 Kuzu,
166 Surrealdb,
167}
168
169#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
170pub(crate) struct SearchFacetFilters {
171 #[serde(skip_serializing_if = "Vec::is_empty", default)]
172 pub(crate) languages: Vec<String>,
173 #[serde(skip_serializing_if = "Vec::is_empty", default)]
174 pub(crate) kinds: Vec<String>,
175 #[serde(skip_serializing_if = "Vec::is_empty", default)]
176 pub(crate) node_kinds: Vec<String>,
177 #[serde(skip_serializing_if = "Vec::is_empty", default)]
178 pub(crate) sections: Vec<String>,
179 #[serde(skip_serializing_if = "Vec::is_empty", default)]
180 pub(crate) parents: Vec<String>,
181 #[serde(skip_serializing_if = "Vec::is_empty", default)]
182 pub(crate) children: Vec<String>,
183 #[serde(skip_serializing_if = "Vec::is_empty", default)]
184 pub(crate) fence_languages: Vec<String>,
185 #[serde(skip_serializing_if = "Vec::is_empty", default)]
186 pub(crate) list_depths: Vec<usize>,
187 #[serde(skip_serializing_if = "Vec::is_empty", default)]
188 pub(crate) heading_levels: Vec<usize>,
189}
190
191impl SearchFacetFilters {
192 pub(crate) fn is_empty(&self) -> bool {
193 self.languages.is_empty()
194 && self.kinds.is_empty()
195 && self.node_kinds.is_empty()
196 && self.sections.is_empty()
197 && self.parents.is_empty()
198 && self.children.is_empty()
199 && self.fence_languages.is_empty()
200 && self.list_depths.is_empty()
201 && self.heading_levels.is_empty()
202 }
203
204 fn needs_ast_context(&self) -> bool {
205 !self.sections.is_empty()
206 || !self.parents.is_empty()
207 || !self.children.is_empty()
208 || !self.fence_languages.is_empty()
209 || !self.list_depths.is_empty()
210 || !self.heading_levels.is_empty()
211 }
212}
213
214#[derive(Serialize)]
215struct GraphDbBackendPromotionGate {
216 status: String,
217 native_adapter_required: bool,
218 required_checks: Vec<String>,
219}
220
221impl GraphDbExperimentalBackend {
222 fn name(self) -> &'static str {
223 match self {
224 Self::DuckdbDuckpgq => "duckdb-duckpgq",
225 Self::Falkordb => "falkordb",
226 Self::Ladybug => "ladybug",
227 Self::Kuzu => "kuzu",
228 Self::Surrealdb => "surrealdb",
229 }
230 }
231
232 fn adapter_label(self) -> &'static str {
233 match self {
234 Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
235 Self::Falkordb => "FalkorDB read-only prototype",
236 Self::Ladybug => "Ladybug read-only prototype",
237 Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
238 Self::Surrealdb => "SurrealDB read-only prototype",
239 }
240 }
241
242 fn projection_load(self) -> &'static str {
243 match self {
244 Self::Falkordb => {
245 "provider-neutral rows loaded into a FalkorDB-shaped read snapshot for parity and timing only; production FalkorDB storage remains behind backend-eval until a real adapter passes the full-projection gate"
246 }
247 Self::Kuzu => {
248 "provider-neutral rows loaded into a Kuzu-compatible in-process read snapshot for parity and performance gates; production Vela-Engineering/kuzu storage remains behind a future optional adapter"
249 }
250 Self::Surrealdb => {
251 "provider-neutral rows loaded into a SurrealDB-compatible read snapshot for parity and timing only; production SurrealDB storage remains behind backend-eval until a real optional adapter passes the full-projection gate"
252 }
253 _ => {
254 "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
255 }
256 }
257 }
258
259 fn lock_behavior(self) -> &'static str {
260 match self {
261 Self::Falkordb => {
262 "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
263 }
264 Self::Kuzu => {
265 "read-only Kuzu prototype snapshot; no SQLite writer lock is taken during benchmarks, and production Vela-Engineering/kuzu promotion must prove concurrent writer semantics before replacing SQLite"
266 }
267 Self::Surrealdb => {
268 "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
269 }
270 _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
271 }
272 }
273
274 fn install_portability(self) -> &'static str {
275 match self {
276 Self::Falkordb => {
277 "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
278 }
279 Self::Kuzu => {
280 "prototype is dependency-free in this binary; production Vela-Engineering/kuzu integration must stay optional so cargo build/install works without a native Kuzu toolchain"
281 }
282 Self::Surrealdb => {
283 "prototype is dependency-free in this binary; production SurrealDB integration must stay optional so cargo build/install works without pulling SurrealDB into the default build"
284 }
285 _ => {
286 "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
287 }
288 }
289 }
290
291 fn prototype_hold_reason(self) -> Option<&'static str> {
292 match self {
293 Self::DuckdbDuckpgq => Some(
294 "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
295 ),
296 Self::Falkordb => Some(
297 "FalkorDB remains behind backend-eval until a production adapter beats SQLite on full_projection conflict-matrix, evidence, dispatch-trace, path tiers, install portability, and lock behavior",
298 ),
299 Self::Ladybug => Some(
300 "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
301 ),
302 Self::Kuzu => Some(
303 "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
304 ),
305 Self::Surrealdb => Some(
306 "SurrealDB remains behind backend-eval until a feature-gated optional adapter proves provider-neutral projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
307 ),
308 }
309 }
310
311 fn promotion_gate(self) -> GraphDbBackendPromotionGate {
312 match self {
313 Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
314 status: "hold_native_adapter_required".to_string(),
315 native_adapter_required: true,
316 required_checks: vec![
317 "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
318 .to_string(),
319 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
320 .to_string(),
321 "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
322 "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
323 .to_string(),
324 ],
325 },
326 Self::Falkordb => GraphDbBackendPromotionGate {
327 status: "hold_native_adapter_required".to_string(),
328 native_adapter_required: true,
329 required_checks: vec![
330 "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
331 .to_string(),
332 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
333 .to_string(),
334 "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
335 .to_string(),
336 "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
337 .to_string(),
338 ],
339 },
340 Self::Ladybug => GraphDbBackendPromotionGate {
341 status: "hold_native_adapter_required".to_string(),
342 native_adapter_required: true,
343 required_checks: vec![
344 "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
345 .to_string(),
346 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
347 .to_string(),
348 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
349 .to_string(),
350 "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
351 .to_string(),
352 ],
353 },
354 Self::Kuzu => GraphDbBackendPromotionGate {
355 status: "hold_native_adapter_required".to_string(),
356 native_adapter_required: true,
357 required_checks: vec![
358 "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
359 .to_string(),
360 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
361 .to_string(),
362 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
363 .to_string(),
364 "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
365 .to_string(),
366 ],
367 },
368 Self::Surrealdb => GraphDbBackendPromotionGate {
369 status: "hold_native_adapter_required".to_string(),
370 native_adapter_required: true,
371 required_checks: vec![
372 "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
373 .to_string(),
374 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
375 .to_string(),
376 "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
377 .to_string(),
378 "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
379 .to_string(),
380 ],
381 },
382 }
383 }
384
385 fn parse(raw: &str) -> Result<Self> {
386 match raw {
387 "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
388 "falkordb" | "falkor" => Ok(Self::Falkordb),
389 "ladybug" => Ok(Self::Ladybug),
390 "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
391 "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
392 _ => {
393 bail!(
394 "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
395 )
396 }
397 }
398 }
399}
400
401
402pub fn run() -> Result<()> {
403 let cli = Cli::parse();
404 let compact = cli.compact;
405 let pretty = cli.pretty;
406 let terse = cli.terse || cli.ultra_terse;
407 let ultra_terse = cli.ultra_terse;
408 let absolute = cli.absolute;
409 let tabular = cli.tabular;
410 let schema = cli.schema;
411 let envelope = cli.envelope;
412 match cli.command {
413 Some(Commands::Search {
414 query,
415 path,
416 limit,
417 strategy,
418 exact,
419 scope,
420 federated,
421 lang,
422 kind,
423 node_kind,
424 section,
425 parent,
426 child,
427 fence_language,
428 list_depth,
429 heading_level,
430 json,
431 autoindex,
432 no_autoindex,
433 timeout,
434 max_items,
435 max_bytes,
436 budget,
437 no_tagpath,
438 tagpath_strict,
439 }) => cmd_search_with_budget(
440 query,
441 path,
442 limit,
443 if exact {
444 Some("exact".to_string())
445 } else {
446 strategy
447 },
448 scope,
449 federated,
450 json || terse || schema || envelope,
451 autoindex || !no_autoindex,
452 timeout,
453 compact,
454 pretty,
455 terse,
456 ultra_terse,
457 absolute,
458 tabular,
459 schema,
460 envelope,
461 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
462 TagpathSearchOpts {
463 no_tagpath,
464 strict: tagpath_strict,
465 },
466 SearchFacetFilters {
467 languages: lang,
468 kinds: kind,
469 node_kinds: node_kind,
470 sections: section,
471 parents: parent,
472 children: child,
473 fence_languages: fence_language,
474 list_depths: list_depth,
475 heading_levels: heading_level,
476 },
477 ),
478 Some(Commands::SearchWorker {
479 path,
480 cache_dir,
481 query,
482 limit,
483 strategy,
484 output,
485 }) => cmd_search_worker(&path, &cache_dir, &query, limit, &strategy, &output),
486 Some(Commands::DigestRunner {
487 kind,
488 path,
489 runner,
490 shell_command,
491 json,
492 }) => cmd_digest_runner(
493 &kind,
494 &path,
495 runner.as_deref(),
496 &shell_command,
497 OutputFormat {
498 json_output: json || terse || schema || envelope,
499 compact,
500 pretty,
501 terse,
502 ultra_terse,
503 schema,
504 envelope,
505 },
506 ),
507 Some(Commands::Edit { dry_run, file }) => {
508 cmd_edit(dry_run, file, compact, pretty, terse, schema)
509 }
510 Some(Commands::EditIntents {
511 path,
512 scope,
513 file,
514 json,
515 apply,
516 verify,
517 verify_command,
518 max_items,
519 max_bytes,
520 budget,
521 }) => cmd_edit_intents(
522 &path,
523 scope.as_deref(),
524 file,
525 apply,
526 SemanticEditVerifyOptions {
527 enabled: verify,
528 command: verify_command.as_deref(),
529 },
530 OutputFormat {
531 json_output: json || terse || schema || envelope,
532 compact,
533 pretty,
534 terse,
535 ultra_terse,
536 schema,
537 envelope,
538 },
539 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
540 ),
541 Some(Commands::Index {
542 path,
543 rebuild,
544 check,
545 exit_code,
546 prune,
547 quiet,
548 workspace,
549 submodule,
550 json,
551 }) => cmd_index(
552 &path,
553 rebuild,
554 check,
555 exit_code,
556 prune,
557 quiet,
558 workspace,
559 submodule.as_deref(),
560 json || terse || schema || envelope,
561 compact,
562 pretty,
563 terse,
564 absolute,
565 schema,
566 ),
567 Some(Commands::Rewrite { command, run }) => cmd_rewrite(
568 &command,
569 run,
570 OutputFormat {
571 json_output: terse || schema || envelope,
572 compact,
573 pretty,
574 terse,
575 ultra_terse,
576 schema,
577 envelope,
578 },
579 ),
580 Some(Commands::Route { task, id }) => cmd_route(&task, id),
581 Some(Commands::Memory { command }) => {
582 let json = command.json_output();
583 cmd_memory(
584 command,
585 OutputFormat {
586 json_output: json || terse || schema || envelope,
587 compact,
588 pretty,
589 terse,
590 ultra_terse,
591 schema,
592 envelope,
593 },
594 )
595 }
596 Some(Commands::Finding { command }) => match command {
597 cli::FindingCommand::Add {
598 path,
599 kind,
600 title,
601 body,
602 about,
603 confidence,
604 status,
605 relates,
606 scope,
607 json,
608 } => commands::finding::cmd_finding_add(
609 &path,
610 &kind,
611 &title,
612 &body,
613 &about,
614 confidence,
615 &status,
616 relates.as_deref(),
617 scope.as_deref(),
618 json || terse || schema || envelope,
619 pretty,
620 ),
621 cli::FindingCommand::List {
622 path,
623 about,
624 kind,
625 status,
626 include_stale,
627 scope,
628 json,
629 } => commands::finding::cmd_finding_list(
630 &path,
631 about.as_deref(),
632 kind.as_deref(),
633 status.as_deref(),
634 include_stale,
635 scope.as_deref(),
636 json || terse || schema || envelope,
637 pretty,
638 ),
639 cli::FindingCommand::Harvest { path, scope, json } => {
640 commands::finding::cmd_finding_harvest(
641 &path,
642 scope.as_deref(),
643 json || terse || schema || envelope,
644 pretty,
645 )
646 }
647 cli::FindingCommand::Promote { id, path, json } => {
648 commands::finding::cmd_finding_promote(
649 &path,
650 &id,
651 json || terse || schema || envelope,
652 pretty,
653 )
654 }
655 },
656 Some(Commands::Graph {
657 symbol,
658 path,
659 callers,
660 callees,
661 scope,
662 limit,
663 json,
664 no_tagpath,
665 tagpath_strict,
666 }) => cmd_graph(
667 &symbol,
668 &path,
669 callers,
670 callees,
671 scope.as_deref(),
672 limit,
673 json || terse || schema || envelope,
674 compact,
675 pretty,
676 terse,
677 absolute,
678 tabular,
679 schema,
680 TagpathSearchOpts {
681 no_tagpath,
682 strict: tagpath_strict,
683 },
684 ),
685 Some(Commands::Sql {
686 db,
687 query,
688 table,
689 json,
690 }) => cmd_sql(
691 &db,
692 query,
693 table,
694 json || terse || schema || envelope,
695 compact,
696 pretty,
697 terse,
698 schema,
699 ),
700 Some(Commands::Communities {
701 path,
702 scope,
703 min_size,
704 limit,
705 json,
706 no_tagpath,
707 tagpath_strict,
708 }) => cmd_communities(
709 &path,
710 scope.as_deref(),
711 min_size,
712 limit,
713 json || terse || schema || envelope,
714 compact,
715 pretty,
716 terse,
717 tabular,
718 schema,
719 TagpathSearchOpts {
720 no_tagpath,
721 strict: tagpath_strict,
722 },
723 ),
724 Some(Commands::Analyze {
725 path,
726 scope,
727 entry_points,
728 limit,
729 json,
730 }) => cmd_analyze(
731 &path,
732 scope.as_deref(),
733 &entry_points,
734 limit,
735 OutputFormat {
736 json_output: json || terse || schema || envelope,
737 compact,
738 pretty,
739 terse,
740 ultra_terse,
741 schema,
742 envelope,
743 },
744 ),
745 Some(Commands::Path {
746 from,
747 to,
748 path,
749 scope,
750 json,
751 no_tagpath,
752 tagpath_strict,
753 }) => cmd_path(
754 &from,
755 &to,
756 &path,
757 scope.as_deref(),
758 json || terse || schema || envelope,
759 compact,
760 pretty,
761 terse,
762 schema,
763 TagpathSearchOpts {
764 no_tagpath,
765 strict: tagpath_strict,
766 },
767 ),
768 Some(Commands::Explain {
769 symbol,
770 path,
771 scope,
772 limit,
773 json,
774 max_items,
775 max_bytes,
776 budget,
777 no_tagpath,
778 tagpath_strict,
779 }) => cmd_explain_with_budget(
780 &symbol,
781 &path,
782 scope.as_deref(),
783 limit,
784 json || terse || schema || envelope,
785 compact,
786 pretty,
787 terse,
788 ultra_terse,
789 absolute,
790 tabular,
791 schema,
792 envelope,
793 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
794 TagpathSearchOpts {
795 no_tagpath,
796 strict: tagpath_strict,
797 },
798 ),
799 Some(Commands::Traverse {
800 node,
801 to,
802 path,
803 scope,
804 depth,
805 limit,
806 format,
807 convex_snapshot,
808 }) => cmd_traverse(
809 node.as_deref(),
810 to.as_deref(),
811 &path,
812 scope.as_deref(),
813 depth,
814 limit,
815 format,
816 pretty,
817 terse,
818 schema,
819 convex_snapshot.as_deref(),
820 ),
821 Some(Commands::ConvexSync {
822 path,
823 scope,
824 snapshot,
825 chunk_size,
826 remote_snapshot,
827 apply,
828 endpoint,
829 auth_token_env,
830 json,
831 }) => cmd_convex_sync(
832 ConvexSyncOptions {
833 path: &path,
834 scope: scope.as_deref(),
835 snapshot: snapshot.as_deref(),
836 chunk_size,
837 remote_snapshot,
838 apply,
839 endpoint: endpoint.as_deref(),
840 auth_token_env: &auth_token_env,
841 },
842 OutputFormat {
843 json_output: json || terse || schema || envelope,
844 compact,
845 pretty,
846 terse,
847 ultra_terse,
848 schema,
849 envelope,
850 },
851 ),
852 Some(Commands::GraphDb {
853 path,
854 scope,
855 backend,
856 convex_snapshot,
857 json,
858 query,
859 }) => cmd_graph_db(
860 &path,
861 scope.as_deref(),
862 backend,
863 convex_snapshot.as_deref(),
864 query,
865 OutputFormat {
866 json_output: json || terse || schema || envelope,
867 compact,
868 pretty,
869 terse,
870 ultra_terse,
871 schema,
872 envelope,
873 },
874 ),
875 Some(Commands::SourceRead {
876 file,
877 path,
878 style,
879 start,
880 lines,
881 end,
882 scope,
883 json,
884 max_items,
885 max_bytes,
886 budget,
887 }) => cmd_source_read(
888 &file,
889 &path,
890 style,
891 start,
892 lines,
893 end,
894 scope.as_deref(),
895 OutputFormat {
896 json_output: json || terse || schema || envelope,
897 compact,
898 pretty,
899 terse,
900 ultra_terse,
901 schema,
902 envelope,
903 },
904 absolute,
905 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
906 ),
907 Some(Commands::MarkdownAst {
908 file,
909 path,
910 node,
911 json,
912 max_items,
913 max_bytes,
914 budget,
915 }) => cmd_markdown_ast(
916 &file,
917 &path,
918 node.as_deref(),
919 OutputFormat {
920 json_output: json || terse || schema || envelope,
921 compact,
922 pretty,
923 terse,
924 ultra_terse,
925 schema,
926 envelope,
927 },
928 absolute,
929 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
930 ),
931 Some(Commands::SymbolRead {
932 symbol,
933 file,
934 path,
935 scope,
936 json,
937 max_items,
938 max_bytes,
939 budget,
940 }) => cmd_symbol_read(
941 &symbol,
942 file.as_deref(),
943 &path,
944 scope.as_deref(),
945 OutputFormat {
946 json_output: json || terse || schema || envelope,
947 compact,
948 pretty,
949 terse,
950 ultra_terse,
951 schema,
952 envelope,
953 },
954 absolute,
955 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
956 ),
957 Some(Commands::Audit {
958 skills_dir,
959 manifest,
960 usage,
961 cleanup,
962 report,
963 json,
964 }) => cmd_audit(
965 &skills_dir,
966 manifest,
967 usage,
968 cleanup,
969 report,
970 json || terse || schema || envelope,
971 compact,
972 pretty,
973 terse,
974 schema,
975 ),
976 Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
977 &path,
978 scope.as_deref(),
979 json || terse || schema || envelope,
980 pretty,
981 terse,
982 schema,
983 ),
984 Some(Commands::Init {
985 path,
986 codex,
987 opencode,
988 workspace,
989 }) => cmd_init(&path, codex, opencode, workspace),
990 Some(Commands::Lint {
991 file,
992 index,
993 entities_from,
994 json,
995 }) => cmd_lint(
996 &file,
997 index,
998 entities_from,
999 json || terse || schema || envelope,
1000 compact,
1001 pretty,
1002 terse,
1003 schema,
1004 ),
1005 Some(Commands::Summarize {
1006 symbol,
1007 file,
1008 extract,
1009 diff,
1010 stats,
1011 path,
1012 json,
1013 }) => cmd_summarize(
1014 symbol,
1015 file,
1016 extract,
1017 diff,
1018 stats,
1019 &path,
1020 json || terse || schema || envelope,
1021 compact,
1022 pretty,
1023 terse,
1024 schema,
1025 ),
1026 Some(Commands::Semantic {
1027 query,
1028 path,
1029 scope,
1030 limit,
1031 kind,
1032 json,
1033 }) => cmd_semantic_related(
1034 &query,
1035 &path,
1036 scope.as_deref(),
1037 limit,
1038 kind,
1039 json || terse || schema || envelope,
1040 compact,
1041 pretty,
1042 terse,
1043 schema,
1044 ),
1045 Some(Commands::DiffDigest {
1046 path,
1047 cached,
1048 revision,
1049 max_parsed_files,
1050 json,
1051 }) => cmd_diff_digest(
1052 &path,
1053 cached,
1054 revision.as_deref(),
1055 max_parsed_files,
1056 OutputFormat {
1057 json_output: json || terse || schema || envelope,
1058 compact,
1059 pretty,
1060 terse,
1061 ultra_terse,
1062 schema,
1063 envelope,
1064 },
1065 ),
1066 Some(Commands::Impact {
1067 path,
1068 cached,
1069 revision,
1070 scope,
1071 limit,
1072 json,
1073 }) => cmd_impact(
1074 &path,
1075 cached,
1076 revision.as_deref(),
1077 scope.as_deref(),
1078 limit,
1079 OutputFormat {
1080 json_output: json || terse || schema || envelope,
1081 compact,
1082 pretty,
1083 terse,
1084 ultra_terse,
1085 schema,
1086 envelope,
1087 },
1088 ),
1089 Some(Commands::TestDigest {
1090 path,
1091 input,
1092 runner,
1093 json,
1094 }) => cmd_test_digest(
1095 &path,
1096 input.as_deref(),
1097 runner.as_deref(),
1098 OutputFormat {
1099 json_output: json || terse || schema || envelope,
1100 compact,
1101 pretty,
1102 terse,
1103 ultra_terse,
1104 schema,
1105 envelope,
1106 },
1107 ),
1108 Some(Commands::LogDigest { path, input, json }) => cmd_log_digest(
1109 &path,
1110 input.as_deref(),
1111 OutputFormat {
1112 json_output: json || terse || schema || envelope,
1113 compact,
1114 pretty,
1115 terse,
1116 ultra_terse,
1117 schema,
1118 envelope,
1119 },
1120 ),
1121 Some(Commands::ContextPack {
1122 path,
1123 test_input,
1124 runner,
1125 log_input,
1126 json,
1127 max_items,
1128 max_bytes,
1129 budget,
1130 convex_snapshot,
1131 }) => cmd_context_pack(
1132 &path,
1133 test_input.as_deref(),
1134 runner.as_deref(),
1135 log_input.as_deref(),
1136 OutputFormat {
1137 json_output: json || terse || schema || envelope,
1138 compact,
1139 pretty,
1140 terse,
1141 ultra_terse,
1142 schema,
1143 envelope,
1144 },
1145 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1146 convex_snapshot.as_deref(),
1147 ),
1148 Some(Commands::ConflictMatrix {
1149 targets,
1150 path,
1151 scope,
1152 depth,
1153 limit,
1154 impact_limit,
1155 json,
1156 }) => cmd_conflict_matrix(
1157 &path,
1158 scope.as_deref(),
1159 &targets,
1160 depth,
1161 limit,
1162 impact_limit,
1163 OutputFormat {
1164 json_output: json || terse || schema || envelope,
1165 compact,
1166 pretty,
1167 terse,
1168 ultra_terse,
1169 schema,
1170 envelope,
1171 },
1172 ),
1173 Some(Commands::DispatchTrace {
1174 targets,
1175 path,
1176 scope,
1177 depth,
1178 limit,
1179 impact_limit,
1180 format,
1181 json,
1182 }) => cmd_dispatch_trace(
1183 DispatchTraceOptions {
1184 path: &path,
1185 scope: scope.as_deref(),
1186 raw_targets: &targets,
1187 depth,
1188 limit,
1189 impact_limit,
1190 trace_format: if json {
1191 DispatchTraceFormat::Json
1192 } else {
1193 format
1194 },
1195 },
1196 OutputFormat {
1197 json_output: json || terse || schema || envelope,
1198 compact,
1199 pretty,
1200 terse,
1201 ultra_terse,
1202 schema,
1203 envelope,
1204 },
1205 ),
1206 Some(Commands::DependencyDag {
1207 targets,
1208 path,
1209 scope,
1210 depth,
1211 limit,
1212 json,
1213 }) => cmd_dependency_dag(
1214 &path,
1215 scope.as_deref(),
1216 &targets,
1217 depth,
1218 limit,
1219 OutputFormat {
1220 json_output: json || terse || schema || envelope,
1221 compact,
1222 pretty,
1223 terse,
1224 ultra_terse,
1225 schema,
1226 envelope,
1227 },
1228 ),
1229 Some(Commands::TokenSavings {
1230 fixture,
1231 fail_under,
1232 json,
1233 }) => token_savings::cmd_token_savings(
1234 &fixture,
1235 fail_under,
1236 OutputFormat {
1237 json_output: json || terse || schema || envelope,
1238 compact,
1239 pretty,
1240 terse,
1241 ultra_terse,
1242 schema,
1243 envelope,
1244 },
1245 ),
1246 Some(Commands::MetricDigest {
1247 input,
1248 baseline,
1249 metrics,
1250 lower_is_better,
1251 higher_is_better,
1252 history,
1253 top,
1254 json,
1255 }) => cmd_metric_digest(
1256 MetricDigestOptions {
1257 input_path: input.as_deref(),
1258 baseline_path: baseline.as_deref(),
1259 metrics: &metrics,
1260 lower_is_better: &lower_is_better,
1261 higher_is_better: &higher_is_better,
1262 history,
1263 top,
1264 },
1265 OutputFormat {
1266 json_output: json || terse || schema || envelope,
1267 compact,
1268 pretty,
1269 terse,
1270 ultra_terse,
1271 schema,
1272 envelope,
1273 },
1274 ),
1275 Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1276 &fixture,
1277 OutputFormat {
1278 json_output: json || terse || schema || envelope,
1279 compact,
1280 pretty,
1281 terse,
1282 ultra_terse,
1283 schema,
1284 envelope,
1285 },
1286 ),
1287 Some(Commands::TokenGate { command }) => {
1288 cmd_token_gate(command, OutputFormat {
1289 json_output: true,
1290 compact,
1291 pretty,
1292 terse,
1293 ultra_terse,
1294 schema,
1295 envelope,
1296 })?;
1297 Ok(())
1298 },
1299 Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1300 &topic,
1301 OutputFormat {
1302 json_output: json || terse || schema || envelope,
1303 compact,
1304 pretty,
1305 terse,
1306 ultra_terse,
1307 schema,
1308 envelope,
1309 },
1310 ),
1311 Some(Commands::SessionDigest {
1312 path,
1313 input,
1314 source,
1315 json,
1316 }) => cmd_session_digest(
1317 &path,
1318 input.as_deref(),
1319 source.as_deref(),
1320 OutputFormat {
1321 json_output: json || terse || schema || envelope,
1322 compact,
1323 pretty,
1324 terse,
1325 ultra_terse,
1326 schema,
1327 envelope,
1328 },
1329 ),
1330 Some(Commands::SessionCost {
1331 input,
1332 source,
1333 json,
1334 }) => cmd_session_cost(
1335 input.as_deref(),
1336 source.as_deref(),
1337 OutputFormat {
1338 json_output: json || terse || schema || envelope,
1339 compact,
1340 pretty,
1341 terse,
1342 ultra_terse,
1343 schema,
1344 envelope,
1345 },
1346 ),
1347 Some(Commands::SessionReview {
1348 path,
1349 next_context,
1350 json,
1351 max_items,
1352 max_bytes,
1353 budget,
1354 }) => cmd_session_review_with_budget(
1355 &path,
1356 next_context,
1357 OutputFormat {
1358 json_output: json || terse || schema || envelope,
1359 compact,
1360 pretty,
1361 terse,
1362 ultra_terse,
1363 schema,
1364 envelope,
1365 },
1366 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1367 ),
1368 Some(Commands::Status {
1369 path,
1370 fix,
1371 no_fix,
1372 json,
1373 }) => cmd_status(
1374 &path,
1375 StatusCommandOptions {
1376 fix,
1377 no_fix,
1378 json_output: json || terse || schema || envelope,
1379 compact,
1380 pretty,
1381 terse,
1382 schema,
1383 },
1384 ),
1385 Some(Commands::Locks { path, scope, json }) => cmd_locks(
1386 &path,
1387 scope.as_deref(),
1388 json || terse || schema || envelope,
1389 compact,
1390 pretty,
1391 terse,
1392 schema,
1393 ),
1394 None => {
1395 println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1396 println!("Run `tsift --help` for usage.");
1397 Ok(())
1398 }
1399 }
1400}
1401
1402pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1405 let lower = task.to_lowercase();
1406 for signal in &[
1408 "architect",
1409 "architecture",
1410 "design",
1411 "plan",
1412 "strateg",
1413 "analy",
1414 "review",
1415 "evaluate",
1416 "assess",
1417 ] {
1418 if lower.contains(signal) {
1419 return ("opus", "claude-opus-4-6");
1420 }
1421 }
1422 for signal in &[
1424 "edit",
1425 "write",
1426 "fix",
1427 "change",
1428 "update",
1429 "create",
1430 "add ",
1431 "remove",
1432 "delete",
1433 "modify",
1434 "refactor",
1435 "implement",
1436 "build",
1437 ] {
1438 if lower.contains(signal) {
1439 return ("sonnet", "claude-sonnet-4-6");
1440 }
1441 }
1442 ("haiku", "claude-haiku-4-5-20251001")
1444}
1445
1446#[cfg(test)]
1447fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
1448 to_json_schema(val, pretty, terse, false, false)
1449}
1450
1451pub(crate) fn inject_tagpath_stale_into_json(
1458 value: &mut serde_json::Value,
1459 stale: bool,
1460 reason: Option<&str>,
1461) {
1462 if !stale {
1463 return;
1464 }
1465 if let Some(obj) = value.as_object_mut() {
1466 obj.insert(
1467 "tagpath_index_stale".to_string(),
1468 serde_json::Value::Bool(true),
1469 );
1470 if let Some(reason) = reason {
1471 obj.insert(
1472 "tagpath_stale_reason".to_string(),
1473 serde_json::Value::String(reason.to_string()),
1474 );
1475 }
1476 }
1477}
1478
1479pub(crate) fn to_json_schema<T: serde::Serialize>(
1480 val: &T,
1481 pretty: bool,
1482 terse: bool,
1483 ultra_terse: bool,
1484 schema: bool,
1485) -> anyhow::Result<String> {
1486 if terse || schema {
1487 let value = serde_json::to_value(val)?;
1488 let mut transformed = if terse { terse_transform(value) } else { value };
1489 if ultra_terse {
1490 transformed = ultra_terse_transform(transformed);
1491 transformed = edge_index_transform(transformed);
1492 }
1493 if schema {
1494 transformed = schema_transform(transformed);
1495 }
1496 if terse {
1497 let terse_schema = terse_schema_for(&transformed);
1498 let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
1499 if pretty {
1500 Ok(serde_json::to_string_pretty(&wrapped)?)
1501 } else {
1502 Ok(serde_json::to_string(&wrapped)?)
1503 }
1504 } else if pretty {
1505 Ok(serde_json::to_string_pretty(&transformed)?)
1506 } else {
1507 Ok(serde_json::to_string(&transformed)?)
1508 }
1509 } else if pretty {
1510 Ok(serde_json::to_string_pretty(val)?)
1511 } else {
1512 Ok(serde_json::to_string(val)?)
1513 }
1514}
1515
1516pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
1517 ToolEnvelopeMetric {
1518 label: label.to_string(),
1519 value: value.to_string(),
1520 }
1521}
1522
1523pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
1524 let mut seen = HashSet::new();
1525 let mut deduped = Vec::new();
1526 for value in values {
1527 if seen.insert(value.clone()) {
1528 deduped.push(value);
1529 }
1530 }
1531 deduped
1532}
1533
1534pub(crate) fn print_json_or_envelope<T: Serialize>(
1535 report: &T,
1536 format: &OutputFormat,
1537 tool: &str,
1538 view: &str,
1539 summary: ToolEnvelopeSummary,
1540 truncated: bool,
1541 follow_up: Vec<String>,
1542) -> Result<()> {
1543 if format.envelope {
1544 let schema = format.schema || tool == "source-read";
1545 let envelope = ToolEnvelope {
1546 tool,
1547 view,
1548 summary,
1549 truncated,
1550 follow_up: dedupe_preserve_order(follow_up),
1551 report,
1552 };
1553 println!(
1554 "{}",
1555 to_json_schema(
1556 &envelope,
1557 format.pretty,
1558 format.terse,
1559 format.ultra_terse,
1560 schema
1561 )?
1562 );
1563 } else {
1564 println!(
1565 "{}",
1566 to_json_schema(
1567 report,
1568 format.pretty,
1569 format.terse,
1570 format.ultra_terse,
1571 format.schema
1572 )?
1573 );
1574 }
1575 Ok(())
1576}
1577
1578pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
1579 bytes.div_ceil(4)
1580}
1581
1582fn cmd_token_gate(
1583 command: cli::TokenGateCommand,
1584 format: OutputFormat,
1585) -> Result<()> {
1586 match command {
1587 cli::TokenGateCommand::Sample {
1588 surface,
1589 path,
1590 scope,
1591 target,
1592 depth,
1593 sample_index,
1594 json: _,
1595 } => cmd_token_gate_sample(&surface, &path, scope.as_deref(), target.as_deref(), depth, sample_index),
1596 cli::TokenGateCommand::Evaluate {
1597 history,
1598 allowed_regression_percent,
1599 json: _,
1600 } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
1601 }
1602}
1603
1604fn cmd_token_gate_sample(
1605 surface: &str,
1606 path: &Path,
1607 scope: Option<&str>,
1608 target: Option<&str>,
1609 depth: usize,
1610 sample_index: usize,
1611) -> Result<()> {
1612 if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
1613 bail!(
1614 "unknown surface `{}`; expected one of: {}",
1615 surface,
1616 token_gate::TOKEN_GATE_SURFACES.join(", ")
1617 );
1618 }
1619
1620 let path_str = path.to_string_lossy().to_string();
1621 let tsift_bin = std::env::current_exe()?;
1622
1623 let args: Vec<String> = match surface {
1624 "context_pack" => vec![
1625 "context-pack".to_string(),
1626 "--json".to_string(),
1627 path_str,
1628 ],
1629 "session_review_next_context" => vec![
1630 "session-review".to_string(),
1631 "--json".to_string(),
1632 "--next-context".to_string(),
1633 path_str,
1634 ],
1635 "graph_db_evidence" => {
1636 let tgt = target.unwrap_or("default").to_string();
1637 vec![
1638 "graph-db".to_string(),
1639 "--json".to_string(),
1640 "--path".to_string(),
1641 path_str,
1642 "evidence".to_string(),
1643 tgt,
1644 "--depth".to_string(),
1645 depth.to_string(),
1646 ]
1647 }
1648 "conflict_matrix" => {
1649 let tgt = target.unwrap_or("default").to_string();
1650 let mut a = vec![
1651 "conflict-matrix".to_string(),
1652 "--json".to_string(),
1653 "--path".to_string(),
1654 path_str,
1655 "--depth".to_string(),
1656 depth.to_string(),
1657 ];
1658 if let Some(s) = scope {
1659 a.push("--scope".to_string());
1660 a.push(s.to_string());
1661 }
1662 a.push(tgt);
1663 a
1664 }
1665 "dispatch_trace" => {
1666 let tgt = target.unwrap_or("default").to_string();
1667 vec![
1668 "dispatch-trace".to_string(),
1669 "--json".to_string(),
1670 "--path".to_string(),
1671 path_str,
1672 tgt,
1673 ]
1674 }
1675 _ => bail!("unhandled surface: {}", surface),
1676 };
1677
1678 let start = Instant::now();
1679 let child = Command::new(&tsift_bin)
1680 .args(&args)
1681 .stdout(Stdio::piped())
1682 .stderr(Stdio::piped())
1683 .env("TSIFT_QUIET", "1")
1684 .spawn();
1685 let output = match child {
1686 Ok(c) => c.wait_with_output()?,
1687 Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
1688 };
1689 let runtime_micros = start.elapsed().as_micros() as f64;
1690
1691 let stdout = String::from_utf8_lossy(&output.stdout);
1692 let envelope_bytes = stdout.trim().len() as f64;
1693 let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
1694
1695 let cache_hit_rate_percent = 0.0;
1696 let raw_read_avoidance = 0.0;
1697 let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
1698
1699 let timestamp = iso_timestamp_now();
1700 let id = format!(
1701 "{surface}-baseline-{}-sample-{sample_index}",
1702 ×tamp[..10]
1703 );
1704 let label = format!(
1705 "token-gate baseline {surface} sample {sample_index} for {}",
1706 path.display()
1707 );
1708
1709 let mut metrics = BTreeMap::new();
1710 metrics.insert("prompt_tokens".to_string(), prompt_tokens);
1711 metrics.insert("envelope_bytes".to_string(), envelope_bytes);
1712 metrics.insert("runtime_micros".to_string(), runtime_micros);
1713 metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
1714 metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
1715 metrics.insert("useful_hit_density".to_string(), useful_hit_density);
1716
1717 let sample = token_gate::TokenGateSample {
1718 label,
1719 id,
1720 timestamp: Some(timestamp),
1721 surface: surface.to_string(),
1722 metrics,
1723 };
1724
1725 println!("{}", serde_json::to_string_pretty(&sample)?);
1726 Ok(())
1727}
1728
1729fn cmd_token_gate_evaluate(
1730 history_path: Option<&Path>,
1731 allowed_regression_percent: f64,
1732 format: &OutputFormat,
1733) -> Result<()> {
1734 let history_path = history_path
1735 .map(PathBuf::from)
1736 .unwrap_or_else(|| {
1737 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1738 p.push("../../fixtures/token-gate-history.json");
1739 p
1740 });
1741
1742 let raw = std::fs::read_to_string(&history_path)
1743 .with_context(|| format!("failed to read token gate history: {}", history_path.display()))?;
1744 let samples = token_gate::parse_token_history(&raw)?;
1745 let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
1746
1747 if format.json_output {
1748 println!("{}", to_json_schema(&report, format.pretty, format.terse, false, format.schema)?);
1749 } else {
1750 println!("Token Gate Report");
1751 println!(" min_samples: {}", report.min_samples);
1752 println!(" allowed_regression: {:.1}%", report.allowed_regression_percent);
1753 println!(" decision: {:?}", report.decision);
1754 for eval in &report.surface_evaluations {
1755 println!(
1756 " {} ({} samples): {:?}",
1757 eval.display_name, eval.sample_count, eval.verdict
1758 );
1759 for me in &eval.metric_evaluations {
1760 println!(
1761 " {} ({:?}): {}",
1762 me.metric, me.direction, me.diagnostic
1763 );
1764 }
1765 }
1766 for d in &report.diagnostics {
1767 println!(" ! {}", d);
1768 }
1769 }
1770 Ok(())
1771}
1772
1773fn iso_timestamp_now() -> String {
1774 let dur = SystemTime::now()
1775 .duration_since(UNIX_EPOCH)
1776 .unwrap_or_default();
1777 let total_secs = dur.as_secs();
1778 let days_since_epoch = total_secs / 86400;
1779 let (year, month, day) = days_to_ymd(days_since_epoch);
1780 let time_of_day = total_secs % 86400;
1781 let hour = (time_of_day / 3600) as u8;
1782 let minute = ((time_of_day % 3600) / 60) as u8;
1783 let second = (time_of_day % 60) as u8;
1784 format!(
1785 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1786 year, month, day, hour, minute, second
1787 )
1788}
1789
1790fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
1791 let mut year = 1970u64;
1792 loop {
1793 let days_in_year = if is_leap(year) { 366 } else { 365 };
1794 if days < days_in_year {
1795 break;
1796 }
1797 days -= days_in_year;
1798 year += 1;
1799 }
1800 let leap = is_leap(year);
1801 let month_days: [u8; 12] = if leap {
1802 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1803 } else {
1804 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1805 };
1806 let mut month: u8 = 1;
1807 for &md in &month_days {
1808 if days < md as u64 {
1809 break;
1810 }
1811 days -= md as u64;
1812 month += 1;
1813 }
1814 let day = days as u8 + 1;
1815 (year, month, day)
1816}
1817
1818fn is_leap(year: u64) -> bool {
1819 year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
1820}
1821
1822fn persist_transcript_artifact(
1823 root: &Path,
1824 prefix: &str,
1825 suffix: &str,
1826 key: &str,
1827 body: &str,
1828 expand: String,
1829) -> Result<TranscriptArtifactRef> {
1830 let handle = stable_handle(prefix, key);
1831 let artifacts_dir = root.join(".tsift/artifacts");
1832 fs::create_dir_all(&artifacts_dir).with_context(|| {
1833 format!(
1834 "creating transcript artifacts dir: {}",
1835 artifacts_dir.display()
1836 )
1837 })?;
1838 let file_name = format!("{handle}.{suffix}");
1839 let artifact_path = artifacts_dir.join(file_name);
1840 fs::write(&artifact_path, body)
1841 .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
1842 let rel_path = relativize_pathbuf(&artifact_path, root);
1843 Ok(TranscriptArtifactRef {
1844 handle,
1845 path: rel_path.display().to_string(),
1846 bytes: body.len(),
1847 lines: body.lines().count(),
1848 expand,
1849 })
1850}
1851
1852fn terse_key(key: &str) -> &str {
1853 match key {
1854 "name" => "n",
1855 "kind" => "k",
1856 "file" => "f",
1857 "line" => "l",
1858 "path" => "p",
1859 "from" => "fr",
1860 "type" => "ty",
1861 "text" => "tx",
1862 "new" => "nw",
1863 "run" => "r",
1864 "use" => "u",
1865 "score" => "sc",
1866 "language" => "la",
1867 "status" => "st",
1868 "state" => "stt",
1869 "error" => "err",
1870 "errors" => "ers",
1871 "hops" => "hp",
1872 "tags" => "tg",
1873 "model" => "ml",
1874 "skill" => "sk",
1875 "count" => "ct",
1876 "total" => "tot",
1877 "column" => "col",
1878 "description" => "dsc",
1879 "end_line" => "el",
1880 "signature" => "sig",
1881 "parent_module" => "pm",
1882 "visibility" => "vis",
1883 "match_type" => "mt",
1884 "caller_file" => "cf",
1885 "caller_name" => "cn",
1886 "caller_line" => "cl",
1887 "callee_name" => "en",
1888 "call_site_line" => "csl",
1889 "members" => "m",
1890 "refs" => "refs",
1891 "role" => "rl",
1892 "peer" => "pr",
1893 "modularity" => "q",
1894 "modularity_contribution" => "mc",
1895 "iterations" => "it",
1896 "node_count" => "nc",
1897 "edge_count" => "ec",
1898 "community_count" => "cc",
1899 "communities" => "cms",
1900 "community" => "cm",
1901 "community_diagnostics" => "cd",
1902 "cache_hit" => "cah",
1903 "tagpath_state" => "tps",
1904 "tagpath_stale_reason" => "tsr",
1905 "annotated_community_count" => "acc",
1906 "annotated_member_count" => "amc",
1907 "ambiguous_member_count" => "ambc",
1908 "ambiguous_members" => "amb",
1909 "candidate_count" => "cand",
1910 "tagpath_candidate_count" => "tcand",
1911 "evidence" => "ev",
1912 "chosen_file" => "chf",
1913 "symbol" => "s",
1914 "symbols" => "sy",
1915 "definitions" => "df",
1916 "callers" => "crs",
1917 "callees" => "ces",
1918 "total_tracked" => "tt",
1919 "modified" => "md",
1920 "deleted" => "dl",
1921 "unchanged" => "uc",
1922 "changes" => "ch",
1923 "prune_stats" => "ps",
1924 "hits" => "h",
1925 "rank" => "rk",
1926 "snippet" => "sn",
1927 "confidence" => "co",
1928 "index" => "ix",
1929 "summaries" => "sms",
1930 "recommendations" => "rec",
1931 "total_files" => "tf",
1932 "stale_files" => "sf",
1933 "last_indexed_secs_ago" => "age",
1934 "cached_files" => "caf",
1935 "total_indexed_files" => "tif",
1936 "coverage_pct" => "cov",
1937 "symbol_name" => "syn",
1938 "file_path" => "fp",
1939 "content_hash" => "hsh",
1940 "summary" => "sum",
1941 "tool" => "tl",
1942 "view" => "vw",
1943 "truncated" => "tr",
1944 "follow_up" => "fu",
1945 "report" => "rp",
1946 "metrics" => "ms",
1947 "label" => "lb",
1948 "value" => "v",
1949 "command" => "cmd",
1950 "exit_code" => "xc",
1951 "success" => "ok",
1952 "artifact" => "art",
1953 "digest" => "dg",
1954 "bytes" => "bt",
1955 "lines" => "lns",
1956 "expand" => "xp",
1957 "entities" => "ent",
1958 "relationships" => "rel",
1959 "concept_labels" => "cls",
1960 "extracted_at" => "at",
1961 "tokens_input" => "ti",
1962 "tokens_output" => "tout",
1963 "total_summaries" => "ts",
1964 "stale_count" => "stc",
1965 "total_tokens_input" => "tti",
1966 "total_tokens_output" => "tto",
1967 "estimated_tokens_saved" => "ets",
1968 "files_processed" => "fps",
1969 "symbols_extracted" => "se",
1970 "skills_dir" => "sd",
1971 "healthy" => "ok",
1972 "broken" => "brk",
1973 "skills" => "sks",
1974 "manifest_diffs" => "mdf",
1975 "similar_pairs" => "sim",
1976 "usage" => "usg",
1977 "cleanup" => "cln",
1978 "has_skill_md" => "hsm",
1979 "is_symlink" => "isl",
1980 "issues" => "iss",
1981 "invocation_count" => "inv",
1982 "reasons" => "rsn",
1983 "token_estimate" => "te",
1984 "skill_a" => "sa",
1985 "skill_b" => "sb",
1986 "desc_a" => "da",
1987 "desc_b" => "db",
1988 "annotations" => "ann",
1989 "entity" => "ety",
1990 "suggestion" => "sug",
1991 "columns" => "cols",
1992 "row_count" => "rc",
1993 "notnull" => "nn",
1994 "default_value" => "dv",
1995 "replace_all" => "ra",
1996 other => other,
1997 }
1998}
1999
2000fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2001 match val {
2002 serde_json::Value::Object(map) => {
2003 let mut new_map = serde_json::Map::new();
2004 for (k, v) in map {
2005 new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2006 }
2007 serde_json::Value::Object(new_map)
2008 }
2009 serde_json::Value::Array(arr) => {
2010 serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2011 }
2012 other => other,
2013 }
2014}
2015
2016fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2017 match val {
2018 serde_json::Value::Object(mut map) => {
2019 let is_graph_node =
2020 map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2021 let is_graph_edge =
2022 map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2023 if is_graph_node || is_graph_edge {
2024 map.remove("properties");
2025 map.remove("provenance");
2026 map.remove("freshness");
2027 }
2028 if is_graph_edge
2029 && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2030 *s = abbreviate_edge_kind(s).to_string();
2031 }
2032 let is_coverage = map.contains_key("mode")
2033 && (map.contains_key("total_sector_count")
2034 || map.contains_key("dirty_sector_count"));
2035 if is_coverage {
2036 map.remove("active_rebuild");
2037 map.remove("completed_dirty_sector_count");
2038 map.remove("mounted_sector_count");
2039 map.remove("rebuilding_sector_count");
2040 map.remove("resumed_sector_count");
2041 map.remove("reused_sector_count");
2042 }
2043 if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2044 *s = truncate_for_ultra_terse(s, 80);
2045 }
2046 if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2047 *s = truncate_for_ultra_terse(s, 80);
2048 }
2049 let new_map: serde_json::Map<String, serde_json::Value> = map
2050 .into_iter()
2051 .map(|(k, v)| (k, ultra_terse_transform(v)))
2052 .collect();
2053 serde_json::Value::Object(new_map)
2054 }
2055 serde_json::Value::Array(arr) => {
2056 serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2057 }
2058 other => other,
2059 }
2060}
2061
2062fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2063 match val {
2064 serde_json::Value::Object(mut map) => {
2065 let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2066 nodes.as_array().map(|arr| {
2067 arr.iter()
2068 .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2069 .collect()
2070 })
2071 });
2072 if let Some(ref ids) = node_ids {
2073 let id_map: std::collections::HashMap<&str, usize> = ids
2074 .iter()
2075 .enumerate()
2076 .map(|(i, id)| (id.as_str(), i))
2077 .collect();
2078 if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2079 for edge in edges.iter_mut() {
2080 if let serde_json::Value::Object(edge_map) = edge {
2081 if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id") {
2082 if let Some(&idx) = id_map.get(fid.as_str()) {
2083 edge_map.insert("from".to_string(), serde_json::Value::Number(idx.into()));
2084 } else {
2085 edge_map.insert("from_id".to_string(), serde_json::Value::String(fid));
2086 }
2087 }
2088 if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2089 if let Some(&idx) = id_map.get(tid.as_str()) {
2090 edge_map.insert("to".to_string(), serde_json::Value::Number(idx.into()));
2091 } else {
2092 edge_map.insert("to_id".to_string(), serde_json::Value::String(tid));
2093 }
2094 }
2095 }
2096 }
2097 }
2098 }
2099 let new_map: serde_json::Map<String, serde_json::Value> = map
2100 .into_iter()
2101 .map(|(k, v)| (k, edge_index_transform(v)))
2102 .collect();
2103 serde_json::Value::Object(new_map)
2104 }
2105 serde_json::Value::Array(arr) => {
2106 serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2107 }
2108 other => other,
2109 }
2110}
2111
2112fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2113 if s.len() <= max_len {
2114 s.to_string()
2115 } else {
2116 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2117 format!("{truncated}...")
2118 }
2119}
2120
2121fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2122 let mut keys = HashSet::new();
2123 collect_terse_keys(val, &mut keys);
2124 let mut schema = serde_json::Map::new();
2125 for (long, short) in TERSE_PAIRS {
2126 if keys.contains(*short) {
2127 schema.insert(
2128 short.to_string(),
2129 serde_json::Value::String(long.to_string()),
2130 );
2131 }
2132 }
2133 serde_json::Value::Object(schema)
2134}
2135
2136fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2137 match val {
2138 serde_json::Value::Object(map) => {
2139 for (k, v) in map {
2140 keys.insert(k.clone());
2141 collect_terse_keys(v, keys);
2142 }
2143 }
2144 serde_json::Value::Array(arr) => {
2145 for v in arr {
2146 collect_terse_keys(v, keys);
2147 }
2148 }
2149 _ => {}
2150 }
2151}
2152
2153fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2154 match val {
2155 serde_json::Value::Array(arr) if arr.len() >= 2 => {
2156 if let Some(cols) = homogeneous_keys(&arr) {
2157 let rows: Vec<serde_json::Value> = arr
2158 .into_iter()
2159 .map(|item| {
2160 if let serde_json::Value::Object(map) = item {
2161 let vals: Vec<serde_json::Value> = cols
2162 .iter()
2163 .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2164 .collect();
2165 serde_json::Value::Array(vals)
2166 } else {
2167 item
2168 }
2169 })
2170 .collect();
2171 let col_vals: Vec<serde_json::Value> =
2172 cols.into_iter().map(serde_json::Value::String).collect();
2173 serde_json::json!({"_c": col_vals, "_r": rows})
2174 } else {
2175 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2176 }
2177 }
2178 serde_json::Value::Array(arr) => {
2179 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2180 }
2181 serde_json::Value::Object(map) => {
2182 let new_map: serde_json::Map<String, serde_json::Value> = map
2183 .into_iter()
2184 .map(|(k, v)| (k, schema_transform(v)))
2185 .collect();
2186 serde_json::Value::Object(new_map)
2187 }
2188 other => other,
2189 }
2190}
2191
2192fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2193 let first = arr.first()?.as_object()?;
2194 let keys: Vec<String> = first.keys().cloned().collect();
2195 for item in &arr[1..] {
2196 let obj = item.as_object()?;
2197 if obj.len() != keys.len() {
2198 return None;
2199 }
2200 for k in &keys {
2201 if !obj.contains_key(k) {
2202 return None;
2203 }
2204 }
2205 }
2206 Some(keys)
2207}
2208
2209const TERSE_PAIRS: &[(&str, &str)] = &[
2210 ("name", "n"),
2211 ("kind", "k"),
2212 ("file", "f"),
2213 ("line", "l"),
2214 ("path", "p"),
2215 ("from", "fr"),
2216 ("type", "ty"),
2217 ("text", "tx"),
2218 ("new", "nw"),
2219 ("run", "r"),
2220 ("use", "u"),
2221 ("score", "sc"),
2222 ("language", "la"),
2223 ("status", "st"),
2224 ("state", "stt"),
2225 ("error", "err"),
2226 ("errors", "ers"),
2227 ("hops", "hp"),
2228 ("tags", "tg"),
2229 ("model", "ml"),
2230 ("skill", "sk"),
2231 ("count", "ct"),
2232 ("total", "tot"),
2233 ("column", "col"),
2234 ("description", "dsc"),
2235 ("end_line", "el"),
2236 ("signature", "sig"),
2237 ("parent_module", "pm"),
2238 ("visibility", "vis"),
2239 ("match_type", "mt"),
2240 ("caller_file", "cf"),
2241 ("caller_name", "cn"),
2242 ("caller_line", "cl"),
2243 ("callee_name", "en"),
2244 ("call_site_line", "csl"),
2245 ("members", "m"),
2246 ("refs", "refs"),
2247 ("role", "rl"),
2248 ("peer", "pr"),
2249 ("modularity", "q"),
2250 ("modularity_contribution", "mc"),
2251 ("iterations", "it"),
2252 ("node_count", "nc"),
2253 ("edge_count", "ec"),
2254 ("community_count", "cc"),
2255 ("communities", "cms"),
2256 ("community", "cm"),
2257 ("community_diagnostics", "cd"),
2258 ("cache_hit", "cah"),
2259 ("tagpath_state", "tps"),
2260 ("tagpath_stale_reason", "tsr"),
2261 ("annotated_community_count", "acc"),
2262 ("annotated_member_count", "amc"),
2263 ("ambiguous_member_count", "ambc"),
2264 ("ambiguous_members", "amb"),
2265 ("candidate_count", "cand"),
2266 ("tagpath_candidate_count", "tcand"),
2267 ("evidence", "ev"),
2268 ("chosen_file", "chf"),
2269 ("symbol", "s"),
2270 ("symbols", "sy"),
2271 ("definitions", "df"),
2272 ("callers", "crs"),
2273 ("callees", "ces"),
2274 ("total_tracked", "tt"),
2275 ("modified", "md"),
2276 ("deleted", "dl"),
2277 ("unchanged", "uc"),
2278 ("changes", "ch"),
2279 ("prune_stats", "ps"),
2280 ("hits", "h"),
2281 ("rank", "rk"),
2282 ("snippet", "sn"),
2283 ("confidence", "co"),
2284 ("index", "ix"),
2285 ("summaries", "sms"),
2286 ("recommendations", "rec"),
2287 ("total_files", "tf"),
2288 ("stale_files", "sf"),
2289 ("last_indexed_secs_ago", "age"),
2290 ("cached_files", "caf"),
2291 ("total_indexed_files", "tif"),
2292 ("coverage_pct", "cov"),
2293 ("symbol_name", "syn"),
2294 ("file_path", "fp"),
2295 ("content_hash", "hsh"),
2296 ("summary", "sum"),
2297 ("tool", "tl"),
2298 ("view", "vw"),
2299 ("truncated", "tr"),
2300 ("follow_up", "fu"),
2301 ("report", "rp"),
2302 ("metrics", "ms"),
2303 ("label", "lb"),
2304 ("value", "v"),
2305 ("command", "cmd"),
2306 ("exit_code", "xc"),
2307 ("success", "ok"),
2308 ("artifact", "art"),
2309 ("digest", "dg"),
2310 ("bytes", "bt"),
2311 ("lines", "lns"),
2312 ("expand", "xp"),
2313 ("entities", "ent"),
2314 ("relationships", "rel"),
2315 ("concept_labels", "cls"),
2316 ("extracted_at", "at"),
2317 ("tokens_input", "ti"),
2318 ("tokens_output", "tout"),
2319 ("total_summaries", "ts"),
2320 ("stale_count", "stc"),
2321 ("total_tokens_input", "tti"),
2322 ("total_tokens_output", "tto"),
2323 ("estimated_tokens_saved", "ets"),
2324 ("files_processed", "fps"),
2325 ("symbols_extracted", "se"),
2326 ("skills_dir", "sd"),
2327 ("healthy", "ok"),
2328 ("broken", "brk"),
2329 ("skills", "sks"),
2330 ("manifest_diffs", "mdf"),
2331 ("similar_pairs", "sim"),
2332 ("usage", "usg"),
2333 ("cleanup", "cln"),
2334 ("has_skill_md", "hsm"),
2335 ("is_symlink", "isl"),
2336 ("issues", "iss"),
2337 ("invocation_count", "inv"),
2338 ("reasons", "rsn"),
2339 ("token_estimate", "te"),
2340 ("skill_a", "sa"),
2341 ("skill_b", "sb"),
2342 ("desc_a", "da"),
2343 ("desc_b", "db"),
2344 ("annotations", "ann"),
2345 ("entity", "ety"),
2346 ("suggestion", "sug"),
2347 ("columns", "cols"),
2348 ("row_count", "rc"),
2349 ("notnull", "nn"),
2350 ("default_value", "dv"),
2351 ("replace_all", "ra"),
2352];
2353
2354pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2355 let root_str = root.to_string_lossy();
2356 let prefix = format!("{}/", root_str.trim_end_matches('/'));
2357 path.strip_prefix(&prefix).unwrap_or(path).to_string()
2358}
2359
2360fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2361 let canonical = path
2362 .canonicalize()
2363 .with_context(|| format!("canonicalizing {}", path.display()))?;
2364 let start = if canonical.is_dir() {
2365 canonical.clone()
2366 } else {
2367 canonical
2368 .parent()
2369 .map(Path::to_path_buf)
2370 .unwrap_or_else(|| canonical.clone())
2371 };
2372
2373 for ancestor in start.ancestors() {
2374 if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2375 return Ok(ancestor.to_path_buf());
2376 }
2377 }
2378
2379 Ok(start)
2380}
2381
2382pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2383 path.strip_prefix(root)
2384 .map(|p| p.to_path_buf())
2385 .unwrap_or_else(|_| path.to_path_buf())
2386}
2387
2388pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
2389 for edge in edges {
2390 edge.caller_file = relativize(&edge.caller_file, root);
2391 }
2392}
2393
2394pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
2395 for sym in symbols {
2396 sym.file = relativize(&sym.file, root);
2397 }
2398}
2399
2400pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
2401 for hit in hits {
2402 hit.file = relativize(&hit.file, root);
2403 }
2404}
2405
2406
2407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2410pub enum EdgeSide {
2411 Caller,
2412 Callee,
2413}
2414
2415const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
2416
2417pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
2418 let root_str = root.to_string_lossy();
2419 let prefix = format!("{}/", root_str.trim_end_matches('/'));
2420 relativize_json_inner(val, &prefix);
2421}
2422
2423fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
2424 match val {
2425 serde_json::Value::Array(arr) => {
2426 for v in arr {
2427 relativize_json_inner(v, prefix);
2428 }
2429 }
2430 serde_json::Value::Object(map) => {
2431 for (k, v) in map.iter_mut() {
2432 if JSON_PATH_KEYS.contains(&k.as_str())
2433 && let serde_json::Value::String(s) = v
2434 && let Some(rest) = s.strip_prefix(prefix)
2435 {
2436 *s = rest.to_string();
2437 }
2438 relativize_json_inner(v, prefix);
2439 }
2440 }
2441 _ => {}
2442 }
2443}
2444
2445pub(crate) fn format_score(score: f64, compact: bool) -> String {
2446 if compact {
2447 format!("{score:.2}")
2448 } else {
2449 format!("{score:.4}")
2450 }
2451}
2452
2453pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
2454 let trimmed = input.trim();
2455 let count = trimmed.chars().count();
2456 if count <= max_chars {
2457 return trimmed.to_string();
2458 }
2459 let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
2460 format!("{prefix}...")
2461}
2462
2463pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
2464 snippet
2465 .lines()
2466 .find(|line| !line.trim().is_empty())
2467 .map(|line| truncate_for_compact(line, 100))
2468}
2469
2470pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
2471 let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
2472 if names.len() <= limit {
2473 return names.join(", ");
2474 }
2475 format!(
2476 "{} (+{} more)",
2477 names[..limit].join(", "),
2478 names.len() - limit
2479 )
2480}
2481
2482pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
2483 let mut hasher = blake3::Hasher::new();
2484 hasher.update(prefix.as_bytes());
2485 hasher.update(&[0]);
2486 hasher.update(key.as_bytes());
2487 let hex = hasher.finalize().to_hex();
2488 format!("{prefix}-{}", &hex[..10])
2489}
2490
2491#[derive(Clone, Debug, PartialEq, Eq)]
2492struct CanonicalTagFamily {
2493 canonical: String,
2494 tag_alias: String,
2495}
2496
2497fn canonical_family_from_tagpath_family(
2498 family: tagpath_family::TagFamily,
2499) -> Option<CanonicalTagFamily> {
2500 let tag_alias = if family.dimensions.is_empty() {
2501 family.tags.join("/")
2502 } else {
2503 family
2504 .dimensions
2505 .iter()
2506 .filter(|dimension| !dimension.tags.is_empty())
2507 .map(|dimension| dimension.tags.join("."))
2508 .collect::<Vec<_>>()
2509 .join("/")
2510 };
2511
2512 if tag_alias.is_empty() {
2513 None
2514 } else {
2515 Some(CanonicalTagFamily {
2516 canonical: family.canonical,
2517 tag_alias,
2518 })
2519 }
2520}
2521
2522fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
2523 let trimmed = name.trim();
2524 if trimmed.is_empty() {
2525 return None;
2526 }
2527
2528 canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
2529}
2530
2531fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
2532 let canonical = tags
2533 .split(',')
2534 .map(str::trim)
2535 .filter(|tag| !tag.is_empty())
2536 .collect::<Vec<_>>()
2537 .join("_");
2538 if canonical.is_empty() {
2539 None
2540 } else {
2541 canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
2542 }
2543}
2544
2545pub(crate) fn canonical_tag_family_from_symbol(name: &str, tags: Option<&str>) -> Option<CanonicalTagFamily> {
2546 tags.and_then(canonical_tag_family_from_tags)
2547 .or_else(|| canonical_tag_family_from_name(name))
2548}
2549
2550fn tag_alias_from_name(name: &str) -> Option<String> {
2551 canonical_tag_family_from_name(name).map(|family| family.tag_alias)
2552}
2553
2554fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
2555 canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
2556}
2557
2558pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
2559 let query = tag_alias
2560 .split(['/', '.'])
2561 .map(str::trim)
2562 .filter(|part| !part.is_empty())
2563 .collect::<Vec<_>>()
2564 .join(" ");
2565 if query.is_empty() { None } else { Some(query) }
2566}
2567
2568#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2569struct CompactOntologyRefPreview {
2570 handle: String,
2571 tag: String,
2572 path: String,
2573 #[serde(skip_serializing_if = "Option::is_none")]
2574 title: Option<String>,
2575 #[serde(skip_serializing_if = "Option::is_none")]
2576 domain: Option<String>,
2577}
2578
2579#[derive(Clone, Debug)]
2580struct TagOntologyPreviewContext {
2581 project_root: PathBuf,
2582 tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
2583}
2584
2585#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2586struct CompactSymbolRefPreview {
2587 handle: String,
2588 name: String,
2589 #[serde(skip_serializing_if = "Option::is_none")]
2590 tag_alias: Option<String>,
2591 #[serde(skip_serializing_if = "Vec::is_empty", default)]
2592 ontology_refs: Vec<CompactOntologyRefPreview>,
2593}
2594
2595fn build_compact_symbol_ref(
2596 prefix: &str,
2597 key: &str,
2598 name: &str,
2599 tags: Option<&str>,
2600 max_bytes: usize,
2601) -> CompactSymbolRefPreview {
2602 build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
2603}
2604
2605fn build_compact_symbol_ref_with_ontology(
2606 prefix: &str,
2607 key: &str,
2608 name: &str,
2609 tags: Option<&str>,
2610 max_bytes: usize,
2611 ontology: Option<&TagOntologyPreviewContext>,
2612) -> CompactSymbolRefPreview {
2613 let tag_alias = tag_alias_from_tags(name, tags);
2614 let ontology_refs = tag_alias
2615 .as_deref()
2616 .map(|alias| ontology_refs_for_alias(ontology, alias))
2617 .unwrap_or_default();
2618 CompactSymbolRefPreview {
2619 handle: stable_handle(prefix, key),
2620 name: truncate_for_budget(name, max_bytes),
2621 tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
2622 ontology_refs,
2623 }
2624}
2625
2626fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
2627 let report = tagpath_ontology::load_project(root).ok()?;
2628 if report.tags.is_empty() {
2629 return None;
2630 }
2631 Some(TagOntologyPreviewContext {
2632 project_root: report.project_path,
2633 tags: report
2634 .tags
2635 .into_iter()
2636 .map(|tag| (tag.tag.clone(), tag))
2637 .collect(),
2638 })
2639}
2640
2641fn ontology_refs_for_alias(
2642 ontology: Option<&TagOntologyPreviewContext>,
2643 alias: &str,
2644) -> Vec<CompactOntologyRefPreview> {
2645 let Some(ontology) = ontology else {
2646 return Vec::new();
2647 };
2648 let mut seen = BTreeSet::new();
2649 alias
2650 .split('/')
2651 .flat_map(|part| part.split('.'))
2652 .map(str::trim)
2653 .filter(|tag| !tag.is_empty())
2654 .filter_map(|tag| {
2655 let key = tag.to_ascii_lowercase();
2656 if !seen.insert(key.clone()) {
2657 return None;
2658 }
2659 let ontology_tag = ontology.tags.get(&key)?;
2660 let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
2661 Some(CompactOntologyRefPreview {
2662 handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
2663 tag: ontology_tag.tag.clone(),
2664 path,
2665 title: ontology_tag.title.clone(),
2666 domain: ontology_tag.domain.clone(),
2667 })
2668 })
2669 .collect()
2670}
2671
2672fn relativize_ontology_path(path: &Path, root: &Path) -> String {
2673 path.strip_prefix(root)
2674 .unwrap_or(path)
2675 .to_string_lossy()
2676 .replace('\\', "/")
2677}
2678
2679fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
2680 match tag_alias {
2681 Some(alias) => format!("{handle} {name} tag:{alias}"),
2682 None => format!("{handle} {name}"),
2683 }
2684}
2685
2686fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
2687 match summary.tag_alias.as_deref() {
2688 Some(alias) => format!(
2689 "{} {} tag:{} expand:{}",
2690 summary.handle, summary.symbol, alias, summary.expand
2691 ),
2692 None => format!(
2693 "{} {} expand:{}",
2694 summary.handle, summary.symbol, summary.expand
2695 ),
2696 }
2697}
2698
2699fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
2700 match symbol.tag_alias.as_deref() {
2701 Some(alias) => format!("{}@{}", symbol.handle, alias),
2702 None => format!("{}@{}", symbol.handle, symbol.name),
2703 }
2704}
2705
2706pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
2707 let trimmed = input.trim();
2708 if trimmed.len() <= max_bytes {
2709 return trimmed.to_string();
2710 }
2711 if max_bytes <= 3 {
2712 return ".".repeat(max_bytes);
2713 }
2714
2715 let mut end = 0usize;
2716 for (idx, ch) in trimmed.char_indices() {
2717 let next = idx + ch.len_utf8();
2718 if next > max_bytes.saturating_sub(3) {
2719 break;
2720 }
2721 end = next;
2722 }
2723
2724 if end == 0 {
2725 "...".to_string()
2726 } else {
2727 format!("{}...", &trimmed[..end])
2728 }
2729}
2730
2731struct TokenCappedPreview {
2732 preview: Vec<SourceLinePreview>,
2733 capped_end: usize,
2734 was_capped: bool,
2735}
2736
2737fn build_token_capped_preview(
2738 all_lines: &[&str],
2739 start: usize,
2740 end: usize,
2741 max_bytes: usize,
2742 token_cap: usize,
2743) -> TokenCappedPreview {
2744 let mut preview = Vec::new();
2745 let mut accumulated_tokens = 0usize;
2746 let mut capped_end = end;
2747 let mut was_capped = false;
2748
2749 for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
2750 let truncated = truncate_for_budget(line, max_bytes);
2751 let line_tokens = estimated_tokens_from_bytes(truncated.len());
2752 if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
2753 capped_end = start + idx - 1;
2754 was_capped = true;
2755 break;
2756 }
2757 accumulated_tokens += line_tokens;
2758 preview.push(SourceLinePreview {
2759 line: start + idx,
2760 text: truncated,
2761 });
2762 }
2763
2764 TokenCappedPreview {
2765 preview,
2766 capped_end,
2767 was_capped,
2768 }
2769}
2770
2771pub(crate) fn abbreviate_kind(kind: &str) -> &str {
2772 match kind {
2773 "function" => "fn",
2774 "method" => "meth",
2775 "module" | "mod" => "mod",
2776 "struct" => "struct",
2777 "trait" => "trait",
2778 "impl" => "impl",
2779 "class" => "cls",
2780 "interface" => "iface",
2781 "type_alias" => "type",
2782 "data_class" => "data_cls",
2783 "sealed_class" => "sealed_cls",
2784 "enum_class" => "enum_cls",
2785 "companion_object" => "comp_obj",
2786 "object" => "obj",
2787 "heading" => "h",
2788 "code_block" => "code",
2789 "alias" => "alias",
2790 other => other,
2791 }
2792}
2793
2794pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
2795 match kind {
2796 "calls" => "c",
2797 "defines" => "d",
2798 "contains" => "ct",
2799 "imports" => "i",
2800 "mentions" => "m",
2801 "mentions_concept" => "mc",
2802 "mentions_entity" => "me",
2803 "semantic_relation" => "sr",
2804 "belongs_to" => "bt",
2805 "scopes_context" => "sctx",
2806 "scopes_source" => "ssrc",
2807 "requests_context" => "rctx",
2808 "explains_result" => "er",
2809 "tagged_concept" => "tc",
2810 "tagged_entity" => "te",
2811 "related_concept" => "relc",
2812 "handled_by" => "hb",
2813 "defines_route" => "dr",
2814 "handles_route" => "hr",
2815 "targets" => "tgt",
2816 "has_vector_handle" => "hv",
2817 "parent" => "p",
2818 "child" => "ch",
2819 "uses" => "u",
2820 "projects_source" => "psrc",
2821 "records_memory_source" => "rms",
2822 "records_memory_event" => "rme",
2823 "has_ast_span" => "ha",
2824 "represents_symbol" => "rs",
2825 "contains_embedded_symbol" => "ces",
2826 "embedded_in_fence" => "ef",
2827 "contains_markdown_block" => "cmb",
2828 "contains_embedded_code" => "cec",
2829 "enclosing_module" => "em",
2830 "enclosing_section" => "es",
2831 "previous_sibling" => "psib",
2832 "next_sibling" => "nsib",
2833 "explicit_depends_on" => "edo",
2834 "worker_result_follow_up" => "wrf",
2835 "shared_resource" => "shr",
2836 "community_member" => "cm",
2837 other => other,
2838 }
2839}
2840
2841pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
2842 match mt {
2843 "exact_name" => "exact",
2844 "all_tags" => "all_tags",
2845 "partial_tags" => "partial",
2846 other => other,
2847 }
2848}
2849
2850pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
2851 path.iter()
2852 .map(|n| n.name.as_str())
2853 .collect::<Vec<_>>()
2854 .join(" -> ")
2855}
2856
2857const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
2858
2859struct SearchHitGroup {
2860 path: String,
2861 first_rank: usize,
2862 top_score: f64,
2863 confidence: String,
2864 hits: usize,
2865 samples: Vec<String>,
2866}
2867
2868fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
2869 let snippet = compact_snippet(&hit.snippet)?;
2870 Some(match hit.location.as_deref() {
2871 Some(location) => format!("{location}: {snippet}"),
2872 None => snippet,
2873 })
2874}
2875
2876pub(crate) fn group_search_hits(
2877 hits: &[sift::SearchHit],
2878 root: &Path,
2879 absolute: bool,
2880) -> Vec<SearchHitGroup> {
2881 let mut positions = BTreeMap::new();
2882 let mut groups = Vec::new();
2883 for hit in hits {
2884 let path = if absolute {
2885 hit.path.clone()
2886 } else {
2887 relativize(&hit.path, root)
2888 };
2889 let entry = positions.entry(path.clone()).or_insert_with(|| {
2890 groups.push(SearchHitGroup {
2891 path: path.clone(),
2892 first_rank: hit.rank,
2893 top_score: hit.score,
2894 confidence: format!("{:?}", hit.confidence),
2895 hits: 0,
2896 samples: Vec::new(),
2897 });
2898 groups.len() - 1
2899 });
2900 let group = &mut groups[*entry];
2901 group.hits += 1;
2902 if hit.rank < group.first_rank {
2903 group.first_rank = hit.rank;
2904 }
2905 if hit.score > group.top_score {
2906 group.top_score = hit.score;
2907 }
2908 if let Some(sample) = format_search_sample(hit)
2909 && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
2910 && !group.samples.contains(&sample)
2911 {
2912 group.samples.push(sample);
2913 }
2914 }
2915 groups.sort_by_key(|group| group.first_rank);
2916 groups
2917}
2918
2919pub(crate) fn should_collapse_search_hits(
2920 hits: &[sift::SearchHit],
2921 root: &Path,
2922 absolute: bool,
2923) -> bool {
2924 let groups = group_search_hits(hits, root, absolute);
2925 let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
2926 max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
2927}
2928
2929pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
2930 let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
2931 for edge in edges {
2932 let key = edge.caller_file.as_str();
2933 let name = if use_callers {
2934 edge.caller_name.as_str()
2935 } else {
2936 edge.callee_name.as_str()
2937 };
2938 let names = grouped.entry(key).or_default();
2939 if !names.contains(&name) {
2940 names.push(name);
2941 }
2942 }
2943
2944 grouped
2945 .into_iter()
2946 .map(|(file, names)| format!(" {} ({}): {}", file, names.len(), names.join(", ")))
2947 .collect()
2948}
2949
2950pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
2951 let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
2952 for edge in edges {
2953 *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
2954 }
2955 let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
2956 max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
2957}
2958
2959
2960fn resolve_query_index_target(
2961 root: &Path,
2962 path_hint: &Path,
2963 scope: Option<&str>,
2964) -> Result<SearchIndexTarget> {
2965 let cfg = config::Config::load(root)?;
2966 if let Some(scope_name) = scope {
2967 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
2968 return Ok(SearchIndexTarget {
2969 label: format!("submodule `{}` index", scope.id),
2970 db_path: cfg.db_path_for(root, &scope.id),
2971 source_root: scope.source_root.clone(),
2972 scope_name: Some(scope.id.clone()),
2973 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
2974 });
2975 }
2976 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
2977 return Ok(cargo_package_index_target(root, package));
2978 }
2979 config::Config::resolve_submodule(root, scope_name)?;
2980 }
2981
2982 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
2983 return Ok(SearchIndexTarget {
2984 label: format!("submodule `{}` index", scope.id),
2985 db_path: cfg.db_path_for(root, &scope.id),
2986 source_root: scope.source_root.clone(),
2987 scope_name: Some(scope.id.clone()),
2988 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
2989 });
2990 }
2991
2992 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
2993 return Ok(cargo_package_index_target(root, package));
2994 }
2995
2996 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
2997 return Ok(SearchIndexTarget {
2998 label: format!("submodule `{}` index", scope.id),
2999 db_path: cfg.db_path_for(root, &scope.id),
3000 source_root: scope.source_root.clone(),
3001 scope_name: Some(scope.id.clone()),
3002 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3003 });
3004 }
3005
3006 let db_path = root.join(".tsift/index.db");
3007 if db_path.exists() {
3008 return Ok(SearchIndexTarget {
3009 label: "index".to_string(),
3010 db_path,
3011 source_root: root.to_path_buf(),
3012 scope_name: None,
3013 reindex_cmd: format!("tsift index {}", root.display()),
3014 });
3015 }
3016
3017 let scopes = config::Config::submodule_dirs(root)?;
3018 if scopes.is_empty() {
3019 return Ok(SearchIndexTarget {
3020 label: "index".to_string(),
3021 db_path,
3022 source_root: root.to_path_buf(),
3023 scope_name: None,
3024 reindex_cmd: format!("tsift index {}", root.display()),
3025 });
3026 }
3027
3028 let available_scopes = scopes
3029 .iter()
3030 .map(|scope| scope.id.as_str())
3031 .collect::<Vec<_>>()
3032 .join(", ");
3033 let indexed_scopes = scopes
3034 .iter()
3035 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3036 .map(|scope| scope.id.as_str())
3037 .collect::<Vec<_>>();
3038 let indexed_label = if indexed_scopes.is_empty() {
3039 "none".to_string()
3040 } else {
3041 indexed_scopes.join(", ")
3042 };
3043
3044 bail!(
3045 "workspace root {} has no shared root index at {}. Read-only graph queries require `--scope <scope>` when the workspace is indexed into `.tsift/indexes/*/index.db`. Available scopes: {}. Indexed scopes: {}.",
3046 root.display(),
3047 db_path.display(),
3048 available_scopes,
3049 indexed_label
3050 );
3051}
3052
3053pub(crate) fn resolve_query_db_path(root: &Path, path_hint: &Path, scope: Option<&str>) -> Result<PathBuf> {
3054 Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3055}
3056
3057fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3058 let state = inspect_search_index(target)?;
3059 let Some(reason) = index_reason_for_state(state) else {
3060 return Ok(());
3061 };
3062
3063 match apply_search_index_update(root, target) {
3064 Ok(_) => {
3065 index::inspect_scope_invalidate_all();
3066 Ok(())
3067 }
3068 Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3069 eprintln!(
3070 "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3071 Continuing with the current read-only index snapshot; graph results may lag. \
3072 Retry `{}` after the active writer finishes for fresh graph results.",
3073 index_reason_detail(target, reason),
3074 target.reindex_cmd
3075 );
3076 Ok(())
3077 }
3078 Err(err) => Err(err),
3079 }
3080}
3081
3082pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3083 let root = lint::resolve_project_root_or_canonical_path(path)?;
3084 let target = resolve_query_index_target(&root, path, scope)?;
3085 ensure_query_index_current(&root, &target)?;
3086 let db_path = target.db_path;
3087 if !db_path.exists() {
3088 bail!(
3089 "no index found at {}. Run `tsift index` first.",
3090 db_path.display()
3091 );
3092 }
3093 index::IndexDb::open_read_only_resilient(&db_path)
3094}
3095
3096pub(crate) fn query_tagpath_root(
3097 root: &std::path::Path,
3098 path_hint: &std::path::Path,
3099 scope: Option<&str>,
3100) -> Result<PathBuf> {
3101 if let Some(scope_name) = scope {
3102 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3103 return Ok(scope.source_root);
3104 }
3105 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3106 return Ok(package.package_root);
3107 }
3108 config::Config::resolve_submodule(root, scope_name)?;
3109 }
3110 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3111 return Ok(scope.source_root);
3112 }
3113 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3114 return Ok(package.package_root);
3115 }
3116 Ok(root.to_path_buf())
3117}
3118
3119#[derive(Clone, Debug, Serialize, PartialEq)]
3120struct TraversalNode {
3121 handle: String,
3122 kind: String,
3123 label: String,
3124 #[serde(skip_serializing_if = "Option::is_none")]
3125 ref_id: Option<String>,
3126 #[serde(skip_serializing_if = "Option::is_none")]
3127 path: Option<String>,
3128 #[serde(skip_serializing_if = "Option::is_none")]
3129 line: Option<i64>,
3130 #[serde(skip_serializing_if = "Option::is_none")]
3131 detail: Option<String>,
3132 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3133 properties: BTreeMap<String, String>,
3134 expand: String,
3135}
3136
3137#[derive(Clone, Debug, Serialize, PartialEq)]
3138struct TraversalEdge {
3139 from: String,
3140 to: String,
3141 relation: String,
3142 #[serde(skip_serializing_if = "Option::is_none")]
3143 label: Option<String>,
3144 weight: usize,
3145}
3146
3147#[derive(Clone, Debug, Default)]
3148struct TraversalGraphBuild {
3149 nodes: BTreeMap<String, TraversalNode>,
3150 edges: Vec<TraversalEdge>,
3151 edge_keys: BTreeSet<(String, String, String)>,
3152 warnings: Vec<String>,
3153}
3154
3155pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3156const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3157const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3158const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3159const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3160 "context-pack-graph-orchestration-v1";
3161const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3162const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3163const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3164const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3165const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3166const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3167const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3168
3169#[derive(Debug, Serialize, PartialEq)]
3170struct TraversalTotals {
3171 nodes: usize,
3172 edges: usize,
3173}
3174
3175#[derive(Debug, Serialize, PartialEq)]
3176struct TraversalPathReport {
3177 from: TraversalNode,
3178 to: TraversalNode,
3179 hops: usize,
3180 nodes: Vec<TraversalNode>,
3181 edges: Vec<TraversalEdge>,
3182}
3183
3184#[derive(Debug, Serialize, PartialEq)]
3185struct TraversalRecommendation {
3186 handle: String,
3187 kind: String,
3188 label: String,
3189 reason: String,
3190 score: usize,
3191 expand: String,
3192}
3193
3194#[derive(Debug, Serialize, PartialEq)]
3195struct TraversalReport {
3196 root: String,
3197 #[serde(skip_serializing_if = "Option::is_none")]
3198 scope: Option<String>,
3199 mode: String,
3200 totals: TraversalTotals,
3201 #[serde(skip_serializing_if = "Option::is_none")]
3202 query: Option<String>,
3203 #[serde(skip_serializing_if = "Option::is_none")]
3204 target: Option<String>,
3205 nodes: Vec<TraversalNode>,
3206 edges: Vec<TraversalEdge>,
3207 #[serde(skip_serializing_if = "Option::is_none")]
3208 shortest_path: Option<TraversalPathReport>,
3209 recommendations: Vec<TraversalRecommendation>,
3210 exploration: ExplorationPacket,
3211 truncated: bool,
3212 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3213 warnings: Vec<String>,
3214}
3215
3216#[derive(Debug, Serialize, PartialEq)]
3217struct SemanticRelatedReport {
3218 root: String,
3219 #[serde(skip_serializing_if = "Option::is_none")]
3220 scope: Option<String>,
3221 query: String,
3222 embedding_model: String,
3223 count: usize,
3224 items: Vec<SemanticRelatedItem>,
3225 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3226 warnings: Vec<String>,
3227}
3228
3229#[derive(Clone, Debug, Serialize, PartialEq)]
3230struct SemanticRelatedItem {
3231 handle: String,
3232 kind: String,
3233 label: String,
3234 score: f64,
3235 #[serde(skip_serializing_if = "Option::is_none")]
3236 file_path: Option<String>,
3237 #[serde(skip_serializing_if = "Option::is_none")]
3238 source_symbol: Option<String>,
3239 #[serde(skip_serializing_if = "Option::is_none")]
3240 detail: Option<String>,
3241 expand: String,
3242}
3243
3244#[derive(Clone)]
3245struct TraversalSymbolIndexEntry {
3246 handle: String,
3247 node: TraversalNode,
3248 tokens: BTreeSet<String>,
3249}
3250
3251#[derive(Clone)]
3252struct TraversalFileIndexEntry {
3253 handle: String,
3254 node: TraversalNode,
3255 tokens: BTreeSet<String>,
3256}
3257
3258#[derive(Clone)]
3259struct TraversalRouteIndexEntry {
3260 handle: String,
3261 node: TraversalNode,
3262 tokens: BTreeSet<String>,
3263}
3264
3265#[derive(Clone)]
3266struct TraversalAstSpanIndexEntry {
3267 handle: String,
3268 symbol_handle: String,
3269 file_handle: Option<String>,
3270 file: String,
3271 name: String,
3272 kind: String,
3273 language: String,
3274 node_kind: String,
3275 start_byte: usize,
3276 end_byte: usize,
3277 parent_module: Option<String>,
3278 markdown: Option<MarkdownSpanMetadata>,
3279}
3280
3281#[derive(Clone)]
3282struct TraversalMultiplicityIndexEntry {
3283 handle: String,
3284 node: TraversalNode,
3285 tokens: BTreeSet<String>,
3286}
3287
3288struct TraversalCodeLookup<'a> {
3289 symbols: &'a [TraversalSymbolIndexEntry],
3290 files: &'a [TraversalFileIndexEntry],
3291 routes: &'a [TraversalRouteIndexEntry],
3292 multiplicities: &'a [TraversalMultiplicityIndexEntry],
3293 symbol_index: HashMap<String, Vec<usize>>,
3294 file_index: HashMap<String, Vec<usize>>,
3295 route_index: HashMap<String, Vec<usize>>,
3296 multiplicity_index: HashMap<String, Vec<usize>>,
3297 file_path_index: HashMap<String, String>,
3298}
3299
3300#[derive(Clone, Debug, Serialize, PartialEq)]
3301struct ExplorationBudget {
3302 project_size: String,
3303 max_source_windows: usize,
3304 lines_per_window: usize,
3305 relationship_limit: usize,
3306}
3307
3308#[derive(Clone, Debug, Serialize, PartialEq)]
3309struct ExplorationRelation {
3310 from: String,
3311 relation: String,
3312 to: String,
3313 #[serde(skip_serializing_if = "Option::is_none")]
3314 label: Option<String>,
3315}
3316
3317#[derive(Clone, Debug, Serialize, PartialEq)]
3318struct ExplorationSourceWindow {
3319 handle: String,
3320 file: String,
3321 start: usize,
3322 end: usize,
3323 reason: String,
3324 expand: String,
3325}
3326
3327#[derive(Clone, Debug, Serialize, PartialEq)]
3328struct ExplorationWorkerContext {
3329 handle: String,
3330 target: String,
3331 summary: String,
3332 expand: String,
3333}
3334
3335#[derive(Clone, Debug, Serialize, PartialEq)]
3336struct ExplorationPacket {
3337 budget: ExplorationBudget,
3338 relationship_map: Vec<ExplorationRelation>,
3339 source_windows: Vec<ExplorationSourceWindow>,
3340 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3341 worker_context: Vec<ExplorationWorkerContext>,
3342 no_reread_guidance: String,
3343}
3344
3345impl TraversalGraphBuild {
3346 fn add_node(&mut self, node: TraversalNode) {
3347 self.nodes.entry(node.handle.clone()).or_insert(node);
3348 }
3349
3350 fn add_edge(
3351 &mut self,
3352 from: &str,
3353 to: &str,
3354 relation: &str,
3355 label: Option<String>,
3356 weight: usize,
3357 ) {
3358 if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3359 return;
3360 }
3361 let key = (from.to_string(), to.to_string(), relation.to_string());
3362 if self.edge_keys.insert(key) {
3363 self.edges.push(TraversalEdge {
3364 from: from.to_string(),
3365 to: to.to_string(),
3366 relation: relation.to_string(),
3367 label,
3368 weight,
3369 });
3370 }
3371 }
3372}
3373
3374pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3375 match scope {
3376 Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3377 None => root.join(".tsift/graph.db"),
3378 }
3379}
3380
3381fn graph_projection_meta_id(scope: Option<&str>) -> String {
3382 format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3383}
3384
3385pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
3386 let bytes = serde_json::to_vec(value)?;
3387 Ok(blake3::hash(&bytes).to_hex().to_string())
3388}
3389
3390fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
3391 let mut hashable = node.clone();
3392 hashable.freshness = None;
3393 node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3394 Ok(node)
3395}
3396
3397fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
3398 let mut hashable = edge.clone();
3399 hashable.freshness = None;
3400 edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3401 Ok(edge)
3402}
3403
3404const SEMANTIC_EMBEDDING_DIM: usize = 32;
3405const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
3406const CLAUDE_MEM_GRAPH_LIMIT_PER_TABLE: usize = 200;
3407
3408fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
3409 match kind {
3410 SemanticRelatedKind::Concept => "concept",
3411 SemanticRelatedKind::Entity => "entity",
3412 SemanticRelatedKind::All => "all",
3413 }
3414}
3415
3416fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
3417 format!(
3418 "tsift semantic {} --path {} --kind {} --limit 10",
3419 shell_quote(query),
3420 shell_quote(root.to_string_lossy().as_ref()),
3421 semantic_related_kind_name(kind)
3422 )
3423}
3424
3425fn semantic_embedding(input: &str) -> Vec<f64> {
3426 let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
3427 let mut tokens = traversal_tokens(input);
3428 if tokens.is_empty() {
3429 let trimmed = input.trim().to_ascii_lowercase();
3430 if !trimmed.is_empty() {
3431 tokens.insert(trimmed);
3432 }
3433 }
3434
3435 for token in tokens {
3436 let hash = blake3::hash(token.as_bytes());
3437 let bytes = hash.as_bytes();
3438 let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
3439 let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
3440 vector[idx] += sign;
3441 }
3442
3443 let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
3444 if norm > 0.0 {
3445 for value in &mut vector {
3446 *value /= norm;
3447 }
3448 }
3449 vector
3450}
3451
3452fn semantic_embedding_property(input: &str) -> String {
3453 semantic_embedding(input)
3454 .iter()
3455 .map(|value| format!("{value:.6}"))
3456 .collect::<Vec<_>>()
3457 .join(",")
3458}
3459
3460fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
3461 let parsed = value
3462 .split(',')
3463 .map(str::trim)
3464 .map(str::parse::<f64>)
3465 .collect::<std::result::Result<Vec<_>, _>>()
3466 .ok()?;
3467 (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
3468}
3469
3470fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
3471 if left.len() != right.len() {
3472 return 0.0;
3473 }
3474 left.iter()
3475 .zip(right.iter())
3476 .map(|(left, right)| left * right)
3477 .sum::<f64>()
3478}
3479
3480fn semantic_entity_handle(name: &str, kind: &str) -> String {
3481 stable_handle(
3482 "gent",
3483 &format!(
3484 "entity:{}:{}",
3485 kind.trim().to_ascii_lowercase(),
3486 name.trim().to_ascii_lowercase()
3487 ),
3488 )
3489}
3490
3491fn semantic_concept_handle(label: &str) -> String {
3492 stable_handle(
3493 "gcon",
3494 &format!("concept:{}", label.trim().to_ascii_lowercase()),
3495 )
3496}
3497
3498fn summary_source_handles(
3499 summary: &summarize::Summary,
3500 file_node_by_path: &BTreeMap<String, String>,
3501 symbol_node_by_file_label: &BTreeMap<(String, String), String>,
3502) -> Vec<String> {
3503 let mut handles = Vec::new();
3504 if let Some(handle) = file_node_by_path.get(&summary.file_path) {
3505 handles.push(handle.clone());
3506 }
3507 if let Some(handle) =
3508 symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
3509 && !handles.iter().any(|existing| existing == handle)
3510 {
3511 handles.push(handle.clone());
3512 }
3513 handles
3514}
3515
3516fn semantic_entity_node(
3517 root: &Path,
3518 summary: &summarize::Summary,
3519 name: &str,
3520 kind: &str,
3521 description: &str,
3522 provenance: &GraphProvenance,
3523) -> SubstrateGraphNode {
3524 let handle = semantic_entity_handle(name, kind);
3525 let detail = if description.trim().is_empty() {
3526 format!("{kind} entity from cached summaries")
3527 } else {
3528 format!("{kind}: {description}")
3529 };
3530 SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
3531 .with_property("handle", handle)
3532 .with_property("ref_id", name.to_string())
3533 .with_property("detail", detail)
3534 .with_property("entity_kind", kind.to_string())
3535 .with_property("description", description.to_string())
3536 .with_property("source_file", summary.file_path.clone())
3537 .with_property("source_symbol", summary.symbol_name.clone())
3538 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3539 .with_property(
3540 "embedding",
3541 semantic_embedding_property(&format!("{name} {kind} {description}")),
3542 )
3543 .with_property(
3544 "expand",
3545 semantic_related_command(root, name, SemanticRelatedKind::Entity),
3546 )
3547 .with_provenance(provenance.clone())
3548}
3549
3550fn semantic_concept_node(
3551 root: &Path,
3552 summary: &summarize::Summary,
3553 label: &str,
3554 provenance: &GraphProvenance,
3555) -> SubstrateGraphNode {
3556 let handle = semantic_concept_handle(label);
3557 SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
3558 .with_property("handle", handle)
3559 .with_property("ref_id", label.to_string())
3560 .with_property("detail", "concept label from cached summaries".to_string())
3561 .with_property("source_file", summary.file_path.clone())
3562 .with_property("source_symbol", summary.symbol_name.clone())
3563 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3564 .with_property("embedding", semantic_embedding_property(label))
3565 .with_property(
3566 "expand",
3567 semantic_related_command(root, label, SemanticRelatedKind::Concept),
3568 )
3569 .with_provenance(provenance.clone())
3570}
3571
3572fn insert_semantic_edge(
3573 edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
3574 edge: SubstrateGraphEdge,
3575) {
3576 edge_map
3577 .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
3578 .or_insert(edge);
3579}
3580
3581fn memory_event_key(event: &MemoryEvent) -> String {
3582 match (event.imported_from.as_deref(), event.imported_id.as_deref()) {
3583 (Some(imported_from), Some(imported_id)) => {
3584 format!("{imported_from}:{imported_id}")
3585 }
3586 _ => event.stable_id(),
3587 }
3588}
3589
3590fn memory_event_label(event: &MemoryEvent) -> String {
3591 let first_line = event
3592 .text
3593 .lines()
3594 .map(str::trim)
3595 .find(|line| !line.is_empty())
3596 .unwrap_or(event.kind.as_str());
3597 match event.kind.as_str() {
3598 "imported_observation" => {
3599 let observation_type = event
3600 .metadata
3601 .get("observation_type")
3602 .map(String::as_str)
3603 .unwrap_or("observation");
3604 truncate_for_compact(&format!("{observation_type}: {first_line}"), 80)
3605 }
3606 "imported_session_summary" => truncate_for_compact(&format!("summary: {first_line}"), 80),
3607 "imported_user_prompt" => truncate_for_compact(&format!("prompt: {first_line}"), 80),
3608 _ => truncate_for_compact(first_line, 80),
3609 }
3610}
3611
3612fn append_tsift_memory_graph_projection_rows(
3613 root: &Path,
3614 nodes: &mut Vec<SubstrateGraphNode>,
3615 edges: &mut Vec<SubstrateGraphEdge>,
3616) -> Result<()> {
3617 let memory_db = default_memory_db_path(root);
3618 if !memory_db.exists() {
3619 return Ok(());
3620 }
3621 let events = match read_memory_events(&memory_db, CLAUDE_MEM_GRAPH_LIMIT_PER_TABLE * 3) {
3622 Ok(events) => events,
3623 Err(_) => return Ok(()),
3624 };
3625 if events.is_empty() {
3626 return Ok(());
3627 }
3628
3629 let mut seen_sessions = BTreeSet::new();
3630 let mut edge_map = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
3631
3632 for event in &events {
3633 let event_id = event.stable_id();
3634 let event_key = memory_event_key(event);
3635 let source_handle = stable_handle("tmemsrc", &event_key);
3636 let semantic_handle = stable_handle("tmemsem", &event_key);
3637 let provenance = GraphProvenance::new("tsift-memory", &event.source_ref);
3638 let imported_from = event.imported_from.as_deref().unwrap_or("native");
3639
3640 if let Some(session_id) = &event.session_id {
3641 let session_handle =
3642 format!("memsess:{}", blake3::hash(session_id.as_bytes()).to_hex());
3643 if seen_sessions.insert(session_id.clone()) {
3644 let session_node = SubstrateGraphNode::new(
3645 session_handle.clone(),
3646 "memory_session",
3647 truncate_for_compact(session_id, 80),
3648 )
3649 .with_property("handle", session_handle.clone())
3650 .with_property("ref_id", session_id.clone())
3651 .with_property("session_id", session_id.clone())
3652 .with_property("provider", "tsift-memory")
3653 .with_property(
3654 "expand",
3655 format!(
3656 "tsift memory status {} --json",
3657 shell_quote(root.to_string_lossy().as_ref())
3658 ),
3659 )
3660 .with_provenance(provenance.clone());
3661 nodes.push(node_with_content_freshness(session_node)?);
3662 }
3663
3664 insert_semantic_edge(
3665 &mut edge_map,
3666 SubstrateGraphEdge::new(
3667 session_handle.clone(),
3668 event_id.clone(),
3669 "records_memory_event",
3670 )
3671 .with_property("label", "tsift-memory session event")
3672 .with_provenance(provenance.clone()),
3673 );
3674 insert_semantic_edge(
3675 &mut edge_map,
3676 SubstrateGraphEdge::new(
3677 session_handle,
3678 source_handle.clone(),
3679 "records_memory_source",
3680 )
3681 .with_property("label", "tsift-memory session source")
3682 .with_provenance(provenance.clone()),
3683 );
3684 }
3685
3686 let label = memory_event_label(event);
3687 let mut event_node =
3688 SubstrateGraphNode::new(event_id.clone(), "memory_event", event.kind.as_str())
3689 .with_property("handle", event_id.clone())
3690 .with_property("ref_id", event.source_ref.clone())
3691 .with_property("source_ref", event.source_ref.clone())
3692 .with_property("provider", "tsift-memory")
3693 .with_property("memory_kind", event.kind.as_str())
3694 .with_property("imported_from", imported_from)
3695 .with_property("text_preview", truncate_for_compact(&event.text, 240))
3696 .with_property("token_estimate", event.token_estimate.to_string())
3697 .with_property(
3698 "expand",
3699 format!(
3700 "tsift memory status {} --json",
3701 shell_quote(root.to_string_lossy().as_ref())
3702 ),
3703 )
3704 .with_provenance(provenance.clone());
3705 if let Some(session_id) = &event.session_id {
3706 event_node = event_node.with_property("session_id", session_id.clone());
3707 }
3708 if let Some(observed_at_unix) = event.observed_at_unix {
3709 event_node = event_node.with_property("observed_at_unix", observed_at_unix.to_string());
3710 }
3711 if let Some(imported_id) = &event.imported_id {
3712 event_node = event_node.with_property("imported_id", imported_id.clone());
3713 }
3714 nodes.push(node_with_content_freshness(event_node)?);
3715
3716 let mut source_node =
3717 SubstrateGraphNode::new(source_handle.clone(), "source_handle", label.clone())
3718 .with_property("handle", source_handle.clone())
3719 .with_property("ref_id", event.source_ref.clone())
3720 .with_property("source_ref", event.source_ref.clone())
3721 .with_property("provider", "tsift-memory")
3722 .with_property("memory_kind", event.kind.as_str())
3723 .with_property("imported_from", imported_from)
3724 .with_property("text_preview", truncate_for_compact(&event.text, 240))
3725 .with_property("token_estimate", event.token_estimate.to_string())
3726 .with_property(
3727 "expand",
3728 format!(
3729 "tsift memory status {} --json",
3730 shell_quote(root.to_string_lossy().as_ref())
3731 ),
3732 )
3733 .with_provenance(provenance.clone());
3734 if let Some(session_id) = &event.session_id {
3735 source_node = source_node.with_property("session_id", session_id.clone());
3736 }
3737 if let Some(observed_at_unix) = event.observed_at_unix {
3738 source_node =
3739 source_node.with_property("observed_at_unix", observed_at_unix.to_string());
3740 }
3741 if let Some(imported_id) = &event.imported_id {
3742 source_node = source_node.with_property("imported_id", imported_id.clone());
3743 }
3744 nodes.push(node_with_content_freshness(source_node)?);
3745
3746 insert_semantic_edge(
3747 &mut edge_map,
3748 SubstrateGraphEdge::new(event_id.clone(), source_handle.clone(), "projects_source")
3749 .with_property("label", "tsift-memory source projection")
3750 .with_provenance(provenance.clone()),
3751 );
3752
3753 let semantic_text = format!("{} {}", label, event.text);
3754 let semantic_node =
3755 SubstrateGraphNode::new(semantic_handle.clone(), "semantic_concept", label.clone())
3756 .with_property("handle", semantic_handle.clone())
3757 .with_property("ref_id", event.source_ref.clone())
3758 .with_property("detail", "semantic row from tsift-memory")
3759 .with_property("source_ref", event.source_ref.clone())
3760 .with_property("provider", "tsift-memory")
3761 .with_property("memory_kind", event.kind.as_str())
3762 .with_property("imported_from", imported_from)
3763 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3764 .with_property("embedding", semantic_embedding_property(&semantic_text))
3765 .with_property(
3766 "expand",
3767 semantic_related_command(root, &label, SemanticRelatedKind::Concept),
3768 )
3769 .with_provenance(provenance.clone());
3770 nodes.push(node_with_content_freshness(semantic_node)?);
3771
3772 insert_semantic_edge(
3773 &mut edge_map,
3774 SubstrateGraphEdge::new(
3775 source_handle.clone(),
3776 semantic_handle.clone(),
3777 "mentions_concept",
3778 )
3779 .with_property("label", "tsift-memory semantic source")
3780 .with_provenance(provenance.clone()),
3781 );
3782 }
3783
3784 for edge in edge_map.into_values() {
3785 edges.push(edge_with_content_freshness(edge)?);
3786 }
3787
3788 Ok(())
3789}
3790
3791fn append_summary_semantic_projection_rows(
3792 root: &Path,
3793 graph: &TraversalGraphBuild,
3794 provenance: &GraphProvenance,
3795 nodes: &mut Vec<SubstrateGraphNode>,
3796 edges: &mut Vec<SubstrateGraphEdge>,
3797) -> Result<()> {
3798 let summaries_db = root.join(".tsift/summaries.db");
3799 if !summaries_db.exists() {
3800 return Ok(());
3801 }
3802
3803 let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
3804 let summaries = summary_db.all()?;
3805 if summaries.is_empty() {
3806 return Ok(());
3807 }
3808
3809 let file_node_by_path = graph
3810 .nodes
3811 .values()
3812 .filter(|node| node.kind == "file")
3813 .filter_map(|node| {
3814 node.path
3815 .as_ref()
3816 .map(|path| (path.clone(), node.handle.clone()))
3817 })
3818 .collect::<BTreeMap<_, _>>();
3819 let symbol_node_by_file_label = graph
3820 .nodes
3821 .values()
3822 .filter(|node| node.kind == "symbol")
3823 .filter_map(|node| {
3824 Some((
3825 (node.path.clone()?, node.label.clone()),
3826 node.handle.clone(),
3827 ))
3828 })
3829 .collect::<BTreeMap<_, _>>();
3830
3831 let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
3832 let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
3833
3834 for summary in &summaries {
3835 let source_handles =
3836 summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
3837 let mut entity_ids_by_name = BTreeMap::<String, String>::new();
3838
3839 if let Some(entities) = &summary.entities {
3840 for entity in entities {
3841 let node = semantic_entity_node(
3842 root,
3843 summary,
3844 &entity.name,
3845 &entity.kind,
3846 &entity.description,
3847 provenance,
3848 );
3849 let entity_id = node.id.clone();
3850 entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
3851 semantic_nodes.entry(entity_id.clone()).or_insert(node);
3852
3853 for source_handle in &source_handles {
3854 insert_semantic_edge(
3855 &mut semantic_edges,
3856 SubstrateGraphEdge::new(
3857 source_handle.clone(),
3858 entity_id.clone(),
3859 "mentions_entity",
3860 )
3861 .with_property("label", format!("summary entity: {}", entity.name))
3862 .with_property("source_file", summary.file_path.clone())
3863 .with_provenance(provenance.clone()),
3864 );
3865 }
3866 }
3867 }
3868
3869 let mut concept_ids = Vec::new();
3870 if let Some(labels) = &summary.concept_labels {
3871 for label in labels
3872 .iter()
3873 .map(|label| label.trim())
3874 .filter(|label| !label.is_empty())
3875 {
3876 let node = semantic_concept_node(root, summary, label, provenance);
3877 let concept_id = node.id.clone();
3878 semantic_nodes.entry(concept_id.clone()).or_insert(node);
3879 concept_ids.push(concept_id.clone());
3880
3881 for source_handle in &source_handles {
3882 insert_semantic_edge(
3883 &mut semantic_edges,
3884 SubstrateGraphEdge::new(
3885 source_handle.clone(),
3886 concept_id.clone(),
3887 "mentions_concept",
3888 )
3889 .with_property("label", format!("summary concept: {label}"))
3890 .with_property("source_file", summary.file_path.clone())
3891 .with_provenance(provenance.clone()),
3892 );
3893 }
3894 }
3895 }
3896
3897 for entity_id in entity_ids_by_name.values() {
3898 for concept_id in &concept_ids {
3899 insert_semantic_edge(
3900 &mut semantic_edges,
3901 SubstrateGraphEdge::new(
3902 entity_id.clone(),
3903 concept_id.clone(),
3904 "tagged_concept",
3905 )
3906 .with_property("label", "entity concept label".to_string())
3907 .with_property("source_file", summary.file_path.clone())
3908 .with_provenance(provenance.clone()),
3909 );
3910 }
3911 }
3912
3913 for idx in 0..concept_ids.len() {
3914 for next_idx in (idx + 1)..concept_ids.len() {
3915 insert_semantic_edge(
3916 &mut semantic_edges,
3917 SubstrateGraphEdge::new(
3918 concept_ids[idx].clone(),
3919 concept_ids[next_idx].clone(),
3920 "related_concept",
3921 )
3922 .with_property("label", format!("co-occurs in {}", summary.symbol_name))
3923 .with_property("source_file", summary.file_path.clone())
3924 .with_provenance(provenance.clone()),
3925 );
3926 }
3927 }
3928
3929 if let Some(relationships) = &summary.relationships {
3930 for relationship in relationships {
3931 let from_id = entity_ids_by_name
3932 .get(&relationship.from.to_ascii_lowercase())
3933 .cloned()
3934 .unwrap_or_else(|| {
3935 let node = semantic_entity_node(
3936 root,
3937 summary,
3938 &relationship.from,
3939 "unknown",
3940 "",
3941 provenance,
3942 );
3943 let id = node.id.clone();
3944 semantic_nodes.entry(id.clone()).or_insert(node);
3945 id
3946 });
3947 let to_id = entity_ids_by_name
3948 .get(&relationship.to.to_ascii_lowercase())
3949 .cloned()
3950 .unwrap_or_else(|| {
3951 let node = semantic_entity_node(
3952 root,
3953 summary,
3954 &relationship.to,
3955 "unknown",
3956 "",
3957 provenance,
3958 );
3959 let id = node.id.clone();
3960 semantic_nodes.entry(id.clone()).or_insert(node);
3961 id
3962 });
3963 insert_semantic_edge(
3964 &mut semantic_edges,
3965 SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
3966 .with_property("relationship_kind", relationship.kind.clone())
3967 .with_property("label", relationship.kind.clone())
3968 .with_property("source_file", summary.file_path.clone())
3969 .with_property("source_symbol", summary.symbol_name.clone())
3970 .with_provenance(provenance.clone()),
3971 );
3972 }
3973 }
3974 }
3975
3976 for node in semantic_nodes.into_values() {
3977 nodes.push(node_with_content_freshness(node)?);
3978 }
3979 for edge in semantic_edges.into_values() {
3980 edges.push(edge_with_content_freshness(edge)?);
3981 }
3982
3983 Ok(())
3984}
3985
3986fn projection_content_hash(
3987 nodes: &[SubstrateGraphNode],
3988 edges: &[SubstrateGraphEdge],
3989) -> Result<String> {
3990 #[derive(Serialize)]
3991 struct Payload<'a> {
3992 version: &'static str,
3993 nodes: &'a [SubstrateGraphNode],
3994 edges: &'a [SubstrateGraphEdge],
3995 }
3996
3997 content_hash(&Payload {
3998 version: GRAPH_PROJECTION_VERSION,
3999 nodes,
4000 edges,
4001 })
4002}
4003
4004pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
4005 projection
4006 .nodes
4007 .iter()
4008 .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
4009 .and_then(|node| node.properties.get("content_hash").cloned())
4010}
4011
4012fn traversal_projection_from_graph(
4013 root: &Path,
4014 scope: Option<&str>,
4015 graph: &TraversalGraphBuild,
4016) -> Result<GraphProjection> {
4017 let provenance = GraphProvenance::new(
4018 "tsift.traverse",
4019 format!("{}:{}", root.display(), scope.unwrap_or("root")),
4020 );
4021 let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
4022 for node in graph.nodes.values() {
4023 let mut projected =
4024 SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
4025 .with_property("handle", node.handle.clone())
4026 .with_property("expand", node.expand.clone())
4027 .with_provenance(provenance.clone());
4028 if let Some(ref_id) = &node.ref_id {
4029 projected = projected.with_property("ref_id", ref_id.clone());
4030 }
4031 if let Some(path) = &node.path {
4032 projected = projected.with_property("path", path.clone());
4033 }
4034 if let Some(line) = node.line {
4035 projected = projected.with_property("line", line.to_string());
4036 }
4037 if let Some(detail) = &node.detail {
4038 projected = projected.with_property("detail", detail.clone());
4039 }
4040 for (key, value) in &node.properties {
4041 projected = projected.with_property(key.clone(), value.clone());
4042 }
4043 nodes.push(node_with_content_freshness(projected)?);
4044 }
4045
4046 let mut edges = Vec::with_capacity(graph.edges.len());
4047 for edge in &graph.edges {
4048 let mut projected =
4049 SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
4050 .with_property("weight", edge.weight.to_string())
4051 .with_provenance(provenance.clone());
4052 if let Some(label) = &edge.label {
4053 projected = projected.with_property("label", label.clone());
4054 }
4055 edges.push(edge_with_content_freshness(projected)?);
4056 }
4057
4058 append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4059 append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4060 append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
4061
4062 let projection_hash = projection_content_hash(&nodes, &edges)?;
4063 let meta = SubstrateGraphNode::new(
4064 graph_projection_meta_id(scope),
4065 GRAPH_PROJECTION_META_KIND,
4066 "tsift traversal projection",
4067 )
4068 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
4069 .with_property("content_hash", projection_hash.clone())
4070 .with_property("root", root.to_string_lossy().to_string())
4071 .with_property("scope", scope.unwrap_or("root"))
4072 .with_property("node_count", graph.nodes.len().to_string())
4073 .with_property("edge_count", graph.edges.len().to_string())
4074 .with_provenance(provenance)
4075 .with_freshness(GraphFreshness::content_hash(projection_hash));
4076 nodes.push(meta);
4077
4078 Ok(GraphProjection { nodes, edges })
4079}
4080
4081#[allow(clippy::too_many_arguments)]
4082fn ensure_traversal_source_handle(
4083 root: &Path,
4084 provenance: &GraphProvenance,
4085 file_node_by_path: &BTreeMap<String, String>,
4086 node: &TraversalNode,
4087 budget: &ExplorationBudget,
4088 source_handle_by_node: &mut BTreeMap<String, String>,
4089 seen_windows: &mut BTreeMap<(String, usize, usize), String>,
4090 nodes: &mut Vec<SubstrateGraphNode>,
4091 edges: &mut Vec<SubstrateGraphEdge>,
4092) -> Result<Option<String>> {
4093 if let Some(handle) = source_handle_by_node.get(&node.handle) {
4094 return Ok(Some(handle.clone()));
4095 }
4096 let Some(window) = exploration_source_window_for_node(root, node, budget) else {
4097 return Ok(None);
4098 };
4099 let window_key = (window.file.clone(), window.start, window.end);
4100 let handle = if let Some(handle) = seen_windows.get(&window_key) {
4101 handle.clone()
4102 } else {
4103 let label = format!("{}:{}-{}", window.file, window.start, window.end);
4104 let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
4105 .with_property("handle", window.handle.clone())
4106 .with_property("file", window.file.clone())
4107 .with_property("start", window.start.to_string())
4108 .with_property("end", window.end.to_string())
4109 .with_property("reason", window.reason.clone())
4110 .with_property("expand", window.expand.clone())
4111 .with_provenance(provenance.clone());
4112 nodes.push(node_with_content_freshness(projected)?);
4113
4114 if let Some(file_handle) = file_node_by_path.get(&window.file) {
4115 let edge = SubstrateGraphEdge::new(
4116 window.handle.clone(),
4117 file_handle.clone(),
4118 "expands_source",
4119 )
4120 .with_property("label", window.reason.clone())
4121 .with_provenance(provenance.clone());
4122 edges.push(edge_with_content_freshness(edge)?);
4123 }
4124 if node.kind != "file" {
4125 let edge = SubstrateGraphEdge::new(
4126 window.handle.clone(),
4127 node.handle.clone(),
4128 "anchors_source",
4129 )
4130 .with_property("label", window.reason.clone())
4131 .with_provenance(provenance.clone());
4132 edges.push(edge_with_content_freshness(edge)?);
4133 }
4134 seen_windows.insert(window_key, window.handle.clone());
4135 window.handle
4136 };
4137 source_handle_by_node.insert(node.handle.clone(), handle.clone());
4138 Ok(Some(handle))
4139}
4140
4141fn push_traversal_backlog_target_handles<'a>(
4142 backlog: &TraversalNode,
4143 edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
4144 node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
4145 max_handles: usize,
4146 seen_target_nodes: &mut BTreeSet<String>,
4147 target_node_handles: &mut Vec<String>,
4148) {
4149 for edge in edges_by_from
4150 .get(backlog.handle.as_str())
4151 .into_iter()
4152 .flatten()
4153 .filter(|edge| edge.relation == "mentions")
4154 {
4155 let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
4156 continue;
4157 };
4158 if !matches!(
4159 target_node.kind.as_str(),
4160 "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
4161 ) {
4162 continue;
4163 }
4164 if target_node
4165 .path
4166 .as_deref()
4167 .zip(backlog.path.as_deref())
4168 .is_some_and(|(target_path, backlog_path)| {
4169 target_path == backlog_path && target_path.ends_with(".md")
4170 })
4171 {
4172 continue;
4173 }
4174 if seen_target_nodes.insert(target_node.handle.clone()) {
4175 target_node_handles.push(target_node.handle.clone());
4176 }
4177 if target_node_handles.len() >= max_handles {
4178 break;
4179 }
4180 }
4181}
4182
4183fn append_traversal_context_projection_rows(
4184 root: &Path,
4185 graph: &TraversalGraphBuild,
4186 provenance: &GraphProvenance,
4187 nodes: &mut Vec<SubstrateGraphNode>,
4188 edges: &mut Vec<SubstrateGraphEdge>,
4189) -> Result<()> {
4190 let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4191 let file_node_by_path = graph
4192 .nodes
4193 .values()
4194 .filter(|node| node.kind == "file")
4195 .filter_map(|node| {
4196 node.path
4197 .as_ref()
4198 .map(|path| (path.clone(), node.handle.clone()))
4199 })
4200 .collect::<BTreeMap<_, _>>();
4201
4202 let node_by_handle = graph
4203 .nodes
4204 .values()
4205 .map(|node| (node.handle.as_str(), node))
4206 .collect::<BTreeMap<_, _>>();
4207 let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4208 for edge in &graph.edges {
4209 edges_by_from
4210 .entry(edge.from.as_str())
4211 .or_default()
4212 .push(edge);
4213 }
4214 for rows in edges_by_from.values_mut() {
4215 rows.sort_by(|left, right| {
4216 right
4217 .weight
4218 .cmp(&left.weight)
4219 .then(left.relation.cmp(&right.relation))
4220 .then(left.to.cmp(&right.to))
4221 });
4222 }
4223
4224 let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4225 let mut source_handle_by_node = BTreeMap::<String, String>::new();
4226
4227 let mut code_context_count = 0usize;
4228 let code_context_limit = budget.relationship_limit.min(8);
4229 for node in graph.nodes.values() {
4230 if !matches!(
4231 node.kind.as_str(),
4232 "backlog" | "job_packet" | "worker_result"
4233 ) {
4234 continue;
4235 }
4236 let mut target_node_handles = Vec::new();
4237 let mut fallback_target_handles = Vec::new();
4238 let mut seen_target_nodes = BTreeSet::new();
4239 if node.kind == "backlog" || node.kind == "worker_result" {
4240 push_traversal_backlog_target_handles(
4241 node,
4242 &edges_by_from,
4243 &node_by_handle,
4244 budget.max_source_windows,
4245 &mut seen_target_nodes,
4246 &mut target_node_handles,
4247 );
4248 fallback_target_handles.push(node.handle.clone());
4249 } else {
4250 for edge in edges_by_from
4251 .get(node.handle.as_str())
4252 .into_iter()
4253 .flatten()
4254 .filter(|edge| edge.relation == "targets")
4255 {
4256 let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4257 continue;
4258 };
4259 fallback_target_handles.push(backlog.handle.clone());
4260 push_traversal_backlog_target_handles(
4261 backlog,
4262 &edges_by_from,
4263 &node_by_handle,
4264 budget.max_source_windows,
4265 &mut seen_target_nodes,
4266 &mut target_node_handles,
4267 );
4268 if target_node_handles.len() >= budget.max_source_windows {
4269 break;
4270 }
4271 }
4272 if fallback_target_handles.is_empty() {
4273 continue;
4274 }
4275 }
4276 let code_context = !target_node_handles.is_empty();
4277 if target_node_handles.is_empty() {
4278 target_node_handles = dedupe_preserve_order(fallback_target_handles);
4279 } else if code_context_count >= code_context_limit {
4280 continue;
4281 }
4282
4283 let mut worker_source_handles = Vec::new();
4284 let mut seen_worker_handles = BTreeSet::new();
4285 for target_handle in target_node_handles {
4286 if worker_source_handles.len() >= budget.max_source_windows {
4287 break;
4288 }
4289 let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4290 continue;
4291 };
4292 let Some(handle) = ensure_traversal_source_handle(
4293 root,
4294 provenance,
4295 &file_node_by_path,
4296 target_node,
4297 &budget,
4298 &mut source_handle_by_node,
4299 &mut seen_windows,
4300 nodes,
4301 edges,
4302 )?
4303 else {
4304 continue;
4305 };
4306 if seen_worker_handles.insert(handle.clone()) {
4307 worker_source_handles.push(handle);
4308 }
4309 }
4310 if worker_source_handles.is_empty() {
4311 continue;
4312 }
4313 let target = node
4314 .path
4315 .clone()
4316 .unwrap_or_else(|| root.to_string_lossy().to_string());
4317 let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4318 let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4319 let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4320 .with_property("handle", handle.clone())
4321 .with_property("target", target.clone())
4322 .with_property("summary", summary)
4323 .with_property(
4324 "source_handle_count",
4325 worker_source_handles.len().to_string(),
4326 )
4327 .with_property(
4328 "expand",
4329 format!(
4330 "tsift --envelope context-pack {} --budget normal",
4331 shell_quote(&target)
4332 ),
4333 )
4334 .with_provenance(provenance.clone());
4335 nodes.push(node_with_content_freshness(projected)?);
4336
4337 let request_edge =
4338 SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4339 .with_property("label", "bounded worker context".to_string())
4340 .with_provenance(provenance.clone());
4341 edges.push(edge_with_content_freshness(request_edge)?);
4342
4343 for source_handle in &worker_source_handles {
4344 let scope_edge =
4345 SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4346 .with_property("label", "bounded worker source window".to_string())
4347 .with_provenance(provenance.clone());
4348 edges.push(edge_with_content_freshness(scope_edge)?);
4349 }
4350 if code_context {
4351 code_context_count += 1;
4352 }
4353 }
4354
4355 Ok(())
4356}
4357
4358fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4359 let handle = node
4360 .properties
4361 .get("handle")
4362 .cloned()
4363 .unwrap_or_else(|| node.id.clone());
4364 TraversalNode {
4365 expand: node
4366 .properties
4367 .get("expand")
4368 .cloned()
4369 .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4370 handle,
4371 kind: node.kind,
4372 label: node.label,
4373 ref_id: node.properties.get("ref_id").cloned(),
4374 path: node.properties.get("path").cloned(),
4375 line: node
4376 .properties
4377 .get("line")
4378 .and_then(|value| value.parse::<i64>().ok()),
4379 detail: node.properties.get("detail").cloned(),
4380 properties: node.properties,
4381 }
4382}
4383
4384fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4385 let mut graph = TraversalGraphBuild::default();
4386 for node in store.all_nodes()? {
4387 if node.kind == GRAPH_PROJECTION_META_KIND {
4388 continue;
4389 }
4390 graph.add_node(traversal_node_from_graph_node(root, node));
4391 }
4392 for edge in store.all_edges()? {
4393 graph.add_edge(
4394 &edge.from_id,
4395 &edge.to_id,
4396 &edge.kind,
4397 edge.properties.get("label").cloned(),
4398 edge.properties
4399 .get("weight")
4400 .and_then(|value| value.parse::<usize>().ok())
4401 .unwrap_or(1),
4402 );
4403 }
4404 Ok(graph)
4405}
4406
4407pub(crate) fn convex_rows_from_graph_store(
4408 store: &impl GraphStore,
4409) -> Result<ConvexProjectionRows> {
4410 Ok(GraphProjection {
4411 nodes: store.all_nodes()?,
4412 edges: store.all_edges()?,
4413 }
4414 .to_convex_rows())
4415}
4416
4417#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4418struct ConvexRequiredIndex {
4419 table: String,
4420 name: String,
4421 fields: Vec<String>,
4422}
4423
4424#[derive(Clone, Debug, Serialize, PartialEq)]
4425struct ConvexSyncChunk {
4426 operation: String,
4427 chunk: usize,
4428 count: usize,
4429 keys: Vec<String>,
4430 max_attempts: usize,
4431 retry_policy: String,
4432}
4433
4434#[derive(Clone, Debug, Serialize, PartialEq)]
4435struct ConvexTransportSummary {
4436 endpoint_env: String,
4437 endpoint_configured: bool,
4438 auth_token_env: String,
4439 auth_configured: bool,
4440 remote_snapshot: bool,
4441 applied_chunks: usize,
4442}
4443
4444#[derive(Clone, Debug, Serialize, PartialEq)]
4445struct ConvexTransportReceipt {
4446 operation: String,
4447 chunk: usize,
4448 attempt: usize,
4449 status: String,
4450 message: Option<String>,
4451}
4452
4453#[derive(Serialize)]
4454#[serde(rename_all = "camelCase")]
4455struct ConvexTransportRequest<'a> {
4456 operation: &'a str,
4457 chunk: usize,
4458 projection_version: &'a str,
4459 projection_hash: Option<&'a str>,
4460 #[serde(skip_serializing_if = "Option::is_none")]
4461 projection_meta_id: Option<&'a str>,
4462 node_rows: Vec<ConvexNodeRow>,
4463 edge_rows: Vec<ConvexEdgeRow>,
4464 keys: Vec<String>,
4465 #[serde(skip_serializing_if = "Option::is_none")]
4466 cursor: Option<String>,
4467 #[serde(skip_serializing_if = "Option::is_none")]
4468 limit: Option<usize>,
4469}
4470
4471#[derive(Deserialize)]
4472#[serde(rename_all = "camelCase")]
4473struct ConvexTransportResponse {
4474 status: Option<String>,
4475 message: Option<String>,
4476 rows: Option<ConvexProjectionRows>,
4477 #[serde(default)]
4478 meta: Option<ConvexSnapshotMeta>,
4479 #[serde(default)]
4480 page: Option<ConvexSnapshotPage>,
4481}
4482
4483#[derive(Deserialize, Debug, Clone)]
4484#[serde(rename_all = "camelCase")]
4485struct ConvexSnapshotMeta {
4486 #[serde(default)]
4490 #[allow(dead_code)]
4491 indexes: Vec<ConvexRequiredIndex>,
4492 #[serde(default)]
4493 #[allow(dead_code)]
4494 node_count: Option<usize>,
4495 #[serde(default)]
4496 #[allow(dead_code)]
4497 edge_count: Option<usize>,
4498 #[serde(default)]
4499 projection_hash: Option<String>,
4500 #[serde(default)]
4501 #[allow(dead_code)]
4502 page_size: Option<usize>,
4503}
4504
4505#[derive(Deserialize, Debug, Clone)]
4510#[serde(rename_all = "camelCase")]
4511struct ConvexSnapshotPage {
4512 rows: Vec<serde_json::Value>,
4513 #[serde(default)]
4514 next_cursor: Option<String>,
4515}
4516
4517#[derive(Clone, Debug, Serialize, PartialEq)]
4518struct ConvexProjectionFreshness {
4519 status: String,
4520 fail_closed: bool,
4521 local_hash: Option<String>,
4522 snapshot_hash: Option<String>,
4523 missing_nodes: Vec<String>,
4524 stale_nodes: Vec<String>,
4525 missing_edges: Vec<String>,
4526 stale_edges: Vec<String>,
4527 diagnostics: Vec<String>,
4528}
4529
4530const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4531
4532impl ConvexProjectionFreshness {
4533 fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4534 Self {
4535 status: "current".to_string(),
4536 fail_closed: false,
4537 local_hash,
4538 snapshot_hash,
4539 missing_nodes: Vec::new(),
4540 stale_nodes: Vec::new(),
4541 missing_edges: Vec::new(),
4542 stale_edges: Vec::new(),
4543 diagnostics: Vec::new(),
4544 }
4545 }
4546}
4547
4548#[derive(Clone, Debug, Serialize, PartialEq)]
4549struct ConvexSyncReport {
4550 root: String,
4551 #[serde(skip_serializing_if = "Option::is_none")]
4552 scope: Option<String>,
4553 graph_db: String,
4554 dry_run: bool,
4555 projection_version: String,
4556 projection_hash: Option<String>,
4557 required_indexes: Vec<ConvexRequiredIndex>,
4558 node_upserts: Vec<ConvexNodeRow>,
4559 edge_upserts: Vec<ConvexEdgeRow>,
4560 node_tombstones: Vec<String>,
4561 edge_tombstones: Vec<String>,
4562 chunks: Vec<ConvexSyncChunk>,
4563 freshness: ConvexProjectionFreshness,
4564 transport: Option<ConvexTransportSummary>,
4565 receipts: Vec<ConvexTransportReceipt>,
4566 diagnostics: Vec<String>,
4567 warnings: Vec<String>,
4568}
4569
4570fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4571 vec![
4572 ConvexRequiredIndex {
4573 table: "nodes".to_string(),
4574 name: "by_external_id".to_string(),
4575 fields: vec!["externalId".to_string()],
4576 },
4577 ConvexRequiredIndex {
4578 table: "nodes".to_string(),
4579 name: "by_kind".to_string(),
4580 fields: vec!["kind".to_string()],
4581 },
4582 ConvexRequiredIndex {
4583 table: "edges".to_string(),
4584 name: "by_edge_key".to_string(),
4585 fields: vec!["edgeKey".to_string()],
4586 },
4587 ConvexRequiredIndex {
4588 table: "edges".to_string(),
4589 name: "by_from_kind".to_string(),
4590 fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4591 },
4592 ConvexRequiredIndex {
4593 table: "edges".to_string(),
4594 name: "by_to_kind".to_string(),
4595 fields: vec!["toExternalId".to_string(), "kind".to_string()],
4596 },
4597 ]
4598}
4599
4600pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
4601 let content = fs::read_to_string(path)
4602 .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
4603 serde_json::from_str(&content)
4604 .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
4605}
4606
4607fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
4608 let mut diagnostics = Vec::new();
4609 let mut node_counts = BTreeMap::<&str, usize>::new();
4610 for row in &rows.nodes {
4611 *node_counts.entry(row.external_id.as_str()).or_default() += 1;
4612 }
4613 for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
4614 diagnostics.push(format!(
4615 "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
4616 ));
4617 }
4618
4619 let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
4620 let mut edge_counts = BTreeMap::<&str, usize>::new();
4621 for edge in &rows.edges {
4622 *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
4623 if !node_ids.contains(edge.from_external_id.as_str()) {
4624 diagnostics.push(format!(
4625 "Convex snapshot edge {} references missing from node {}",
4626 edge.edge_key, edge.from_external_id
4627 ));
4628 }
4629 if !node_ids.contains(edge.to_external_id.as_str()) {
4630 diagnostics.push(format!(
4631 "Convex snapshot edge {} references missing to node {}",
4632 edge.edge_key, edge.to_external_id
4633 ));
4634 }
4635 let expected_key =
4636 ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
4637 if edge.edge_key != expected_key {
4638 diagnostics.push(format!(
4639 "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
4640 edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
4641 ));
4642 }
4643 }
4644 for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
4645 diagnostics.push(format!(
4646 "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
4647 ));
4648 }
4649 diagnostics
4650}
4651
4652pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
4653 let diagnostics = convex_projection_row_diagnostics(rows);
4654 if diagnostics.is_empty() {
4655 Ok(())
4656 } else {
4657 bail!("{}", diagnostics.join("; "))
4658 }
4659}
4660
4661pub(crate) struct ConvexHttpTransport {
4662 endpoint: String,
4663 auth_token_env: String,
4664 auth_token: Option<String>,
4665}
4666
4667impl ConvexHttpTransport {
4668 fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
4669 let endpoint = endpoint
4670 .map(str::to_string)
4671 .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
4672 .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
4673 let auth_token = env::var(auth_token_env)
4674 .ok()
4675 .filter(|value| !value.trim().is_empty());
4676 Ok(Self {
4677 endpoint,
4678 auth_token_env: auth_token_env.to_string(),
4679 auth_token,
4680 })
4681 }
4682
4683 fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
4684 ConvexTransportSummary {
4685 endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
4686 endpoint_configured: true,
4687 auth_token_env: self.auth_token_env.clone(),
4688 auth_configured: self.auth_token.is_some(),
4689 remote_snapshot,
4690 applied_chunks,
4691 }
4692 }
4693
4694 fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
4695 let mut builder = ureq::post(&self.endpoint);
4696 if let Some(token) = &self.auth_token {
4697 builder = builder.header("Authorization", &format!("Bearer {token}"));
4698 }
4699 builder
4700 .send_json(request)
4701 .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
4702 .body_mut()
4703 .read_json::<ConvexTransportResponse>()
4704 .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
4705 }
4706
4707 fn fetch_snapshot(
4718 &self,
4719 projection_version: &str,
4720 scope: Option<&str>,
4721 local_hash: Option<&str>,
4722 local_rows: Option<&ConvexProjectionRows>,
4723 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4724 match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
4725 Ok(rows) => Ok(rows),
4726 Err(err) => {
4727 let msg = format!("{err:#}");
4732 let is_unknown_op = msg.contains("unknown operation")
4733 || msg.contains("snapshot_meta")
4734 || msg.contains("404");
4735 if !is_unknown_op {
4736 return Err(err);
4737 }
4738 self.fetch_snapshot_legacy(projection_version)
4739 .map(|rows| (rows, Vec::new()))
4740 }
4741 }
4742 }
4743
4744 fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
4745 let response = self.post(&ConvexTransportRequest {
4746 operation: "snapshot",
4747 chunk: 0,
4748 projection_version,
4749 projection_hash: None,
4750 projection_meta_id: None,
4751 node_rows: Vec::new(),
4752 edge_rows: Vec::new(),
4753 keys: Vec::new(),
4754 cursor: None,
4755 limit: None,
4756 })?;
4757 response
4758 .rows
4759 .context("Convex snapshot response did not include rows")
4760 }
4761
4762 fn fetch_snapshot_paginated(
4763 &self,
4764 projection_version: &str,
4765 scope: Option<&str>,
4766 local_hash: Option<&str>,
4767 local_rows: Option<&ConvexProjectionRows>,
4768 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4769 let projection_meta_id = graph_projection_meta_id(scope);
4770 let meta_response = self.post(&ConvexTransportRequest {
4771 operation: "snapshot_meta",
4772 chunk: 0,
4773 projection_version,
4774 projection_hash: None,
4775 projection_meta_id: Some(&projection_meta_id),
4776 node_rows: Vec::new(),
4777 edge_rows: Vec::new(),
4778 keys: Vec::new(),
4779 cursor: None,
4780 limit: None,
4781 })?;
4782 if matches!(meta_response.status.as_deref(), Some("error")) {
4783 anyhow::bail!(
4784 "Convex snapshot_meta returned error: {}",
4785 meta_response.message.unwrap_or_default()
4786 );
4787 }
4788 let meta = meta_response
4789 .meta
4790 .context("Convex snapshot_meta response did not include meta")?;
4791 if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
4792 (meta.projection_hash.as_deref(), local_hash, local_rows)
4793 && remote_hash == local_hash
4794 {
4795 return Ok((
4796 local_rows.clone(),
4797 vec![
4798 "remote projection hash matched local graph; skipped full row-page snapshot diff"
4799 .to_string(),
4800 ],
4801 ));
4802 }
4803
4804 let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
4805 let mut node_cursor: Option<String> = None;
4806 loop {
4807 let response = self.post(&ConvexTransportRequest {
4808 operation: "snapshot_nodes_page",
4809 chunk: 0,
4810 projection_version,
4811 projection_hash: None,
4812 projection_meta_id: None,
4813 node_rows: Vec::new(),
4814 edge_rows: Vec::new(),
4815 keys: Vec::new(),
4816 cursor: node_cursor.clone(),
4817 limit: None,
4818 })?;
4819 let page = response
4820 .page
4821 .context("Convex snapshot_nodes_page response did not include page")?;
4822 for raw in page.rows {
4823 let row: ConvexNodeRow =
4824 serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
4825 nodes.push(row);
4826 }
4827 match page.next_cursor {
4828 Some(next) => node_cursor = Some(next),
4829 None => break,
4830 }
4831 }
4832
4833 let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
4834 let mut edge_cursor: Option<String> = None;
4835 loop {
4836 let response = self.post(&ConvexTransportRequest {
4837 operation: "snapshot_edges_page",
4838 chunk: 0,
4839 projection_version,
4840 projection_hash: None,
4841 projection_meta_id: None,
4842 node_rows: Vec::new(),
4843 edge_rows: Vec::new(),
4844 keys: Vec::new(),
4845 cursor: edge_cursor.clone(),
4846 limit: None,
4847 })?;
4848 let page = response
4849 .page
4850 .context("Convex snapshot_edges_page response did not include page")?;
4851 for raw in page.rows {
4852 let row: ConvexEdgeRow =
4853 serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
4854 edges.push(row);
4855 }
4856 match page.next_cursor {
4857 Some(next) => edge_cursor = Some(next),
4858 None => break,
4859 }
4860 }
4861
4862 Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
4863 }
4864
4865 fn apply_chunk(
4866 &self,
4867 report: &ConvexSyncReport,
4868 chunk: &ConvexSyncChunk,
4869 ) -> Result<ConvexTransportReceipt> {
4870 let node_rows = if chunk.operation == "upsert_nodes" {
4871 report
4872 .node_upserts
4873 .iter()
4874 .filter(|row| chunk.keys.contains(&row.external_id))
4875 .cloned()
4876 .collect()
4877 } else {
4878 Vec::new()
4879 };
4880 let edge_rows = if chunk.operation == "upsert_edges" {
4881 report
4882 .edge_upserts
4883 .iter()
4884 .filter(|row| chunk.keys.contains(&row.edge_key))
4885 .cloned()
4886 .collect()
4887 } else {
4888 Vec::new()
4889 };
4890 let request = ConvexTransportRequest {
4891 operation: &chunk.operation,
4892 chunk: chunk.chunk,
4893 projection_version: &report.projection_version,
4894 projection_hash: report.projection_hash.as_deref(),
4895 projection_meta_id: None,
4896 node_rows,
4897 edge_rows,
4898 keys: chunk.keys.clone(),
4899 cursor: None,
4900 limit: None,
4901 };
4902 let mut last_error = None;
4903 for attempt in 1..=chunk.max_attempts {
4904 match self.post(&request) {
4905 Ok(response) => {
4906 return Ok(ConvexTransportReceipt {
4907 operation: chunk.operation.clone(),
4908 chunk: chunk.chunk,
4909 attempt,
4910 status: response.status.unwrap_or_else(|| "ok".to_string()),
4911 message: response.message,
4912 });
4913 }
4914 Err(err) => {
4915 last_error = Some(err);
4916 if attempt < chunk.max_attempts {
4917 std::thread::sleep(Duration::from_millis(100 * attempt as u64));
4918 }
4919 }
4920 }
4921 }
4922 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
4923 .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
4924 }
4925}
4926
4927fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
4928 let meta_id = graph_projection_meta_id(scope);
4929 rows.nodes
4930 .iter()
4931 .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
4932 .and_then(|row| row.properties.get("content_hash").cloned())
4933}
4934
4935fn convex_projection_freshness(
4936 local: &ConvexProjectionRows,
4937 snapshot: Option<&ConvexProjectionRows>,
4938 scope: Option<&str>,
4939) -> ConvexProjectionFreshness {
4940 let local_hash = convex_projection_hash(local, scope);
4941 let Some(snapshot) = snapshot else {
4942 return ConvexProjectionFreshness {
4943 status: "unchecked".to_string(),
4944 fail_closed: false,
4945 local_hash,
4946 snapshot_hash: None,
4947 missing_nodes: Vec::new(),
4948 stale_nodes: Vec::new(),
4949 missing_edges: Vec::new(),
4950 stale_edges: Vec::new(),
4951 diagnostics: vec![
4952 "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
4953 ],
4954 };
4955 };
4956
4957 let snapshot_hash = convex_projection_hash(snapshot, scope);
4958 let snapshot_nodes = snapshot
4959 .nodes
4960 .iter()
4961 .map(|row| (row.external_id.as_str(), row))
4962 .collect::<BTreeMap<_, _>>();
4963 let snapshot_edges = snapshot
4964 .edges
4965 .iter()
4966 .map(|row| (row.edge_key.as_str(), row))
4967 .collect::<BTreeMap<_, _>>();
4968
4969 let mut missing_nodes = Vec::new();
4970 let mut stale_nodes = Vec::new();
4971 for row in &local.nodes {
4972 match snapshot_nodes.get(row.external_id.as_str()) {
4973 Some(snapshot_row) if *snapshot_row == row => {}
4974 Some(_) => stale_nodes.push(row.external_id.clone()),
4975 None => missing_nodes.push(row.external_id.clone()),
4976 }
4977 }
4978
4979 let mut missing_edges = Vec::new();
4980 let mut stale_edges = Vec::new();
4981 for row in &local.edges {
4982 match snapshot_edges.get(row.edge_key.as_str()) {
4983 Some(snapshot_row) if *snapshot_row == row => {}
4984 Some(_) => stale_edges.push(row.edge_key.clone()),
4985 None => missing_edges.push(row.edge_key.clone()),
4986 }
4987 }
4988
4989 let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
4990 let rows_current = missing_nodes.is_empty()
4991 && stale_nodes.is_empty()
4992 && missing_edges.is_empty()
4993 && stale_edges.is_empty();
4994 if hash_current && rows_current {
4995 return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
4996 }
4997
4998 let mut diagnostics = Vec::new();
4999 if local_hash != snapshot_hash {
5000 diagnostics.push(format!(
5001 "projection hash mismatch: local={} snapshot={}",
5002 local_hash.as_deref().unwrap_or("missing"),
5003 snapshot_hash.as_deref().unwrap_or("missing")
5004 ));
5005 }
5006 if !missing_nodes.is_empty() || !missing_edges.is_empty() {
5007 diagnostics.push(format!(
5008 "Convex snapshot is missing {} node(s) and {} edge(s)",
5009 missing_nodes.len(),
5010 missing_edges.len()
5011 ));
5012 }
5013 if !stale_nodes.is_empty() || !stale_edges.is_empty() {
5014 diagnostics.push(format!(
5015 "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
5016 stale_nodes.len(),
5017 stale_edges.len()
5018 ));
5019 }
5020
5021 ConvexProjectionFreshness {
5022 status: "stale".to_string(),
5023 fail_closed: true,
5024 local_hash,
5025 snapshot_hash,
5026 missing_nodes,
5027 stale_nodes,
5028 missing_edges,
5029 stale_edges,
5030 diagnostics,
5031 }
5032}
5033
5034pub(crate) fn verify_convex_projection_snapshot(
5035 root: &Path,
5036 scope: Option<&str>,
5037 snapshot_path: &Path,
5038) -> Result<()> {
5039 let graph_db = graph_substrate_db_path(root, scope);
5040 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5041 let local = convex_rows_from_graph_store(&store)?;
5042 let snapshot = load_convex_projection_rows(snapshot_path)?;
5043 validate_convex_projection_rows(&snapshot)?;
5044 let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
5045 if freshness.fail_closed {
5046 bail!(
5047 "Convex graph projection is not current for {}: {}",
5048 root.display(),
5049 freshness.diagnostics.join("; ")
5050 );
5051 }
5052 Ok(())
5053}
5054
5055fn convex_rows_diff(
5056 local: &ConvexProjectionRows,
5057 snapshot: Option<&ConvexProjectionRows>,
5058) -> (
5059 Vec<ConvexNodeRow>,
5060 Vec<ConvexEdgeRow>,
5061 Vec<String>,
5062 Vec<String>,
5063) {
5064 let Some(snapshot) = snapshot else {
5065 return (
5066 local.nodes.clone(),
5067 local.edges.clone(),
5068 Vec::new(),
5069 Vec::new(),
5070 );
5071 };
5072 let local_nodes = local
5073 .nodes
5074 .iter()
5075 .map(|row| (row.external_id.as_str(), row))
5076 .collect::<BTreeMap<_, _>>();
5077 let local_edges = local
5078 .edges
5079 .iter()
5080 .map(|row| (row.edge_key.as_str(), row))
5081 .collect::<BTreeMap<_, _>>();
5082 let snapshot_nodes = snapshot
5083 .nodes
5084 .iter()
5085 .map(|row| (row.external_id.as_str(), row))
5086 .collect::<BTreeMap<_, _>>();
5087 let snapshot_edges = snapshot
5088 .edges
5089 .iter()
5090 .map(|row| (row.edge_key.as_str(), row))
5091 .collect::<BTreeMap<_, _>>();
5092
5093 let node_upserts = local
5094 .nodes
5095 .iter()
5096 .filter(|row| {
5097 snapshot_nodes
5098 .get(row.external_id.as_str())
5099 .is_none_or(|snapshot_row| *snapshot_row != *row)
5100 })
5101 .cloned()
5102 .collect::<Vec<_>>();
5103 let edge_upserts = local
5104 .edges
5105 .iter()
5106 .filter(|row| {
5107 snapshot_edges
5108 .get(row.edge_key.as_str())
5109 .is_none_or(|snapshot_row| *snapshot_row != *row)
5110 })
5111 .cloned()
5112 .collect::<Vec<_>>();
5113 let node_tombstones = snapshot
5114 .nodes
5115 .iter()
5116 .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
5117 .map(|row| row.external_id.clone())
5118 .collect::<Vec<_>>();
5119 let edge_tombstones = snapshot
5120 .edges
5121 .iter()
5122 .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
5123 .map(|row| row.edge_key.clone())
5124 .collect::<Vec<_>>();
5125
5126 (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
5127}
5128
5129fn push_sync_chunks(
5130 chunks: &mut Vec<ConvexSyncChunk>,
5131 operation: &str,
5132 keys: Vec<String>,
5133 size: usize,
5134) {
5135 if keys.is_empty() {
5136 return;
5137 }
5138 for (idx, chunk) in keys.chunks(size).enumerate() {
5139 chunks.push(ConvexSyncChunk {
5140 operation: operation.to_string(),
5141 chunk: idx + 1,
5142 count: chunk.len(),
5143 keys: chunk.to_vec(),
5144 max_attempts: 3,
5145 retry_policy:
5146 "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
5147 .to_string(),
5148 });
5149 }
5150}
5151
5152pub(crate) fn build_convex_sync_report_with_snapshot(
5153 path: &Path,
5154 scope: Option<&str>,
5155 snapshot: Option<ConvexProjectionRows>,
5156 chunk_size: usize,
5157 dry_run: bool,
5158) -> Result<ConvexSyncReport> {
5159 if chunk_size == 0 {
5160 bail!("--chunk-size must be greater than zero");
5161 }
5162 let root = lint::resolve_project_root_or_canonical_path(path)?;
5163 let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
5164 let graph_db = graph_substrate_db_path(&root, scope);
5165 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5166 let local = convex_rows_from_graph_store(&store)?;
5167 let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
5168 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
5169 convex_rows_diff(&local, snapshot.as_ref());
5170
5171 let mut chunks = Vec::new();
5172 push_sync_chunks(
5173 &mut chunks,
5174 "delete_edges",
5175 edge_tombstones.clone(),
5176 chunk_size,
5177 );
5178 push_sync_chunks(
5179 &mut chunks,
5180 "upsert_nodes",
5181 node_upserts
5182 .iter()
5183 .map(|row| row.external_id.clone())
5184 .collect(),
5185 chunk_size,
5186 );
5187 push_sync_chunks(
5188 &mut chunks,
5189 "upsert_edges",
5190 edge_upserts
5191 .iter()
5192 .map(|row| row.edge_key.clone())
5193 .collect(),
5194 chunk_size,
5195 );
5196 push_sync_chunks(
5197 &mut chunks,
5198 "delete_nodes",
5199 node_tombstones.clone(),
5200 chunk_size,
5201 );
5202
5203 let mut diagnostics = vec![
5204 "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5205 .to_string(),
5206 ];
5207 if dry_run {
5208 diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5209 }
5210 if freshness.fail_closed {
5211 diagnostics.push(
5212 "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5213 .to_string(),
5214 );
5215 }
5216
5217 Ok(ConvexSyncReport {
5218 root: root.to_string_lossy().to_string(),
5219 scope: scope.map(str::to_string),
5220 graph_db: graph_db.to_string_lossy().to_string(),
5221 dry_run,
5222 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5223 projection_hash: convex_projection_hash(&local, scope),
5224 required_indexes: convex_required_indexes(),
5225 node_upserts,
5226 edge_upserts,
5227 node_tombstones,
5228 edge_tombstones,
5229 chunks,
5230 freshness,
5231 transport: None,
5232 receipts: Vec::new(),
5233 diagnostics,
5234 warnings: graph.warnings,
5235 })
5236}
5237
5238#[cfg(test)]
5239fn build_convex_sync_report(
5240 path: &Path,
5241 scope: Option<&str>,
5242 snapshot_path: Option<&Path>,
5243 chunk_size: usize,
5244) -> Result<ConvexSyncReport> {
5245 let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5246 build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5247}
5248
5249pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5250 if compact {
5251 println!(
5252 "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5253 report.node_upserts.len(),
5254 report.node_tombstones.len(),
5255 report.edge_upserts.len(),
5256 report.edge_tombstones.len(),
5257 report.chunks.len(),
5258 report.freshness.status
5259 );
5260 return;
5261 }
5262
5263 println!(
5264 "Convex graph sync {}",
5265 if report.dry_run { "dry-run" } else { "apply" }
5266 );
5267 println!("root: {}", report.root);
5268 println!("graph_db: {}", report.graph_db);
5269 println!(
5270 "upserts: {} node(s), {} edge(s)",
5271 report.node_upserts.len(),
5272 report.edge_upserts.len()
5273 );
5274 println!(
5275 "tombstones: {} node(s), {} edge(s)",
5276 report.node_tombstones.len(),
5277 report.edge_tombstones.len()
5278 );
5279 println!("chunks: {}", report.chunks.len());
5280 println!("freshness: {}", report.freshness.status);
5281 if let Some(transport) = &report.transport {
5282 println!(
5283 "transport: endpoint_env={} auth_env={} applied_chunks={}",
5284 transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5285 );
5286 }
5287 for receipt in &report.receipts {
5288 println!(
5289 "receipt: {} chunk {} attempt {} {}",
5290 receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5291 );
5292 }
5293 for diagnostic in report
5294 .diagnostics
5295 .iter()
5296 .chain(report.freshness.diagnostics.iter())
5297 {
5298 println!("- {}", diagnostic);
5299 }
5300}
5301
5302pub(crate) struct ConvexSyncOptions<'a> {
5303 path: &'a Path,
5304 scope: Option<&'a str>,
5305 snapshot: Option<&'a Path>,
5306 chunk_size: usize,
5307 remote_snapshot: bool,
5308 apply: bool,
5309 endpoint: Option<&'a str>,
5310 auth_token_env: &'a str,
5311}
5312
5313#[derive(Serialize)]
5314struct GraphDbSchemaField {
5315 name: &'static str,
5316 value_type: &'static str,
5317 description: &'static str,
5318}
5319
5320#[derive(Serialize)]
5321struct GraphDbSchemaOperation {
5322 command: &'static str,
5323 description: &'static str,
5324}
5325
5326#[derive(Serialize)]
5327struct GraphDbSchemaContract {
5328 name: &'static str,
5329 version: &'static str,
5330 description: &'static str,
5331}
5332
5333#[derive(Serialize)]
5334struct GraphDbSchema {
5335 contract_versions: Vec<GraphDbSchemaContract>,
5336 node_fields: Vec<GraphDbSchemaField>,
5337 edge_fields: Vec<GraphDbSchemaField>,
5338 operations: Vec<GraphDbSchemaOperation>,
5339}
5340
5341#[derive(Clone, Serialize, Deserialize)]
5342struct GraphDbFreshnessReport {
5343 status: String,
5344 fail_closed: bool,
5345 projection_version: Option<String>,
5346 content_hash: Option<String>,
5347 source_watermark: Option<String>,
5348 diagnostics: Vec<String>,
5349}
5350
5351#[derive(Clone, Debug, Serialize)]
5352pub(crate) struct GraphEffectivenessReadiness {
5353 pub(crate) status: String,
5354 pub(crate) fail_closed: bool,
5355 pub(crate) reason: String,
5356 pub(crate) diagnostics: Vec<String>,
5357 pub(crate) next_commands: Vec<String>,
5358}
5359
5360#[derive(Clone, Debug, Serialize, PartialEq)]
5361struct GraphDbPropertyFilter {
5362 key: String,
5363 value: String,
5364}
5365
5366#[derive(Clone, Debug, Default)]
5367struct GraphDbQueryOptions {
5368 cursor: Option<String>,
5369 limit: Option<usize>,
5370 property_filters: Vec<GraphDbPropertyFilter>,
5371}
5372
5373#[derive(Clone, Debug, Serialize, PartialEq)]
5374struct GraphDbPageReport {
5375 #[serde(skip_serializing_if = "Option::is_none")]
5376 cursor: Option<String>,
5377 #[serde(skip_serializing_if = "Option::is_none")]
5378 limit: Option<usize>,
5379 #[serde(skip_serializing_if = "Option::is_none")]
5380 next_cursor: Option<String>,
5381 returned_nodes: usize,
5382 returned_edges: usize,
5383 truncated: bool,
5384 property_filters: Vec<GraphDbPropertyFilter>,
5385 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5386 diagnostics: Vec<String>,
5387}
5388
5389type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5390
5391#[derive(Clone, Debug, Serialize)]
5392struct CommunityTruncationSummary {
5393 total_communities: usize,
5394 fully_kept: usize,
5395 partially_pruned: usize,
5396 fully_pruned: usize,
5397 pruned_community_kinds: Vec<String>,
5398 pruned_community_top_labels: Vec<String>,
5399}
5400
5401#[derive(Clone, Debug, Serialize)]
5402struct GraphDbRankedNeighborhoodComparison {
5403 traversal_nodes: usize,
5404 traversal_edges: usize,
5405 pruned_count: usize,
5406 total_discovered: usize,
5407 latency_micros: u128,
5408 overlap_with_unranked_pct: f64,
5409 useful_hit_density_ranked: f64,
5410 useful_hit_density_unranked: f64,
5411 duplicate_name_count_ranked: usize,
5412 duplicate_name_count_unranked: usize,
5413 handle_coverage_ranked_pct: f64,
5414 handle_coverage_unranked_pct: f64,
5415 #[serde(skip_serializing_if = "Option::is_none")]
5416 community_truncation_summary: Option<CommunityTruncationSummary>,
5417 diagnostics: Vec<String>,
5418}
5419
5420#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5421struct GraphDbDroppedByBudget {
5422 item: String,
5423 kind: String,
5424 dropped: usize,
5425 reason: String,
5426}
5427
5428#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5429struct GraphDbOutputBudgetReport {
5430 max_tokens: usize,
5431 estimated_tokens: usize,
5432 selected_nodes: usize,
5433 selected_edges: usize,
5434 candidate_nodes: usize,
5435 candidate_edges: usize,
5436 dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5437 diagnostics: Vec<String>,
5438}
5439
5440#[derive(Clone, Debug, Serialize, PartialEq)]
5441struct GraphDbKnowledgeRetrieval {
5442 mode: String,
5443 query: String,
5444 seed_kind: String,
5445 seed_limit: usize,
5446 seed_count: usize,
5447 depth: usize,
5448 limit: usize,
5449 node_count: usize,
5450 edge_count: usize,
5451 truncated: bool,
5452 traversal: String,
5453 freshness_boundary: String,
5454 privacy_boundary: String,
5455 diagnostics: Vec<String>,
5456}
5457
5458struct GraphDbSemanticSeededSubgraph {
5459 nodes: Vec<SubstrateGraphNode>,
5460 edges: Vec<SubstrateGraphEdge>,
5461 truncated: bool,
5462 diagnostics: Vec<String>,
5463}
5464
5465type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5466
5467#[derive(Serialize)]
5468struct GraphDbReport {
5469 root: String,
5470 #[serde(skip_serializing_if = "Option::is_none")]
5471 scope: Option<String>,
5472 backend: String,
5473 query: String,
5474 freshness: GraphDbFreshnessReport,
5475 #[serde(skip_serializing_if = "Option::is_none")]
5476 readiness: Option<GraphEffectivenessReadiness>,
5477 #[serde(skip_serializing_if = "Option::is_none")]
5478 schema: Option<GraphDbSchema>,
5479 #[serde(skip_serializing_if = "Option::is_none")]
5480 node: Option<SubstrateTerseGraphNode>,
5481 #[serde(skip_serializing_if = "Option::is_none")]
5482 edge: Option<SubstrateTerseGraphEdge>,
5483 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5484 nodes: Vec<SubstrateTerseGraphNode>,
5485 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5486 edges: Vec<SubstrateTerseGraphEdge>,
5487 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5488 ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5489 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5490 semantic_related: Vec<SemanticRelatedItem>,
5491 #[serde(skip_serializing_if = "Option::is_none")]
5492 neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5493 #[serde(skip_serializing_if = "Option::is_none")]
5494 ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5495 #[serde(skip_serializing_if = "Option::is_none")]
5496 knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5497 #[serde(skip_serializing_if = "Option::is_none")]
5498 output_budget: Option<GraphDbOutputBudgetReport>,
5499 #[serde(skip_serializing_if = "Option::is_none")]
5500 path: Option<substrate::GraphPath>,
5501 #[serde(skip_serializing_if = "Option::is_none")]
5502 page: Option<GraphDbPageReport>,
5503 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5504 warnings: Vec<String>,
5505}
5506
5507struct ExperimentalReadOnlyGraphStore {
5508 backend: GraphDbExperimentalBackend,
5509 nodes: BTreeMap<String, SubstrateGraphNode>,
5510 edges: BTreeMap<String, SubstrateGraphEdge>,
5511 node_ids_by_kind: BTreeMap<String, Vec<String>>,
5512 outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5513}
5514
5515impl ExperimentalReadOnlyGraphStore {
5516 fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5517 validate_convex_projection_rows(rows)?;
5518 let nodes = rows
5519 .nodes
5520 .iter()
5521 .map(|row| {
5522 let node = SubstrateGraphNode {
5523 id: row.external_id.clone(),
5524 kind: row.kind.clone(),
5525 label: row.label.clone(),
5526 properties: row.properties.clone(),
5527 provenance: row.provenance.clone(),
5528 freshness: row.freshness.clone(),
5529 };
5530 (node.id.clone(), node)
5531 })
5532 .collect::<BTreeMap<_, _>>();
5533 let edges = rows
5534 .edges
5535 .iter()
5536 .map(|row| {
5537 let edge = SubstrateGraphEdge {
5538 id: row.edge_key.clone(),
5539 from_id: row.from_external_id.clone(),
5540 to_id: row.to_external_id.clone(),
5541 kind: row.kind.clone(),
5542 properties: row.properties.clone(),
5543 provenance: row.provenance.clone(),
5544 freshness: row.freshness.clone(),
5545 };
5546 (graph_db_edge_key(&edge), edge)
5547 })
5548 .collect::<BTreeMap<_, _>>();
5549 let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5550 for node in nodes.values() {
5551 node_ids_by_kind
5552 .entry(node.kind.clone())
5553 .or_default()
5554 .push(node.id.clone());
5555 }
5556 for ids in node_ids_by_kind.values_mut() {
5557 ids.sort();
5558 }
5559 let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5560 for edge in edges.values() {
5561 outgoing_edge_keys_by_from
5562 .entry(edge.from_id.clone())
5563 .or_default()
5564 .push(graph_db_edge_key(edge));
5565 }
5566 for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5567 edge_keys.sort_by(|left_key, right_key| {
5568 let left = &edges[left_key];
5569 let right = &edges[right_key];
5570 left.to_id
5571 .cmp(&right.to_id)
5572 .then(left.kind.cmp(&right.kind))
5573 .then(left_key.cmp(right_key))
5574 });
5575 }
5576 Ok(Self {
5577 backend,
5578 nodes,
5579 edges,
5580 node_ids_by_kind,
5581 outgoing_edge_keys_by_from,
5582 })
5583 }
5584}
5585
5586impl GraphStore for ExperimentalReadOnlyGraphStore {
5587 fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5588 bail!("{} backend-eval adapter is read-only", self.backend.name())
5589 }
5590
5591 fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5592 bail!("{} backend-eval adapter is read-only", self.backend.name())
5593 }
5594
5595 fn delete_node(&self, _id: &str) -> Result<usize> {
5596 bail!("{} backend-eval adapter is read-only", self.backend.name())
5597 }
5598
5599 fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
5600 bail!("{} backend-eval adapter is read-only", self.backend.name())
5601 }
5602
5603 fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
5604 Ok(self.nodes.get(id).cloned())
5605 }
5606
5607 fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
5608 Ok(self.nodes.values().cloned().collect())
5609 }
5610
5611 fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
5612 let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
5613 edges.sort_by(|left, right| {
5614 left.from_id
5615 .cmp(&right.from_id)
5616 .then(left.kind.cmp(&right.kind))
5617 .then(left.to_id.cmp(&right.to_id))
5618 });
5619 Ok(edges)
5620 }
5621
5622 fn graph_counts(&self) -> Result<(usize, usize)> {
5623 Ok((self.nodes.len(), self.edges.len()))
5624 }
5625
5626 fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
5627 let mut edges = self
5628 .edges
5629 .values()
5630 .filter(|edge| edge.from_id != edge.to_id)
5631 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5632 .cloned()
5633 .collect::<Vec<_>>();
5634 edges.sort_by(|left, right| {
5635 left.from_id
5636 .cmp(&right.from_id)
5637 .then(left.kind.cmp(&right.kind))
5638 .then(left.to_id.cmp(&right.to_id))
5639 });
5640 Ok(edges.into_iter().next())
5641 }
5642
5643 fn sample_edge_with_property(
5644 &self,
5645 ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
5646 Ok(self
5647 .edges
5648 .values()
5649 .filter(|edge| edge.from_id != edge.to_id)
5650 .filter_map(|edge| {
5651 edge.properties.iter().next().map(|(key, value)| {
5652 (
5653 edge,
5654 GraphPropertyFilter {
5655 key: key.clone(),
5656 value: value.clone(),
5657 },
5658 )
5659 })
5660 })
5661 .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
5662 left_filter
5663 .key
5664 .cmp(&right_filter.key)
5665 .then(left_filter.value.cmp(&right_filter.value))
5666 .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
5667 })
5668 .map(|(edge, filter)| (edge.clone(), filter)))
5669 }
5670
5671 fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
5672 Ok(self
5673 .node_ids_by_kind
5674 .get(kind)
5675 .into_iter()
5676 .flatten()
5677 .filter_map(|id| self.nodes.get(id).cloned())
5678 .collect())
5679 }
5680
5681 fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
5682 Ok(self
5683 .outgoing_edge_keys_by_from
5684 .get(from_id)
5685 .into_iter()
5686 .flatten()
5687 .filter_map(|key| self.edges.get(key))
5688 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5689 .cloned()
5690 .collect())
5691 }
5692
5693 fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
5694 Ok(self
5695 .edges
5696 .values()
5697 .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
5698 .cloned()
5699 .collect())
5700 }
5701
5702 fn shortest_path(
5703 &self,
5704 from_id: &str,
5705 to_id: &str,
5706 kind: Option<&str>,
5707 ) -> Result<Option<substrate::GraphPath>> {
5708 if from_id == to_id {
5709 return Ok(Some(substrate::GraphPath {
5710 nodes: vec![from_id.to_string()],
5711 hops: 0,
5712 }));
5713 }
5714
5715 let mut queue = VecDeque::new();
5716 let mut parent = BTreeMap::<String, String>::new();
5717 parent.insert(from_id.to_string(), String::new());
5718 queue.push_back(from_id.to_string());
5719
5720 while let Some(current) = queue.pop_front() {
5721 for edge in self.outgoing_edges(¤t, kind)? {
5722 if parent.contains_key(&edge.to_id) {
5723 continue;
5724 }
5725 parent.insert(edge.to_id.clone(), current.clone());
5726 if edge.to_id == to_id {
5727 let mut nodes = vec![to_id.to_string()];
5728 let mut cursor = to_id;
5729 while let Some(previous) = parent.get(cursor) {
5730 if previous.is_empty() {
5731 break;
5732 }
5733 nodes.push(previous.clone());
5734 cursor = previous;
5735 }
5736 nodes.reverse();
5737 return Ok(Some(substrate::GraphPath {
5738 hops: nodes.len().saturating_sub(1),
5739 nodes,
5740 }));
5741 }
5742 queue.push_back(edge.to_id);
5743 }
5744 }
5745
5746 Ok(None)
5747 }
5748
5749 fn reachable_nodes_by_kinds(
5750 &self,
5751 from_id: &str,
5752 kinds: &[&str],
5753 depth: usize,
5754 limit: usize,
5755 ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
5756 let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
5757 let mut rows = requested
5758 .iter()
5759 .map(|kind| {
5760 (
5761 (*kind).to_string(),
5762 BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
5763 )
5764 })
5765 .collect::<BTreeMap<_, _>>();
5766 if requested.is_empty() {
5767 return Ok(BTreeMap::new());
5768 }
5769
5770 let mut seen = BTreeSet::from([from_id.to_string()]);
5771 let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
5772 while let Some((current, path)) = queue.pop_front() {
5773 let current_depth = path.len().saturating_sub(1);
5774 if current_depth >= depth {
5775 continue;
5776 }
5777 for edge in self.outgoing_edges(¤t, None)? {
5778 if !seen.insert(edge.to_id.clone()) {
5779 continue;
5780 }
5781 let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
5782 continue;
5783 };
5784 let mut next_path = path.clone();
5785 next_path.push(edge.to_id.clone());
5786 let graph_path = substrate::GraphPath {
5787 hops: next_path.len().saturating_sub(1),
5788 nodes: next_path.clone(),
5789 };
5790 if requested.contains(node.kind.as_str()) {
5791 rows.entry(node.kind.clone())
5792 .or_default()
5793 .entry(node.id.clone())
5794 .or_insert((node.clone(), graph_path));
5795 }
5796 queue.push_back((edge.to_id, next_path));
5797 }
5798 }
5799
5800 Ok(rows
5801 .into_iter()
5802 .map(|(kind, values)| {
5803 let mut values = values.into_values().collect::<Vec<_>>();
5804 values.sort_by(|(left_node, left_path), (right_node, right_path)| {
5805 left_path
5806 .hops
5807 .cmp(&right_path.hops)
5808 .then(left_node.label.cmp(&right_node.label))
5809 .then(left_node.id.cmp(&right_node.id))
5810 });
5811 if limit > 0 && values.len() > limit {
5812 values.truncate(limit);
5813 }
5814 (kind, values)
5815 })
5816 .collect())
5817 }
5818}
5819
5820pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
5821pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
5822pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
5823const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
5824pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
5825const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
5826const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
5827const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
5828const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
5829
5830#[derive(Clone, Serialize, Deserialize)]
5831pub(crate) struct GraphDbBackendEvalPhaseTiming {
5832 name: String,
5833 duration_micros: u128,
5834 detail: String,
5835}
5836
5837#[derive(Serialize, Deserialize)]
5838struct GraphDbBackendEvalFullProjectionCache {
5839 version: String,
5840 key: String,
5841 source_watermark: String,
5842 projection: GraphProjection,
5843 warnings: Vec<String>,
5844}
5845
5846#[derive(Clone, Default)]
5847struct GraphDbBackendEvalFullProjectionCacheStats {
5848 hit: bool,
5849 disk_bytes: u64,
5850 json_bytes: u64,
5851 pruned_files: usize,
5852 pruned_bytes: u64,
5853}
5854
5855#[derive(Serialize)]
5856struct GraphDbBackendEvalRawSourceWatermarkRow {
5857 path: String,
5858 bytes: u64,
5859 content_hash: String,
5860}
5861
5862#[derive(Clone)]
5863struct GraphDbBackendEvalFullProjectionSourceWatermark {
5864 value: String,
5865 detail: String,
5866}
5867
5868#[derive(Serialize)]
5869pub(crate) struct GraphDbBackendEvalConfig {
5870 high_degree_nodes: usize,
5871 high_degree_fanout: usize,
5872 deep_chain_nodes: usize,
5873 deep_chain_fanout: usize,
5874 depth: usize,
5875 limit: usize,
5876 impact_limit: usize,
5877 path_max_hops: usize,
5878 path_direct_hop_budget: usize,
5879 path_deep_chain_hop_budget: usize,
5880 path_extended_hop_budgets: Vec<usize>,
5881 path_hop_policy: String,
5882 path_probe_strategy: String,
5883 path_query_plan_checks: Vec<String>,
5884 full_projection_enabled: bool,
5885 full_projection_profile: String,
5886 normalization_row_unit: usize,
5887}
5888
5889#[derive(Clone)]
5890struct GraphDbBackendEvalSignature {
5891 operation: String,
5892 value: serde_json::Value,
5893}
5894
5895#[derive(Serialize)]
5896struct GraphDbBackendEvalOperation {
5897 name: String,
5898 supported: bool,
5899 status: String,
5900 duration_micros: u128,
5901 #[serde(skip_serializing_if = "Option::is_none")]
5902 rows: Option<usize>,
5903 #[serde(skip_serializing_if = "Option::is_none")]
5904 error: Option<String>,
5905}
5906
5907#[derive(Serialize)]
5908struct GraphDbBackendEvalParity {
5909 matches_sqlite: bool,
5910 diagnostics: Vec<String>,
5911}
5912
5913#[derive(Serialize)]
5914struct GraphDbBackendEvalBackendReport {
5915 backend: String,
5916 adapter: String,
5917 read_only: bool,
5918 projection_load: String,
5919 operations: Vec<GraphDbBackendEvalOperation>,
5920 total_micros: u128,
5921 parity: GraphDbBackendEvalParity,
5922 lock_behavior: String,
5923 install_portability: String,
5924}
5925
5926#[derive(Serialize)]
5927struct GraphDbBackendEvalDataset {
5928 name: String,
5929 target_count: usize,
5930 nodes: usize,
5931 edges: usize,
5932 backends: Vec<GraphDbBackendEvalBackendReport>,
5933}
5934
5935#[derive(Serialize)]
5936struct GraphDbBackendPromotionDecision {
5937 backend: String,
5938 decision: String,
5939 reasons: Vec<String>,
5940 gate: GraphDbBackendPromotionGate,
5941}
5942
5943#[derive(Serialize)]
5944struct GraphDbBackendEvalPerformanceGate {
5945 baseline_fixture: String,
5946 ci_profile: String,
5947 opt_in_real_profile: String,
5948 full_projection_cache_hit_gate: String,
5949 allowed_regression_percent: f64,
5950 minimum_sample_runs: usize,
5951 normalized_metric_unit: String,
5952 required_metrics: Vec<String>,
5953 digest_command: String,
5954 repeated_sample_command: String,
5955 hop_cap_promotion: GraphDbHopCapPromotionGate,
5956 backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
5957}
5958
5959#[derive(Serialize)]
5960struct GraphDbHopCapPromotionGate {
5961 status: String,
5962 current_default_hops: usize,
5963 candidate_hop_tiers: Vec<usize>,
5964 required_backend: String,
5965 required_workloads: Vec<String>,
5966 required_metrics: Vec<String>,
5967 allowed_regression_percent: f64,
5968 minimum_sample_runs: usize,
5969 decision_rule: String,
5970}
5971
5972#[derive(Serialize)]
5973struct GraphDbBackendAdapterSpikeGate {
5974 status: String,
5975 candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
5976 required_workloads: Vec<String>,
5977 required_checks: Vec<String>,
5978 decision_rule: String,
5979 evidence_plan: String,
5980}
5981
5982#[derive(Serialize)]
5983struct GraphDbBackendAdapterSpikeCandidate {
5984 backend: String,
5985 adapter_label: String,
5986 projection_load: String,
5987 lock_behavior: String,
5988 install_portability: String,
5989}
5990
5991#[derive(Serialize)]
5992pub(crate) struct GraphDbBackendEvalReport {
5993 root: String,
5994 #[serde(skip_serializing_if = "Option::is_none")]
5995 scope: Option<String>,
5996 label: String,
5997 baseline_backend: String,
5998 candidates: Vec<String>,
5999 targets: Vec<String>,
6000 config: GraphDbBackendEvalConfig,
6001 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6002 datasets: Vec<GraphDbBackendEvalDataset>,
6003 promotion: Vec<GraphDbBackendPromotionDecision>,
6004 performance_gate: GraphDbBackendEvalPerformanceGate,
6005 metrics: BTreeMap<String, f64>,
6006 metric_digest_command: String,
6007 warnings: Vec<String>,
6008}
6009
6010#[derive(Clone, Debug, Serialize)]
6011struct GraphDbDoctorCheck {
6012 name: String,
6013 status: String,
6014 fail_closed: bool,
6015 diagnostics: Vec<String>,
6016 repair_commands: Vec<String>,
6017}
6018
6019#[derive(Serialize)]
6020pub(crate) struct GraphDbDoctorReport {
6021 root: String,
6022 #[serde(skip_serializing_if = "Option::is_none")]
6023 scope: Option<String>,
6024 backend: String,
6025 graph_db: String,
6026 #[serde(skip_serializing_if = "Option::is_none")]
6027 convex_snapshot: Option<String>,
6028 status: String,
6029 fail_closed: bool,
6030 checks: Vec<GraphDbDoctorCheck>,
6031 repair_commands: Vec<String>,
6032 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6033 required_indexes: Vec<ConvexRequiredIndex>,
6034}
6035
6036#[derive(Serialize)]
6037struct GraphDbDriftSummary {
6038 node_upserts: usize,
6039 edge_upserts: usize,
6040 node_tombstones: usize,
6041 edge_tombstones: usize,
6042 stale_nodes: usize,
6043 stale_edges: usize,
6044 stale_projection_metadata: usize,
6045 duplicate_failures: usize,
6046 orphan_failures: usize,
6047 missing_required_indexes: usize,
6048}
6049
6050#[derive(Serialize)]
6051struct GraphDbDriftReport {
6052 root: String,
6053 #[serde(skip_serializing_if = "Option::is_none")]
6054 scope: Option<String>,
6055 graph_db: String,
6056 convex_snapshot: String,
6057 status: String,
6058 graph_reads_allowed: bool,
6059 projection_version: String,
6060 local_hash: Option<String>,
6061 snapshot_hash: Option<String>,
6062 summary: GraphDbDriftSummary,
6063 node_upserts: Vec<String>,
6064 edge_upserts: Vec<String>,
6065 node_tombstones: Vec<String>,
6066 edge_tombstones: Vec<String>,
6067 stale_nodes: Vec<String>,
6068 stale_edges: Vec<String>,
6069 diagnostics: Vec<String>,
6070 next_commands: Vec<String>,
6071 required_indexes: Vec<ConvexRequiredIndex>,
6072 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6073 warnings: Vec<String>,
6074}
6075
6076#[derive(Clone, Serialize)]
6077struct GraphDbTombstoneCounts {
6078 nodes: usize,
6079 edges: usize,
6080 total: usize,
6081}
6082
6083#[derive(Clone, Serialize)]
6084struct GraphDbOperatorCounts {
6085 nodes: usize,
6086 edges: usize,
6087 tombstones: GraphDbTombstoneCounts,
6088 #[serde(skip_serializing_if = "Option::is_none")]
6089 file_size_bytes: Option<u64>,
6090 #[serde(skip_serializing_if = "Option::is_none")]
6091 freelist_bytes: Option<u64>,
6092}
6093
6094#[derive(Clone, Serialize)]
6095struct GraphDbCompactionPolicy {
6096 status: String,
6097 tombstone_scan_rows: usize,
6098 live_rows: usize,
6099 file_size_bytes: Option<u64>,
6100 freelist_bytes: Option<u64>,
6101 safe_to_prune_tombstones: bool,
6102 requires_convex_reconciliation: bool,
6103 recommendations: Vec<String>,
6104 proof: Vec<String>,
6105}
6106
6107#[derive(Serialize)]
6108pub(crate) struct GraphDbRefreshSummary {
6109 scope: String,
6110 projection_version: String,
6111 mode: String,
6112 #[serde(skip_serializing_if = "Option::is_none")]
6113 source_watermark: Option<String>,
6114 tombstoned_nodes: usize,
6115 tombstoned_edges: usize,
6116 upserted_nodes: usize,
6117 upserted_edges: usize,
6118 unchanged_nodes: usize,
6119 unchanged_edges: usize,
6120 upserted_properties: usize,
6121 unchanged_properties: usize,
6122 deleted_properties: usize,
6123 deleted_nodes: usize,
6124 deleted_edges: usize,
6125 pruned_tombstones: usize,
6126 #[serde(skip_serializing_if = "Option::is_none")]
6127 file_size_bytes_before: Option<u64>,
6128 #[serde(skip_serializing_if = "Option::is_none")]
6129 file_size_bytes_after: Option<u64>,
6130 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6131 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6132}
6133
6134#[derive(Serialize)]
6135struct GraphDbOperatorReport {
6136 root: String,
6137 #[serde(skip_serializing_if = "Option::is_none")]
6138 scope: Option<String>,
6139 graph_db: String,
6140 operation: String,
6141 status: String,
6142 materialized: bool,
6143 freshness: GraphDbFreshnessReport,
6144 readiness: GraphEffectivenessReadiness,
6145 counts: GraphDbOperatorCounts,
6146 #[serde(skip_serializing_if = "Option::is_none")]
6147 refresh: Option<GraphDbRefreshSummary>,
6148 compaction: GraphDbCompactionPolicy,
6149 #[serde(skip_serializing_if = "Option::is_none")]
6150 recovery: Option<index::ReadOnlyRecovery>,
6151 next_commands: Vec<String>,
6152 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6153 warnings: Vec<String>,
6154}
6155
6156#[derive(Serialize)]
6157pub(crate) struct GraphDbCompactionReport {
6158 root: String,
6159 #[serde(skip_serializing_if = "Option::is_none")]
6160 scope: Option<String>,
6161 graph_db: String,
6162 applied: bool,
6163 pruned_tombstones: usize,
6164 counts_before: GraphDbOperatorCounts,
6165 counts_after: GraphDbOperatorCounts,
6166 compaction_before: GraphDbCompactionPolicy,
6167 compaction_after: GraphDbCompactionPolicy,
6168 reclaimed_bytes: i64,
6169 next_commands: Vec<String>,
6170 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6171 warnings: Vec<String>,
6172}
6173
6174#[derive(Clone, Serialize, Deserialize)]
6175struct GraphDbEvidencePath {
6176 to: String,
6177 kind: String,
6178 label: String,
6179 #[serde(skip_serializing_if = "Option::is_none")]
6180 path: Option<substrate::GraphPath>,
6181 #[serde(skip_serializing_if = "Option::is_none")]
6182 expand: Option<String>,
6183}
6184
6185#[derive(Clone, Serialize, Deserialize)]
6186struct GraphDbFixtureCoverage {
6187 test: String,
6188 fixture: String,
6189 assertions: Vec<String>,
6190}
6191
6192#[derive(Clone, Serialize, Deserialize)]
6193struct GraphDbEvidenceReport {
6194 root: String,
6195 #[serde(skip_serializing_if = "Option::is_none")]
6196 scope: Option<String>,
6197 backend: String,
6198 contract_version: String,
6199 target: String,
6200 packet_id: String,
6201 #[serde(skip_serializing_if = "Option::is_none")]
6202 projection_hash: Option<String>,
6203 freshness: GraphDbFreshnessReport,
6204 target_node: SubstrateTerseGraphNode,
6205 worker_context: Vec<SubstrateTerseGraphNode>,
6206 source_handles: Vec<SubstrateTerseGraphNode>,
6207 worker_results: Vec<SubstrateTerseGraphNode>,
6208 semantic_related: Vec<SubstrateTerseGraphNode>,
6209 shortest_paths: Vec<GraphDbEvidencePath>,
6210 #[serde(skip_serializing_if = "Option::is_none")]
6211 output_budget: Option<GraphDbOutputBudgetReport>,
6212 #[serde(default)]
6213 truncated: bool,
6214 #[serde(skip_serializing_if = "Option::is_none")]
6215 next_cursor: Option<String>,
6216 next_commands: Vec<String>,
6217 replay_commands: Vec<String>,
6218 repair_commands: Vec<String>,
6219 fixture_coverage: GraphDbFixtureCoverage,
6220 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6221 warnings: Vec<String>,
6222}
6223
6224pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6225 root: &'a Path,
6226 scope: Option<&'a str>,
6227 backend: &'a str,
6228 target: &'a str,
6229 depth: usize,
6230 limit: usize,
6231 cursor: Option<&'a str>,
6232 store: &'a S,
6233 freshness: GraphDbFreshnessReport,
6234 warnings: Vec<String>,
6235}
6236
6237impl GraphDbDoctorReport {
6238 fn new(
6239 root: &Path,
6240 scope: Option<&str>,
6241 backend: &str,
6242 graph_db: &Path,
6243 convex_snapshot: Option<&Path>,
6244 ) -> Self {
6245 Self {
6246 root: root.to_string_lossy().to_string(),
6247 scope: scope.map(str::to_string),
6248 backend: backend.to_string(),
6249 graph_db: graph_db.to_string_lossy().to_string(),
6250 convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6251 status: "ok".to_string(),
6252 fail_closed: false,
6253 checks: Vec::new(),
6254 repair_commands: Vec::new(),
6255 required_indexes: Vec::new(),
6256 }
6257 }
6258
6259 fn push_check(&mut self, check: GraphDbDoctorCheck) {
6260 self.checks.push(check);
6261 }
6262
6263 fn finalize(&mut self) {
6264 self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6265 self.status = if self.fail_closed {
6266 "fail_closed"
6267 } else {
6268 "ok"
6269 }
6270 .to_string();
6271 let mut commands = BTreeSet::new();
6272 for check in &self.checks {
6273 commands.extend(check.repair_commands.iter().cloned());
6274 }
6275 self.repair_commands = commands.into_iter().collect();
6276 }
6277
6278 fn summary(&self) -> String {
6279 self.checks
6280 .iter()
6281 .filter(|check| check.fail_closed)
6282 .flat_map(|check| check.diagnostics.iter())
6283 .take(3)
6284 .cloned()
6285 .collect::<Vec<_>>()
6286 .join("; ")
6287 }
6288}
6289
6290fn graph_db_doctor_check(
6291 name: impl Into<String>,
6292 diagnostics: Vec<String>,
6293 repair_commands: Vec<String>,
6294) -> GraphDbDoctorCheck {
6295 let fail_closed = !diagnostics.is_empty();
6296 GraphDbDoctorCheck {
6297 name: name.into(),
6298 status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6299 fail_closed,
6300 diagnostics,
6301 repair_commands: if fail_closed {
6302 repair_commands
6303 } else {
6304 Vec::new()
6305 },
6306 }
6307}
6308
6309pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6310 scope
6311 .map(|scope| format!(" --scope {}", shell_quote(scope)))
6312 .unwrap_or_default()
6313}
6314
6315fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6316 format!(
6317 "tsift graph-db --path {}{} refresh --json",
6318 shell_quote(root.to_string_lossy().as_ref()),
6319 graph_db_scope_arg(scope)
6320 )
6321}
6322
6323fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6324 graph_db_refresh_command(root, scope)
6325}
6326
6327fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6328 let backup = format!("{}.bak", graph_db.to_string_lossy());
6329 format!(
6330 "mv {} {} && {}",
6331 shell_quote(graph_db.to_string_lossy().as_ref()),
6332 shell_quote(&backup),
6333 graph_db_rebuild_command(root, scope)
6334 )
6335}
6336
6337fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6338 format!(
6339 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6340 shell_quote(root.to_string_lossy().as_ref()),
6341 graph_db_scope_arg(scope)
6342 )
6343}
6344
6345fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6346 substrate::open_graph_read_only_connection_resilient(graph_db)
6347}
6348
6349fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6350 conn.query_row(
6351 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6352 [table],
6353 |row| row.get::<_, bool>(0),
6354 )
6355 .map_err(Into::into)
6356}
6357
6358fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6359 let sql = match table {
6360 "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6361 "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6362 "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6363 other => bail!("unsupported graph count table {other}"),
6364 };
6365 conn.query_row(sql, [], |row| row.get::<_, usize>(0))
6366 .map_err(Into::into)
6367}
6368
6369fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6370 if !sqlite_table_exists(conn, "graph_tombstones")? {
6371 return Ok(GraphDbTombstoneCounts {
6372 nodes: 0,
6373 edges: 0,
6374 total: 0,
6375 });
6376 }
6377 let mut stmt =
6378 conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6379 let mut rows = stmt.query([])?;
6380 let mut nodes = 0usize;
6381 let mut edges = 0usize;
6382 while let Some(row) = rows.next()? {
6383 let row_kind: String = row.get(0)?;
6384 let count: usize = row.get(1)?;
6385 match row_kind.as_str() {
6386 "node" => nodes = count,
6387 "edge" => edges = count,
6388 _ => {}
6389 }
6390 }
6391 Ok(GraphDbTombstoneCounts {
6392 nodes,
6393 edges,
6394 total: nodes + edges,
6395 })
6396}
6397
6398fn sqlite_graph_counts_from_cache(
6399 conn: &Connection,
6400 scope: &str,
6401) -> Result<Option<GraphDbOperatorCounts>> {
6402 if !sqlite_table_exists(conn, "graph_operator_stats")? {
6403 return Ok(None);
6404 }
6405 let row = conn
6406 .query_row(
6407 r#"
6408 SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6409 FROM graph_operator_stats
6410 WHERE scope = ?1
6411 "#,
6412 [scope],
6413 |row| {
6414 Ok((
6415 row.get::<_, usize>(0)?,
6416 row.get::<_, usize>(1)?,
6417 row.get::<_, usize>(2)?,
6418 row.get::<_, usize>(3)?,
6419 row.get::<_, Option<i64>>(4)?,
6420 row.get::<_, Option<i64>>(5)?,
6421 ))
6422 },
6423 )
6424 .optional()?;
6425 Ok(row.map(
6426 |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6427 GraphDbOperatorCounts {
6428 nodes,
6429 edges,
6430 tombstones: GraphDbTombstoneCounts {
6431 nodes: tombstone_nodes,
6432 edges: tombstone_edges,
6433 total: tombstone_nodes + tombstone_edges,
6434 },
6435 file_size_bytes: file_size_bytes
6436 .and_then(|value| u64::try_from(value).ok())
6437 .or_else(|| sqlite_database_size_bytes(conn).ok()),
6438 freelist_bytes: freelist_bytes
6439 .and_then(|value| u64::try_from(value).ok())
6440 .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6441 }
6442 },
6443 ))
6444}
6445
6446fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6447 if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6448 return Ok(counts);
6449 }
6450 let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6451 sqlite_known_table_count(conn, "graph_nodes")?
6452 } else {
6453 0
6454 };
6455 let edges = if sqlite_table_exists(conn, "graph_edges")? {
6456 sqlite_known_table_count(conn, "graph_edges")?
6457 } else {
6458 0
6459 };
6460 Ok(GraphDbOperatorCounts {
6461 nodes,
6462 edges,
6463 tombstones: sqlite_tombstone_counts(conn)?,
6464 file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6465 freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6466 })
6467}
6468
6469fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6470 if !sqlite_table_exists(conn, "graph_nodes")? {
6471 return Ok(0);
6472 }
6473 let count: i64 = conn.query_row(
6474 "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6475 [],
6476 |row| row.get(0),
6477 )?;
6478 Ok(count as usize)
6479}
6480
6481pub(crate) fn graph_db_compaction_policy(
6482 root: &Path,
6483 scope: Option<&str>,
6484 counts: &GraphDbOperatorCounts,
6485 prune_confirmed: bool,
6486) -> GraphDbCompactionPolicy {
6487 let live_rows = counts.nodes + counts.edges;
6488 let tombstone_scan_rows = counts.tombstones.total;
6489 let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6490 let freelist_heavy = counts
6491 .file_size_bytes
6492 .zip(counts.freelist_bytes)
6493 .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6494 let status = if tombstone_heavy || freelist_heavy {
6495 "recommended"
6496 } else {
6497 "not_needed"
6498 }
6499 .to_string();
6500 let mut recommendations = vec![
6501 convex_refresh_command(root, scope),
6502 graph_db_refresh_command(root, scope),
6503 format!(
6504 "tsift graph-db --path {}{} compact --apply --json",
6505 shell_quote(root.to_string_lossy().as_ref()),
6506 graph_db_scope_arg(scope)
6507 ),
6508 ];
6509 if prune_confirmed {
6510 recommendations.push(format!(
6511 "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6512 shell_quote(root.to_string_lossy().as_ref()),
6513 graph_db_scope_arg(scope)
6514 ));
6515 }
6516 let proof = vec![
6517 format!("{live_rows} live graph row(s)"),
6518 format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6519 format!(
6520 "graph.db file_size={} byte(s), freelist={} byte(s)",
6521 counts.file_size_bytes.unwrap_or(0),
6522 counts.freelist_bytes.unwrap_or(0)
6523 ),
6524 ];
6525 GraphDbCompactionPolicy {
6526 status,
6527 tombstone_scan_rows,
6528 live_rows,
6529 file_size_bytes: counts.file_size_bytes,
6530 freelist_bytes: counts.freelist_bytes,
6531 safe_to_prune_tombstones: prune_confirmed,
6532 requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6533 recommendations,
6534 proof,
6535 }
6536}
6537
6538fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6539 let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
6540 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6541 Ok(page_count.saturating_mul(page_size))
6542}
6543
6544fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6545 let freelist_count: u64 = conn.query_row("PRAGMA freelist_count", [], |row| row.get(0))?;
6546 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6547 Ok(freelist_count.saturating_mul(page_size))
6548}
6549
6550fn sqlite_graph_tombstone_retention_diagnostics(
6551 conn: &Connection,
6552 scope: &str,
6553) -> Result<Vec<String>> {
6554 if !sqlite_table_exists(conn, "graph_tombstones")? {
6555 return Ok(Vec::new());
6556 }
6557 let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6558 let counts = match cached.clone() {
6559 Some(counts) => counts,
6560 None => sqlite_graph_counts(conn, scope)?,
6561 };
6562 let live_rows = counts.nodes + counts.edges;
6563 let file_size = counts.file_size_bytes.unwrap_or(0);
6564 let freelist = counts.freelist_bytes.unwrap_or(0);
6565 let stale_live_tombstones = if cached.is_some() {
6566 0
6567 } else {
6568 let mut live_keys = BTreeSet::new();
6569 if sqlite_table_exists(conn, "graph_nodes")? {
6570 let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6571 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6572 live_keys.insert(format!("node:{}", row?));
6573 }
6574 }
6575 if sqlite_table_exists(conn, "graph_edges")? {
6576 let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6577 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6578 live_keys.insert(format!("edge:{}", row?));
6579 }
6580 }
6581 let mut stale_live_tombstones = 0usize;
6582 let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6583 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6584 if live_keys.contains(&row?) {
6585 stale_live_tombstones += 1;
6586 }
6587 }
6588 stale_live_tombstones
6589 };
6590
6591 let mut diagnostics = Vec::new();
6592 if stale_live_tombstones > 0 {
6593 diagnostics.push(format!(
6594 "{stale_live_tombstones} tombstone(s) reference rows that are live again; the next graph-db refresh prunes those stale tombstones before inserting new deletion markers"
6595 ));
6596 }
6597 if counts.tombstones.total > live_rows.max(1) {
6598 let source = if cached.is_some() {
6599 "cached refresh stats"
6600 } else {
6601 "live row scan"
6602 };
6603 diagnostics.push(format!(
6604 "tombstone retention exceeds live graph rows: {} tombstone(s) vs {} live row(s) from {}; graph.db file_size={} byte(s), freelist={} byte(s), status/doctor tombstone scans inspect {} extra row(s). Run convex-sync against the remote snapshot before rebuild/compaction if a remote consumer may still need deletion reconciliation.",
6605 counts.tombstones.total,
6606 live_rows,
6607 source,
6608 file_size,
6609 freelist,
6610 counts.tombstones.total
6611 ));
6612 }
6613 Ok(diagnostics)
6614}
6615
6616fn sqlite_graph_freshness_from_conn(
6617 conn: &Connection,
6618 scope: &str,
6619) -> Result<GraphDbFreshnessReport> {
6620 if !sqlite_table_exists(conn, "graph_projection_versions")? {
6621 return Ok(GraphDbFreshnessReport {
6622 status: "missing".to_string(),
6623 fail_closed: true,
6624 projection_version: None,
6625 content_hash: None,
6626 source_watermark: None,
6627 diagnostics: vec![
6628 "graph projection metadata table is missing; refresh graph.db before trusting reads"
6629 .to_string(),
6630 ],
6631 });
6632 }
6633 let version = conn
6634 .query_row(
6635 r#"
6636 SELECT projection_version, content_hash, source_watermark
6637 FROM graph_projection_versions
6638 WHERE scope = ?1
6639 "#,
6640 [scope],
6641 |row| {
6642 Ok((
6643 row.get::<_, String>(0)?,
6644 row.get::<_, Option<String>>(1)?,
6645 row.get::<_, Option<String>>(2)?,
6646 ))
6647 },
6648 )
6649 .optional()?;
6650 let Some((projection_version, content_hash, source_watermark)) = version else {
6651 return Ok(GraphDbFreshnessReport {
6652 status: "missing".to_string(),
6653 fail_closed: true,
6654 projection_version: None,
6655 content_hash: None,
6656 source_watermark: None,
6657 diagnostics: vec![
6658 "graph projection metadata is missing; refresh graph.db before trusting reads"
6659 .to_string(),
6660 ],
6661 });
6662 };
6663
6664 let mut diagnostics = Vec::new();
6665 if projection_version != GRAPH_PROJECTION_VERSION {
6666 diagnostics.push(format!(
6667 "projection version mismatch: expected {} got {}",
6668 GRAPH_PROJECTION_VERSION, projection_version
6669 ));
6670 }
6671 if content_hash.is_none() {
6672 diagnostics.push("projection content hash is missing".to_string());
6673 }
6674 let fail_closed = !diagnostics.is_empty();
6675 Ok(GraphDbFreshnessReport {
6676 status: if fail_closed { "stale" } else { "current" }.to_string(),
6677 fail_closed,
6678 projection_version: Some(projection_version),
6679 content_hash,
6680 source_watermark,
6681 diagnostics,
6682 })
6683}
6684
6685fn graph_db_operator_next_commands(
6686 root: &Path,
6687 scope: Option<&str>,
6688 include_refresh: bool,
6689) -> Vec<String> {
6690 let mut commands = Vec::new();
6691 if include_refresh {
6692 commands.push(graph_db_refresh_command(root, scope));
6693 }
6694 commands.push(format!(
6695 "tsift graph-db --path {}{} doctor --json",
6696 shell_quote(root.to_string_lossy().as_ref()),
6697 graph_db_scope_arg(scope)
6698 ));
6699 commands.push(format!(
6700 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
6701 shell_quote(root.to_string_lossy().as_ref()),
6702 graph_db_scope_arg(scope)
6703 ));
6704 commands.push(format!(
6705 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6706 shell_quote(root.to_string_lossy().as_ref()),
6707 graph_db_scope_arg(scope)
6708 ));
6709 commands
6710}
6711
6712pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
6713 match recovery {
6714 index::ReadOnlyRecovery::SnapshotFallback => {
6715 "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
6716 }
6717 index::ReadOnlyRecovery::SnapshotFallbackWal => {
6718 "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
6719 }
6720 }
6721}
6722
6723fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
6724 let mut stmt = conn.prepare(sql)?;
6725 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6726 let mut values = BTreeSet::new();
6727 for row in rows {
6728 values.insert(row?);
6729 }
6730 Ok(values)
6731}
6732
6733fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
6734 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
6735 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
6736 let mut columns = BTreeSet::new();
6737 for row in rows {
6738 columns.insert(row?);
6739 }
6740 Ok(columns)
6741}
6742
6743fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6744 let mut diagnostics = Vec::new();
6745 let user_version: i64 =
6746 conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
6747 if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
6748 diagnostics.push(format!(
6749 "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6750 ));
6751 } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
6752 diagnostics.push(format!(
6753 "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6754 ));
6755 }
6756
6757 let tables = sqlite_string_set(
6758 conn,
6759 "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
6760 )?;
6761 let required_tables = [
6762 (
6763 "graph_nodes",
6764 vec![
6765 "id",
6766 "kind",
6767 "label",
6768 "properties_json",
6769 "provenance_json",
6770 "freshness_json",
6771 "row_hash",
6772 "source_watermark",
6773 ],
6774 ),
6775 (
6776 "graph_edges",
6777 vec![
6778 "edge_key",
6779 "from_id",
6780 "to_id",
6781 "kind",
6782 "properties_json",
6783 "provenance_json",
6784 "freshness_json",
6785 "row_hash",
6786 "source_watermark",
6787 ],
6788 ),
6789 (
6790 "graph_projection_versions",
6791 vec![
6792 "scope",
6793 "projection_version",
6794 "content_hash",
6795 "source_watermark",
6796 "observed_at_unix",
6797 ],
6798 ),
6799 (
6800 "graph_tombstones",
6801 vec!["row_key", "row_kind", "deleted_at_unix"],
6802 ),
6803 ("graph_node_properties", vec!["node_id", "key", "value"]),
6804 ("graph_edge_properties", vec!["edge_key", "key", "value"]),
6805 ];
6806 for (table, required_columns) in required_tables {
6807 if !tables.contains(table) {
6808 diagnostics.push(format!("graph.db schema drift: missing table {table}"));
6809 continue;
6810 }
6811 let columns = sqlite_column_names(conn, table)?;
6812 for column in required_columns {
6813 if !columns.contains(column) {
6814 diagnostics.push(format!(
6815 "graph.db schema drift: missing column {table}.{column}"
6816 ));
6817 }
6818 }
6819 }
6820
6821 let indexes = sqlite_string_set(
6822 conn,
6823 "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
6824 )?;
6825 for index in [
6826 "idx_graph_nodes_kind",
6827 "idx_graph_edges_from_kind",
6828 "idx_graph_edges_to_kind",
6829 "idx_graph_edges_edge_key",
6830 "idx_graph_node_properties_key_value_node",
6831 "idx_graph_edge_properties_key_value_edge",
6832 ] {
6833 if !indexes.contains(index) {
6834 diagnostics.push(format!("graph.db schema drift: missing index {index}"));
6835 }
6836 }
6837
6838 if tables.contains("graph_edges") {
6839 let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
6840 let rows = stmt.query_map([], |row| {
6841 Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
6842 })?;
6843 let mut fks = BTreeSet::new();
6844 for row in rows {
6845 fks.insert(row?);
6846 }
6847 for expected in [
6848 ("from_id".to_string(), "id".to_string()),
6849 ("to_id".to_string(), "id".to_string()),
6850 ] {
6851 if !fks.contains(&expected) {
6852 diagnostics.push(format!(
6853 "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
6854 expected.0, expected.1
6855 ));
6856 }
6857 }
6858 }
6859
6860 Ok(diagnostics)
6861}
6862
6863fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
6864 let mut stmt = conn.prepare(sql)?;
6865 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6866 let mut diagnostics = Vec::new();
6867 for row in rows {
6868 diagnostics.push(row?);
6869 }
6870 Ok(diagnostics)
6871}
6872
6873fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6874 let mut diagnostics = sqlite_query_diagnostics(
6875 conn,
6876 r#"
6877 SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
6878 FROM graph_nodes
6879 GROUP BY id
6880 HAVING COUNT(*) > 1
6881 ORDER BY id
6882 "#,
6883 )?;
6884 diagnostics.extend(sqlite_query_diagnostics(
6885 conn,
6886 r#"
6887 SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
6888 FROM graph_edges
6889 GROUP BY from_id, to_id, kind
6890 HAVING COUNT(*) > 1
6891 ORDER BY from_id, kind, to_id
6892 "#,
6893 )?);
6894 diagnostics.extend(sqlite_query_diagnostics(
6895 conn,
6896 r#"
6897 SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
6898 FROM graph_edges
6899 GROUP BY edge_key
6900 HAVING COUNT(*) > 1
6901 ORDER BY edge_key
6902 "#,
6903 )?);
6904 Ok(diagnostics)
6905}
6906
6907fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6908 sqlite_query_diagnostics(
6909 conn,
6910 r#"
6911 SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6912 FROM graph_edges e
6913 LEFT JOIN graph_nodes n ON n.id = e.from_id
6914 WHERE n.id IS NULL
6915 UNION ALL
6916 SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6917 FROM graph_edges e
6918 LEFT JOIN graph_nodes n ON n.id = e.to_id
6919 WHERE n.id IS NULL
6920 ORDER BY 1
6921 "#,
6922 )
6923}
6924
6925fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6926 let mut diagnostics = Vec::new();
6927 let mut node_stmt = conn.prepare(
6928 "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6929 )?;
6930 let node_rows = node_stmt.query_map([], |row| {
6931 Ok((
6932 row.get::<_, String>(0)?,
6933 row.get::<_, String>(1)?,
6934 row.get::<_, String>(2)?,
6935 row.get::<_, Option<String>>(3)?,
6936 ))
6937 })?;
6938 for row in node_rows {
6939 let (id, properties_json, provenance_json, freshness_json) = row?;
6940 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6941 diagnostics.push(format!(
6942 "graph_nodes {id} properties_json is invalid: {err}"
6943 ));
6944 }
6945 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6946 diagnostics.push(format!(
6947 "graph_nodes {id} provenance_json is invalid: {err}"
6948 ));
6949 }
6950 if let Some(freshness_json) = freshness_json
6951 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6952 {
6953 diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
6954 }
6955 }
6956
6957 let mut edge_stmt = conn.prepare(
6958 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6959 )?;
6960 let edge_rows = edge_stmt.query_map([], |row| {
6961 Ok((
6962 row.get::<_, String>(0)?,
6963 row.get::<_, String>(1)?,
6964 row.get::<_, String>(2)?,
6965 row.get::<_, String>(3)?,
6966 row.get::<_, String>(4)?,
6967 row.get::<_, String>(5)?,
6968 row.get::<_, Option<String>>(6)?,
6969 ))
6970 })?;
6971 for row in edge_rows {
6972 let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
6973 row?;
6974 let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
6975 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6976 diagnostics.push(format!(
6977 "graph_edges {edge} properties_json is invalid: {err}"
6978 ));
6979 }
6980 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6981 diagnostics.push(format!(
6982 "graph_edges {edge} provenance_json is invalid: {err}"
6983 ));
6984 }
6985 if let Some(freshness_json) = freshness_json
6986 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6987 {
6988 diagnostics.push(format!(
6989 "graph_edges {edge} freshness_json is invalid: {err}"
6990 ));
6991 }
6992 }
6993 Ok(diagnostics)
6994}
6995
6996fn sqlite_graph_projection_metadata_diagnostics(
6997 conn: &Connection,
6998 scope: Option<&str>,
6999) -> Result<Vec<String>> {
7000 let mut diagnostics = Vec::new();
7001 let scope_key = scope.unwrap_or("root");
7002 let version = conn
7003 .query_row(
7004 r#"
7005 SELECT projection_version, content_hash, source_watermark
7006 FROM graph_projection_versions
7007 WHERE scope = ?1
7008 "#,
7009 [scope_key],
7010 |row| {
7011 Ok((
7012 row.get::<_, String>(0)?,
7013 row.get::<_, Option<String>>(1)?,
7014 row.get::<_, Option<String>>(2)?,
7015 ))
7016 },
7017 )
7018 .optional()?;
7019 let Some((projection_version, content_hash, _source_watermark)) = version else {
7020 diagnostics.push(format!(
7021 "graph projection metadata is missing for scope {scope_key}"
7022 ));
7023 return Ok(diagnostics);
7024 };
7025 if projection_version != GRAPH_PROJECTION_VERSION {
7026 diagnostics.push(format!(
7027 "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
7028 ));
7029 }
7030 if content_hash.is_none() {
7031 diagnostics.push("projection content hash is missing".to_string());
7032 }
7033
7034 let meta_id = graph_projection_meta_id(scope);
7035 let meta_properties = conn
7036 .query_row(
7037 "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
7038 (&meta_id, GRAPH_PROJECTION_META_KIND),
7039 |row| row.get::<_, String>(0),
7040 )
7041 .optional()?;
7042 let Some(meta_properties) = meta_properties else {
7043 diagnostics.push(format!("projection_meta node {meta_id} is missing"));
7044 return Ok(diagnostics);
7045 };
7046 let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
7047 .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
7048 if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
7049 diagnostics.push(format!(
7050 "projection_meta node {meta_id} has stale projection_version"
7051 ));
7052 }
7053 if properties.get("content_hash") != content_hash.as_ref() {
7054 diagnostics.push(format!(
7055 "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
7056 ));
7057 }
7058 Ok(diagnostics)
7059}
7060
7061pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
7062 let mut node_stmt = conn.prepare(
7063 "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7064 )?;
7065 let node_rows = node_stmt.query_map([], |row| {
7066 let properties_json: String = row.get(3)?;
7067 let provenance_json: String = row.get(4)?;
7068 let freshness_json: Option<String> = row.get(5)?;
7069 Ok((
7070 row.get::<_, String>(0)?,
7071 row.get::<_, String>(1)?,
7072 row.get::<_, String>(2)?,
7073 properties_json,
7074 provenance_json,
7075 freshness_json,
7076 ))
7077 })?;
7078 let mut nodes = Vec::new();
7079 for row in node_rows {
7080 let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
7081 nodes.push(ConvexNodeRow {
7082 external_id,
7083 kind,
7084 label,
7085 properties: serde_json::from_str(&properties_json)?,
7086 provenance: serde_json::from_str(&provenance_json)?,
7087 freshness: freshness_json
7088 .map(|value| serde_json::from_str(&value))
7089 .transpose()?,
7090 });
7091 }
7092
7093 let mut edge_stmt = conn.prepare(
7094 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7095 )?;
7096 let edge_rows = edge_stmt.query_map([], |row| {
7097 let properties_json: String = row.get(4)?;
7098 let provenance_json: String = row.get(5)?;
7099 let freshness_json: Option<String> = row.get(6)?;
7100 Ok((
7101 row.get::<_, String>(0)?,
7102 row.get::<_, String>(1)?,
7103 row.get::<_, String>(2)?,
7104 row.get::<_, String>(3)?,
7105 properties_json,
7106 provenance_json,
7107 freshness_json,
7108 ))
7109 })?;
7110 let mut edges = Vec::new();
7111 for row in edge_rows {
7112 let (
7113 edge_key,
7114 from_external_id,
7115 to_external_id,
7116 kind,
7117 properties_json,
7118 provenance_json,
7119 freshness_json,
7120 ) = row?;
7121 edges.push(ConvexEdgeRow {
7122 edge_key,
7123 from_external_id,
7124 to_external_id,
7125 kind,
7126 properties: serde_json::from_str(&properties_json)?,
7127 provenance: serde_json::from_str(&provenance_json)?,
7128 freshness: freshness_json
7129 .map(|value| serde_json::from_str(&value))
7130 .transpose()?,
7131 });
7132 }
7133 Ok(ConvexProjectionRows { nodes, edges })
7134}
7135
7136fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
7137 format!("{}.{}({})", index.table, index.name, index.fields.join(","))
7138}
7139
7140fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
7141 value
7142 .get("indexes")
7143 .or_else(|| value.get("requiredIndexes"))
7144 .or_else(|| {
7145 value
7146 .get("metadata")
7147 .and_then(|metadata| metadata.get("indexes"))
7148 })
7149}
7150
7151fn convex_snapshot_declared_indexes(
7152 value: &serde_json::Value,
7153) -> Result<Option<Vec<ConvexRequiredIndex>>> {
7154 convex_snapshot_index_value(value)
7155 .map(|indexes| {
7156 serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
7157 .context("parsing Convex snapshot index metadata")
7158 })
7159 .transpose()
7160}
7161
7162fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
7163 let required = convex_required_indexes();
7164 let Some(declared) = convex_snapshot_declared_indexes(value)? else {
7165 return Ok(vec![format!(
7166 "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
7167 required
7168 .iter()
7169 .map(convex_required_index_label)
7170 .collect::<Vec<_>>()
7171 .join(", ")
7172 )]);
7173 };
7174 let declared = declared.into_iter().collect::<BTreeSet<_>>();
7175 let missing = required
7176 .iter()
7177 .filter(|index| !declared.contains(*index))
7178 .map(convex_required_index_label)
7179 .collect::<Vec<_>>();
7180 if missing.is_empty() {
7181 Ok(Vec::new())
7182 } else {
7183 Ok(vec![format!(
7184 "Convex snapshot is missing required index metadata: {}",
7185 missing.join(", ")
7186 )])
7187 }
7188}
7189
7190pub(crate) fn load_convex_projection_snapshot_value(
7191 snapshot_path: &Path,
7192) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7193 let content = fs::read_to_string(snapshot_path).with_context(|| {
7194 format!(
7195 "reading Convex projection snapshot {}",
7196 snapshot_path.display()
7197 )
7198 })?;
7199 let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7200 format!(
7201 "parsing Convex projection snapshot {}",
7202 snapshot_path.display()
7203 )
7204 })?;
7205 let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7206 .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7207 Ok((rows, value))
7208}
7209
7210pub(crate) fn append_sqlite_graph_doctor_checks(
7211 report: &mut GraphDbDoctorReport,
7212 root: &Path,
7213 scope: Option<&str>,
7214 graph_db: &Path,
7215) -> Option<substrate::SqliteReadOnlyConnection> {
7216 let rebuild = graph_db_rebuild_command(root, scope);
7217 let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7218 if !graph_db.exists() {
7219 report.push_check(graph_db_doctor_check(
7220 "sqlite_graph_db_exists",
7221 vec![format!("graph.db is missing at {}", graph_db.display())],
7222 vec![rebuild],
7223 ));
7224 return None;
7225 }
7226 report.push_check(graph_db_doctor_check(
7227 "sqlite_graph_db_exists",
7228 Vec::new(),
7229 vec![rebuild.clone()],
7230 ));
7231
7232 let conn = match open_sqlite_graph_db_readonly(graph_db) {
7233 Ok(conn) => conn,
7234 Err(err) => {
7235 report.push_check(graph_db_doctor_check(
7236 "sqlite_graph_db_open",
7237 vec![err.to_string()],
7238 vec![backup_rebuild],
7239 ));
7240 return None;
7241 }
7242 };
7243 report.push_check(graph_db_doctor_check(
7244 "sqlite_graph_db_open",
7245 Vec::new(),
7246 vec![rebuild.clone()],
7247 ));
7248 if let Some(recovery) = conn.recovery() {
7249 report.push_check(GraphDbDoctorCheck {
7250 name: "sqlite_graph_db_read_recovery".to_string(),
7251 status: "recovered".to_string(),
7252 fail_closed: false,
7253 diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7254 repair_commands: Vec::new(),
7255 });
7256 }
7257
7258 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7259 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7260 report.push_check(graph_db_doctor_check(
7261 "sqlite_schema",
7262 schema_diagnostics,
7263 vec![backup_rebuild.clone()],
7264 ));
7265
7266 let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7267 .unwrap_or_else(|err| {
7268 vec![format!(
7269 "graph projection metadata inspection failed: {err}"
7270 )]
7271 });
7272 report.push_check(graph_db_doctor_check(
7273 "sqlite_projection_metadata",
7274 metadata_diagnostics,
7275 vec![rebuild.clone()],
7276 ));
7277
7278 let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7279 .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7280 report.push_check(graph_db_doctor_check(
7281 "sqlite_duplicate_ids",
7282 duplicate_diagnostics,
7283 vec![backup_rebuild.clone()],
7284 ));
7285
7286 let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7287 .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7288 report.push_check(graph_db_doctor_check(
7289 "sqlite_orphan_edges",
7290 orphan_diagnostics,
7291 vec![rebuild.clone()],
7292 ));
7293
7294 let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7295 .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7296 report.push_check(graph_db_doctor_check(
7297 "sqlite_row_json",
7298 json_diagnostics,
7299 vec![backup_rebuild],
7300 ));
7301
7302 let tombstone_diagnostics =
7303 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7304 .unwrap_or_else(|err| {
7305 vec![format!(
7306 "graph tombstone retention inspection failed: {err}"
7307 )]
7308 });
7309 report.push_check(GraphDbDoctorCheck {
7310 name: "sqlite_tombstone_retention".to_string(),
7311 status: if tombstone_diagnostics.is_empty() {
7312 "ok".to_string()
7313 } else {
7314 "warning".to_string()
7315 },
7316 fail_closed: false,
7317 diagnostics: tombstone_diagnostics,
7318 repair_commands: Vec::new(),
7319 });
7320 let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7321 Ok(counts) => {
7322 let policy = graph_db_compaction_policy(root, scope, &counts, false);
7323 GraphDbDoctorCheck {
7324 name: "sqlite_compaction_policy".to_string(),
7325 status: policy.status.clone(),
7326 fail_closed: false,
7327 diagnostics: policy.proof,
7328 repair_commands: if policy.status == "recommended" {
7329 policy.recommendations
7330 } else {
7331 Vec::new()
7332 },
7333 }
7334 }
7335 Err(err) => GraphDbDoctorCheck {
7336 name: "sqlite_compaction_policy".to_string(),
7337 status: "warning".to_string(),
7338 fail_closed: false,
7339 diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7340 repair_commands: Vec::new(),
7341 },
7342 };
7343 report.push_check(compaction_check);
7344
7345 Some(conn)
7346}
7347
7348pub(crate) fn append_convex_snapshot_doctor_checks(
7349 report: &mut GraphDbDoctorReport,
7350 root: &Path,
7351 scope: Option<&str>,
7352 local_rows: Option<&ConvexProjectionRows>,
7353 snapshot_path: Option<&Path>,
7354) {
7355 let repair = convex_refresh_command(root, scope);
7356 let Some(snapshot_path) = snapshot_path else {
7357 report.push_check(graph_db_doctor_check(
7358 "convex_snapshot_present",
7359 vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7360 vec![format!(
7361 "tsift convex-sync {}{} --json > convex-rows.json",
7362 shell_quote(root.to_string_lossy().as_ref()),
7363 graph_db_scope_arg(scope)
7364 )],
7365 ));
7366 return;
7367 };
7368 report.push_check(graph_db_doctor_check(
7369 "convex_snapshot_present",
7370 Vec::new(),
7371 vec![repair.clone()],
7372 ));
7373
7374 let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7375 Ok(snapshot) => snapshot,
7376 Err(err) => {
7377 report.push_check(graph_db_doctor_check(
7378 "convex_snapshot_parse",
7379 vec![err.to_string()],
7380 vec![repair],
7381 ));
7382 return;
7383 }
7384 };
7385 report.push_check(graph_db_doctor_check(
7386 "convex_snapshot_parse",
7387 Vec::new(),
7388 vec![repair.clone()],
7389 ));
7390
7391 let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7392 report.push_check(graph_db_doctor_check(
7393 "convex_snapshot_rows",
7394 row_diagnostics,
7395 vec![repair.clone()],
7396 ));
7397
7398 let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7399 .unwrap_or_else(|err| vec![err.to_string()]);
7400 report.required_indexes = convex_required_indexes();
7401 report.push_check(graph_db_doctor_check(
7402 "convex_required_indexes",
7403 index_diagnostics,
7404 vec![
7405 "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7406 .to_string(),
7407 ],
7408 ));
7409
7410 if let Some(local_rows) = local_rows {
7411 let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7412 report.push_check(graph_db_doctor_check(
7413 "convex_projection_freshness",
7414 freshness.diagnostics,
7415 vec![repair],
7416 ));
7417 } else {
7418 report.push_check(graph_db_doctor_check(
7419 "convex_projection_freshness",
7420 vec![
7421 "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7422 .to_string(),
7423 ],
7424 vec![graph_db_rebuild_command(root, scope)],
7425 ));
7426 }
7427}
7428
7429fn graph_db_convex_snapshot_doctor_command(
7430 root: &Path,
7431 scope: Option<&str>,
7432 snapshot_path: &Path,
7433) -> String {
7434 format!(
7435 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7436 shell_quote(root.to_string_lossy().as_ref()),
7437 graph_db_scope_arg(scope),
7438 shell_quote(snapshot_path.to_string_lossy().as_ref())
7439 )
7440}
7441
7442fn graph_db_convex_snapshot_read_command(
7443 root: &Path,
7444 scope: Option<&str>,
7445 snapshot_path: &Path,
7446) -> String {
7447 format!(
7448 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7449 shell_quote(root.to_string_lossy().as_ref()),
7450 graph_db_scope_arg(scope),
7451 shell_quote(snapshot_path.to_string_lossy().as_ref())
7452 )
7453}
7454
7455fn convex_sync_snapshot_diff_command(
7456 root: &Path,
7457 scope: Option<&str>,
7458 snapshot_path: &Path,
7459) -> String {
7460 format!(
7461 "tsift convex-sync {}{} --snapshot {} --json",
7462 shell_quote(root.to_string_lossy().as_ref()),
7463 graph_db_scope_arg(scope),
7464 shell_quote(snapshot_path.to_string_lossy().as_ref())
7465 )
7466}
7467
7468pub(crate) struct GraphDbDriftInput<'a> {
7469 root: &'a Path,
7470 scope: Option<&'a str>,
7471 graph_db: &'a Path,
7472 snapshot_path: &'a Path,
7473 local: &'a ConvexProjectionRows,
7474 snapshot: &'a ConvexProjectionRows,
7475 snapshot_value: &'a serde_json::Value,
7476 warnings: Vec<String>,
7477}
7478
7479pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7480 let GraphDbDriftInput {
7481 root,
7482 scope,
7483 graph_db,
7484 snapshot_path,
7485 local,
7486 snapshot,
7487 snapshot_value,
7488 warnings,
7489 } = input;
7490 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7491 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7492 convex_rows_diff(local, Some(snapshot));
7493 let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7494 let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7495 .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7496 let local_hash = freshness.local_hash.clone();
7497 let snapshot_hash = freshness.snapshot_hash.clone();
7498 let stale_nodes = freshness.stale_nodes.clone();
7499 let stale_edges = freshness.stale_edges.clone();
7500
7501 let duplicate_failures = row_diagnostics
7502 .iter()
7503 .filter(|diagnostic| diagnostic.contains("duplicate"))
7504 .count();
7505 let orphan_failures = row_diagnostics
7506 .iter()
7507 .filter(|diagnostic| diagnostic.contains("references missing"))
7508 .count();
7509 let missing_required_indexes = index_diagnostics.len();
7510 let stale_projection_metadata =
7511 usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7512 let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7513 let has_drift = freshness.fail_closed
7514 || !node_upserts.is_empty()
7515 || !edge_upserts.is_empty()
7516 || !node_tombstones.is_empty()
7517 || !edge_tombstones.is_empty();
7518 let status = if hard_failures > 0 {
7519 "fail_closed"
7520 } else if has_drift {
7521 "drift"
7522 } else {
7523 "current"
7524 }
7525 .to_string();
7526
7527 let mut diagnostics = Vec::new();
7528 diagnostics.extend(row_diagnostics);
7529 diagnostics.extend(index_diagnostics);
7530 diagnostics.extend(freshness.diagnostics.clone());
7531 if has_drift {
7532 diagnostics.push(format!(
7533 "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7534 node_upserts.len(),
7535 edge_upserts.len(),
7536 node_tombstones.len(),
7537 edge_tombstones.len()
7538 ));
7539 }
7540
7541 let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7542 root,
7543 scope,
7544 snapshot_path,
7545 )];
7546 if status == "current" {
7547 next_commands.push(graph_db_convex_snapshot_read_command(
7548 root,
7549 scope,
7550 snapshot_path,
7551 ));
7552 } else {
7553 next_commands.push(convex_sync_snapshot_diff_command(
7554 root,
7555 scope,
7556 snapshot_path,
7557 ));
7558 next_commands.push(convex_refresh_command(root, scope));
7559 }
7560
7561 GraphDbDriftReport {
7562 root: root.to_string_lossy().to_string(),
7563 scope: scope.map(str::to_string),
7564 graph_db: graph_db.to_string_lossy().to_string(),
7565 convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7566 status: status.clone(),
7567 graph_reads_allowed: status == "current",
7568 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7569 local_hash,
7570 snapshot_hash,
7571 summary: GraphDbDriftSummary {
7572 node_upserts: node_upserts.len(),
7573 edge_upserts: edge_upserts.len(),
7574 node_tombstones: node_tombstones.len(),
7575 edge_tombstones: edge_tombstones.len(),
7576 stale_nodes: stale_nodes.len(),
7577 stale_edges: stale_edges.len(),
7578 stale_projection_metadata,
7579 duplicate_failures,
7580 orphan_failures,
7581 missing_required_indexes,
7582 },
7583 node_upserts: node_upserts
7584 .into_iter()
7585 .map(|row| row.external_id)
7586 .collect(),
7587 edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
7588 node_tombstones,
7589 edge_tombstones,
7590 stale_nodes,
7591 stale_edges,
7592 diagnostics,
7593 next_commands,
7594 required_indexes: convex_required_indexes(),
7595 warnings,
7596 }
7597}
7598
7599pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
7600 println!(
7601 "graph-db drift status: {} reads_allowed: {}",
7602 report.status, report.graph_reads_allowed
7603 );
7604 println!("graph_db: {}", report.graph_db);
7605 println!("convex_snapshot: {}", report.convex_snapshot);
7606 println!(
7607 "upserts: {} node(s), {} edge(s)",
7608 report.summary.node_upserts, report.summary.edge_upserts
7609 );
7610 println!(
7611 "tombstones: {} node(s), {} edge(s)",
7612 report.summary.node_tombstones, report.summary.edge_tombstones
7613 );
7614 for diagnostic in &report.diagnostics {
7615 println!("diagnostic: {diagnostic}");
7616 }
7617 for command in &report.next_commands {
7618 println!("next: {command}");
7619 }
7620}
7621
7622pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
7623 println!(
7624 "graph-db doctor backend: {} status: {}",
7625 report.backend, report.status
7626 );
7627 println!("graph_db: {}", report.graph_db);
7628 if let Some(snapshot) = &report.convex_snapshot {
7629 println!("convex_snapshot: {snapshot}");
7630 }
7631 for check in &report.checks {
7632 println!("check: {} {}", check.name, check.status);
7633 for diagnostic in &check.diagnostics {
7634 println!(" diagnostic: {diagnostic}");
7635 }
7636 }
7637 for command in &report.repair_commands {
7638 println!("repair: {command}");
7639 }
7640}
7641
7642pub(crate) fn graph_db_operator_report_from_disk(
7643 root: &Path,
7644 scope: Option<&str>,
7645 graph_db: &Path,
7646 operation: &str,
7647 refresh: Option<GraphDbRefreshSummary>,
7648 warnings: Vec<String>,
7649) -> Result<GraphDbOperatorReport> {
7650 if !graph_db.exists() {
7651 let next_commands = graph_db_operator_next_commands(root, scope, true);
7652 let counts = GraphDbOperatorCounts {
7653 nodes: 0,
7654 edges: 0,
7655 tombstones: GraphDbTombstoneCounts {
7656 nodes: 0,
7657 edges: 0,
7658 total: 0,
7659 },
7660 file_size_bytes: None,
7661 freelist_bytes: None,
7662 };
7663 return Ok(GraphDbOperatorReport {
7664 root: root.to_string_lossy().to_string(),
7665 scope: scope.map(str::to_string),
7666 graph_db: graph_db.to_string_lossy().to_string(),
7667 operation: operation.to_string(),
7668 status: "missing".to_string(),
7669 materialized: false,
7670 freshness: GraphDbFreshnessReport {
7671 status: "missing".to_string(),
7672 fail_closed: true,
7673 projection_version: None,
7674 content_hash: None,
7675 source_watermark: None,
7676 diagnostics: vec![
7677 "graph.db is missing; run graph-db refresh before trusting graph reads"
7678 .to_string(),
7679 ],
7680 },
7681 readiness: graph_effectiveness_blocked(
7682 "graph_db_missing",
7683 vec![
7684 "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
7685 ],
7686 next_commands.clone(),
7687 ),
7688 counts: counts.clone(),
7689 refresh,
7690 compaction: graph_db_compaction_policy(root, scope, &counts, false),
7691 recovery: None,
7692 next_commands,
7693 warnings,
7694 });
7695 }
7696
7697 let conn = open_sqlite_graph_db_readonly(graph_db)?;
7698 let recovery = conn.recovery();
7699 let mut warnings = warnings;
7700 if let Some(recovery) = recovery {
7701 warnings.push(graph_db_read_recovery_diagnostic(recovery));
7702 }
7703 let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
7704 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7705 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7706 if !schema_diagnostics.is_empty() {
7707 freshness.diagnostics.extend(schema_diagnostics);
7708 freshness.fail_closed = true;
7709 freshness.status = "stale".to_string();
7710 }
7711 let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
7712 let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
7713 warnings.extend(
7714 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7715 .unwrap_or_else(|err| {
7716 vec![format!(
7717 "graph tombstone retention inspection failed: {err}"
7718 )]
7719 }),
7720 );
7721 let status = if freshness.fail_closed {
7722 "stale"
7723 } else {
7724 "current"
7725 }
7726 .to_string();
7727
7728 Ok(GraphDbOperatorReport {
7729 root: root.to_string_lossy().to_string(),
7730 scope: scope.map(str::to_string),
7731 graph_db: graph_db.to_string_lossy().to_string(),
7732 operation: operation.to_string(),
7733 status,
7734 materialized: true,
7735 freshness,
7736 readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
7737 compaction: graph_db_compaction_policy(root, scope, &counts, false),
7738 counts,
7739 refresh,
7740 recovery,
7741 next_commands: graph_db_operator_next_commands(root, scope, false),
7742 warnings,
7743 })
7744}
7745
7746fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
7747 println!(
7748 "graph-db {} status: {} materialized: {}",
7749 report.operation, report.status, report.materialized
7750 );
7751 println!("graph_db: {}", report.graph_db);
7752 println!(
7753 "projection: version={} hash={} watermark={}",
7754 report
7755 .freshness
7756 .projection_version
7757 .as_deref()
7758 .unwrap_or("<missing>"),
7759 report
7760 .freshness
7761 .content_hash
7762 .as_deref()
7763 .unwrap_or("<missing>"),
7764 report
7765 .freshness
7766 .source_watermark
7767 .as_deref()
7768 .unwrap_or("<missing>")
7769 );
7770 println!(
7771 "rows: {} node(s), {} edge(s), {} tombstone(s)",
7772 report.counts.nodes, report.counts.edges, report.counts.tombstones.total
7773 );
7774 println!(
7775 "readiness: {} reason: {} fail_closed: {}",
7776 report.readiness.status, report.readiness.reason, report.readiness.fail_closed
7777 );
7778 if let Some(file_size) = report.counts.file_size_bytes {
7779 println!(
7780 "storage: {} byte(s), {} free byte(s)",
7781 file_size,
7782 report.counts.freelist_bytes.unwrap_or(0)
7783 );
7784 }
7785 if let Some(refresh) = &report.refresh {
7786 println!(
7787 "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
7788 refresh.tombstoned_nodes, refresh.tombstoned_edges
7789 );
7790 println!(
7791 "delta: {} node upsert(s), {} edge upsert(s), {} property row upsert(s), {} unchanged node(s), {} unchanged edge(s), {} unchanged property row(s), {} deleted property row(s), {} pruned tombstone(s)",
7792 refresh.upserted_nodes,
7793 refresh.upserted_edges,
7794 refresh.upserted_properties,
7795 refresh.unchanged_nodes,
7796 refresh.unchanged_edges,
7797 refresh.unchanged_properties,
7798 refresh.deleted_properties,
7799 refresh.pruned_tombstones
7800 );
7801 }
7802 println!(
7803 "compaction: {} tombstone_scan_rows={} live_rows={}",
7804 report.compaction.status,
7805 report.compaction.tombstone_scan_rows,
7806 report.compaction.live_rows
7807 );
7808 for proof in &report.compaction.proof {
7809 println!("compaction proof: {proof}");
7810 }
7811 if let Some(recovery) = report.recovery {
7812 println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
7813 }
7814 for diagnostic in &report.freshness.diagnostics {
7815 println!("diagnostic: {diagnostic}");
7816 }
7817 for diagnostic in &report.readiness.diagnostics {
7818 println!("readiness diagnostic: {diagnostic}");
7819 }
7820 for warning in &report.warnings {
7821 println!("warning: {warning}");
7822 }
7823 for command in &report.readiness.next_commands {
7824 println!("readiness next: {command}");
7825 }
7826 for command in &report.next_commands {
7827 println!("next: {command}");
7828 }
7829}
7830
7831pub(crate) fn print_graph_db_operator_report(
7832 report: &GraphDbOperatorReport,
7833 format: OutputFormat,
7834) -> Result<()> {
7835 if format.json_output {
7836 print_json_or_envelope(
7837 report,
7838 &format,
7839 "graph-db",
7840 &report.operation,
7841 ToolEnvelopeSummary {
7842 text: format!(
7843 "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
7844 report.operation,
7845 report.status,
7846 report.counts.nodes,
7847 report.counts.edges,
7848 report.counts.tombstones.total
7849 ),
7850 metrics: vec![
7851 envelope_metric("operation", &report.operation),
7852 envelope_metric("status", &report.status),
7853 envelope_metric("nodes", report.counts.nodes),
7854 envelope_metric("edges", report.counts.edges),
7855 envelope_metric("tombstones", report.counts.tombstones.total),
7856 envelope_metric("compaction", &report.compaction.status),
7857 envelope_metric("readiness", &report.readiness.status),
7858 ],
7859 },
7860 false,
7861 report.next_commands.clone(),
7862 )
7863 } else {
7864 print_graph_db_operator_human(report);
7865 Ok(())
7866 }
7867}
7868
7869fn status_run_command_without_notes(run: &str) -> &str {
7870 run.split_once(" (")
7871 .map(|(command, _)| command)
7872 .unwrap_or(run)
7873}
7874
7875fn status_summarize_extract_command(run: &str) -> &str {
7876 let run = status_run_command_without_notes(run);
7877 run.split(" && ")
7878 .find(|command| command.contains("summarize --extract"))
7879 .unwrap_or(run)
7880}
7881
7882fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
7883 report
7884 .recommendations
7885 .run
7886 .as_deref()
7887 .filter(|command| command.contains("summarize --extract"))
7888 .map(status_summarize_extract_command)
7889 .unwrap_or("tsift summarize --extract .")
7890 .to_string()
7891}
7892
7893fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
7894 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7895 readiness.diagnostics.push(format!(
7896 "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
7897 ));
7898 readiness
7899}
7900
7901fn graph_db_semantic_readiness(
7902 root: &Path,
7903 scope: Option<&str>,
7904 semantic_row_count: Option<usize>,
7905) -> GraphEffectivenessReadiness {
7906 if let Some(row_count) = semantic_row_count
7907 && row_count > 0
7908 {
7909 return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
7910 }
7911
7912 let report = match status::check_status(root) {
7913 Ok(report) => report,
7914 Err(err) => {
7915 return graph_effectiveness_blocked(
7916 "status_check_unavailable",
7917 vec![format!(
7918 "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
7919 )],
7920 vec![graph_db_refresh_command(root, scope)],
7921 );
7922 }
7923 };
7924
7925 match &report.summaries {
7926 status::SummaryStatus::Available {
7927 cached_files,
7928 total_indexed_files,
7929 coverage_pct,
7930 ..
7931 } => {
7932 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7933 readiness.diagnostics.push(format!(
7934 "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
7935 ));
7936 readiness
7937 }
7938 status::SummaryStatus::None { .. } => {
7939 let summarize = graph_db_status_summarize_command(&report);
7940 let index_command = report
7941 .recommendations
7942 .run
7943 .as_deref()
7944 .filter(|cmd| cmd.contains("index"))
7945 .map(str::to_string);
7946 let mut repair = Vec::new();
7947 if let Some(cmd) = index_command {
7948 repair.push(cmd);
7949 }
7950 repair.push(summarize.clone());
7951 repair.push(graph_db_refresh_command(root, scope));
7952 graph_effectiveness_blocked(
7953 "summary_cache_empty",
7954 vec![format!(
7955 "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7956 summarize,
7957 root.display(),
7958 graph_db_refresh_command(root, scope)
7959 )],
7960 repair,
7961 )
7962 }
7963 status::SummaryStatus::Unavailable => {
7964 let mut repair: Vec<String> = report
7965 .recommendations
7966 .run
7967 .clone()
7968 .into_iter()
7969 .collect();
7970 let summarize = "tsift summarize --extract .".to_string();
7971 repair.push(summarize);
7972 repair.push(graph_db_refresh_command(root, scope));
7973 graph_effectiveness_blocked(
7974 "summary_cache_unavailable",
7975 vec![
7976 "summary cache unavailable because the source index is missing; build the index, extract summaries, and refresh the graph before relying on semantic graph evidence".to_string(),
7977 ],
7978 repair,
7979 )
7980 }
7981 }
7982}
7983
7984pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
7985 let report = match status::check_status(root) {
7986 Ok(report) => report,
7987 Err(err) => {
7988 return vec![format!(
7989 "status check unavailable after graph-db refresh: {err:#}"
7990 )];
7991 }
7992 };
7993
7994 let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7995 Some(graph_db_status_summarize_command(&report))
7996 } else {
7997 None
7998 };
7999 let mut warnings = report.reminders;
8000 if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8001 let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
8002 warnings.push(format!(
8003 "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8004 run,
8005 root.display(),
8006 graph_db_refresh_command(root, scope)
8007 ));
8008 }
8009 dedupe_preserve_order(warnings)
8010}
8011
8012pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
8013 println!(
8014 "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
8015 report.applied, report.pruned_tombstones, report.reclaimed_bytes
8016 );
8017 println!("graph_db: {}", report.graph_db);
8018 println!(
8019 "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8020 report.counts_before.nodes,
8021 report.counts_before.edges,
8022 report.counts_before.tombstones.total,
8023 report.counts_before.file_size_bytes.unwrap_or(0),
8024 report.counts_before.freelist_bytes.unwrap_or(0)
8025 );
8026 println!(
8027 "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8028 report.counts_after.nodes,
8029 report.counts_after.edges,
8030 report.counts_after.tombstones.total,
8031 report.counts_after.file_size_bytes.unwrap_or(0),
8032 report.counts_after.freelist_bytes.unwrap_or(0)
8033 );
8034 for proof in &report.compaction_after.proof {
8035 println!("proof: {proof}");
8036 }
8037 for warning in &report.warnings {
8038 println!("warning: {warning}");
8039 }
8040 for command in &report.next_commands {
8041 println!("next: {command}");
8042 }
8043}
8044
8045fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
8046 raw.iter()
8047 .map(|value| {
8048 let (key, filter_value) = value
8049 .split_once('=')
8050 .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
8051 let key = key.trim();
8052 let filter_value = filter_value.trim();
8053 if key.is_empty() || filter_value.is_empty() {
8054 bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
8055 }
8056 Ok(GraphDbPropertyFilter {
8057 key: key.to_string(),
8058 value: filter_value.to_string(),
8059 })
8060 })
8061 .collect()
8062}
8063
8064fn graph_db_query_options(
8065 cursor: Option<String>,
8066 limit: Option<usize>,
8067 property_filters: &[String],
8068) -> Result<GraphDbQueryOptions> {
8069 Ok(GraphDbQueryOptions {
8070 cursor,
8071 limit: limit.filter(|limit| *limit > 0),
8072 property_filters: parse_graph_db_property_filters(property_filters)?,
8073 })
8074}
8075
8076fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
8077 GraphQueryOptions {
8078 cursor: options.cursor.clone(),
8079 limit: options.limit,
8080 property_filters: options
8081 .property_filters
8082 .iter()
8083 .map(|filter| GraphPropertyFilter {
8084 key: filter.key.clone(),
8085 value: filter.value.clone(),
8086 })
8087 .collect(),
8088 }
8089}
8090
8091fn graph_db_page_report_from_store(
8092 page: GraphQueryPage,
8093 property_filters: Vec<GraphDbPropertyFilter>,
8094) -> GraphDbPageReport {
8095 GraphDbPageReport {
8096 cursor: page.cursor,
8097 limit: page.limit,
8098 next_cursor: page.next_cursor,
8099 returned_nodes: page.returned_nodes,
8100 returned_edges: page.returned_edges,
8101 truncated: page.truncated,
8102 property_filters,
8103 diagnostics: page.diagnostics,
8104 }
8105}
8106
8107fn graph_db_neighborhood_ranking_gate(
8108 ranked_neighbor_cap: usize,
8109) -> GraphDbNeighborhoodRankingGate {
8110 GraphDbNeighborhoodRankingGate {
8111 status: "held_default_order_unchanged".to_string(),
8112 ranked_output_default: false,
8113 default_order: "stable_node_id".to_string(),
8114 default_change_gate: "community_search_quality_metrics".to_string(),
8115 required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
8116 .iter()
8117 .map(|workload| (*workload).to_string())
8118 .collect(),
8119 required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
8120 .iter()
8121 .map(|metric| (*metric).to_string())
8122 .collect(),
8123 max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
8124 min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
8125 min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
8126 min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
8127 diagnostics: vec![
8128 "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
8129 format!(
8130 "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
8131 ),
8132 "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
8133 ],
8134 }
8135}
8136
8137fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
8138 match limit {
8139 Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
8140 Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
8141 }
8142}
8143
8144fn graph_db_ranked_neighbors(
8145 center_id: &str,
8146 nodes: &[SubstrateGraphNode],
8147 edges: &[SubstrateGraphEdge],
8148 cap: usize,
8149) -> Vec<GraphDbRankedNeighbor> {
8150 resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
8151}
8152
8153fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
8154 center_id: &str,
8155 depth: usize,
8156 edge_kind: Option<&str>,
8157 limit: Option<usize>,
8158 unranked_nodes: &[SubstrateGraphNode],
8159 unranked_edges: &[SubstrateGraphEdge],
8160 store: &S,
8161) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
8162 use std::time::Instant;
8163 let max_nodes = match limit {
8164 Some(0) | None => 200,
8165 Some(n) => n.clamp(10, 500),
8166 };
8167 let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
8168 .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
8169 if let Some(kind) = edge_kind {
8170 options = options.with_edge_kind(kind);
8171 }
8172 let start = Instant::now();
8173 let result = store.ranked_neighborhood(center_id, &options)?;
8174 let latency = start.elapsed().as_micros();
8175 let Some(ranked) = result else {
8176 return Ok(None);
8177 };
8178 let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
8179 let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
8180 let overlap_count = ranked_ids.intersection(&unranked_ids).count();
8181 let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
8182 0.0
8183 } else {
8184 (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8185 };
8186 let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8187 let mut name_count = BTreeMap::<&str, usize>::new();
8188 for n in nodes {
8189 *name_count.entry(&n.label).or_default() += 1;
8190 }
8191 name_count.values().filter(|&&c| c > 1).count()
8192 };
8193 let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8194 if nodes.is_empty() {
8195 return 100.0;
8196 }
8197 let with_handle = nodes
8198 .iter()
8199 .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8200 .count();
8201 (with_handle as f64 / nodes.len() as f64) * 100.0
8202 };
8203 let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8204 if nodes.is_empty() {
8205 return 0.0;
8206 }
8207 let semantic_kinds = [
8208 "semantic_concept",
8209 "semantic_entity",
8210 "symbol",
8211 "file",
8212 "source_handle",
8213 ];
8214 let useful = nodes
8215 .iter()
8216 .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8217 .count();
8218 let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8219 let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8220 (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8221 / nodes.len() as f64
8222 };
8223 let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8224 let edge_pairs: Vec<(String, String)> = ranked
8225 .edges
8226 .iter()
8227 .map(|e| (e.from_id.clone(), e.to_id.clone()))
8228 .collect();
8229 let cr = tsift_graph::detect_communities(&edge_pairs);
8230 let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8231 let mut fully_kept = 0usize;
8232 let mut partially_pruned = 0usize;
8233 let mut fully_pruned = 0usize;
8234 let mut pruned_kinds = BTreeSet::new();
8235 let mut pruned_labels = Vec::new();
8236 for comm in &cr.communities {
8237 let kept_in_comm: Vec<&str> = comm
8238 .members
8239 .iter()
8240 .filter(|m| kept_labels.contains(m.name.as_str()))
8241 .map(|m| m.name.as_str())
8242 .collect();
8243 if kept_in_comm.len() == comm.members.len() {
8244 fully_kept += 1;
8245 } else if kept_in_comm.is_empty() {
8246 fully_pruned += 1;
8247 for m in &comm.members {
8248 if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8249 pruned_kinds.insert(n.kind.clone());
8250 }
8251 pruned_labels.push(m.name.clone());
8252 }
8253 } else {
8254 partially_pruned += 1;
8255 }
8256 }
8257 pruned_labels.truncate(5);
8258 Some(CommunityTruncationSummary {
8259 total_communities: cr.communities.len(),
8260 fully_kept,
8261 partially_pruned,
8262 fully_pruned,
8263 pruned_community_kinds: pruned_kinds.into_iter().collect(),
8264 pruned_community_top_labels: pruned_labels,
8265 })
8266 } else {
8267 None
8268 };
8269 Ok(Some(GraphDbRankedNeighborhoodComparison {
8270 traversal_nodes: ranked.nodes.len(),
8271 traversal_edges: ranked.edges.len(),
8272 pruned_count: ranked.pruned_count,
8273 total_discovered: ranked.total_discovered,
8274 latency_micros: latency,
8275 overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8276 useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8277 / 1000.0,
8278 useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8279 .round()
8280 / 1000.0,
8281 duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8282 duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8283 handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8284 handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8285 / 100.0,
8286 community_truncation_summary,
8287 diagnostics: vec![
8288 format!(
8289 "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8290 ranked.nodes.len(),
8291 ranked.edges.len(),
8292 ranked.pruned_count,
8293 ranked.total_discovered,
8294 latency
8295 ),
8296 format!(
8297 "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8298 overlap_pct,
8299 overlap_count,
8300 unranked_ids.len(),
8301 ranked_ids.len()
8302 ),
8303 "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8304 ],
8305 }))
8306}
8307
8308struct GraphDbBudgetedSubgraph {
8309 nodes: Vec<SubstrateGraphNode>,
8310 edges: Vec<SubstrateGraphEdge>,
8311 report: GraphDbOutputBudgetReport,
8312 truncated: bool,
8313 next_cursor: Option<String>,
8314}
8315
8316const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8317const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8318const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8319
8320fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8321 match limit {
8322 Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8323 Some(limit) => limit
8324 .saturating_mul(320)
8325 .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8326 }
8327}
8328
8329fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8330 if matches!(limit, Some(0) | None) {
8331 return match kind {
8332 "source_handle" => 10,
8333 "worker_context" | "worker_result" => 8,
8334 "semantic_concept" | "semantic_entity" => 10,
8335 "file" | "symbol" | "route" => 12,
8336 _ => 8,
8337 };
8338 }
8339 let base = limit.unwrap_or(0).max(1);
8340 match kind {
8341 "source_handle" => base.saturating_add(4),
8342 "worker_context" | "worker_result" => base.saturating_add(2),
8343 "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8344 "file" | "symbol" | "route" => base.saturating_add(4),
8345 _ => base.saturating_add(1),
8346 }
8347}
8348
8349fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8350 if matches!(limit, Some(0) | None) {
8351 return match kind {
8352 "mentions" | "mentions_concept" | "mentions_entity" => 24,
8353 "semantic_relation" | "calls" | "defines" => 20,
8354 _ => 16,
8355 };
8356 }
8357 let base = limit.unwrap_or(0).max(1);
8358 match kind {
8359 "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8360 "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8361 _ => base.saturating_add(2),
8362 }
8363}
8364
8365fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8366 serde_json::to_vec(value)
8367 .map(|bytes| bytes.len().div_ceil(4).max(1))
8368 .unwrap_or(1)
8369}
8370
8371fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8372 let mut parts = vec![node.kind.clone(), node.label.clone()];
8373 for key in [
8374 "detail",
8375 "description",
8376 "source_ref",
8377 "path",
8378 "source_file",
8379 "source_symbol",
8380 "text_preview",
8381 ] {
8382 if let Some(value) = node.properties.get(key) {
8383 parts.push(value.clone());
8384 }
8385 }
8386 parts.join(" ")
8387}
8388
8389fn graph_db_semantic_scores_for_query(
8390 query: Option<&str>,
8391 nodes: &[SubstrateGraphNode],
8392) -> BTreeMap<String, f64> {
8393 let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8394 return BTreeMap::new();
8395 };
8396 let query_embedding = semantic_embedding(query);
8397 nodes
8398 .iter()
8399 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8400 .filter_map(|node| {
8401 let embedding = node
8402 .properties
8403 .get("embedding")
8404 .and_then(|value| parse_semantic_embedding_property(value))?;
8405 Some((
8406 node.id.clone(),
8407 semantic_cosine(&query_embedding, &embedding),
8408 ))
8409 })
8410 .collect()
8411}
8412
8413fn graph_db_depth_by_id(
8414 origin_ids: &[String],
8415 edges: &[SubstrateGraphEdge],
8416) -> BTreeMap<String, usize> {
8417 let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8418 for edge in edges {
8419 adjacency
8420 .entry(edge.from_id.clone())
8421 .or_default()
8422 .push(edge.to_id.clone());
8423 adjacency
8424 .entry(edge.to_id.clone())
8425 .or_default()
8426 .push(edge.from_id.clone());
8427 }
8428
8429 let mut depth_by_id = BTreeMap::<String, usize>::new();
8430 let mut queue = VecDeque::<String>::new();
8431 for origin in origin_ids {
8432 if depth_by_id.insert(origin.clone(), 0).is_none() {
8433 queue.push_back(origin.clone());
8434 }
8435 }
8436 while let Some(current) = queue.pop_front() {
8437 let depth = depth_by_id.get(¤t).copied().unwrap_or(0);
8438 for next in adjacency.get(¤t).into_iter().flatten() {
8439 if depth_by_id.contains_key(next) {
8440 continue;
8441 }
8442 depth_by_id.insert(next.clone(), depth.saturating_add(1));
8443 queue.push_back(next.clone());
8444 }
8445 }
8446 depth_by_id
8447}
8448
8449fn graph_db_source_covered_ids(
8450 nodes: &[SubstrateGraphNode],
8451 edges: &[SubstrateGraphEdge],
8452) -> BTreeSet<String> {
8453 let source_ids = nodes
8454 .iter()
8455 .filter(|node| node.kind == "source_handle")
8456 .map(|node| node.id.as_str())
8457 .collect::<BTreeSet<_>>();
8458 let mut covered = source_ids
8459 .iter()
8460 .map(|id| (*id).to_string())
8461 .collect::<BTreeSet<_>>();
8462 for edge in edges {
8463 if source_ids.contains(edge.from_id.as_str()) {
8464 covered.insert(edge.to_id.clone());
8465 }
8466 if source_ids.contains(edge.to_id.as_str()) {
8467 covered.insert(edge.from_id.clone());
8468 }
8469 }
8470 covered
8471}
8472
8473fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8474 for key in [
8475 "observed_at_unix",
8476 "completed_at_unix",
8477 "created_at_unix",
8478 "started_at_unix",
8479 ] {
8480 if let Some(value) = node.properties.get(key)
8481 && let Ok(epoch) = value.parse::<i64>()
8482 {
8483 return epoch.div_euclid(86_400).clamp(0, 40_000);
8484 }
8485 }
8486 0
8487}
8488
8489fn graph_db_node_kind_score(kind: &str) -> i64 {
8490 match kind {
8491 "source_handle" => 180,
8492 "worker_context" => 170,
8493 "worker_result" => 160,
8494 "semantic_concept" | "semantic_entity" => 150,
8495 "backlog" | "job_packet" => 130,
8496 "symbol" => 120,
8497 "file" => 110,
8498 "route" => 105,
8499 "session" => 90,
8500 _ => 40,
8501 }
8502}
8503
8504fn graph_db_edge_kind_score(kind: &str) -> i64 {
8505 match kind {
8506 "mentions_concept" | "mentions_entity" => 180,
8507 "semantic_relation" => 170,
8508 "mentions" => 165,
8509 "requests_context" | "scopes_context" | "scopes_source" => 155,
8510 "explains_result" => 150,
8511 "calls" => 145,
8512 "defines" | "handled_by" | "defines_route" => 130,
8513 "contains" | "targets" => 120,
8514 "records_memory_source" | "has_vector_handle" => 115,
8515 _ => 40,
8516 }
8517}
8518
8519fn graph_db_node_usefulness_score(
8520 node: &SubstrateGraphNode,
8521 depth_by_id: &BTreeMap<String, usize>,
8522 semantic_scores: &BTreeMap<String, f64>,
8523 source_covered_ids: &BTreeSet<String>,
8524 origin_ids: &[String],
8525) -> i64 {
8526 if origin_ids.iter().any(|origin| origin == &node.id) {
8527 return 1_000_000;
8528 }
8529 let semantic = semantic_scores
8530 .get(&node.id)
8531 .map(|score| (score.max(0.0) * 1_000.0) as i64)
8532 .unwrap_or(0);
8533 let depth_penalty = depth_by_id
8534 .get(&node.id)
8535 .map(|depth| (*depth as i64).saturating_mul(55))
8536 .unwrap_or(180);
8537 let source_coverage = if source_covered_ids.contains(&node.id)
8538 || node.properties.contains_key("source_ref")
8539 || node.properties.contains_key("path")
8540 {
8541 120
8542 } else {
8543 0
8544 };
8545 graph_db_node_kind_score(&node.kind)
8546 + semantic
8547 + source_coverage
8548 + graph_db_recency_score(node).min(80)
8549 - depth_penalty
8550}
8551
8552fn graph_db_edge_usefulness_score(
8553 edge: &SubstrateGraphEdge,
8554 node_score_by_id: &BTreeMap<String, i64>,
8555 depth_by_id: &BTreeMap<String, usize>,
8556) -> i64 {
8557 let endpoint_score = node_score_by_id
8558 .get(&edge.from_id)
8559 .copied()
8560 .unwrap_or_default()
8561 .max(
8562 node_score_by_id
8563 .get(&edge.to_id)
8564 .copied()
8565 .unwrap_or_default(),
8566 );
8567 let depth_penalty = depth_by_id
8568 .get(&edge.from_id)
8569 .into_iter()
8570 .chain(depth_by_id.get(&edge.to_id))
8571 .min()
8572 .map(|depth| (*depth as i64).saturating_mul(35))
8573 .unwrap_or(140);
8574 graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8575}
8576
8577fn graph_db_push_drop(
8578 drops: &mut BTreeMap<(String, String, String), usize>,
8579 item: &str,
8580 kind: &str,
8581 reason: &str,
8582) {
8583 *drops
8584 .entry((item.to_string(), kind.to_string(), reason.to_string()))
8585 .or_default() += 1;
8586}
8587
8588fn graph_db_budget_drop_report(
8589 drops: BTreeMap<(String, String, String), usize>,
8590) -> Vec<GraphDbDroppedByBudget> {
8591 drops
8592 .into_iter()
8593 .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
8594 item,
8595 kind,
8596 reason,
8597 dropped,
8598 })
8599 .collect()
8600}
8601
8602fn graph_db_apply_output_budget(
8603 origin_ids: &[String],
8604 semantic_scores: &BTreeMap<String, f64>,
8605 nodes: Vec<SubstrateGraphNode>,
8606 edges: Vec<SubstrateGraphEdge>,
8607 limit: Option<usize>,
8608) -> GraphDbBudgetedSubgraph {
8609 graph_db_apply_output_budget_with_depths_and_cursor(
8610 origin_ids,
8611 semantic_scores,
8612 nodes,
8613 edges,
8614 limit,
8615 None,
8616 None,
8617 )
8618}
8619
8620fn graph_db_apply_output_budget_with_depths_and_cursor(
8621 origin_ids: &[String],
8622 semantic_scores: &BTreeMap<String, f64>,
8623 nodes: Vec<SubstrateGraphNode>,
8624 edges: Vec<SubstrateGraphEdge>,
8625 limit: Option<usize>,
8626 depth_overrides: Option<&BTreeMap<String, usize>>,
8627 cursor: Option<&str>,
8628) -> GraphDbBudgetedSubgraph {
8629 let max_tokens = graph_db_output_token_cap(limit);
8630 let candidate_nodes = nodes.len();
8631 let candidate_edges = edges.len();
8632 let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
8633 if let Some(depth_overrides) = depth_overrides {
8634 for (id, depth) in depth_overrides {
8635 depth_by_id
8636 .entry(id.clone())
8637 .and_modify(|current| *current = (*current).min(*depth))
8638 .or_insert(*depth);
8639 }
8640 }
8641 let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
8642 let node_score_by_id = nodes
8643 .iter()
8644 .map(|node| {
8645 (
8646 node.id.clone(),
8647 graph_db_node_usefulness_score(
8648 node,
8649 &depth_by_id,
8650 semantic_scores,
8651 &source_covered_ids,
8652 origin_ids,
8653 ),
8654 )
8655 })
8656 .collect::<BTreeMap<_, _>>();
8657
8658 let mut node_candidates = nodes.iter().collect::<Vec<_>>();
8659 node_candidates.sort_by(|left, right| {
8660 node_score_by_id
8661 .get(&right.id)
8662 .cmp(&node_score_by_id.get(&left.id))
8663 .then_with(|| left.kind.cmp(&right.kind))
8664 .then_with(|| left.label.cmp(&right.label))
8665 .then_with(|| left.id.cmp(&right.id))
8666 });
8667
8668 let cursor_skip = if let Some(cursor) = cursor {
8669 node_candidates
8670 .iter()
8671 .position(|node| node.id == cursor)
8672 .map(|pos| pos.saturating_add(1))
8673 .unwrap_or(0)
8674 } else {
8675 0
8676 };
8677 if cursor_skip > 0 {
8678 node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
8679 }
8680
8681 let mut selected_node_ids = BTreeSet::new();
8682 let mut selected_node_counts = BTreeMap::<String, usize>::new();
8683 let mut estimated_tokens = 0usize;
8684 let mut drops = BTreeMap::<(String, String, String), usize>::new();
8685 for node in &node_candidates {
8686 let kind_count = selected_node_counts
8687 .get(&node.kind)
8688 .copied()
8689 .unwrap_or_default();
8690 if !origin_ids.iter().any(|origin| origin == &node.id)
8691 && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
8692 {
8693 graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
8694 continue;
8695 }
8696 let tokens = graph_db_estimated_tokens(node);
8697 if !origin_ids.iter().any(|origin| origin == &node.id)
8698 && estimated_tokens.saturating_add(tokens) > max_tokens
8699 {
8700 graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
8701 continue;
8702 }
8703 selected_node_ids.insert(node.id.clone());
8704 *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
8705 estimated_tokens = estimated_tokens.saturating_add(tokens);
8706 }
8707
8708 let has_remaining_candidates = node_candidates
8709 .iter()
8710 .any(|node| !selected_node_ids.contains(&node.id));
8711
8712 let mut selected_nodes = nodes
8713 .into_iter()
8714 .filter(|node| selected_node_ids.contains(&node.id))
8715 .collect::<Vec<_>>();
8716
8717 let mut edge_candidates = edges
8718 .iter()
8719 .filter(|edge| {
8720 selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
8721 })
8722 .collect::<Vec<_>>();
8723 let edge_score_by_key = edge_candidates
8724 .iter()
8725 .map(|edge| {
8726 (
8727 graph_db_edge_key(edge),
8728 graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
8729 )
8730 })
8731 .collect::<BTreeMap<_, _>>();
8732 edge_candidates.sort_by(|left, right| {
8733 edge_score_by_key
8734 .get(&graph_db_edge_key(right))
8735 .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
8736 .then_with(|| left.kind.cmp(&right.kind))
8737 .then_with(|| left.from_id.cmp(&right.from_id))
8738 .then_with(|| left.to_id.cmp(&right.to_id))
8739 });
8740
8741 let endpoint_dropped_edges = edges
8742 .iter()
8743 .filter(|edge| {
8744 !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
8745 })
8746 .count();
8747 if endpoint_dropped_edges > 0 {
8748 drops.insert(
8749 (
8750 "edge".to_string(),
8751 "*".to_string(),
8752 "endpoint_node_dropped".to_string(),
8753 ),
8754 endpoint_dropped_edges,
8755 );
8756 }
8757
8758 let mut selected_edge_ids = BTreeSet::new();
8759 let mut selected_edge_counts = BTreeMap::<String, usize>::new();
8760 for edge in edge_candidates {
8761 let kind_count = selected_edge_counts
8762 .get(&edge.kind)
8763 .copied()
8764 .unwrap_or_default();
8765 if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
8766 graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
8767 continue;
8768 }
8769 let tokens = graph_db_estimated_tokens(edge);
8770 if estimated_tokens.saturating_add(tokens) > max_tokens {
8771 graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
8772 continue;
8773 }
8774 selected_edge_ids.insert(graph_db_edge_key(edge));
8775 *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
8776 estimated_tokens = estimated_tokens.saturating_add(tokens);
8777 }
8778
8779 let selected_edges = edges
8780 .into_iter()
8781 .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
8782 .collect::<Vec<_>>();
8783 let dropped_by_budget = graph_db_budget_drop_report(drops);
8784 let truncated = has_remaining_candidates;
8785 let next_cursor = if truncated {
8786 selected_nodes.last().map(|node| node.id.clone())
8787 } else {
8788 None
8789 };
8790 let mut diagnostics = vec![
8791 "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
8792 .to_string(),
8793 format!(
8794 "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
8795 selected_nodes.len(),
8796 candidate_nodes,
8797 selected_edges.len(),
8798 candidate_edges,
8799 max_tokens
8800 ),
8801 ];
8802 if cursor.is_some() {
8803 diagnostics.push(format!(
8804 "cursor skipped {} previously returned candidate(s)",
8805 cursor_skip
8806 ));
8807 }
8808 if next_cursor.is_some() {
8809 diagnostics.push(
8810 "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
8811 );
8812 }
8813 selected_nodes.shrink_to_fit();
8814
8815 GraphDbBudgetedSubgraph {
8816 nodes: selected_nodes,
8817 edges: selected_edges,
8818 report: GraphDbOutputBudgetReport {
8819 max_tokens,
8820 estimated_tokens,
8821 selected_nodes: selected_node_ids.len(),
8822 selected_edges: selected_edge_ids.len(),
8823 candidate_nodes,
8824 candidate_edges,
8825 dropped_by_budget,
8826 diagnostics,
8827 },
8828 truncated,
8829 next_cursor,
8830 }
8831}
8832
8833fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
8834 if edge.id.is_empty() {
8835 substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
8836 } else {
8837 edge.id.clone()
8838 }
8839}
8840
8841fn graph_db_schema() -> GraphDbSchema {
8842 GraphDbSchema {
8843 contract_versions: vec![
8844 GraphDbSchemaContract {
8845 name: "graph_db_evidence",
8846 version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
8847 description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
8848 },
8849 GraphDbSchemaContract {
8850 name: "worker_prompt_packet",
8851 version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
8852 description: "conflict-matrix worker prompt packet with owned scope, scheduler fields, stable graph handles, expected tests, expansion commands, token budget, semantic ranking reasons, worker feedback closure controls, and fail-closed prompt text",
8853 },
8854 GraphDbSchemaContract {
8855 name: "conflict_matrix",
8856 version: CONFLICT_MATRIX_CONTRACT_VERSION,
8857 description: "parallel-dispatch decision report keyed by graph evidence packets, scheduler block fields, hard file/symbol/test/config gates, and soft worker-feedback closure ranking",
8858 },
8859 GraphDbSchemaContract {
8860 name: "context_pack_graph_orchestration",
8861 version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
8862 description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
8863 },
8864 GraphDbSchemaContract {
8865 name: "session_review_follow_up",
8866 version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
8867 description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
8868 },
8869 GraphDbSchemaContract {
8870 name: "dispatch_trace",
8871 version: DISPATCH_TRACE_CONTRACT_VERSION,
8872 description: "operator review trace linking backlog, job packets, worker results, source handles, semantic rows, scheduler fields, evidence packet ids, worker feedback closure controls, and worker prompt packets",
8873 },
8874 GraphDbSchemaContract {
8875 name: "dependency_dag",
8876 version: DEPENDENCY_DAG_CONTRACT_VERSION,
8877 description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
8878 },
8879 ],
8880 node_fields: vec![
8881 GraphDbSchemaField {
8882 name: "id",
8883 value_type: "string",
8884 description: "Stable provider-neutral node id",
8885 },
8886 GraphDbSchemaField {
8887 name: "kind",
8888 value_type: "string",
8889 description: "Application-defined node family such as file, symbol, or backlog",
8890 },
8891 GraphDbSchemaField {
8892 name: "label",
8893 value_type: "string",
8894 description: "Human-readable label",
8895 },
8896 GraphDbSchemaField {
8897 name: "properties",
8898 value_type: "object<string,string>",
8899 description: "Adapter-specific string properties",
8900 },
8901 GraphDbSchemaField {
8902 name: "provenance",
8903 value_type: "array",
8904 description: "Source system and source reference metadata",
8905 },
8906 GraphDbSchemaField {
8907 name: "freshness",
8908 value_type: "object|null",
8909 description: "Optional content hash and observed timestamp",
8910 },
8911 ],
8912 edge_fields: vec![
8913 GraphDbSchemaField {
8914 name: "id",
8915 value_type: "string",
8916 description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
8917 },
8918 GraphDbSchemaField {
8919 name: "from_id",
8920 value_type: "string",
8921 description: "Source node id",
8922 },
8923 GraphDbSchemaField {
8924 name: "to_id",
8925 value_type: "string",
8926 description: "Target node id",
8927 },
8928 GraphDbSchemaField {
8929 name: "kind",
8930 value_type: "string",
8931 description: "Application-defined edge relation",
8932 },
8933 GraphDbSchemaField {
8934 name: "properties",
8935 value_type: "object<string,string>",
8936 description: "Adapter-specific string properties",
8937 },
8938 GraphDbSchemaField {
8939 name: "provenance",
8940 value_type: "array",
8941 description: "Source system and source reference metadata",
8942 },
8943 GraphDbSchemaField {
8944 name: "freshness",
8945 value_type: "object|null",
8946 description: "Optional content hash and observed timestamp",
8947 },
8948 ],
8949 operations: vec![
8950 GraphDbSchemaOperation {
8951 command: "refresh",
8952 description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
8953 },
8954 GraphDbSchemaOperation {
8955 command: "status",
8956 description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
8957 },
8958 GraphDbSchemaOperation {
8959 command: "doctor",
8960 description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
8961 },
8962 GraphDbSchemaOperation {
8963 command: "drift",
8964 description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
8965 },
8966 GraphDbSchemaOperation {
8967 command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
8968 description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
8969 },
8970 GraphDbSchemaOperation {
8971 command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
8972 description: "Benchmark experimental read-only GraphStore backend prototypes against SQLite on bounded real, optional full-project, and synthetic projections across refresh/status/path tiers/evidence/conflict-matrix/dispatch-trace and emit promotion hold/eligibility gates",
8973 },
8974 GraphDbSchemaOperation {
8975 command: "evidence <target> [--depth N] [--limit N]",
8976 description: "Return a bounded versioned graph-db handoff packet for a backlog id or job packet handle, including packet_id, projection hash, worker_context rows, source_handle rows, worker_result rows, semantic_concept/entity rows, shortest paths, replay commands, repair commands, and next commands",
8977 },
8978 GraphDbSchemaOperation {
8979 command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
8980 description: "Resolve a natural-language phrase to cached semantic concept/entity seed nodes, then return an incident/outgoing GraphStore neighborhood around those seeds for general knowledge retrieval without changing stable neighborhood pagination defaults",
8981 },
8982 GraphDbSchemaOperation {
8983 command: "dispatch-trace [target...] --path <session> [--format json|html]",
8984 description: "Export a compact graph-backed dispatch trace with evidence packet ids, worker-result feedback closure summaries, graph links, and conflict-matrix worker prompt packets",
8985 },
8986 GraphDbSchemaOperation {
8987 command: "dependency-dag [target...] --path <session>",
8988 description: "Extract a versioned agent-doc dependency DAG from backlog ids, explicit depends-on text, shared file/symbol/test/config evidence, semantic overlap, and worker-result follow-up ids",
8989 },
8990 GraphDbSchemaOperation {
8991 command: "schema",
8992 description: "Return record and operation schemas",
8993 },
8994 GraphDbSchemaOperation {
8995 command: "node <id>",
8996 description: "Return one node by stable id",
8997 },
8998 GraphDbSchemaOperation {
8999 command: "edge <id>",
9000 description: "Return one edge by stable edge id",
9001 },
9002 GraphDbSchemaOperation {
9003 command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9004 description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
9005 },
9006 GraphDbSchemaOperation {
9007 command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9008 description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
9009 },
9010 GraphDbSchemaOperation {
9011 command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
9012 description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
9013 },
9014 GraphDbSchemaOperation {
9015 command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
9016 description: "Return a directed outgoing subgraph around a node using batched SQLite recursive traversal plus pushed filters/paging when available; JSON also includes additive ranked_neighbors while default nodes remain stable-id ordered",
9017 },
9018 GraphDbSchemaOperation {
9019 command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
9020 description: "Return the shortest directed path by node id, optionally bounded by hop count",
9021 },
9022 ],
9023 }
9024}
9025
9026pub(crate) fn sqlite_graph_freshness(
9027 store: &SqliteGraphStore,
9028 scope: &str,
9029) -> Result<GraphDbFreshnessReport> {
9030 let version = store.projection_version(scope)?;
9031 let Some(version) = version else {
9032 return Ok(GraphDbFreshnessReport {
9033 status: "missing".to_string(),
9034 fail_closed: true,
9035 projection_version: None,
9036 content_hash: None,
9037 source_watermark: None,
9038 diagnostics: vec![
9039 "graph projection metadata is missing; rebuild the graph before trusting reads"
9040 .to_string(),
9041 ],
9042 });
9043 };
9044 let mut diagnostics = Vec::new();
9045 let fail_closed =
9046 version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
9047 if version.projection_version != GRAPH_PROJECTION_VERSION {
9048 diagnostics.push(format!(
9049 "projection version mismatch: expected {} got {}",
9050 GRAPH_PROJECTION_VERSION, version.projection_version
9051 ));
9052 }
9053 if version.content_hash.is_none() {
9054 diagnostics.push("projection content hash is missing".to_string());
9055 }
9056 Ok(GraphDbFreshnessReport {
9057 status: if fail_closed { "stale" } else { "current" }.to_string(),
9058 fail_closed,
9059 projection_version: Some(version.projection_version),
9060 content_hash: version.content_hash,
9061 source_watermark: version.source_watermark,
9062 diagnostics,
9063 })
9064}
9065
9066pub(crate) fn convex_graph_freshness(
9067 local: &ConvexProjectionRows,
9068 snapshot: &ConvexProjectionRows,
9069 scope: Option<&str>,
9070) -> GraphDbFreshnessReport {
9071 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
9072 GraphDbFreshnessReport {
9073 status: freshness.status,
9074 fail_closed: freshness.fail_closed,
9075 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
9076 content_hash: freshness.snapshot_hash,
9077 source_watermark: None,
9078 diagnostics: freshness.diagnostics,
9079 }
9080}
9081
9082pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
9083 let (nodes, edges) = store.graph_counts()?;
9084 let files = store.file_count()?;
9085 Ok(GraphDbFreshnessReport {
9086 status: "current".to_string(),
9087 fail_closed: false,
9088 projection_version: Some("tokensave-readonly".to_string()),
9089 content_hash: None,
9090 source_watermark: Some(store.db_path().to_string_lossy().to_string()),
9091 diagnostics: vec![format!(
9092 "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
9093 nodes, edges, files
9094 )],
9095 })
9096}
9097
9098pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
9099 match TokensaveDb::discover(root) {
9100 Ok(Some(store)) => {
9101 report.push_check(GraphDbDoctorCheck {
9102 name: "tokensave_db_open".to_string(),
9103 status: "ok".to_string(),
9104 fail_closed: false,
9105 diagnostics: vec![format!(
9106 "opened tokensave database at {}",
9107 store.db_path().display()
9108 )],
9109 repair_commands: Vec::new(),
9110 });
9111 match (store.node_count(), store.edge_count(), store.file_count()) {
9112 (Ok(nodes), Ok(edges), Ok(files)) => {
9113 report.push_check(GraphDbDoctorCheck {
9114 name: "tokensave_counts".to_string(),
9115 status: "ok".to_string(),
9116 fail_closed: false,
9117 diagnostics: vec![format!(
9118 "tokensave contains {} node(s), {} edge(s), {} file(s)",
9119 nodes, edges, files
9120 )],
9121 repair_commands: Vec::new(),
9122 });
9123 }
9124 (nodes, edges, files) => {
9125 report.push_check(graph_db_doctor_check(
9126 "tokensave_counts",
9127 vec![format!(
9128 "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
9129 nodes.err(),
9130 edges.err(),
9131 files.err()
9132 )],
9133 Vec::new(),
9134 ));
9135 }
9136 }
9137 }
9138 Ok(None) => report.push_check(graph_db_doctor_check(
9139 "tokensave_db_exists",
9140 vec![format!(
9141 "tokensave database is missing at {}",
9142 root.join(".tokensave").join("tokensave.db").display()
9143 )],
9144 Vec::new(),
9145 )),
9146 Err(err) => report.push_check(graph_db_doctor_check(
9147 "tokensave_db_open",
9148 vec![err.to_string()],
9149 Vec::new(),
9150 )),
9151 }
9152}
9153
9154pub(crate) fn graph_db_resolve_evidence_target(
9155 store: &impl GraphStore,
9156 target: &str,
9157) -> Result<Option<SubstrateGraphNode>> {
9158 store.resolve_evidence_target(
9159 target,
9160 &[
9161 "backlog",
9162 "job_packet",
9163 "worker_result",
9164 "worker_context",
9165 "source_handle",
9166 ],
9167 )
9168}
9169
9170fn graph_db_reachable_nodes_by_kind(
9171 store: &impl GraphStore,
9172 from_id: &str,
9173 kind: &str,
9174 depth: usize,
9175 limit: usize,
9176) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
9177 store.reachable_nodes_by_kind(from_id, kind, depth, limit)
9178}
9179
9180fn graph_db_evidence_completed_queue_drift_warnings(
9181 store: &impl GraphStore,
9182 target: &SubstrateGraphNode,
9183 worker_results: &[SubstrateGraphNode],
9184) -> Result<Vec<String>> {
9185 let ref_id = target.properties.get("ref_id").map(String::as_str);
9186 let has_completed_result = worker_results.iter().any(|node| {
9187 node.properties.get("status").map(String::as_str) == Some("completed")
9188 && node.properties.get("ref_id").map(String::as_str) == ref_id
9189 });
9190 if !has_completed_result {
9191 return Ok(Vec::new());
9192 }
9193 let active_jobs = store
9194 .nodes_by_kind("job_packet")?
9195 .into_iter()
9196 .filter(|node| {
9197 node.properties.get("ref_id").map(String::as_str) == ref_id
9198 && node.label.starts_with("do #")
9199 })
9200 .collect::<Vec<_>>();
9201 if active_jobs.is_empty() {
9202 return Ok(Vec::new());
9203 }
9204 let repair = match (target.properties.get("path"), ref_id) {
9205 (Some(path), Some(id)) => format!(
9206 "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9207 shell_quote(path),
9208 shell_quote(id),
9209 shell_quote(id)
9210 ),
9211 _ => {
9212 "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9213 }
9214 };
9215 Ok(vec![format!(
9216 "queue-head drift: target {} has {} active queued do packet(s) but already has a completed worker_result; {repair}; do not redispatch or reactivate the completed item",
9217 target.label,
9218 active_jobs.len()
9219 )])
9220}
9221
9222fn graph_db_evidence_next_commands(
9223 root: &Path,
9224 scope: Option<&str>,
9225 target: &SubstrateGraphNode,
9226 worker_context: &[SubstrateGraphNode],
9227 source_handles: &[SubstrateGraphNode],
9228 worker_results: &[SubstrateGraphNode],
9229 semantic_related: &[SubstrateGraphNode],
9230) -> Vec<String> {
9231 let mut commands = BTreeSet::new();
9232 if let Some(expand) = target.properties.get("expand") {
9233 commands.insert(expand.clone());
9234 }
9235 for worker in worker_context {
9236 if let Some(expand) = worker.properties.get("expand") {
9237 commands.insert(expand.clone());
9238 }
9239 }
9240 for source in source_handles {
9241 if let Some(expand) = source.properties.get("expand") {
9242 commands.insert(expand.clone());
9243 }
9244 }
9245 for result in worker_results {
9246 if let Some(expand) = result.properties.get("expand") {
9247 commands.insert(expand.clone());
9248 }
9249 }
9250 for semantic in semantic_related {
9251 if let Some(expand) = semantic.properties.get("expand") {
9252 commands.insert(expand.clone());
9253 }
9254 }
9255 commands.insert(format!(
9256 "tsift graph-db --path {}{} status --json",
9257 shell_quote(root.to_string_lossy().as_ref()),
9258 graph_db_scope_arg(scope)
9259 ));
9260 commands.insert(format!(
9261 "tsift graph-db --path {}{} doctor --json",
9262 shell_quote(root.to_string_lossy().as_ref()),
9263 graph_db_scope_arg(scope)
9264 ));
9265 commands.into_iter().collect()
9266}
9267
9268fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9269 vec![
9270 format!(
9271 "tsift graph-db --path {}{} refresh --json",
9272 shell_quote(root.to_string_lossy().as_ref()),
9273 graph_db_scope_arg(scope)
9274 ),
9275 format!(
9276 "tsift graph-db --path {}{} doctor --json",
9277 shell_quote(root.to_string_lossy().as_ref()),
9278 graph_db_scope_arg(scope)
9279 ),
9280 ]
9281}
9282
9283fn graph_db_evidence_replay_commands(
9284 root: &Path,
9285 scope: Option<&str>,
9286 target: &str,
9287 depth: usize,
9288 limit: usize,
9289) -> Vec<String> {
9290 vec![
9291 format!(
9292 "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9293 shell_quote(root.to_string_lossy().as_ref()),
9294 graph_db_scope_arg(scope),
9295 shell_quote(target),
9296 depth,
9297 limit
9298 ),
9299 format!(
9300 "tsift conflict-matrix --path {} {} --json",
9301 shell_quote(root.to_string_lossy().as_ref()),
9302 shell_quote(target)
9303 ),
9304 ]
9305}
9306
9307fn graph_db_evidence_packet_id(
9308 target: &str,
9309 target_node: &SubstrateGraphNode,
9310 freshness: &GraphDbFreshnessReport,
9311) -> String {
9312 stable_handle(
9313 "gevd",
9314 &format!(
9315 "{}:{}:{}:{}",
9316 GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9317 target,
9318 target_node.id,
9319 freshness.content_hash.as_deref().unwrap_or("no-hash")
9320 ),
9321 )
9322}
9323
9324pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9325 input: GraphDbEvidenceInput<'_, S>,
9326) -> Result<GraphDbEvidenceReport> {
9327 let GraphDbEvidenceInput {
9328 root,
9329 scope,
9330 backend,
9331 target,
9332 depth,
9333 limit,
9334 cursor,
9335 store,
9336 freshness,
9337 mut warnings,
9338 } = input;
9339 let repair_commands = graph_db_repair_commands(root, scope);
9340 if freshness.fail_closed {
9341 bail!(
9342 "graph database evidence failed closed for {} backend: {}; repair: {}",
9343 backend,
9344 freshness.diagnostics.join("; "),
9345 repair_commands.join("; ")
9346 );
9347 }
9348 let semantic_readiness = graph_db_semantic_readiness(
9349 root,
9350 scope,
9351 graph_store_semantic_node_count(store).ok(),
9352 );
9353 if semantic_readiness.fail_closed {
9354 warnings.push(format!(
9355 "graph evidence semantic readiness blocked: {} — {}",
9356 semantic_readiness.reason,
9357 semantic_readiness.diagnostics.join("; ")
9358 ));
9359 warnings.push(format!(
9360 "repair: {}",
9361 semantic_readiness.next_commands.join("; then ")
9362 ));
9363 }
9364 let target_node = graph_db_resolve_evidence_target(store, target)?
9365 .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9366 let max_rows = if limit == 0 { usize::MAX } else { limit };
9367 let mut reachable = store.reachable_nodes_by_kinds(
9368 &target_node.id,
9369 &[
9370 "worker_context",
9371 "source_handle",
9372 "worker_result",
9373 "semantic_concept",
9374 "semantic_entity",
9375 ],
9376 depth,
9377 max_rows,
9378 )?;
9379 let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9380 let source_paths = reachable.remove("source_handle").unwrap_or_default();
9381 let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9382 let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9383 semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9384 semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9385 left_path
9386 .hops
9387 .cmp(&right_path.hops)
9388 .then(left_node.kind.cmp(&right_node.kind))
9389 .then(left_node.label.cmp(&right_node.label))
9390 .then(left_node.id.cmp(&right_node.id))
9391 });
9392 if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9393 semantic_paths.truncate(max_rows);
9394 }
9395
9396 let evidence_nodes = worker_paths
9397 .iter()
9398 .chain(source_paths.iter())
9399 .chain(worker_result_paths.iter())
9400 .chain(semantic_paths.iter())
9401 .map(|(node, _)| node.clone())
9402 .collect::<Vec<_>>();
9403 let evidence_depth_by_id = worker_paths
9404 .iter()
9405 .chain(source_paths.iter())
9406 .chain(worker_result_paths.iter())
9407 .chain(semantic_paths.iter())
9408 .map(|(node, path)| (node.id.clone(), path.hops))
9409 .collect::<BTreeMap<_, _>>();
9410 let target_query = graph_db_node_search_text(&target_node);
9411 let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9412 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9413 std::slice::from_ref(&target_node.id),
9414 &semantic_scores,
9415 evidence_nodes,
9416 Vec::new(),
9417 Some(limit),
9418 Some(&evidence_depth_by_id),
9419 cursor,
9420 );
9421 let output_budget = budgeted.report;
9422 let truncated = budgeted.truncated;
9423 let next_cursor = budgeted.next_cursor;
9424 let retained_evidence_ids = budgeted
9425 .nodes
9426 .iter()
9427 .map(|node| node.id.as_str())
9428 .collect::<BTreeSet<_>>();
9429 let worker_context = worker_paths
9430 .iter()
9431 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9432 .map(|(node, _)| node.clone())
9433 .collect::<Vec<_>>();
9434 let source_handles = source_paths
9435 .iter()
9436 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9437 .map(|(node, _)| node.clone())
9438 .collect::<Vec<_>>();
9439 let worker_results = worker_result_paths
9440 .iter()
9441 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9442 .map(|(node, _)| node.clone())
9443 .collect::<Vec<_>>();
9444 let semantic_related = semantic_paths
9445 .iter()
9446 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9447 .map(|(node, _)| node.clone())
9448 .collect::<Vec<_>>();
9449 warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9450 store,
9451 &target_node,
9452 &worker_results,
9453 )?);
9454 if worker_context.is_empty()
9455 && source_handles.is_empty()
9456 && worker_results.is_empty()
9457 && semantic_related.is_empty()
9458 {
9459 warnings.push(format!(
9460 "graph-db evidence target {} resolved to a {} node but has no projection-linked context rows; add source/file tokens to the backlog text or rerun graph-db refresh after the session document is indexed",
9461 target, target_node.kind
9462 ));
9463 }
9464 let shortest_paths = worker_paths
9465 .iter()
9466 .chain(source_paths.iter())
9467 .chain(worker_result_paths.iter())
9468 .chain(semantic_paths.iter())
9469 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9470 .map(|(node, path)| GraphDbEvidencePath {
9471 to: node.id.clone(),
9472 kind: node.kind.clone(),
9473 label: node.label.clone(),
9474 path: Some(path.clone()),
9475 expand: node.properties.get("expand").cloned(),
9476 })
9477 .collect::<Vec<_>>();
9478 let next_commands = graph_db_evidence_next_commands(
9479 root,
9480 scope,
9481 &target_node,
9482 &worker_context,
9483 &source_handles,
9484 &worker_results,
9485 &semantic_related,
9486 );
9487 let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9488 let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9489 let projection_hash = freshness.content_hash.clone();
9490
9491 Ok(GraphDbEvidenceReport {
9492 root: root.to_string_lossy().to_string(),
9493 scope: scope.map(str::to_string),
9494 backend: backend.to_string(),
9495 contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9496 target: target.to_string(),
9497 packet_id,
9498 projection_hash,
9499 freshness,
9500 target_node: target_node.into(),
9501 worker_context: worker_context.into_iter().map(Into::into).collect(),
9502 source_handles: source_handles.into_iter().map(Into::into).collect(),
9503 worker_results: worker_results.into_iter().map(Into::into).collect(),
9504 semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9505 shortest_paths,
9506 output_budget: Some(output_budget),
9507 truncated,
9508 next_cursor,
9509 next_commands,
9510 replay_commands,
9511 repair_commands,
9512 fixture_coverage: GraphDbFixtureCoverage {
9513 test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9514 .to_string(),
9515 fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
9516 assertions: vec![
9517 "backlog id and job packet handle resolve to graph nodes".to_string(),
9518 "worker_context rows are reachable from queued work".to_string(),
9519 "source_handle rows are reachable through bounded shortest paths".to_string(),
9520 "worker_result rows are reachable from completed or blocked work".to_string(),
9521 ],
9522 },
9523 warnings,
9524 })
9525}
9526
9527fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
9528 println!(
9529 "graph-db evidence backend: {} target: {} [{}] packet:{}",
9530 report.backend, report.target_node.id, report.target_node.kind, report.packet_id
9531 );
9532 let page_info = if report.truncated {
9533 let cursor = report.next_cursor.as_deref().unwrap_or("?");
9534 format!(" (truncated, next_cursor: {cursor})")
9535 } else {
9536 String::new()
9537 };
9538 println!(
9539 "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
9540 report.worker_context.len(),
9541 report.source_handles.len(),
9542 report.worker_results.len(),
9543 report.semantic_related.len(),
9544 report.shortest_paths.len()
9545 );
9546 for path in &report.shortest_paths {
9547 if let Some(graph_path) = &path.path {
9548 println!(
9549 "path: {} hop(s) {}",
9550 graph_path.hops,
9551 graph_path.nodes.join(" -> ")
9552 );
9553 }
9554 }
9555 for command in &report.next_commands {
9556 println!("next: {command}");
9557 }
9558 for warning in &report.warnings {
9559 println!("warning: {warning}");
9560 }
9561}
9562
9563pub(crate) fn print_graph_db_evidence_report(
9564 report: &GraphDbEvidenceReport,
9565 format: OutputFormat,
9566) -> Result<()> {
9567 if format.json_output {
9568 let page_info = if report.truncated {
9569 let cursor = report.next_cursor.as_deref().unwrap_or("?");
9570 format!(" (truncated, next_cursor: {cursor})")
9571 } else {
9572 String::new()
9573 };
9574 print_json_or_envelope(
9575 report,
9576 &format,
9577 "graph-db",
9578 "evidence",
9579 ToolEnvelopeSummary {
9580 text: format!(
9581 "Graph DB evidence for {} returned {} worker context row(s), {} source handle(s), {} worker result row(s), {} semantic row(s), and {} shortest path(s){page_info}",
9582 report.target,
9583 report.worker_context.len(),
9584 report.source_handles.len(),
9585 report.worker_results.len(),
9586 report.semantic_related.len(),
9587 report.shortest_paths.len()
9588 ),
9589 metrics: vec![
9590 envelope_metric("backend", &report.backend),
9591 envelope_metric("worker_context", report.worker_context.len()),
9592 envelope_metric("source_handles", report.source_handles.len()),
9593 envelope_metric("worker_results", report.worker_results.len()),
9594 envelope_metric("semantic_related", report.semantic_related.len()),
9595 envelope_metric("paths", report.shortest_paths.len()),
9596 ],
9597 },
9598 report.truncated,
9599 report.next_commands.clone(),
9600 )
9601 } else {
9602 print_graph_db_evidence_human(report);
9603 Ok(())
9604 }
9605}
9606
9607pub(crate) fn graph_db_report_from_store(
9608 root: &Path,
9609 scope: Option<&str>,
9610 backend: &str,
9611 query: GraphDbQuery,
9612 store: &impl GraphStore,
9613 freshness: GraphDbFreshnessReport,
9614 warnings: Vec<String>,
9615) -> Result<GraphDbReport> {
9616 if freshness.fail_closed {
9617 bail!(
9618 "graph database read failed closed for {} backend: {}",
9619 backend,
9620 freshness.diagnostics.join("; ")
9621 );
9622 }
9623 let mut report = GraphDbReport {
9624 root: root.to_string_lossy().to_string(),
9625 scope: scope.map(str::to_string),
9626 backend: backend.to_string(),
9627 query: format!("{query:?}"),
9628 freshness,
9629 readiness: None,
9630 schema: None,
9631 node: None,
9632 edge: None,
9633 nodes: Vec::new(),
9634 edges: Vec::new(),
9635 ranked_neighbors: Vec::new(),
9636 semantic_related: Vec::new(),
9637 neighborhood_ranking_gate: None,
9638 ranked_neighborhood_comparison: None,
9639 knowledge_retrieval: None,
9640 output_budget: None,
9641 path: None,
9642 page: None,
9643 warnings,
9644 };
9645
9646 match query {
9647 GraphDbQuery::Refresh => {
9648 bail!("graph-db refresh must be handled by the refresh command path");
9649 }
9650 GraphDbQuery::Status => {
9651 bail!("graph-db status must be handled by the status command path");
9652 }
9653 GraphDbQuery::Doctor => {
9654 bail!("graph-db doctor must be handled by the doctor command path");
9655 }
9656 GraphDbQuery::Drift => {
9657 bail!("graph-db drift must be handled by the drift command path");
9658 }
9659 GraphDbQuery::Compact { .. } => {
9660 bail!("graph-db compact must be handled by the compact command path");
9661 }
9662 GraphDbQuery::BackendEval { .. } => {
9663 bail!("graph-db backend-eval must be handled by the benchmark command path");
9664 }
9665 GraphDbQuery::Evidence { .. } => {
9666 bail!("graph-db evidence must be handled by the evidence command path");
9667 }
9668 GraphDbQuery::Related {
9669 query,
9670 kind,
9671 depth,
9672 seed_limit,
9673 limit,
9674 } => {
9675 let semantic =
9676 semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
9677 let SemanticRelatedReport {
9678 items,
9679 warnings: semantic_warnings,
9680 ..
9681 } = semantic;
9682 let readiness = graph_db_semantic_readiness(
9683 root,
9684 scope,
9685 (!items.is_empty()).then_some(items.len()),
9686 );
9687 report.warnings.extend(semantic_warnings);
9688 let seed_ids = items
9689 .iter()
9690 .map(|item| item.handle.clone())
9691 .collect::<Vec<_>>();
9692 let semantic_scores = items
9693 .iter()
9694 .map(|item| (item.handle.clone(), item.score))
9695 .collect::<BTreeMap<_, _>>();
9696 let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
9697 let seed_count = seed_ids.len();
9698 let mut diagnostics = subgraph.diagnostics;
9699 let budgeted = graph_db_apply_output_budget(
9700 &seed_ids,
9701 &semantic_scores,
9702 subgraph.nodes,
9703 subgraph.edges,
9704 Some(limit),
9705 );
9706 let budget_report = budgeted.report;
9707 let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
9708 diagnostics.extend(budget_report.diagnostics.clone());
9709 diagnostics.extend(readiness.diagnostics.clone());
9710
9711 report.readiness = Some(readiness);
9712 report.semantic_related = items;
9713 if let Some(seed_id) = seed_ids.first() {
9714 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
9715 report.ranked_neighbors = graph_db_ranked_neighbors(
9716 seed_id,
9717 &budgeted.nodes,
9718 &budgeted.edges,
9719 ranked_neighbor_cap,
9720 );
9721 report.neighborhood_ranking_gate =
9722 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9723 }
9724 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9725 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9726 report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
9727 mode: "semantic_seeded_neighborhood".to_string(),
9728 query,
9729 seed_kind: semantic_related_kind_name(kind).to_string(),
9730 seed_limit,
9731 seed_count,
9732 depth,
9733 limit,
9734 node_count: report.nodes.len(),
9735 edge_count: report.edges.len(),
9736 truncated: subgraph.truncated || dropped_by_budget,
9737 traversal: "incident_plus_outgoing_edges".to_string(),
9738 freshness_boundary:
9739 "semantic rows must come from refreshed summary or tsift-memory graph records"
9740 .to_string(),
9741 privacy_boundary:
9742 "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
9743 .to_string(),
9744 diagnostics,
9745 });
9746 report.output_budget = Some(budget_report);
9747 }
9748 GraphDbQuery::Schema => {
9749 report.schema = Some(graph_db_schema());
9750 }
9751 GraphDbQuery::Node { id } => {
9752 report.node = store.node(&id)?.map(Into::into);
9753 }
9754 GraphDbQuery::Edge { id } => {
9755 report.edge = store.edge(&id)?.map(Into::into);
9756 }
9757 GraphDbQuery::Edges {
9758 edge_kind,
9759 cursor,
9760 limit,
9761 property_filters,
9762 } => {
9763 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9764 let paged = store.paged_edges(
9765 edge_kind.as_deref(),
9766 graph_db_query_options_for_store(&options),
9767 )?;
9768 report.edges = paged.edges.into_iter().map(Into::into).collect();
9769 report.page = Some(graph_db_page_report_from_store(
9770 paged.page,
9771 options.property_filters,
9772 ));
9773 }
9774 GraphDbQuery::Incident {
9775 id,
9776 edge_kind,
9777 cursor,
9778 limit,
9779 property_filters,
9780 } => {
9781 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9782 let paged = store.paged_incident_edges(
9783 &id,
9784 edge_kind.as_deref(),
9785 graph_db_query_options_for_store(&options),
9786 )?;
9787 report.edges = paged.edges.into_iter().map(Into::into).collect();
9788 report.page = Some(graph_db_page_report_from_store(
9789 paged.page,
9790 options.property_filters,
9791 ));
9792 }
9793 GraphDbQuery::Kind {
9794 kind,
9795 cursor,
9796 limit,
9797 property_filters,
9798 } => {
9799 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9800 let paged =
9801 store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
9802 report.nodes = paged.nodes.into_iter().map(Into::into).collect();
9803 report.edges = paged.edges.into_iter().map(Into::into).collect();
9804 report.page = Some(graph_db_page_report_from_store(
9805 paged.page,
9806 options.property_filters,
9807 ));
9808 }
9809 GraphDbQuery::Neighborhood {
9810 id,
9811 depth,
9812 edge_kind,
9813 cursor,
9814 limit,
9815 property_filters,
9816 } => {
9817 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9818 if let Some(paged) = store.paged_neighborhood(
9819 &id,
9820 depth,
9821 edge_kind.as_deref(),
9822 graph_db_query_options_for_store(&options),
9823 )? {
9824 let budgeted = graph_db_apply_output_budget(
9825 std::slice::from_ref(&id),
9826 &BTreeMap::new(),
9827 paged.nodes,
9828 paged.edges,
9829 options.limit,
9830 );
9831 let budget_report = budgeted.report;
9832 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
9833 let ranked_neighbors = graph_db_ranked_neighbors(
9834 &id,
9835 &budgeted.nodes,
9836 &budgeted.edges,
9837 ranked_neighbor_cap,
9838 );
9839 let comparison = graph_db_ranked_neighborhood_comparison(
9840 &id,
9841 depth,
9842 edge_kind.as_deref(),
9843 options.limit,
9844 &budgeted.nodes,
9845 &budgeted.edges,
9846 store,
9847 )?;
9848 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9849 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9850 report.ranked_neighbors = ranked_neighbors;
9851 report.neighborhood_ranking_gate =
9852 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9853 let mut page =
9854 graph_db_page_report_from_store(paged.page, options.property_filters);
9855 page.returned_nodes = report.nodes.len();
9856 page.returned_edges = report.edges.len();
9857 page.truncated |= !budget_report.dropped_by_budget.is_empty();
9858 page.diagnostics.extend(budget_report.diagnostics.clone());
9859 report.page = Some(page);
9860 report.output_budget = Some(budget_report);
9861 if let Some(comparison) = comparison {
9862 report.ranked_neighborhood_comparison = Some(comparison);
9863 }
9864 }
9865 }
9866 GraphDbQuery::Path {
9867 from,
9868 to,
9869 edge_kind,
9870 max_hops,
9871 } => {
9872 report.path =
9873 store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
9874 if let Some(max_hops) = max_hops
9875 && report.path.is_none()
9876 {
9877 report.warnings.push(format!(
9878 "no directed path found within --max-hops {}",
9879 max_hops
9880 ));
9881 }
9882 }
9883 GraphDbQuery::Map { .. } => {
9884 bail!("graph-db map must be handled by the map command path");
9885 }
9886 }
9887 Ok(report)
9888}
9889
9890pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
9891 if compact {
9892 println!(
9893 "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
9894 report.backend,
9895 report.query,
9896 report.nodes.len() + usize::from(report.node.is_some()),
9897 report.edges.len() + usize::from(report.edge.is_some()),
9898 report.freshness.status
9899 );
9900 return;
9901 }
9902 println!("graph-db backend: {}", report.backend);
9903 println!("freshness: {}", report.freshness.status);
9904 if let Some(readiness) = &report.readiness {
9905 println!(
9906 "readiness: {} reason: {} fail_closed: {}",
9907 readiness.status, readiness.reason, readiness.fail_closed
9908 );
9909 for diagnostic in &readiness.diagnostics {
9910 println!("readiness diagnostic: {diagnostic}");
9911 }
9912 for command in &readiness.next_commands {
9913 println!("readiness next: {command}");
9914 }
9915 }
9916 if let Some(schema) = &report.schema {
9917 println!(
9918 "schema: {} node fields, {} edge fields, {} operations",
9919 schema.node_fields.len(),
9920 schema.edge_fields.len(),
9921 schema.operations.len()
9922 );
9923 }
9924 if let Some(node) = &report.node {
9925 println!("node: {} [{}] {}", node.id, node.kind, node.label);
9926 }
9927 if let Some(edge) = &report.edge {
9928 let edge_full: SubstrateGraphEdge = edge.into();
9929 println!(
9930 "edge: {} {} -{}-> {}",
9931 graph_db_edge_key(&edge_full),
9932 edge.from_id,
9933 edge.kind,
9934 edge.to_id
9935 );
9936 }
9937 if let Some(knowledge) = &report.knowledge_retrieval {
9938 println!(
9939 "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
9940 knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
9941 );
9942 }
9943 for item in &report.semantic_related {
9944 println!(
9945 "semantic_seed: {:.3} [{}] {} ({})",
9946 item.score, item.kind, item.label, item.handle
9947 );
9948 }
9949 for node in &report.nodes {
9950 println!("node: {} [{}] {}", node.id, node.kind, node.label);
9951 }
9952 for edge in &report.edges {
9953 let edge_full: SubstrateGraphEdge = edge.into();
9954 println!(
9955 "edge: {} {} -{}-> {}",
9956 graph_db_edge_key(&edge_full),
9957 edge.from_id,
9958 edge.kind,
9959 edge.to_id
9960 );
9961 }
9962 for neighbor in &report.ranked_neighbors {
9963 println!(
9964 "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
9965 neighbor.rank,
9966 neighbor.score,
9967 neighbor
9968 .depth
9969 .map(|depth| depth.to_string())
9970 .unwrap_or_else(|| "unknown".to_string()),
9971 neighbor.node_id,
9972 neighbor.kind,
9973 neighbor.label
9974 );
9975 }
9976 if let Some(gate) = &report.neighborhood_ranking_gate {
9977 println!(
9978 "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
9979 gate.status, gate.default_order, gate.ranked_output_default
9980 );
9981 }
9982 if let Some(path) = &report.path {
9983 println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
9984 }
9985 if let Some(page) = &report.page {
9986 if let Some(next_cursor) = &page.next_cursor {
9987 println!("next_cursor: {next_cursor}");
9988 }
9989 for diagnostic in &page.diagnostics {
9990 println!("page: {diagnostic}");
9991 }
9992 }
9993 for warning in &report.warnings {
9994 println!("warning: {warning}");
9995 }
9996}
9997
9998pub(crate) fn graph_db_backend_eval_phase_timing(
9999 name: &str,
10000 duration_micros: u128,
10001 detail: &str,
10002) -> GraphDbBackendEvalPhaseTiming {
10003 GraphDbBackendEvalPhaseTiming {
10004 name: name.to_string(),
10005 duration_micros,
10006 detail: detail.to_string(),
10007 }
10008}
10009
10010pub(crate) fn graph_db_backend_eval_timed_phase<T>(
10011 phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
10012 name: &str,
10013 detail: &str,
10014 run: impl FnOnce() -> Result<T>,
10015) -> Result<T> {
10016 let started = Instant::now();
10017 let result = run();
10018 phases.push(graph_db_backend_eval_phase_timing(
10019 name,
10020 started.elapsed().as_micros(),
10021 detail,
10022 ));
10023 result
10024}
10025
10026pub(crate) fn graph_db_backend_eval_refresh_total_micros(
10027 phases: &[GraphDbBackendEvalPhaseTiming],
10028) -> u128 {
10029 phases
10030 .iter()
10031 .filter(|phase| phase.name != "conflict_matrix_preparation")
10032 .map(|phase| phase.duration_micros)
10033 .sum()
10034}
10035
10036pub(crate) fn graph_db_backend_eval_cached_refresh(
10037 root: &Path,
10038 scope: Option<&str>,
10039 source_watermark: Option<&str>,
10040) -> Result<
10041 Option<(
10042 TraversalGraphBuild,
10043 SqliteProjectionRefresh,
10044 Vec<GraphDbBackendEvalPhaseTiming>,
10045 )>,
10046> {
10047 let Some(source_watermark) = source_watermark else {
10048 return Ok(None);
10049 };
10050 let graph_db = graph_substrate_db_path(root, scope);
10051 if !graph_db.exists() {
10052 return Ok(None);
10053 }
10054
10055 let started = Instant::now();
10056 let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
10057 Ok(store) => store,
10058 Err(_) => return Ok(None),
10059 };
10060 if store.has_user_triggers().unwrap_or(true) {
10061 return Ok(None);
10062 }
10063 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
10064 if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
10065 return Ok(None);
10066 }
10067
10068 let phases = vec![
10069 graph_db_backend_eval_phase_timing(
10070 "source_graph_build",
10071 started.elapsed().as_micros(),
10072 "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10073 ),
10074 graph_db_backend_eval_phase_timing(
10075 "projection_rows",
10076 0,
10077 "reused cached provider-neutral projection rows from graph.db",
10078 ),
10079 graph_db_backend_eval_phase_timing(
10080 "sqlite_open",
10081 0,
10082 "reused existing graph.db projection without opening a write transaction",
10083 ),
10084 ];
10085 let refresh = SqliteProjectionRefresh {
10086 scope: scope.unwrap_or("root").to_string(),
10087 projection_version: freshness
10088 .projection_version
10089 .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
10090 source_watermark: Some(source_watermark.to_string()),
10091 tombstoned_nodes: Vec::new(),
10092 tombstoned_edges: Vec::new(),
10093 upserted_nodes: 0,
10094 upserted_edges: 0,
10095 unchanged_nodes: 0,
10096 unchanged_edges: 0,
10097 upserted_properties: 0,
10098 unchanged_properties: 0,
10099 deleted_properties: 0,
10100 deleted_nodes: 0,
10101 deleted_edges: 0,
10102 pruned_tombstones: 0,
10103 file_size_bytes_before: None,
10104 file_size_bytes_after: None,
10105 phase_timings: Vec::new(),
10106 };
10107 Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
10108}
10109
10110pub(crate) fn graph_db_backend_eval_reused_cached_projection(
10111 phases: &[GraphDbBackendEvalPhaseTiming],
10112) -> bool {
10113 phases.iter().any(|phase| {
10114 phase.name == "source_graph_build"
10115 && phase.detail.contains("reused current graph.db projection")
10116 })
10117}
10118
10119pub(crate) fn graph_db_backend_eval_update_source_watermark(
10120 root: &Path,
10121 path_hint: &Path,
10122 scope: Option<&str>,
10123) -> Result<()> {
10124 let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
10125 return Ok(());
10126 };
10127 let graph_db = graph_substrate_db_path(root, scope);
10128 let mut store = SqliteGraphStore::open(&graph_db)?;
10129 store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
10130 Ok(())
10131}
10132
10133pub(crate) fn graph_db_backend_eval_refresh_with_profile(
10134 root: &Path,
10135 path_hint: &Path,
10136 scope: Option<&str>,
10137) -> Result<(
10138 TraversalGraphBuild,
10139 SqliteProjectionRefresh,
10140 Vec<GraphDbBackendEvalPhaseTiming>,
10141)> {
10142 let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
10143 if let Some(cached) =
10144 graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
10145 {
10146 return Ok(cached);
10147 }
10148
10149 let mut phases = Vec::new();
10150 let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
10151 "bounded session projection: index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads; skips global call-edge materialization because full-projection is the complete-call-graph regression guard"
10152 } else {
10153 "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
10154 };
10155 let source_graph = graph_db_backend_eval_timed_phase(
10156 &mut phases,
10157 "source_graph_build",
10158 source_graph_detail,
10159 || build_traversal_graph_source_with_options(root, path_hint, scope, false),
10160 )?;
10161 let projection = graph_db_backend_eval_timed_phase(
10162 &mut phases,
10163 "projection_rows",
10164 "provider-neutral GraphStore node/edge row construction before SQLite persistence",
10165 || traversal_projection_from_graph(root, scope, &source_graph),
10166 )?;
10167 let graph_db = graph_substrate_db_path(root, scope);
10168 let mut store = graph_db_backend_eval_timed_phase(
10169 &mut phases,
10170 "sqlite_open",
10171 "open the local SQLite graph.db with WAL and busy-timeout settings",
10172 || SqliteGraphStore::open(&graph_db),
10173 )?;
10174 let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
10175 .ok()
10176 .flatten();
10177 let refresh = store.replace_projection_with_version(
10178 scope.unwrap_or("root"),
10179 &projection,
10180 Some(GRAPH_PROJECTION_VERSION),
10181 refreshed_source_watermark
10182 .or(source_watermark)
10183 .or_else(|| graph_projection_content_hash(&projection)),
10184 )?;
10185 phases.extend(
10186 refresh
10187 .phase_timings
10188 .iter()
10189 .map(|phase| GraphDbBackendEvalPhaseTiming {
10190 name: phase.name.clone(),
10191 duration_micros: phase.duration_micros,
10192 detail: phase.detail.clone(),
10193 }),
10194 );
10195 Ok((source_graph, refresh, phases))
10196}
10197
10198fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10199 root.join(".tsift/backend-eval-cache")
10200}
10201
10202fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10203 graph_db_backend_eval_disk_cache_dir(root)
10204 .join(kind)
10205 .join(format!("{key}.json.gz"))
10206}
10207
10208fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10209 graph_db_backend_eval_disk_cache_dir(root)
10210 .join(kind)
10211 .join(format!("{key}.json"))
10212}
10213
10214#[derive(Default, Clone)]
10215struct GraphDbBackendEvalDiskCacheReadProfile {
10216 file_read_micros: u128,
10217 gzip_decode_micros: u128,
10218 serde_decode_micros: u128,
10219 legacy: bool,
10220}
10221
10222fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10223 root: &Path,
10224 kind: &str,
10225 key: &str,
10226) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10227 let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10228 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10229 let read_started = Instant::now();
10230 let read_result = fs::read(&path);
10231 profile.file_read_micros = read_started.elapsed().as_micros();
10232 if let Ok(bytes) = read_result {
10233 let decode_started = Instant::now();
10234 let mut decoder = GzDecoder::new(bytes.as_slice());
10235 let mut decoded = Vec::new();
10236 let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10237 profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10238 if decode_ok {
10239 let serde_started = Instant::now();
10240 let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10241 profile.serde_decode_micros = serde_started.elapsed().as_micros();
10242 if let Some(value) = parsed {
10243 return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10244 }
10245 }
10246 }
10247
10248 let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10249 let legacy_started = Instant::now();
10250 let bytes = fs::read(legacy_path).ok()?;
10251 profile.file_read_micros = profile
10252 .file_read_micros
10253 .saturating_add(legacy_started.elapsed().as_micros());
10254 let serde_started = Instant::now();
10255 let value = serde_json::from_slice(&bytes).ok()?;
10256 profile.serde_decode_micros = profile
10257 .serde_decode_micros
10258 .saturating_add(serde_started.elapsed().as_micros());
10259 profile.legacy = true;
10260 Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10261}
10262
10263#[derive(Default, Clone)]
10264struct GraphDbBackendEvalDiskCacheWriteProfile {
10265 serde_encode_micros: u128,
10266 gzip_encode_micros: u128,
10267 file_write_micros: u128,
10268}
10269
10270fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10271 root: &Path,
10272 kind: &str,
10273 key: &str,
10274 value: &T,
10275) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10276 let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10277 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10278 let parent = path.parent()?;
10279 if fs::create_dir_all(parent).is_err() {
10280 return None;
10281 }
10282 let serde_started = Instant::now();
10283 let bytes = serde_json::to_vec(value).ok()?;
10284 profile.serde_encode_micros = serde_started.elapsed().as_micros();
10285 let gzip_started = Instant::now();
10286 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10287 if encoder.write_all(&bytes).is_err() {
10288 return None;
10289 }
10290 let encoded = encoder.finish().ok()?;
10291 profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10292 let write_started = Instant::now();
10293 if fs::write(&path, &encoded).is_err() {
10294 return None;
10295 }
10296 profile.file_write_micros = write_started.elapsed().as_micros();
10297 Some((encoded.len() as u64, bytes.len() as u64, profile))
10298}
10299
10300fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10301 let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10302 let Ok(entries) = fs::read_dir(dir) else {
10303 return (0, 0);
10304 };
10305 let keep_name = format!("{keep_key}.json.gz");
10306 let mut pruned_files = 0usize;
10307 let mut pruned_bytes = 0u64;
10308 for entry in entries.flatten() {
10309 let path = entry.path();
10310 if !path.is_file() {
10311 continue;
10312 }
10313 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10314 continue;
10315 };
10316 if name == keep_name {
10317 continue;
10318 }
10319 let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10320 if !is_backend_eval_cache {
10321 continue;
10322 }
10323 let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10324 if fs::remove_file(&path).is_ok() {
10325 pruned_files += 1;
10326 pruned_bytes += bytes;
10327 }
10328 }
10329 (pruned_files, pruned_bytes)
10330}
10331
10332fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10333 root: &Path,
10334 source_root: &Path,
10335) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10336 let mut rows = Vec::new();
10337 let mut entries = walk::walk_files(source_root)?;
10338 entries.sort_by(|left, right| left.path.cmp(&right.path));
10339 for entry in entries {
10340 if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10341 continue;
10342 }
10343 if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10344 continue;
10345 }
10346 let bytes = fs::read(&entry.path)
10347 .with_context(|| format!("reading source input {}", entry.path.display()))?;
10348 rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10349 path: traversal_watermark_path(root, &entry.path),
10350 bytes: bytes.len() as u64,
10351 content_hash: content_hash(&bytes)?,
10352 });
10353 }
10354 Ok(rows)
10355}
10356
10357fn graph_db_backend_eval_full_projection_source_watermark(
10358 root: &Path,
10359 scope: Option<&str>,
10360) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10361 let path_hint = root;
10362 let mut detail_parts = Vec::new();
10363 let mut parts = vec![
10364 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10365 format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10366 "watermark_kind:stable_full_projection_inputs".to_string(),
10367 format!("scope:{}", scope.unwrap_or("root")),
10368 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10369 ];
10370
10371 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10372 match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10373 Some(db_path) => {
10374 let db = index::IndexDb::open_read_only_resilient(db_path)?;
10375 parts.push("index_mode:indexed".to_string());
10376 detail_parts.push("mode=indexed".to_string());
10377 parts.push(format!(
10378 "index_source_root:{}",
10379 traversal_watermark_path(root, &gate.source_root)
10380 ));
10381
10382 let symbols = db
10383 .all_symbols()?
10384 .into_iter()
10385 .filter(|symbol| {
10386 !traversal_path_is_generated_artifact(
10387 root,
10388 &gate.source_root,
10389 Path::new(&symbol.file),
10390 ) && !traversal_path_is_session_markdown(
10391 root,
10392 &gate.source_root,
10393 Path::new(&symbol.file),
10394 )
10395 })
10396 .collect::<Vec<_>>();
10397 let symbols_hash = content_hash(&symbols)?;
10398 detail_parts.push(format!("symbols={symbols_hash}"));
10399 parts.push(format!("index_symbols:{symbols_hash}"));
10400
10401 let edges = db
10402 .all_stored_edges()?
10403 .into_iter()
10404 .filter(|edge| {
10405 !traversal_path_is_generated_artifact(
10406 root,
10407 &gate.source_root,
10408 Path::new(&edge.caller_file),
10409 ) && !traversal_path_is_session_markdown(
10410 root,
10411 &gate.source_root,
10412 Path::new(&edge.caller_file),
10413 )
10414 })
10415 .collect::<Vec<_>>();
10416 let edges_hash = content_hash(&edges)?;
10417 detail_parts.push(format!("call_edges={edges_hash}"));
10418 parts.push(format!("index_call_edges:{edges_hash}"));
10419
10420 let routes = db
10421 .all_routes()?
10422 .into_iter()
10423 .filter(|route| {
10424 !traversal_path_is_generated_artifact(
10425 root,
10426 &gate.source_root,
10427 Path::new(&route.file),
10428 ) && !traversal_path_is_session_markdown(
10429 root,
10430 &gate.source_root,
10431 Path::new(&route.file),
10432 )
10433 })
10434 .collect::<Vec<_>>();
10435 let routes_hash = content_hash(&routes)?;
10436 detail_parts.push(format!("routes={routes_hash}"));
10437 parts.push(format!("index_routes:{routes_hash}"));
10438 }
10439 None => {
10440 parts.push("index_mode:raw_fallback".to_string());
10441 detail_parts.push("mode=raw_fallback".to_string());
10442 parts.push(format!(
10443 "raw_source_root:{}",
10444 traversal_watermark_path(root, &gate.source_root)
10445 ));
10446 let raw_rows =
10447 graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10448 let raw_hash = content_hash(&raw_rows)?;
10449 detail_parts.push(format!("raw_source_files={raw_hash}"));
10450 parts.push(format!("raw_source_files:{raw_hash}"));
10451 }
10452 }
10453
10454 parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10455 detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10456 let summaries_start = parts.len();
10457 push_traversal_summaries_watermark_part(root, &mut parts)?;
10458 let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10459 detail_parts.push(format!("summaries={summaries_hash}"));
10460 let value = content_hash(&parts)?;
10461 detail_parts.push(format!("watermark={value}"));
10462 Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10463 value,
10464 detail: detail_parts.join(" "),
10465 })
10466}
10467
10468fn graph_db_backend_eval_full_projection_cache_key(
10469 root: &Path,
10470 scope: Option<&str>,
10471) -> Result<(String, String, String)> {
10472 let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10473 let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10474 root,
10475 scope,
10476 &source_watermark.value,
10477 )?;
10478 Ok((source_watermark.value, key, source_watermark.detail))
10479}
10480
10481fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10482 root: &Path,
10483 scope: Option<&str>,
10484 source_watermark: &str,
10485) -> Result<String> {
10486 content_hash(&serde_json::json!({
10487 "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10488 "root": root.display().to_string(),
10489 "scope": scope.unwrap_or("root"),
10490 "source_watermark": source_watermark,
10491 }))
10492}
10493
10494pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10495 root: &Path,
10496 scope: Option<&str>,
10497) -> Result<(
10498 GraphProjection,
10499 Vec<String>,
10500 Vec<GraphDbBackendEvalPhaseTiming>,
10501 GraphDbBackendEvalFullProjectionCacheStats,
10502)> {
10503 let (source_watermark, key, source_watermark_detail) =
10504 graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10505 let lookup_started = Instant::now();
10506 if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10507 graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10508 root,
10509 "full_projection",
10510 &key,
10511 )
10512 && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
10513 && cached.key == key
10514 && cached.source_watermark == source_watermark
10515 {
10516 let lookup_overhead_micros = lookup_started
10517 .elapsed()
10518 .as_micros()
10519 .saturating_sub(read_profile.file_read_micros)
10520 .saturating_sub(read_profile.gzip_decode_micros)
10521 .saturating_sub(read_profile.serde_decode_micros);
10522 let prune_started = Instant::now();
10523 let (pruned_files, pruned_bytes) =
10524 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
10525 let prune_micros = prune_started.elapsed().as_micros();
10526 let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
10527 hit: true,
10528 disk_bytes,
10529 json_bytes,
10530 pruned_files,
10531 pruned_bytes,
10532 };
10533 let read_detail_suffix = if read_profile.legacy {
10534 " (legacy uncompressed cache path)"
10535 } else {
10536 ""
10537 };
10538 return Ok((
10539 cached.projection,
10540 cached.warnings,
10541 vec![
10542 graph_db_backend_eval_phase_timing(
10543 "full_projection.cache_lookup",
10544 lookup_overhead_micros,
10545 &format!(
10546 "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
10547 ),
10548 ),
10549 graph_db_backend_eval_phase_timing(
10550 "full_projection.cache.file_read",
10551 read_profile.file_read_micros,
10552 &format!(
10553 "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
10554 ),
10555 ),
10556 graph_db_backend_eval_phase_timing(
10557 "full_projection.cache.gzip_decode",
10558 read_profile.gzip_decode_micros,
10559 "gunzip the compressed projection cache bytes",
10560 ),
10561 graph_db_backend_eval_phase_timing(
10562 "full_projection.cache.serde_decode",
10563 read_profile.serde_decode_micros,
10564 "serde_json deserialize the decoded projection cache payload",
10565 ),
10566 graph_db_backend_eval_phase_timing(
10567 "full_projection.cache.prune",
10568 prune_micros,
10569 "prune sibling cache files older than the current key",
10570 ),
10571 graph_db_backend_eval_phase_timing(
10572 "full_projection.source_graph_build",
10573 0,
10574 "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10575 ),
10576 graph_db_backend_eval_phase_timing(
10577 "full_projection.projection_rows",
10578 0,
10579 "reused cached provider-neutral full-project projection rows",
10580 ),
10581 ],
10582 cache_stats,
10583 ));
10584 }
10585
10586 let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
10587 let mut phases = vec![graph_db_backend_eval_phase_timing(
10588 "full_projection.cache_lookup",
10589 lookup_started.elapsed().as_micros(),
10590 &format!(
10591 "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
10592 ),
10593 )];
10594 let full_source = graph_db_backend_eval_timed_phase(
10595 &mut phases,
10596 "full_projection.source_graph_build",
10597 "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
10598 || build_traversal_graph_source_with_options(root, root, scope, false),
10599 )?;
10600 let projection = graph_db_backend_eval_timed_phase(
10601 &mut phases,
10602 "full_projection.projection_rows",
10603 "provider-neutral row construction for the opt-in full-project projection dataset",
10604 || traversal_projection_from_graph(root, scope, &full_source),
10605 )?;
10606 let warnings = full_source.warnings;
10607 let refreshed_source_watermark =
10608 graph_db_backend_eval_full_projection_source_watermark(root, scope)
10609 .map(|watermark| watermark.value)
10610 .unwrap_or_else(|_| source_watermark.clone());
10611 let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10612 root,
10613 scope,
10614 &refreshed_source_watermark,
10615 )?;
10616 let cache = GraphDbBackendEvalFullProjectionCache {
10617 version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
10618 key: write_key.clone(),
10619 source_watermark: refreshed_source_watermark,
10620 projection: projection.clone(),
10621 warnings: warnings.clone(),
10622 };
10623 if let Some((disk_bytes, json_bytes, write_profile)) =
10624 graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
10625 {
10626 cache_stats.disk_bytes = disk_bytes;
10627 cache_stats.json_bytes = json_bytes;
10628 phases.push(graph_db_backend_eval_phase_timing(
10629 "full_projection.cache.serde_encode",
10630 write_profile.serde_encode_micros,
10631 "serde_json serialize the projection cache payload before compression",
10632 ));
10633 phases.push(graph_db_backend_eval_phase_timing(
10634 "full_projection.cache.gzip_encode",
10635 write_profile.gzip_encode_micros,
10636 "gzip-compress the serialized projection cache payload",
10637 ));
10638 phases.push(graph_db_backend_eval_phase_timing(
10639 "full_projection.cache.file_write",
10640 write_profile.file_write_micros,
10641 "write the compressed projection cache bytes to .tsift/backend-eval-cache",
10642 ));
10643 }
10644 let prune_started = Instant::now();
10645 let (pruned_files, pruned_bytes) =
10646 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
10647 phases.push(graph_db_backend_eval_phase_timing(
10648 "full_projection.cache.prune",
10649 prune_started.elapsed().as_micros(),
10650 "prune sibling cache files older than the current key",
10651 ));
10652 cache_stats.pruned_files = pruned_files;
10653 cache_stats.pruned_bytes = pruned_bytes;
10654 Ok((projection, warnings, phases, cache_stats))
10655}
10656
10657fn graph_db_backend_eval_timed(
10658 name: &str,
10659 run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
10660) -> (
10661 GraphDbBackendEvalOperation,
10662 Option<GraphDbBackendEvalSignature>,
10663) {
10664 let started = Instant::now();
10665 match run() {
10666 Ok((rows, value)) => (
10667 GraphDbBackendEvalOperation {
10668 name: name.to_string(),
10669 supported: true,
10670 status: "ok".to_string(),
10671 duration_micros: started.elapsed().as_micros(),
10672 rows,
10673 error: None,
10674 },
10675 Some(GraphDbBackendEvalSignature {
10676 operation: name.to_string(),
10677 value,
10678 }),
10679 ),
10680 Err(err) => (
10681 GraphDbBackendEvalOperation {
10682 name: name.to_string(),
10683 supported: false,
10684 status: "error".to_string(),
10685 duration_micros: started.elapsed().as_micros(),
10686 rows: None,
10687 error: Some(format!("{err:#}")),
10688 },
10689 None,
10690 ),
10691 }
10692}
10693
10694fn graph_db_backend_eval_parity(
10695 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10696 candidate_signatures: &[GraphDbBackendEvalSignature],
10697) -> GraphDbBackendEvalParity {
10698 let Some(sqlite_signatures) = sqlite_signatures else {
10699 return GraphDbBackendEvalParity {
10700 matches_sqlite: true,
10701 diagnostics: Vec::new(),
10702 };
10703 };
10704 let sqlite = sqlite_signatures
10705 .iter()
10706 .map(|signature| (signature.operation.as_str(), &signature.value))
10707 .collect::<BTreeMap<_, _>>();
10708 let candidate = candidate_signatures
10709 .iter()
10710 .map(|signature| (signature.operation.as_str(), &signature.value))
10711 .collect::<BTreeMap<_, _>>();
10712 let mut diagnostics = Vec::new();
10713 for (operation, sqlite_value) in sqlite {
10714 match candidate.get(operation) {
10715 Some(candidate_value) if *candidate_value == sqlite_value => {}
10716 Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
10717 None => diagnostics.push(format!(
10718 "{operation} did not complete for candidate backend"
10719 )),
10720 }
10721 }
10722 GraphDbBackendEvalParity {
10723 matches_sqlite: diagnostics.is_empty(),
10724 diagnostics,
10725 }
10726}
10727
10728pub(crate) fn graph_db_backend_eval_targets(
10729 store: &impl GraphStore,
10730 requested: &[String],
10731) -> Result<Vec<String>> {
10732 let requested = requested
10733 .iter()
10734 .filter_map(|target| normalize_conflict_target(target))
10735 .collect::<Vec<_>>();
10736 if !requested.is_empty() {
10737 return Ok(requested);
10738 }
10739
10740 for kind in ["backlog", "job_packet"] {
10741 let nodes = store.nodes_by_kind(kind)?;
10742 if let Some(node) = nodes.first() {
10743 if let Some(ref_id) = node.properties.get("ref_id") {
10744 return Ok(vec![ref_id.clone()]);
10745 }
10746 return Ok(vec![node.id.clone()]);
10747 }
10748 }
10749 Ok(Vec::new())
10750}
10751
10752fn graph_db_backend_eval_path_targets(
10753 store: &impl GraphStore,
10754 max_hops: usize,
10755) -> Result<Option<(String, String, usize)>> {
10756 let synthetic_from = "gsym-synthetic-0000";
10757 let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
10758 if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
10759 let outgoing = store.outgoing_edges(synthetic_from, None)?;
10760 if outgoing.len() > 1
10761 && let Some(edge) = outgoing.first()
10762 {
10763 return Ok(Some((
10764 edge.from_id.clone(),
10765 edge.to_id.clone(),
10766 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10767 )));
10768 }
10769 return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
10770 }
10771
10772 Ok(store.sample_edge(None)?.map(|edge| {
10773 (
10774 edge.from_id,
10775 edge.to_id,
10776 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10777 )
10778 }))
10779}
10780
10781fn graph_db_backend_eval_path_operation<S: GraphStore>(
10782 store: &S,
10783 configured_max_hops: usize,
10784) -> (
10785 GraphDbBackendEvalOperation,
10786 Option<GraphDbBackendEvalSignature>,
10787) {
10788 let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10789 "path_max_hops".to_string()
10790 } else {
10791 format!("path_max_hops_{configured_max_hops}")
10792 };
10793 graph_db_backend_eval_timed(&operation_name, || {
10794 let (from, to, effective_max_hops) =
10795 graph_db_backend_eval_path_targets(store, configured_max_hops)?
10796 .context("backend-eval path probe requires at least one traversable edge")?;
10797 let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
10798 let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10799 Some(format!(
10800 "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
10801 GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
10802 ))
10803 } else if path.is_none() && effective_max_hops == configured_max_hops {
10804 Some(format!(
10805 "path probe truncated at {configured_max_hops} hops before a route was found"
10806 ))
10807 } else {
10808 None
10809 };
10810 Ok((
10811 path.as_ref().map(|path| path.nodes.len()),
10812 serde_json::json!({
10813 "from": from,
10814 "to": to,
10815 "configured_max_hops": configured_max_hops,
10816 "effective_max_hops": effective_max_hops,
10817 "hops": path.as_ref().map(|path| path.hops),
10818 "nodes": path.as_ref().map(|path| &path.nodes),
10819 "found": path.is_some(),
10820 "warning": warning,
10821 }),
10822 ))
10823 })
10824}
10825
10826fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
10827 store: &S,
10828 depth: usize,
10829 limit: usize,
10830) -> (
10831 GraphDbBackendEvalOperation,
10832 Option<GraphDbBackendEvalSignature>,
10833) {
10834 graph_db_backend_eval_timed("neighborhood", || {
10835 let edge = match store.sample_edge(Some("calls"))? {
10836 Some(edge) => edge,
10837 None => store.sample_edge(None)?.context(
10838 "backend-eval neighborhood probe requires at least one traversable edge",
10839 )?,
10840 };
10841 let page = store
10842 .paged_neighborhood(
10843 &edge.from_id,
10844 depth,
10845 Some(&edge.kind),
10846 GraphQueryOptions {
10847 limit: Some(limit.max(1)),
10848 ..GraphQueryOptions::default()
10849 },
10850 )?
10851 .with_context(|| {
10852 format!(
10853 "backend-eval neighborhood target not found: {}",
10854 edge.from_id
10855 )
10856 })?;
10857 Ok((
10858 Some(page.nodes.len() + page.edges.len()),
10859 serde_json::json!({
10860 "center": edge.from_id,
10861 "kind": edge.kind,
10862 "depth": depth,
10863 "limit": limit.max(1),
10864 "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10865 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10866 "truncated": page.page.truncated,
10867 }),
10868 ))
10869 })
10870}
10871
10872fn graph_db_backend_eval_related_operation<S: GraphStore>(
10873 root: &Path,
10874 scope: Option<&str>,
10875 store: &S,
10876 depth: usize,
10877 limit: usize,
10878) -> (
10879 GraphDbBackendEvalOperation,
10880 Option<GraphDbBackendEvalSignature>,
10881) {
10882 graph_db_backend_eval_timed("related", || {
10883 let query = "backend evaluation";
10884 let semantic = semantic_related_report_from_store(
10885 root,
10886 scope,
10887 query,
10888 3,
10889 SemanticRelatedKind::All,
10890 store,
10891 )?;
10892 let seed_ids = semantic
10893 .items
10894 .iter()
10895 .map(|item| item.handle.clone())
10896 .collect::<Vec<_>>();
10897 let subgraph =
10898 graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
10899 Ok((
10900 Some(subgraph.nodes.len() + subgraph.edges.len()),
10901 serde_json::json!({
10902 "query": query,
10903 "seed_ids": seed_ids,
10904 "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10905 "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10906 "truncated": subgraph.truncated,
10907 "warnings": semantic.warnings,
10908 "diagnostics": subgraph.diagnostics,
10909 }),
10910 ))
10911 })
10912}
10913
10914fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
10915 serde_json::json!({
10916 "target": report.target,
10917 "target_node_id": report.target_node.id,
10918 "target_kind": report.target_node.kind,
10919 "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
10920 "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
10921 "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
10922 "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
10923 "path_count": report.shortest_paths.len(),
10924 })
10925}
10926
10927fn graph_db_backend_eval_target_resolution_signature(
10928 resolved: &[(String, SubstrateGraphNode)],
10929) -> serde_json::Value {
10930 serde_json::json!({
10931 "targets": resolved.iter().map(|(target, node)| {
10932 serde_json::json!({
10933 "target": target,
10934 "target_node_id": node.id,
10935 "target_kind": node.kind,
10936 "target_label": node.label,
10937 })
10938 }).collect::<Vec<_>>(),
10939 })
10940}
10941
10942fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
10943 serde_json::json!({
10944 "targets": report.targets,
10945 "can_parallel": report.can_parallel,
10946 "fail_closed": report.fail_closed,
10947 "cross_target_parallel_safe": report.cross_target_parallel_safe,
10948 "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
10949 "candidates": report.candidates.iter().map(|candidate| {
10950 serde_json::json!({
10951 "target": candidate.target,
10952 "risk": conflict_risk_label(candidate.risk),
10953 "owned_files": candidate.owned_files,
10954 "owned_symbols": candidate.owned_symbols,
10955 "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
10956 "previously_completed": candidate.previously_completed,
10957 "parallel_safe": candidate.parallel_safe,
10958 })
10959 }).collect::<Vec<_>>(),
10960 "conflicts": report.conflicts.iter().map(|pair| {
10961 serde_json::json!({
10962 "left": pair.left,
10963 "right": pair.right,
10964 "risk": conflict_risk_label(pair.risk),
10965 })
10966 }).collect::<Vec<_>>(),
10967 })
10968}
10969
10970fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
10971 serde_json::json!({
10972 "targets": report.targets,
10973 "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10974 "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
10975 "evidence_packet_ids": report.evidence_packet_ids,
10976 "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
10977 "truncated": report.truncated,
10978 })
10979}
10980
10981fn graph_db_backend_eval_edge_scan_probe(
10982 store: &impl GraphStore,
10983) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
10984 if let Some((edge, filter)) = store.sample_edge_with_property()? {
10985 return Ok((edge, vec![filter]));
10986 }
10987 let edge = store
10988 .sample_edge(None)?
10989 .context("backend-eval edge scan requires at least one edge")?;
10990 Ok((edge, Vec::new()))
10991}
10992
10993#[allow(clippy::too_many_arguments)]
10994fn graph_db_backend_eval_report_for_store<S: GraphStore>(
10995 backend: &str,
10996 adapter: &str,
10997 read_only: bool,
10998 root: &Path,
10999 path: &Path,
11000 scope: Option<&str>,
11001 targets: &[String],
11002 depth: usize,
11003 limit: usize,
11004 impact_limit: usize,
11005 store: &S,
11006 freshness: GraphDbFreshnessReport,
11007 refresh_operation: GraphDbBackendEvalOperation,
11008 refresh_signature: Option<GraphDbBackendEvalSignature>,
11009 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11010 extra_warnings: Vec<String>,
11011 prepared: &ConflictMatrixPreparedInputs,
11012 projection_load: &str,
11013 lock_behavior: &str,
11014 install_portability: &str,
11015) -> (
11016 GraphDbBackendEvalBackendReport,
11017 Vec<GraphDbBackendEvalSignature>,
11018) {
11019 let mut operations = vec![refresh_operation];
11020 let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
11021
11022 let (operation, signature) = graph_db_backend_eval_timed("status", || {
11023 let (nodes, edges) = store.graph_counts()?;
11024 Ok((
11025 Some(nodes + edges),
11026 serde_json::json!({
11027 "freshness": freshness.status,
11028 "nodes": nodes,
11029 "edges": edges,
11030 }),
11031 ))
11032 });
11033 operations.push(operation);
11034 signatures.extend(signature);
11035
11036 let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
11037 let edge = store
11038 .sample_edge(None)?
11039 .context("backend-eval edge lookup requires at least one edge")?;
11040 let edge_id = graph_db_edge_key(&edge);
11041 let found = store
11042 .edge(&edge_id)?
11043 .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
11044 Ok((
11045 Some(1),
11046 serde_json::json!({
11047 "edge_id": edge_id,
11048 "from_id": found.from_id,
11049 "to_id": found.to_id,
11050 "kind": found.kind,
11051 }),
11052 ))
11053 });
11054 operations.push(operation);
11055 signatures.extend(signature);
11056
11057 let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
11058 let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
11059 let page = store.paged_edges(
11060 Some(&edge.kind),
11061 GraphQueryOptions {
11062 limit: Some(limit.max(1)),
11063 property_filters: filters.clone(),
11064 ..GraphQueryOptions::default()
11065 },
11066 )?;
11067 Ok((
11068 Some(page.edges.len()),
11069 serde_json::json!({
11070 "kind": edge.kind,
11071 "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
11072 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11073 "truncated": page.page.truncated,
11074 }),
11075 ))
11076 });
11077 operations.push(operation);
11078 signatures.extend(signature);
11079
11080 let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
11081 let edge = store
11082 .sample_edge(None)?
11083 .context("backend-eval incident edge scan requires at least one edge")?;
11084 let page = store.paged_incident_edges(
11085 &edge.from_id,
11086 Some(&edge.kind),
11087 GraphQueryOptions {
11088 limit: Some(limit.max(1)),
11089 ..GraphQueryOptions::default()
11090 },
11091 )?;
11092 Ok((
11093 Some(page.edges.len()),
11094 serde_json::json!({
11095 "node_id": edge.from_id,
11096 "kind": edge.kind,
11097 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11098 "truncated": page.page.truncated,
11099 }),
11100 ))
11101 });
11102 operations.push(operation);
11103 signatures.extend(signature);
11104
11105 let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
11106 operations.push(operation);
11107 signatures.extend(signature);
11108
11109 let (operation, signature) =
11110 graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
11111 operations.push(operation);
11112 signatures.extend(signature);
11113
11114 for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
11115 .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
11116 {
11117 let (operation, signature) =
11118 graph_db_backend_eval_path_operation(store, configured_max_hops);
11119 operations.push(operation);
11120 signatures.extend(signature);
11121 }
11122
11123 let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
11124 let resolved = targets
11125 .iter()
11126 .map(|target| {
11127 let node = graph_db_resolve_evidence_target(store, target)?
11128 .with_context(|| format!("backend-eval target not found: {target}"))?;
11129 Ok((target.clone(), node))
11130 })
11131 .collect::<Result<Vec<_>>>()?;
11132 let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
11133 Ok((Some(resolved.len()), signature))
11134 });
11135 operations.push(operation);
11136 signatures.extend(signature);
11137
11138 let mut evidence_for_report = None;
11139 let mut graph_snapshot_for_trace = None;
11140 let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
11141 let resolved_targets =
11142 resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
11143 let evidence = collect_conflict_matrix_evidence_packets(
11144 root,
11145 scope,
11146 backend,
11147 &resolved_targets,
11148 depth,
11149 limit,
11150 store,
11151 freshness.clone(),
11152 )?;
11153 let report = &evidence
11154 .first()
11155 .context("backend-eval evidence requires at least one target")?
11156 .report;
11157 let rows = evidence
11158 .iter()
11159 .map(|entry| {
11160 entry.report.worker_context.len()
11161 + entry.report.source_handles.len()
11162 + entry.report.worker_results.len()
11163 + entry.report.semantic_related.len()
11164 })
11165 .sum();
11166 let signature = graph_db_backend_eval_evidence_signature(report);
11167 evidence_for_report = Some((resolved_targets, evidence));
11168 Ok((Some(rows), signature))
11169 });
11170 operations.push(operation);
11171 signatures.extend(signature);
11172
11173 let mut conflict_for_trace = None;
11174 let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
11175 let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
11176 let graph =
11177 conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
11178 let shared_preparation =
11179 conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
11180 ConflictMatrixGraphPreparedInputs {
11181 targets,
11182 graph,
11183 evidence,
11184 shared_preparation,
11185 }
11186 } else {
11187 prepare_conflict_matrix_graph_orchestration(
11188 root,
11189 scope,
11190 backend,
11191 targets,
11192 prepared,
11193 depth,
11194 limit,
11195 store,
11196 freshness.clone(),
11197 )?
11198 };
11199 let report = build_conflict_matrix_report_from_prepared_graph(
11200 root,
11201 path,
11202 scope,
11203 depth,
11204 limit,
11205 impact_limit,
11206 freshness.clone(),
11207 extra_warnings.clone(),
11208 prepared,
11209 &graph_prepared,
11210 )?;
11211 let signature = graph_db_backend_eval_conflict_signature(&report);
11212 let rows = report.candidates.len() + report.conflicts.len();
11213 conflict_for_trace = Some(report);
11214 graph_snapshot_for_trace = Some(graph_prepared.graph);
11215 Ok((Some(rows), signature))
11216 });
11217 operations.push(operation);
11218 signatures.extend(signature);
11219
11220 let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11221 let conflict = conflict_for_trace
11222 .take()
11223 .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11224 let graph = graph_snapshot_for_trace
11225 .take()
11226 .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11227 let report = build_dispatch_trace_report_from_conflict_snapshot(
11228 root,
11229 scope,
11230 conflict,
11231 graph.nodes,
11232 graph.edges,
11233 depth,
11234 limit,
11235 Vec::new(),
11236 )?;
11237 Ok((
11238 Some(report.nodes.len() + report.edges.len()),
11239 graph_db_backend_eval_dispatch_signature(&report),
11240 ))
11241 });
11242 operations.push(operation);
11243 signatures.extend(signature);
11244
11245 let total_micros = operations
11246 .iter()
11247 .map(|operation| operation.duration_micros)
11248 .sum();
11249 let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11250 (
11251 GraphDbBackendEvalBackendReport {
11252 backend: backend.to_string(),
11253 adapter: adapter.to_string(),
11254 read_only,
11255 projection_load: projection_load.to_string(),
11256 operations,
11257 total_micros,
11258 parity,
11259 lock_behavior: lock_behavior.to_string(),
11260 install_portability: install_portability.to_string(),
11261 },
11262 signatures,
11263 )
11264}
11265
11266pub(crate) fn graph_db_backend_eval_refresh_operation(
11267 duration_micros: u128,
11268 rows: usize,
11269 value: serde_json::Value,
11270) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11271 (
11272 GraphDbBackendEvalOperation {
11273 name: "refresh".to_string(),
11274 supported: true,
11275 status: "ok".to_string(),
11276 duration_micros,
11277 rows: Some(rows),
11278 error: None,
11279 },
11280 GraphDbBackendEvalSignature {
11281 operation: "refresh".to_string(),
11282 value,
11283 },
11284 )
11285}
11286
11287pub(crate) fn graph_db_backend_eval_synthetic_projection(
11288 nodes: usize,
11289 fanout: usize,
11290) -> GraphProjection {
11291 let nodes = nodes.max(12);
11292 let symbol_count = nodes.saturating_sub(9).max(1);
11293 let source = GraphProvenance::new("backend-eval", "synthetic");
11294 let mut projection_nodes = vec![
11295 SubstrateGraphNode::new(
11296 "projection:tsift-traversal:synthetic",
11297 GRAPH_PROJECTION_META_KIND,
11298 "synthetic projection",
11299 )
11300 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11301 .with_property(
11302 "content_hash",
11303 format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11304 )
11305 .with_provenance(source.clone()),
11306 SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11307 .with_property("ref_id", "synthetic-session"),
11308 SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11309 .with_property("ref_id", "synthetic")
11310 .with_property("path", "tasks/software/synthetic.md")
11311 .with_property("line", "1")
11312 .with_property(
11313 "expand",
11314 "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11315 ),
11316 SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11317 .with_property("ref_id", "synthetic"),
11318 SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11319 .with_property("target", "synthetic")
11320 .with_property("summary", "Synthetic worker owns synthetic.rs")
11321 .with_property(
11322 "expand",
11323 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11324 ),
11325 SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11326 .with_property("file", "synthetic.rs")
11327 .with_property("start", "1")
11328 .with_property("end", "80")
11329 .with_property(
11330 "expand",
11331 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11332 ),
11333 SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11334 .with_property("path", "synthetic.rs"),
11335 SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11336 .with_property("handle", "gsem-synthetic")
11337 .with_property("label", "backend evaluation")
11338 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11339 .with_property(
11340 "embedding",
11341 semantic_embedding_property("backend evaluation"),
11342 ),
11343 SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11344 .with_property("ref_id", "synthetic")
11345 .with_property("status", "completed")
11346 .with_property("touched_files", "synthetic.rs")
11347 .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11348 ];
11349 for idx in 0..symbol_count {
11350 projection_nodes.push(
11351 SubstrateGraphNode::new(
11352 format!("gsym-synthetic-{idx:04}"),
11353 "symbol",
11354 format!("synthetic_symbol_{idx:04}"),
11355 )
11356 .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11357 .with_property("path", "synthetic.rs")
11358 .with_property("line", (idx + 1).to_string()),
11359 );
11360 }
11361
11362 let mut projection_edges = vec![
11363 SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11364 SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11365 SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11366 SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11367 SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11368 SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11369 SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11370 SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11371 SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11372 ];
11373 for idx in 0..symbol_count {
11374 let from = format!("gsym-synthetic-{idx:04}");
11375 for offset in 1..=fanout.max(1).min(symbol_count) {
11376 let to_idx = (idx + offset) % symbol_count;
11377 if to_idx != idx {
11378 projection_edges.push(SubstrateGraphEdge::new(
11379 from.clone(),
11380 format!("gsym-synthetic-{to_idx:04}"),
11381 "calls",
11382 ));
11383 }
11384 }
11385 }
11386
11387 GraphProjection {
11388 nodes: projection_nodes,
11389 edges: projection_edges
11390 .into_iter()
11391 .map(|edge| {
11392 edge.with_property("dataset", "synthetic")
11393 .with_provenance(source.clone())
11394 })
11395 .collect(),
11396 }
11397}
11398
11399pub(crate) fn graph_db_backend_eval_promotion(
11400 datasets: &[GraphDbBackendEvalDataset],
11401 candidates: &[GraphDbExperimentalBackend],
11402) -> Vec<GraphDbBackendPromotionDecision> {
11403 let mut decisions = Vec::new();
11404 for candidate in candidates {
11405 let mut reasons = Vec::new();
11406 let mut faster_everywhere = true;
11407 let mut parity_everywhere = true;
11408 for dataset in datasets {
11409 let Some(sqlite_report) = dataset
11410 .backends
11411 .iter()
11412 .find(|backend| backend.backend == "sqlite")
11413 else {
11414 parity_everywhere = false;
11415 faster_everywhere = false;
11416 reasons.push(format!(
11417 "{} dataset is missing SQLite baseline",
11418 dataset.name
11419 ));
11420 continue;
11421 };
11422 let sqlite_total = sqlite_report.total_micros;
11423 let Some(candidate_report) = dataset
11424 .backends
11425 .iter()
11426 .find(|backend| backend.backend == candidate.name())
11427 else {
11428 parity_everywhere = false;
11429 reasons.push(format!("{} dataset did not run", dataset.name));
11430 continue;
11431 };
11432 if !candidate_report.parity.matches_sqlite {
11433 parity_everywhere = false;
11434 reasons.push(format!("{} parity differed from SQLite", dataset.name));
11435 }
11436 if candidate_report.total_micros >= sqlite_total {
11437 faster_everywhere = false;
11438 reasons.push(format!(
11439 "{} total {}us did not beat SQLite {}us",
11440 dataset.name, candidate_report.total_micros, sqlite_total
11441 ));
11442 }
11443 let sqlite_operations = sqlite_report
11444 .operations
11445 .iter()
11446 .map(|operation| (operation.name.as_str(), operation.duration_micros))
11447 .collect::<BTreeMap<_, _>>();
11448 for operation in &candidate_report.operations {
11449 if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11450 && operation.duration_micros >= *sqlite_duration
11451 {
11452 faster_everywhere = false;
11453 reasons.push(format!(
11454 "{} {} operation {}us did not beat SQLite {}us",
11455 dataset.name, operation.name, operation.duration_micros, sqlite_duration
11456 ));
11457 }
11458 }
11459 if candidate_report
11460 .operations
11461 .iter()
11462 .any(|operation| operation.status != "ok")
11463 {
11464 parity_everywhere = false;
11465 reasons.push(format!("{} has failed benchmark operations", dataset.name));
11466 }
11467 }
11468 let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11469 reasons.push(reason.to_string());
11470 reasons.push(
11471 "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11472 .to_string(),
11473 );
11474 "hold"
11475 } else if parity_everywhere && faster_everywhere {
11476 reasons.push(
11477 "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11478 .to_string(),
11479 );
11480 "eligible"
11481 } else {
11482 reasons.push(
11483 "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11484 .to_string(),
11485 );
11486 "hold"
11487 };
11488 decisions.push(GraphDbBackendPromotionDecision {
11489 backend: candidate.name().to_string(),
11490 decision: decision.to_string(),
11491 reasons: dedupe_preserve_order(reasons),
11492 gate: candidate.promotion_gate(),
11493 });
11494 }
11495 decisions
11496}
11497
11498pub(crate) fn graph_db_backend_eval_metrics(
11499 datasets: &[GraphDbBackendEvalDataset],
11500) -> BTreeMap<String, f64> {
11501 let mut metrics = BTreeMap::new();
11502 for dataset in datasets {
11503 let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11504 metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11505 metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11506 metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11507 for backend in &dataset.backends {
11508 let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
11509 metrics.insert(
11510 format!("{prefix}.total_duration_micros"),
11511 backend.total_micros as f64,
11512 );
11513 append_graph_db_backend_eval_normalized_duration_metric(
11514 &mut metrics,
11515 &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
11516 backend.total_micros,
11517 graph_rows,
11518 );
11519 for operation in &backend.operations {
11520 metrics.insert(
11521 format!("{prefix}.{}.duration_micros", operation.name),
11522 operation.duration_micros as f64,
11523 );
11524 append_graph_db_backend_eval_normalized_duration_metric(
11525 &mut metrics,
11526 &format!(
11527 "{prefix}.{}.duration_micros_per_1k_graph_rows",
11528 operation.name
11529 ),
11530 operation.duration_micros,
11531 graph_rows,
11532 );
11533 if let Some(rows) = operation.rows {
11534 metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
11535 }
11536 }
11537 }
11538 }
11539 metrics
11540}
11541
11542pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
11543 dataset.nodes + dataset.edges
11544}
11545
11546pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
11547 metrics: &mut BTreeMap<String, f64>,
11548 key: &str,
11549 duration_micros: u128,
11550 graph_rows: usize,
11551) {
11552 if graph_rows == 0 {
11553 return;
11554 }
11555 metrics.insert(
11556 key.to_string(),
11557 duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
11558 );
11559}
11560
11561pub(crate) fn append_graph_db_backend_eval_phase_metrics(
11562 metrics: &mut BTreeMap<String, f64>,
11563 dataset: &str,
11564 graph_rows: usize,
11565 phases: &[GraphDbBackendEvalPhaseTiming],
11566) {
11567 for phase in phases {
11568 metrics.insert(
11569 format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
11570 phase.duration_micros as f64,
11571 );
11572 append_graph_db_backend_eval_normalized_duration_metric(
11573 metrics,
11574 &format!(
11575 "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
11576 phase.name
11577 ),
11578 phase.duration_micros,
11579 graph_rows,
11580 );
11581 }
11582}
11583
11584fn graph_db_backend_eval_base_command(
11585 root: &Path,
11586 scope: Option<&str>,
11587 full_projection: bool,
11588) -> String {
11589 let full_projection_arg = if full_projection {
11590 " --full-projection"
11591 } else {
11592 ""
11593 };
11594 format!(
11595 "tsift graph-db --path {}{} --json backend-eval{}",
11596 shell_quote(root.to_string_lossy().as_ref()),
11597 graph_db_scope_arg(scope),
11598 full_projection_arg
11599 )
11600}
11601
11602pub(crate) fn graph_db_backend_eval_metric_digest_command(
11603 root: &Path,
11604 scope: Option<&str>,
11605 full_projection: bool,
11606) -> String {
11607 format!(
11608 "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11609 graph_db_backend_eval_base_command(root, scope, full_projection)
11610 )
11611}
11612
11613fn graph_db_backend_eval_repeated_sample_command(
11614 root: &Path,
11615 scope: Option<&str>,
11616 full_projection: bool,
11617) -> String {
11618 format!(
11619 "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11620 graph_db_backend_eval_base_command(root, scope, full_projection)
11621 )
11622}
11623
11624fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
11625 let mut required_metrics = Vec::new();
11626 for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
11627 required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
11628 required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
11629 for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
11630 required_metrics.push(format!(
11631 "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
11632 ));
11633 required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
11634 }
11635 }
11636 GraphDbHopCapPromotionGate {
11637 status: "hold_64_default_until_gate_passes".to_string(),
11638 current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
11639 candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
11640 required_backend: perf_gate::BASELINE_BACKEND.to_string(),
11641 required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
11642 .iter()
11643 .map(|workload| (*workload).to_string())
11644 .collect(),
11645 required_metrics,
11646 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11647 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11648 decision_rule:
11649 "keep 64 as the user-facing default until each candidate tier has repeated real, full_projection, and synthetic_deep_chain SQLite samples within the latency-regression budget and returning useful path rows; full_projection samples are binding only after a cold populate leg proves a cache-hit leg"
11650 .to_string(),
11651 }
11652}
11653
11654fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
11655 let candidate_backends = [
11656 GraphDbExperimentalBackend::Falkordb,
11657 GraphDbExperimentalBackend::Kuzu,
11658 GraphDbExperimentalBackend::Surrealdb,
11659 ]
11660 .into_iter()
11661 .map(|backend| GraphDbBackendAdapterSpikeCandidate {
11662 backend: backend.name().to_string(),
11663 adapter_label: backend.adapter_label().to_string(),
11664 projection_load: backend.projection_load().to_string(),
11665 lock_behavior: backend.lock_behavior().to_string(),
11666 install_portability: backend.install_portability().to_string(),
11667 })
11668 .collect();
11669
11670 GraphDbBackendAdapterSpikeGate {
11671 status: "hold_real_optional_adapter_required".to_string(),
11672 candidate_backends,
11673 required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
11674 .iter()
11675 .map(|workload| (*workload).to_string())
11676 .collect(),
11677 required_checks: vec![
11678 "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
11679 "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
11680 "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
11681 "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
11682 "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
11683 .to_string(),
11684 "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
11685 "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
11686 ],
11687 decision_rule:
11688 "do not promote a read-only prototype; FalkorDB, Kuzu, or SurrealDB can only advance after a real optional adapter proves projection writes/load, lock semantics, install portability, full parity, and faster-than-SQLite results across every required workload"
11689 .to_string(),
11690 evidence_plan: "plans/gback-evidence.md".to_string(),
11691 }
11692}
11693
11694pub(crate) fn graph_db_backend_eval_performance_gate(
11695 root: &Path,
11696 scope: Option<&str>,
11697 full_projection: bool,
11698) -> GraphDbBackendEvalPerformanceGate {
11699 let mut required_metrics = vec![
11700 "real.sqlite.refresh.duration_micros".to_string(),
11701 "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
11702 "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
11703 "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
11704 "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11705 "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11706 "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
11707 "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
11708 "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11709 "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
11710 "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
11711 "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
11712 "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
11713 "real.sqlite.conflict_matrix.duration_micros".to_string(),
11714 "real.sqlite.dispatch_trace.duration_micros".to_string(),
11715 "real.sqlite.path_max_hops.duration_micros".to_string(),
11716 "real.sqlite.path_max_hops_128.duration_micros".to_string(),
11717 "real.sqlite.path_max_hops_256.duration_micros".to_string(),
11718 "real.sqlite.path_max_hops_512.duration_micros".to_string(),
11719 "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
11720 "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
11721 "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
11722 "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
11723 "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11724 "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11725 "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
11726 .to_string(),
11727 "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11728 .to_string(),
11729 "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11730 "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11731 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
11732 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
11733 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
11734 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
11735 "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11736 .to_string(),
11737 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
11738 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
11739 .to_string(),
11740 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
11741 .to_string(),
11742 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
11743 .to_string(),
11744 ];
11745 if full_projection {
11746 required_metrics.extend([
11747 "full_projection.cache.hit".to_string(),
11748 "full_projection.cache.disk_bytes".to_string(),
11749 "full_projection.cache.compression_ratio".to_string(),
11750 "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
11751 "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11752 "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
11753 .to_string(),
11754 "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
11755 .to_string(),
11756 "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
11757 "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
11758 "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
11759 "full_projection.sqlite.neighborhood.duration_micros".to_string(),
11760 "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
11761 "full_projection.sqlite.evidence.duration_micros".to_string(),
11762 "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
11763 "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
11764 "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
11765 "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
11766 "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
11767 "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
11768 ]);
11769 }
11770 GraphDbBackendEvalPerformanceGate {
11771 baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
11772 ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
11773 .to_string(),
11774 opt_in_real_profile:
11775 "pass --full-projection to add the full-project dataset when checking for large projection regressions"
11776 .to_string(),
11777 full_projection_cache_hit_gate: if full_projection {
11778 "binding full_projection performance evidence requires a cold populate leg followed by cache-leg samples with full_projection.cache.hit=1; cache-miss samples are diagnostics, not backend or hop-cap promotion proof"
11779 .to_string()
11780 } else {
11781 "not evaluated until --full-projection is enabled".to_string()
11782 },
11783 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11784 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11785 normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
11786 required_metrics,
11787 digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
11788 repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
11789 root,
11790 scope,
11791 full_projection,
11792 ),
11793 hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
11794 backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
11795 }
11796}
11797
11798#[cfg(feature = "backend-surrealdb")]
11799fn graph_db_backend_eval_path_segment(value: &str) -> String {
11800 value
11801 .chars()
11802 .map(|ch| {
11803 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
11804 ch
11805 } else {
11806 '_'
11807 }
11808 })
11809 .collect()
11810}
11811
11812#[cfg(feature = "backend-surrealdb")]
11813fn graph_db_backend_eval_surrealdb_store_path(
11814 root: &Path,
11815 scope: Option<&str>,
11816 dataset: &str,
11817) -> PathBuf {
11818 root.join(".tsift/backend-eval-cache/surrealdb")
11819 .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
11820 .join(graph_db_backend_eval_path_segment(dataset))
11821 .join("surrealkv")
11822}
11823
11824pub(crate) struct GraphDbBackendEvalOptions<'a> {
11825 path: &'a Path,
11826 scope: Option<&'a str>,
11827 candidates: &'a [String],
11828 targets: &'a [String],
11829 full_projection: bool,
11830}
11831
11832#[allow(clippy::too_many_arguments)]
11833pub(crate) fn graph_db_backend_eval_dataset(
11834 name: &str,
11835 root: &Path,
11836 path: &Path,
11837 scope: Option<&str>,
11838 targets: &[String],
11839 depth: usize,
11840 limit: usize,
11841 impact_limit: usize,
11842 candidates: &[GraphDbExperimentalBackend],
11843 sqlite_store: &SqliteGraphStore,
11844 sqlite_freshness: GraphDbFreshnessReport,
11845 sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
11846 sqlite_rows: ConvexProjectionRows,
11847 extra_warnings: Vec<String>,
11848 prepared: &ConflictMatrixPreparedInputs,
11849) -> Result<GraphDbBackendEvalDataset> {
11850 let (nodes, edges) = sqlite_store.graph_counts()?;
11851 let (sqlite_operation, sqlite_signature) = sqlite_refresh;
11852 let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
11853 "sqlite",
11854 "SQLite GraphStore correctness baseline",
11855 false,
11856 root,
11857 path,
11858 scope,
11859 targets,
11860 depth,
11861 limit,
11862 impact_limit,
11863 sqlite_store,
11864 sqlite_freshness,
11865 sqlite_operation,
11866 Some(sqlite_signature),
11867 None,
11868 extra_warnings.clone(),
11869 prepared,
11870 "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
11871 "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
11872 "bundled rusqlite baseline; no external service or runtime required",
11873 );
11874
11875 let mut backends = vec![sqlite_report];
11876 for candidate in candidates {
11877 #[cfg(feature = "backend-surrealdb")]
11878 if *candidate == GraphDbExperimentalBackend::Surrealdb {
11879 let started = Instant::now();
11880 let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
11881 let (store, warm_start) =
11882 SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
11883 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11884 let rows = candidate_nodes + candidate_edges;
11885 let mut refresh_meta = serde_json::json!({
11886 "nodes": candidate_nodes,
11887 "edges": candidate_edges,
11888 });
11889 if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
11890 refresh_meta["warm_start"] = serde_json::json!("cache_hit");
11891 }
11892 let refresh = graph_db_backend_eval_refresh_operation(
11893 started.elapsed().as_micros(),
11894 rows,
11895 refresh_meta,
11896 );
11897 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11898 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11899 candidate.name(),
11900 "SurrealDB SurrealKV optional adapter spike",
11901 false,
11902 root,
11903 path,
11904 scope,
11905 targets,
11906 depth,
11907 limit,
11908 impact_limit,
11909 &store,
11910 freshness,
11911 refresh.0,
11912 Some(refresh.1),
11913 Some(&sqlite_signatures),
11914 extra_warnings.clone(),
11915 prepared,
11916 "provider-neutral rows written into an embedded/file-backed SurrealDB SurrealKV store through the optional tsift-surrealdb adapter; warm-start reuses existing store when row hash matches",
11917 "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
11918 "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
11919 );
11920 backends.push(candidate_report);
11921 continue;
11922 }
11923 let started = Instant::now();
11924 let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
11925 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11926 let rows = candidate_nodes + candidate_edges;
11927 let refresh = graph_db_backend_eval_refresh_operation(
11928 started.elapsed().as_micros(),
11929 rows,
11930 serde_json::json!({
11931 "nodes": candidate_nodes,
11932 "edges": candidate_edges,
11933 }),
11934 );
11935 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11936 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11937 candidate.name(),
11938 candidate.adapter_label(),
11939 true,
11940 root,
11941 path,
11942 scope,
11943 targets,
11944 depth,
11945 limit,
11946 impact_limit,
11947 &store,
11948 freshness,
11949 refresh.0,
11950 Some(refresh.1),
11951 Some(&sqlite_signatures),
11952 extra_warnings.clone(),
11953 prepared,
11954 candidate.projection_load(),
11955 candidate.lock_behavior(),
11956 candidate.install_portability(),
11957 );
11958 backends.push(candidate_report);
11959 }
11960
11961 Ok(GraphDbBackendEvalDataset {
11962 name: name.to_string(),
11963 target_count: targets.len(),
11964 nodes,
11965 edges,
11966 backends,
11967 })
11968}
11969
11970pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
11971 println!(
11972 "graph-db backend-eval baseline:{} candidates:{}",
11973 report.baseline_backend,
11974 report.candidates.join(", ")
11975 );
11976 for phase in &report.phase_timings {
11977 println!(
11978 "phase:{} {}us {}",
11979 phase.name, phase.duration_micros, phase.detail
11980 );
11981 }
11982 for dataset in &report.datasets {
11983 println!(
11984 "dataset:{} targets:{} rows:{}",
11985 dataset.name,
11986 dataset.target_count,
11987 dataset.nodes + dataset.edges
11988 );
11989 for backend in &dataset.backends {
11990 println!(
11991 " backend:{} total:{}us parity:{}",
11992 backend.backend, backend.total_micros, backend.parity.matches_sqlite
11993 );
11994 println!(" projection-load: {}", backend.projection_load);
11995 println!(" lock-behavior: {}", backend.lock_behavior);
11996 println!(" install-portability: {}", backend.install_portability);
11997 for operation in &backend.operations {
11998 println!(
11999 " {} {} {}us",
12000 operation.name, operation.status, operation.duration_micros
12001 );
12002 }
12003 for diagnostic in &backend.parity.diagnostics {
12004 println!(" parity: {diagnostic}");
12005 }
12006 }
12007 }
12008 for decision in &report.promotion {
12009 println!("promotion {}: {}", decision.backend, decision.decision);
12010 println!(" gate: {}", decision.gate.status);
12011 for reason in &decision.reasons {
12012 println!(" reason: {reason}");
12013 }
12014 for check in &decision.gate.required_checks {
12015 println!(" check: {check}");
12016 }
12017 }
12018 println!("metric-digest: {}", report.metric_digest_command);
12019 println!(
12020 "repeat-samples: {}",
12021 report.performance_gate.repeated_sample_command
12022 );
12023}
12024
12025fn traversal_expand_command(root: &Path, handle: &str) -> String {
12026 format!(
12027 "tsift traverse {} --path {} --depth 1 --limit 50",
12028 shell_quote(handle),
12029 shell_quote(root.to_string_lossy().as_ref())
12030 )
12031}
12032
12033fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
12034 let display = relativize(file, root);
12035 let handle = stable_handle("gfil", &format!("file:{display}"));
12036 TraversalNode {
12037 handle: handle.clone(),
12038 kind: "file".to_string(),
12039 label: display.clone(),
12040 ref_id: Some(display.clone()),
12041 path: Some(display),
12042 line: None,
12043 detail: None,
12044 properties: BTreeMap::new(),
12045 expand: traversal_expand_command(root, &handle),
12046 }
12047}
12048
12049fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
12050 let mut node = traversal_file_node(root, file);
12051 if let Some(path) = node.path.clone() {
12052 node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
12053 node.expand = source_read_command(root, &path, 1, 80);
12054 }
12055 node
12056}
12057
12058fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
12059 let file = relativize(&symbol.file, root);
12060 let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
12061 let handle = stable_handle("gsym", &key);
12062 TraversalNode {
12063 handle: handle.clone(),
12064 kind: "symbol".to_string(),
12065 label: symbol.name.clone(),
12066 ref_id: Some(symbol.name.clone()),
12067 path: Some(file),
12068 line: Some(symbol.line),
12069 detail: Some(format!("{} {}", symbol.language, symbol.kind)),
12070 properties: BTreeMap::new(),
12071 expand: traversal_expand_command(root, &handle),
12072 }
12073}
12074
12075fn traversal_ast_span_expand_command(
12076 root: &Path,
12077 file: &str,
12078 symbol: &index::StoredSymbol,
12079 span: &AstSpanPreview,
12080) -> String {
12081 if symbol.language == "markdown" {
12082 markdown_ast_command(root, file, Some(&span.handle))
12083 } else {
12084 let line_count = span
12085 .end_line
12086 .saturating_sub(span.start_line)
12087 .saturating_add(1)
12088 .max(1);
12089 source_read_command(root, file, span.start_line, line_count)
12090 }
12091}
12092
12093fn traversal_ast_span_node(
12094 root: &Path,
12095 symbol: &index::StoredSymbol,
12096 source: &[u8],
12097 symbols: &[index::StoredSymbol],
12098) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
12099 let span = stored_symbol_ast_span(symbol, source, symbols, usize::MAX)?;
12100 let file = relativize(&symbol.file, root);
12101 let mut properties = BTreeMap::new();
12102 properties.insert("layer".to_string(), "ast_navigation".to_string());
12103 properties.insert("language".to_string(), symbol.language.clone());
12104 properties.insert("symbol_kind".to_string(), symbol.kind.clone());
12105 properties.insert("node_kind".to_string(), span.node_kind.clone());
12106 properties.insert("start_byte".to_string(), span.start_byte.to_string());
12107 properties.insert("end_byte".to_string(), span.end_byte.to_string());
12108 properties.insert("end_line".to_string(), span.end_line.to_string());
12109 if let Some(body_start_byte) = span.body_start_byte {
12110 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12111 }
12112 if let Some(body_end_byte) = span.body_end_byte {
12113 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12114 }
12115 if let Some(body_start_line) = span.body_start_line {
12116 properties.insert("body_start_line".to_string(), body_start_line.to_string());
12117 }
12118 if let Some(body_end_line) = span.body_end_line {
12119 properties.insert("body_end_line".to_string(), body_end_line.to_string());
12120 }
12121 if let Some(parent_handle) = &span.parent_handle {
12122 properties.insert("parent_handle".to_string(), parent_handle.clone());
12123 }
12124 if !span.child_handles.is_empty() {
12125 properties.insert("child_handles".to_string(), span.child_handles.join(","));
12126 }
12127 if let Some(parent_module) = &symbol.parent_module {
12128 properties.insert("parent_module".to_string(), parent_module.clone());
12129 }
12130 if let Some(markdown) = &span.markdown {
12131 properties.insert(
12132 "markdown_block_kind".to_string(),
12133 markdown_ast_block_kind(&symbol.kind),
12134 );
12135 if let Some(heading_level) = markdown.heading_level {
12136 properties.insert("heading_level".to_string(), heading_level.to_string());
12137 }
12138 if !markdown.section_path.is_empty() {
12139 properties.insert(
12140 "section_path".to_string(),
12141 markdown.section_path.join(" > "),
12142 );
12143 }
12144 if let Some(section_handle) = &markdown.section_handle {
12145 properties.insert("section_handle".to_string(), section_handle.clone());
12146 }
12147 if let Some(list_depth) = markdown.list_depth {
12148 properties.insert("list_depth".to_string(), list_depth.to_string());
12149 }
12150 if let Some(fence_language) = &markdown.fence_language {
12151 properties.insert("fence_language".to_string(), fence_language.clone());
12152 }
12153 }
12154
12155 let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
12156 let node = TraversalNode {
12157 handle: span.handle.clone(),
12158 kind: "ast_span".to_string(),
12159 label: symbol.name.clone(),
12160 ref_id: Some(symbol.name.clone()),
12161 path: Some(file.clone()),
12162 line: Some(line),
12163 detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
12164 properties,
12165 expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
12166 };
12167 let entry = TraversalAstSpanIndexEntry {
12168 handle: span.handle,
12169 symbol_handle: String::new(),
12170 file_handle: None,
12171 file,
12172 name: symbol.name.clone(),
12173 kind: symbol.kind.clone(),
12174 language: symbol.language.clone(),
12175 node_kind: span.node_kind,
12176 start_byte: span.start_byte,
12177 end_byte: span.end_byte,
12178 parent_module: symbol.parent_module.clone(),
12179 markdown: span.markdown,
12180 };
12181 Some((node, entry))
12182}
12183
12184fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
12185 let handle = stable_handle("gsym", &format!("symbol:{name}"));
12186 TraversalNode {
12187 handle: handle.clone(),
12188 kind: "symbol".to_string(),
12189 label: name.to_string(),
12190 ref_id: Some(name.to_string()),
12191 path: None,
12192 line: None,
12193 detail: Some("unresolved call target".to_string()),
12194 properties: BTreeMap::new(),
12195 expand: traversal_expand_command(root, &handle),
12196 }
12197}
12198
12199fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12200 let file = relativize(&route.file, root);
12201 let method = route.method.as_deref().unwrap_or("any");
12202 let key = format!(
12203 "route:{file}:{}:{}:{}",
12204 route.line, method, route.route_path
12205 );
12206 let handle = stable_handle("grte", &key);
12207 TraversalNode {
12208 handle: handle.clone(),
12209 kind: "route".to_string(),
12210 label: format!("{} {}", method.to_uppercase(), route.route_path),
12211 ref_id: Some(route.route_path.clone()),
12212 path: Some(file),
12213 line: Some(route.line),
12214 detail: Some(format!(
12215 "{} route handled by {}",
12216 route.framework, route.handler_name
12217 )),
12218 properties: BTreeMap::new(),
12219 expand: traversal_expand_command(root, &handle),
12220 }
12221}
12222
12223fn traversal_cargo_workspace_node(
12224 root: &Path,
12225 workspace: &multiplicity::CargoWorkspaceInfo,
12226) -> TraversalNode {
12227 let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12228 .to_string_lossy()
12229 .replace('\\', "/");
12230 let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12231 .to_string_lossy()
12232 .replace('\\', "/");
12233 let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12234 let mut properties = BTreeMap::new();
12235 properties.insert("layer".to_string(), "cargo_workspace".to_string());
12236 properties.insert("workspace_root".to_string(), workspace_root.clone());
12237 properties.insert("members".to_string(), workspace.members.join(","));
12238 properties.insert(
12239 "default_members".to_string(),
12240 workspace.default_members.join(","),
12241 );
12242 TraversalNode {
12243 handle: handle.clone(),
12244 kind: "cargo_workspace".to_string(),
12245 label: if workspace_root.is_empty() {
12246 "root cargo workspace".to_string()
12247 } else {
12248 workspace_root
12249 },
12250 ref_id: Some(workspace.id.clone()),
12251 path: Some(manifest),
12252 line: None,
12253 detail: Some("Cargo workspace manifest".to_string()),
12254 properties,
12255 expand: traversal_expand_command(root, &handle),
12256 }
12257}
12258
12259fn traversal_cargo_package_node(
12260 root: &Path,
12261 package: &multiplicity::CargoPackageInfo,
12262) -> TraversalNode {
12263 let manifest = relativize_pathbuf(&package.manifest_path, root)
12264 .to_string_lossy()
12265 .replace('\\', "/");
12266 let package_root = relativize_pathbuf(&package.package_root, root)
12267 .to_string_lossy()
12268 .replace('\\', "/");
12269 let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12270 .to_string_lossy()
12271 .replace('\\', "/");
12272 let handle = stable_handle(
12273 "gcpk",
12274 &format!("cargo-package:{manifest}:{}", package.name),
12275 );
12276 let mut properties = BTreeMap::new();
12277 properties.insert("layer".to_string(), "cargo_package".to_string());
12278 properties.insert("package_name".to_string(), package.name.clone());
12279 properties.insert(
12280 "normalized_name".to_string(),
12281 package.normalized_name.clone(),
12282 );
12283 properties.insert("package_root".to_string(), package_root.clone());
12284 properties.insert("workspace_root".to_string(), workspace_root);
12285 properties.insert("features".to_string(), package.features.join(","));
12286 properties.insert("targets".to_string(), package.targets.join(","));
12287 properties.insert(
12288 "dependencies".to_string(),
12289 package
12290 .dependencies
12291 .iter()
12292 .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12293 .collect::<Vec<_>>()
12294 .join(","),
12295 );
12296 TraversalNode {
12297 handle: handle.clone(),
12298 kind: "cargo_package".to_string(),
12299 label: package.name.clone(),
12300 ref_id: Some(package.scope_id.clone()),
12301 path: Some(manifest),
12302 line: None,
12303 detail: Some(format!(
12304 "Cargo package in {}",
12305 if package_root.is_empty() {
12306 "."
12307 } else {
12308 package_root.as_str()
12309 }
12310 )),
12311 properties,
12312 expand: traversal_expand_command(root, &handle),
12313 }
12314}
12315
12316fn traversal_session_node(
12317 root: &Path,
12318 markdown_path: &Path,
12319 session_id: Option<&str>,
12320) -> TraversalNode {
12321 let display = relativize_pathbuf(markdown_path, root)
12322 .to_string_lossy()
12323 .replace('\\', "/");
12324 let handle = stable_handle("gses", &format!("session:{display}"));
12325 TraversalNode {
12326 handle: handle.clone(),
12327 kind: "session".to_string(),
12328 label: session_id.unwrap_or(&display).to_string(),
12329 ref_id: session_id.map(str::to_string),
12330 path: Some(display),
12331 line: None,
12332 detail: Some("agent-doc session artifact".to_string()),
12333 properties: BTreeMap::new(),
12334 expand: traversal_expand_command(root, &handle),
12335 }
12336}
12337
12338fn traversal_backlog_node(
12339 root: &Path,
12340 markdown_path: &Path,
12341 id: &str,
12342 text: &str,
12343 line: i64,
12344) -> TraversalNode {
12345 let display = relativize_pathbuf(markdown_path, root)
12346 .to_string_lossy()
12347 .replace('\\', "/");
12348 let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12349 TraversalNode {
12350 handle: handle.clone(),
12351 kind: "backlog".to_string(),
12352 label: format!("#{id}"),
12353 ref_id: Some(id.to_string()),
12354 path: Some(display),
12355 line: Some(line),
12356 detail: Some(text.to_string()),
12357 properties: BTreeMap::new(),
12358 expand: traversal_expand_command(root, &handle),
12359 }
12360}
12361
12362fn traversal_job_packet_node(
12363 root: &Path,
12364 markdown_path: &Path,
12365 label: &str,
12366 ref_id: Option<&str>,
12367 detail: &str,
12368 line: i64,
12369) -> TraversalNode {
12370 let display = relativize_pathbuf(markdown_path, root)
12371 .to_string_lossy()
12372 .replace('\\', "/");
12373 let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12374 TraversalNode {
12375 handle: handle.clone(),
12376 kind: "job_packet".to_string(),
12377 label: label.to_string(),
12378 ref_id: ref_id.map(str::to_string),
12379 path: Some(display),
12380 line: Some(line),
12381 detail: Some(detail.to_string()),
12382 properties: BTreeMap::new(),
12383 expand: traversal_expand_command(root, &handle),
12384 }
12385}
12386
12387#[derive(Clone, Debug)]
12388struct ParsedWorkerResult {
12389 id: String,
12390 status: String,
12391 touched_files: Vec<String>,
12392 tests: Vec<String>,
12393 follow_up_ids: Vec<String>,
12394}
12395
12396fn traversal_worker_result_node(
12397 root: &Path,
12398 markdown_path: &Path,
12399 parsed: &ParsedWorkerResult,
12400 line_text: &str,
12401 line: i64,
12402) -> TraversalNode {
12403 let display = relativize_pathbuf(markdown_path, root)
12404 .to_string_lossy()
12405 .replace('\\', "/");
12406 let handle = stable_handle(
12407 "wres",
12408 &format!(
12409 "worker-result:{display}:{}:{}:{}",
12410 parsed.id, parsed.status, line
12411 ),
12412 );
12413 let mut properties = BTreeMap::new();
12414 properties.insert("status".to_string(), parsed.status.clone());
12415 if !parsed.touched_files.is_empty() {
12416 properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12417 }
12418 if !parsed.tests.is_empty() {
12419 properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12420 }
12421 if !parsed.follow_up_ids.is_empty() {
12422 properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12423 }
12424 TraversalNode {
12425 handle: handle.clone(),
12426 kind: "worker_result".to_string(),
12427 label: format!("{} #{}", parsed.status, parsed.id),
12428 ref_id: Some(parsed.id.clone()),
12429 path: Some(display),
12430 line: Some(line),
12431 detail: Some(line_text.trim().to_string()),
12432 properties,
12433 expand: traversal_expand_command(root, &handle),
12434 }
12435}
12436
12437fn traversal_tokens(input: &str) -> BTreeSet<String> {
12438 input
12439 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12440 .flat_map(|part| part.split(['_', '-']))
12441 .map(str::trim)
12442 .filter(|part| part.len() >= 3)
12443 .map(|part| part.to_ascii_lowercase())
12444 .collect()
12445}
12446
12447fn traversal_ast_span_contains(
12448 parent: &TraversalAstSpanIndexEntry,
12449 child: &TraversalAstSpanIndexEntry,
12450) -> bool {
12451 parent.handle != child.handle
12452 && parent.file == child.file
12453 && parent.start_byte <= child.start_byte
12454 && parent.end_byte >= child.end_byte
12455}
12456
12457fn traversal_ast_parent_handle<'a>(
12458 entry: &TraversalAstSpanIndexEntry,
12459 entries: &'a [TraversalAstSpanIndexEntry],
12460) -> Option<&'a str> {
12461 entries
12462 .iter()
12463 .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12464 .min_by_key(|candidate| {
12465 (
12466 candidate.end_byte.saturating_sub(candidate.start_byte),
12467 candidate.start_byte,
12468 candidate.end_byte,
12469 candidate.kind.as_str(),
12470 candidate.name.as_str(),
12471 candidate.node_kind.as_str(),
12472 )
12473 })
12474 .map(|candidate| candidate.handle.as_str())
12475}
12476
12477fn traversal_ast_enclosing_module_handle<'a>(
12478 entry: &TraversalAstSpanIndexEntry,
12479 entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12480 parent_by_handle: &BTreeMap<String, String>,
12481) -> Option<&'a str> {
12482 let mut current = parent_by_handle.get(&entry.handle);
12483 while let Some(handle) = current {
12484 let Some(parent) = entries_by_handle.get(handle) else {
12485 break;
12486 };
12487 if matches!(parent.kind.as_str(), "module" | "mod")
12488 || entry
12489 .parent_module
12490 .as_deref()
12491 .is_some_and(|module| module == parent.name)
12492 {
12493 return Some(parent.handle.as_str());
12494 }
12495 current = parent_by_handle.get(&parent.handle);
12496 }
12497 None
12498}
12499
12500fn link_ast_navigation_edges(
12501 graph: &mut TraversalGraphBuild,
12502 entries: &[TraversalAstSpanIndexEntry],
12503) {
12504 let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12505 let entries_by_handle = entries
12506 .iter()
12507 .map(|entry| (entry.handle.clone(), entry.clone()))
12508 .collect::<BTreeMap<_, _>>();
12509 let mut parent_by_handle = BTreeMap::<String, String>::new();
12510 let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
12511
12512 for entry in entries {
12513 entries_by_file
12514 .entry(entry.file.clone())
12515 .or_default()
12516 .push(entry.clone());
12517 }
12518
12519 for file_entries in entries_by_file.values() {
12520 for entry in file_entries {
12521 let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
12522 if let Some(parent) = &parent {
12523 parent_by_handle.insert(entry.handle.clone(), parent.clone());
12524 }
12525 let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
12526 children_by_parent
12527 .entry(sibling_key)
12528 .or_default()
12529 .push(entry.clone());
12530 }
12531 }
12532
12533 for entry in entries {
12534 let parent = parent_by_handle.get(&entry.handle);
12535 if let Some(parent) = parent {
12536 graph.add_edge(
12537 parent,
12538 &entry.handle,
12539 "contains",
12540 Some("AST parent contains child span".to_string()),
12541 1,
12542 );
12543 graph.add_edge(
12544 parent,
12545 &entry.handle,
12546 "child",
12547 Some("AST child span".to_string()),
12548 1,
12549 );
12550 graph.add_edge(
12551 &entry.handle,
12552 parent,
12553 "parent",
12554 Some("AST parent span".to_string()),
12555 1,
12556 );
12557 } else if let Some(file_handle) = &entry.file_handle {
12558 graph.add_edge(
12559 file_handle,
12560 &entry.handle,
12561 "contains",
12562 Some("file contains top-level AST span".to_string()),
12563 1,
12564 );
12565 }
12566
12567 if let Some(module_handle) =
12568 traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
12569 {
12570 graph.add_edge(
12571 &entry.handle,
12572 module_handle,
12573 "enclosing_module",
12574 Some("nearest enclosing module AST span".to_string()),
12575 1,
12576 );
12577 }
12578
12579 if entry.language == "markdown"
12580 && let Some(markdown) = &entry.markdown
12581 && let Some(section_handle) = &markdown.section_handle
12582 && section_handle != &entry.handle
12583 {
12584 graph.add_edge(
12585 section_handle,
12586 &entry.handle,
12587 "contains_markdown_block",
12588 Some("Markdown section contains block".to_string()),
12589 1,
12590 );
12591 graph.add_edge(
12592 &entry.handle,
12593 section_handle,
12594 "enclosing_section",
12595 Some("Markdown enclosing section".to_string()),
12596 1,
12597 );
12598 }
12599 }
12600
12601 for siblings in children_by_parent.values_mut() {
12602 siblings.sort_by(|left, right| {
12603 left.start_byte
12604 .cmp(&right.start_byte)
12605 .then(left.end_byte.cmp(&right.end_byte))
12606 .then(left.kind.cmp(&right.kind))
12607 .then(left.name.cmp(&right.name))
12608 .then(left.node_kind.cmp(&right.node_kind))
12609 .then(left.handle.cmp(&right.handle))
12610 });
12611 for pair in siblings.windows(2) {
12612 let previous = &pair[0];
12613 let next = &pair[1];
12614 graph.add_edge(
12615 &previous.handle,
12616 &next.handle,
12617 "next_sibling",
12618 Some("next AST sibling span".to_string()),
12619 1,
12620 );
12621 graph.add_edge(
12622 &next.handle,
12623 &previous.handle,
12624 "previous_sibling",
12625 Some("previous AST sibling span".to_string()),
12626 1,
12627 );
12628 }
12629 }
12630}
12631
12632fn traversal_markdown_embedded_symbol_node(
12633 root: &Path,
12634 entry: &TraversalAstSpanIndexEntry,
12635 markdown: &MarkdownSpanMetadata,
12636 embedded: &MarkdownEmbeddedSymbol,
12637) -> TraversalNode {
12638 let mut properties = BTreeMap::new();
12639 properties.insert("layer".to_string(), "embedded_code".to_string());
12640 properties.insert("embedded".to_string(), "true".to_string());
12641 properties.insert("language".to_string(), embedded.language.clone());
12642 properties.insert("symbol_kind".to_string(), embedded.kind.clone());
12643 properties.insert("node_kind".to_string(), embedded.node_kind.clone());
12644 properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
12645 properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
12646 properties.insert("end_line".to_string(), embedded.end_line.to_string());
12647 properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
12648 properties.insert(
12649 "markdown_block_kind".to_string(),
12650 markdown_ast_block_kind(&entry.kind),
12651 );
12652 if let Some(body_start_byte) = embedded.body_start_byte {
12653 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12654 }
12655 if let Some(body_end_byte) = embedded.body_end_byte {
12656 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12657 }
12658 if let Some(body_start_line) = embedded.body_start_line {
12659 properties.insert("body_start_line".to_string(), body_start_line.to_string());
12660 }
12661 if let Some(body_end_line) = embedded.body_end_line {
12662 properties.insert("body_end_line".to_string(), body_end_line.to_string());
12663 }
12664 if let Some(fence_language) = &markdown.fence_language {
12665 properties.insert("fence_language".to_string(), fence_language.clone());
12666 }
12667 if !markdown.section_path.is_empty() {
12668 properties.insert(
12669 "section_path".to_string(),
12670 markdown.section_path.join(" > "),
12671 );
12672 }
12673 if let Some(section_handle) = &markdown.section_handle {
12674 properties.insert("section_handle".to_string(), section_handle.clone());
12675 }
12676 let line_count = embedded
12677 .end_line
12678 .saturating_sub(embedded.start_line)
12679 .saturating_add(1)
12680 .max(1);
12681 TraversalNode {
12682 handle: embedded.handle.clone(),
12683 kind: "ast_span".to_string(),
12684 label: embedded.name.clone(),
12685 ref_id: Some(embedded.name.clone()),
12686 path: Some(entry.file.clone()),
12687 line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
12688 detail: Some(format!(
12689 "{} {} embedded in Markdown fence",
12690 embedded.language, embedded.kind
12691 )),
12692 properties,
12693 expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
12694 }
12695}
12696
12697fn link_markdown_embedded_code_edges(
12698 graph: &mut TraversalGraphBuild,
12699 root: &Path,
12700 entries: &[TraversalAstSpanIndexEntry],
12701) {
12702 for entry in entries {
12703 let Some(markdown) = &entry.markdown else {
12704 continue;
12705 };
12706 for embedded in &markdown.embedded_symbols {
12707 let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
12708 graph.add_node(node);
12709 graph.add_edge(
12710 &entry.handle,
12711 &embedded.handle,
12712 "contains",
12713 Some("Markdown fence contains embedded AST symbol".to_string()),
12714 1,
12715 );
12716 graph.add_edge(
12717 &entry.handle,
12718 &embedded.handle,
12719 "child",
12720 Some("embedded code symbol".to_string()),
12721 1,
12722 );
12723 graph.add_edge(
12724 &entry.handle,
12725 &embedded.handle,
12726 "contains_embedded_symbol",
12727 Some("Markdown fence contains embedded code symbol".to_string()),
12728 1,
12729 );
12730 graph.add_edge(
12731 &embedded.handle,
12732 &entry.handle,
12733 "parent",
12734 Some("Markdown fence parent span".to_string()),
12735 1,
12736 );
12737 graph.add_edge(
12738 &embedded.handle,
12739 &entry.handle,
12740 "embedded_in_fence",
12741 Some("embedded code symbol belongs to Markdown fence".to_string()),
12742 1,
12743 );
12744 if let Some(section_handle) = &markdown.section_handle
12745 && section_handle != &entry.handle
12746 {
12747 graph.add_edge(
12748 section_handle,
12749 &embedded.handle,
12750 "contains_embedded_code",
12751 Some("Markdown section contains embedded code symbol".to_string()),
12752 1,
12753 );
12754 graph.add_edge(
12755 &embedded.handle,
12756 section_handle,
12757 "enclosing_section",
12758 Some("Markdown enclosing section".to_string()),
12759 1,
12760 );
12761 }
12762 }
12763 }
12764}
12765
12766fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
12767 let mut tokens = traversal_tokens(&node.label);
12768 if let Some(ref_id) = &node.ref_id {
12769 tokens.extend(traversal_tokens(ref_id));
12770 }
12771 if let Some(path) = &node.path {
12772 tokens.extend(traversal_tokens(path));
12773 }
12774 if let Some(detail) = &node.detail {
12775 tokens.extend(traversal_tokens(detail));
12776 }
12777 tokens
12778}
12779
12780fn parse_agent_doc_session_id(content: &str) -> Option<String> {
12781 content.lines().find_map(|line| {
12782 let trimmed = line.trim();
12783 trimmed
12784 .strip_prefix("agent_doc_session:")
12785 .map(str::trim)
12786 .filter(|value| !value.is_empty())
12787 .map(str::to_string)
12788 })
12789}
12790
12791fn parse_backlog_line(line: &str) -> Option<(String, String)> {
12792 let trimmed = line.trim();
12793 if !trimmed.starts_with("- [") {
12794 return None;
12795 }
12796 let start = trimmed.find("[#")?;
12797 let after_start = start + 2;
12798 let rest = &trimmed[after_start..];
12799 let end = rest.find(']')?;
12800 let id = rest[..end].trim();
12801 if id.is_empty() {
12802 return None;
12803 }
12804 let text = rest[end + 1..].trim().to_string();
12805 Some((id.to_string(), text))
12806}
12807
12808fn parse_queue_dispatch_line(line: &str) -> Option<String> {
12809 let trimmed = line.trim();
12810 ["dispatch ", "preset "].iter().find_map(|prefix| {
12811 trimmed
12812 .strip_prefix(prefix)
12813 .map(str::trim)
12814 .filter(|value| !value.is_empty())
12815 .map(str::to_string)
12816 })
12817}
12818
12819fn parse_queue_do_line(line: &str) -> Option<String> {
12820 let trimmed = line.trim();
12821 let rest = trimmed.strip_prefix("- do [#")?;
12822 let end = rest.find(']')?;
12823 let id = rest[..end].trim();
12824 (!id.is_empty()).then(|| id.to_string())
12825}
12826
12827fn markdown_code_spans(input: &str) -> Vec<String> {
12828 input
12829 .split('`')
12830 .enumerate()
12831 .filter(|(idx, _)| idx % 2 == 1)
12832 .map(|(_, part)| part.trim().to_string())
12833 .filter(|part| !part.is_empty())
12834 .collect()
12835}
12836
12837fn push_traversal_token_index(
12838 index: &mut HashMap<String, Vec<usize>>,
12839 tokens: &BTreeSet<String>,
12840 entry_index: usize,
12841) {
12842 for token in tokens {
12843 index.entry(token.clone()).or_default().push(entry_index);
12844 }
12845}
12846
12847impl<'a> TraversalCodeLookup<'a> {
12848 fn new(
12849 symbols: &'a [TraversalSymbolIndexEntry],
12850 files: &'a [TraversalFileIndexEntry],
12851 routes: &'a [TraversalRouteIndexEntry],
12852 multiplicities: &'a [TraversalMultiplicityIndexEntry],
12853 ) -> Self {
12854 let mut symbol_index = HashMap::new();
12855 for (idx, entry) in symbols.iter().enumerate() {
12856 push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
12857 }
12858 let mut file_index = HashMap::new();
12859 let mut file_path_index = HashMap::new();
12860 for (idx, entry) in files.iter().enumerate() {
12861 push_traversal_token_index(&mut file_index, &entry.tokens, idx);
12862 if let Some(path) = entry.node.path.as_ref() {
12863 file_path_index.insert(path.clone(), path.clone());
12864 }
12865 }
12866 let mut route_index = HashMap::new();
12867 for (idx, entry) in routes.iter().enumerate() {
12868 push_traversal_token_index(&mut route_index, &entry.tokens, idx);
12869 }
12870 let mut multiplicity_index = HashMap::new();
12871 for (idx, entry) in multiplicities.iter().enumerate() {
12872 push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
12873 }
12874 Self {
12875 symbols,
12876 files,
12877 routes,
12878 multiplicities,
12879 symbol_index,
12880 file_index,
12881 route_index,
12882 multiplicity_index,
12883 file_path_index,
12884 }
12885 }
12886
12887 fn touched_files_for_line(&self, line: &str) -> Vec<String> {
12888 let mut touched_files = BTreeSet::new();
12889 for candidate in markdown_code_spans(line)
12890 .into_iter()
12891 .chain(line.split_whitespace().map(str::to_string))
12892 {
12893 for path in traversal_path_candidates(&candidate) {
12894 if let Some(file) = self.file_path_index.get(&path) {
12895 touched_files.insert(file.clone());
12896 }
12897 }
12898 }
12899 touched_files.into_iter().collect()
12900 }
12901}
12902
12903fn traversal_path_candidates(candidate: &str) -> Vec<String> {
12904 let trimmed = candidate.trim_matches(|ch: char| {
12905 matches!(
12906 ch,
12907 '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
12908 )
12909 });
12910 if trimmed.is_empty() {
12911 return Vec::new();
12912 }
12913 let mut candidates = vec![trimmed.to_string()];
12914 if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
12915 && !path.is_empty()
12916 && line_suffix.chars().all(|ch| ch.is_ascii_digit())
12917 {
12918 candidates.push(path.to_string());
12919 }
12920 candidates
12921}
12922
12923fn parse_worker_result_line(
12924 line: &str,
12925 lookup: &TraversalCodeLookup<'_>,
12926) -> Vec<ParsedWorkerResult> {
12927 if line.trim_start().starts_with("- [") {
12928 return Vec::new();
12929 }
12930 let lower = line.to_ascii_lowercase();
12931 let status =
12932 if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
12933 {
12934 "completed"
12935 } else if lower.contains("blocked") || lower.contains("externally blocked") {
12936 "blocked"
12937 } else {
12938 return Vec::new();
12939 };
12940 let result_prefix_end = ["follow-up", "follow up", "next:"]
12941 .iter()
12942 .filter_map(|marker| lower.find(marker))
12943 .min()
12944 .unwrap_or(line.len());
12945 let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
12946 if ids.is_empty() {
12947 return Vec::new();
12948 }
12949 let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
12950 let all_ids = extract_conflict_target_refs(line);
12951
12952 let touched_files = lookup.touched_files_for_line(line);
12953 let tests = markdown_code_spans(line)
12954 .into_iter()
12955 .filter(|span| span.to_ascii_lowercase().contains("test"))
12956 .collect::<Vec<_>>();
12957
12958 ids.iter()
12959 .map(|id| ParsedWorkerResult {
12960 id: id.clone(),
12961 status: status.to_string(),
12962 touched_files: touched_files.clone(),
12963 tests: tests.clone(),
12964 follow_up_ids: all_ids
12965 .iter()
12966 .filter(|other| *other != id && !result_ids.contains(*other))
12967 .cloned()
12968 .collect(),
12969 })
12970 .collect()
12971}
12972
12973fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
12974 let hinted_path = if path_hint.is_absolute() {
12975 path_hint.to_path_buf()
12976 } else {
12977 root.join(path_hint)
12978 };
12979 if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
12980 return Some(hinted_path);
12981 }
12982 None
12983}
12984
12985fn traversal_markdown_content_looks_like_session(content: &str) -> bool {
12986 parse_agent_doc_session_id(content).is_some()
12987 || content.contains("<!-- agent:exchange")
12988 || content.contains("<!-- agent:backlog")
12989 || content.contains("## Backlog")
12990}
12991
12992fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
12993 let candidate = if path.is_absolute() {
12994 path.to_path_buf()
12995 } else {
12996 source_root.join(path)
12997 };
12998 if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
12999 return false;
13000 }
13001 if !matches!(
13002 candidate.extension().and_then(|ext| ext.to_str()),
13003 Some("md" | "mdx")
13004 ) {
13005 return false;
13006 }
13007 fs::read_to_string(&candidate)
13008 .map(|content| traversal_markdown_content_looks_like_session(&content))
13009 .unwrap_or(false)
13010}
13011
13012fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
13013 if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
13014 return Ok(vec![hinted_path]);
13015 }
13016 let mut files = Vec::new();
13017 let walker = ignore::WalkBuilder::new(root)
13018 .hidden(true)
13019 .git_ignore(true)
13020 .git_global(true)
13021 .git_exclude(true)
13022 .build();
13023 for result in walker {
13024 let entry =
13025 result.with_context(|| format!("walking markdown files under {}", root.display()))?;
13026 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
13027 continue;
13028 }
13029 if traversal_path_is_generated_artifact(root, root, entry.path()) {
13030 continue;
13031 }
13032 if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
13033 files.push(entry.path().to_path_buf());
13034 }
13035 }
13036 files.sort();
13037 Ok(files)
13038}
13039
13040fn traversal_watermark_path(root: &Path, path: &Path) -> String {
13041 path.strip_prefix(root)
13042 .unwrap_or(path)
13043 .to_string_lossy()
13044 .replace('\\', "/")
13045}
13046
13047fn push_traversal_metadata_watermark_part(
13048 root: &Path,
13049 path: &Path,
13050 label: &str,
13051 parts: &mut Vec<String>,
13052) {
13053 let display = traversal_watermark_path(root, path);
13054 match fs::metadata(path) {
13055 Ok(metadata) => {
13056 let (secs, nanos) = metadata
13057 .modified()
13058 .ok()
13059 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
13060 .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
13061 .unwrap_or((0, 0));
13062 parts.push(format!(
13063 "{label}:{display}:len={}:mtime={secs}.{nanos}",
13064 metadata.len()
13065 ));
13066 }
13067 Err(_) => parts.push(format!("{label}:{display}:missing")),
13068 }
13069}
13070
13071#[derive(Serialize)]
13072struct TraversalSummaryWatermarkRow<'a> {
13073 symbol_name: &'a str,
13074 file_path: &'a str,
13075 entities: &'a Option<Vec<summarize::Entity>>,
13076 relationships: &'a Option<Vec<summarize::Relationship>>,
13077 concept_labels: &'a Option<Vec<String>>,
13078}
13079
13080fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
13081 let summaries_db = root.join(".tsift/summaries.db");
13082 if !summaries_db.exists() {
13083 parts.push("summaries_db:absent".to_string());
13084 return Ok(());
13085 }
13086
13087 match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
13088 .and_then(|summary_db| summary_db.all())
13089 {
13090 Ok(summaries) => {
13091 let rows = summaries
13092 .iter()
13093 .map(|summary| TraversalSummaryWatermarkRow {
13094 symbol_name: &summary.symbol_name,
13095 file_path: &summary.file_path,
13096 entities: &summary.entities,
13097 relationships: &summary.relationships,
13098 concept_labels: &summary.concept_labels,
13099 })
13100 .collect::<Vec<_>>();
13101 parts.push(format!(
13102 "summaries_db:rows={}:semantic_hash={}",
13103 rows.len(),
13104 content_hash(&rows)?
13105 ));
13106 }
13107 Err(_) => {
13108 push_traversal_metadata_watermark_part(
13109 root,
13110 &summaries_db,
13111 "summaries_db_unreadable",
13112 parts,
13113 );
13114 }
13115 }
13116 Ok(())
13117}
13118
13119#[cfg(test)]
13120fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
13121 resolution::relative_path_is_generated_artifact(relative)
13122}
13123
13124fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
13125 resolution::path_is_generated_artifact(root, source_root, path)
13126}
13127
13128fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
13129 resolution::index_snapshot_part_is_generated(root, source_root, part)
13130}
13131
13132pub(crate) fn traversal_source_watermark(
13133 root: &Path,
13134 path_hint: &Path,
13135 scope: Option<&str>,
13136 session_only: bool,
13137) -> Result<Option<String>> {
13138 let mut parts = vec![
13139 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
13140 format!("scope:{}", scope.unwrap_or("root")),
13141 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
13142 format!("session_only:{session_only}"),
13143 ];
13144
13145 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13146 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13147 Ok(targets) => targets,
13148 Err(_) => return Ok(None),
13149 };
13150 let Some(target) = targets.into_iter().next() else {
13151 return Ok(None);
13152 };
13153 let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
13154 Ok(db) => db,
13155 Err(_) => return Ok(None),
13156 };
13157 parts.push(format!("index_label:{}", target.label));
13158 parts.push(format!(
13159 "index_scope:{}",
13160 target.scope_name.as_deref().unwrap_or("root")
13161 ));
13162 parts.push(format!(
13163 "index_source_root:{}",
13164 traversal_watermark_path(root, &target.source_root)
13165 ));
13166 let mut snapshot_rows = 0usize;
13167 for part in db.source_snapshot_parts()? {
13168 if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
13169 continue;
13170 }
13171 snapshot_rows += 1;
13172 parts.push(format!("index_snapshot:{part}"));
13173 }
13174 parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
13175 }
13176
13177 let markdown_files = markdown_files_for_traversal(root, path_hint)?;
13178 parts.push(format!("markdown_count:{}", markdown_files.len()));
13179 for markdown_path in markdown_files {
13180 push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
13181 }
13182
13183 push_traversal_summaries_watermark_part(root, &mut parts)?;
13184
13185 Ok(Some(content_hash(&parts)?))
13186}
13187
13188fn ranked_symbol_matches<'a>(
13189 query_tokens: &BTreeSet<String>,
13190 entries: &'a [TraversalSymbolIndexEntry],
13191 index: &HashMap<String, Vec<usize>>,
13192) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13193 let mut scores = BTreeMap::<usize, usize>::new();
13194 for token in query_tokens {
13195 if let Some(indices) = index.get(token) {
13196 for idx in indices {
13197 *scores.entry(*idx).or_default() += 1;
13198 }
13199 }
13200 }
13201 let mut matches = scores
13202 .into_iter()
13203 .map(|(idx, score)| (score, &entries[idx]))
13204 .collect::<Vec<_>>();
13205 matches.sort_by(|(left_score, left), (right_score, right)| {
13206 right_score
13207 .cmp(left_score)
13208 .then_with(|| left.node.label.cmp(&right.node.label))
13209 .then_with(|| left.handle.cmp(&right.handle))
13210 });
13211 matches
13212}
13213
13214fn ranked_file_matches<'a>(
13215 query_tokens: &BTreeSet<String>,
13216 entries: &'a [TraversalFileIndexEntry],
13217 index: &HashMap<String, Vec<usize>>,
13218) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13219 let mut scores = BTreeMap::<usize, usize>::new();
13220 for token in query_tokens {
13221 if let Some(indices) = index.get(token) {
13222 for idx in indices {
13223 *scores.entry(*idx).or_default() += 1;
13224 }
13225 }
13226 }
13227 let mut matches = scores
13228 .into_iter()
13229 .map(|(idx, score)| (score, &entries[idx]))
13230 .collect::<Vec<_>>();
13231 matches.sort_by(|(left_score, left), (right_score, right)| {
13232 right_score
13233 .cmp(left_score)
13234 .then_with(|| left.node.label.cmp(&right.node.label))
13235 .then_with(|| left.handle.cmp(&right.handle))
13236 });
13237 matches
13238}
13239
13240fn ranked_route_matches<'a>(
13241 query_tokens: &BTreeSet<String>,
13242 entries: &'a [TraversalRouteIndexEntry],
13243 index: &HashMap<String, Vec<usize>>,
13244) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13245 let mut scores = BTreeMap::<usize, usize>::new();
13246 for token in query_tokens {
13247 if let Some(indices) = index.get(token) {
13248 for idx in indices {
13249 *scores.entry(*idx).or_default() += 1;
13250 }
13251 }
13252 }
13253 let mut matches = scores
13254 .into_iter()
13255 .map(|(idx, score)| (score, &entries[idx]))
13256 .collect::<Vec<_>>();
13257 matches.sort_by(|(left_score, left), (right_score, right)| {
13258 right_score
13259 .cmp(left_score)
13260 .then_with(|| left.node.label.cmp(&right.node.label))
13261 .then_with(|| left.handle.cmp(&right.handle))
13262 });
13263 matches
13264}
13265
13266fn ranked_multiplicity_matches<'a>(
13267 query_tokens: &BTreeSet<String>,
13268 entries: &'a [TraversalMultiplicityIndexEntry],
13269 index: &HashMap<String, Vec<usize>>,
13270) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13271 let mut scores = BTreeMap::<usize, usize>::new();
13272 for token in query_tokens {
13273 if let Some(indices) = index.get(token) {
13274 for idx in indices {
13275 *scores.entry(*idx).or_default() += 1;
13276 }
13277 }
13278 }
13279 let mut matches = scores
13280 .into_iter()
13281 .map(|(idx, score)| (score, &entries[idx]))
13282 .collect::<Vec<_>>();
13283 matches.sort_by(|(left_score, left), (right_score, right)| {
13284 right_score
13285 .cmp(left_score)
13286 .then_with(|| left.node.kind.cmp(&right.node.kind))
13287 .then_with(|| left.node.label.cmp(&right.node.label))
13288 .then_with(|| left.handle.cmp(&right.handle))
13289 });
13290 matches
13291}
13292
13293fn link_backlog_to_code_nodes(
13294 graph: &mut TraversalGraphBuild,
13295 backlog: &TraversalNode,
13296 text: &str,
13297 lookup: &TraversalCodeLookup<'_>,
13298 limit: usize,
13299) {
13300 let mut query_tokens = traversal_tokens(text);
13301 if let Some(ref_id) = &backlog.ref_id {
13302 query_tokens.extend(traversal_tokens(ref_id));
13303 }
13304 if query_tokens.is_empty() {
13305 return;
13306 }
13307
13308 for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13309 .into_iter()
13310 .take(limit)
13311 {
13312 graph.add_edge(
13313 &backlog.handle,
13314 &entry.handle,
13315 "mentions",
13316 Some("backlog text matches symbol tokens".to_string()),
13317 score,
13318 );
13319 }
13320
13321 for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13322 .into_iter()
13323 .take(limit.min(5))
13324 {
13325 graph.add_edge(
13326 &backlog.handle,
13327 &entry.handle,
13328 "mentions",
13329 Some("backlog text matches file tokens".to_string()),
13330 score,
13331 );
13332 }
13333
13334 for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13335 .into_iter()
13336 .take(limit.min(5))
13337 {
13338 graph.add_edge(
13339 &backlog.handle,
13340 &entry.handle,
13341 "mentions",
13342 Some("backlog text matches route tokens".to_string()),
13343 score,
13344 );
13345 }
13346
13347 for (score, entry) in ranked_multiplicity_matches(
13348 &query_tokens,
13349 lookup.multiplicities,
13350 &lookup.multiplicity_index,
13351 )
13352 .into_iter()
13353 .take(limit.min(5))
13354 {
13355 graph.add_edge(
13356 &backlog.handle,
13357 &entry.handle,
13358 "mentions",
13359 Some("backlog text matches multiplicity tokens".to_string()),
13360 score,
13361 );
13362 }
13363}
13364
13365fn load_agent_doc_traversal_nodes(
13366 root: &Path,
13367 path_hint: &Path,
13368 graph: &mut TraversalGraphBuild,
13369 lookup: &TraversalCodeLookup<'_>,
13370) -> Result<()> {
13371 for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13372 let content = match fs::read_to_string(&markdown_path) {
13373 Ok(content) => content,
13374 Err(err) => {
13375 graph.warnings.push(format!(
13376 "session artifact unavailable: {}: {err}",
13377 markdown_path.display()
13378 ));
13379 continue;
13380 }
13381 };
13382 if !traversal_markdown_content_looks_like_session(&content) {
13383 continue;
13384 }
13385
13386 let session_id = parse_agent_doc_session_id(&content);
13387 let session = traversal_session_node(root, &markdown_path, session_id.as_deref());
13388 graph.add_node(session.clone());
13389 let lines = content.lines().collect::<Vec<_>>();
13390 let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13391 for (idx, line) in lines.iter().enumerate() {
13392 let Some((id, text)) = parse_backlog_line(line) else {
13393 continue;
13394 };
13395 let backlog = traversal_backlog_node(root, &markdown_path, &id, &text, idx as i64 + 1);
13396 graph.add_node(backlog.clone());
13397 backlog_by_id.insert(id.clone(), backlog.clone());
13398 graph.add_edge(
13399 &session.handle,
13400 &backlog.handle,
13401 "contains",
13402 Some("session backlog item".to_string()),
13403 1,
13404 );
13405 link_backlog_to_code_nodes(graph, &backlog, &text, lookup, 8);
13406 }
13407
13408 let mut in_queue = false;
13409 let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13410 for (idx, line) in lines.iter().enumerate() {
13411 let trimmed = line.trim();
13412 if trimmed.starts_with("<!-- agent:queue") {
13413 in_queue = true;
13414 continue;
13415 }
13416 if trimmed.starts_with("<!-- /agent:queue") {
13417 in_queue = false;
13418 continue;
13419 }
13420 if !in_queue {
13421 continue;
13422 }
13423 if let Some(dispatch) = parse_queue_dispatch_line(line) {
13424 let dispatch_ref = dispatch.strip_prefix('#').unwrap_or(dispatch.as_str());
13425 let node = traversal_job_packet_node(
13426 root,
13427 &markdown_path,
13428 &format!("dispatch {dispatch}"),
13429 Some(dispatch_ref),
13430 "agent-doc dispatch preset",
13431 idx as i64 + 1,
13432 );
13433 graph.add_node(node.clone());
13434 graph.add_edge(
13435 &session.handle,
13436 &node.handle,
13437 "contains",
13438 Some("session queued dispatch".to_string()),
13439 1,
13440 );
13441 continue;
13442 }
13443 if let Some(id) = parse_queue_do_line(line) {
13444 let detail = backlog_by_id
13445 .get(&id)
13446 .and_then(|node| node.detail.clone())
13447 .unwrap_or_else(|| "queued backlog item".to_string());
13448 let node = traversal_job_packet_node(
13449 root,
13450 &markdown_path,
13451 &format!("do #{id}"),
13452 Some(&id),
13453 &detail,
13454 idx as i64 + 1,
13455 );
13456 graph.add_node(node.clone());
13457 graph.add_edge(
13458 &session.handle,
13459 &node.handle,
13460 "contains",
13461 Some("session queued job packet".to_string()),
13462 1,
13463 );
13464 if let Some(backlog) = backlog_by_id.get(&id) {
13465 graph.add_edge(
13466 &node.handle,
13467 &backlog.handle,
13468 "targets",
13469 Some("queued backlog item".to_string()),
13470 1,
13471 );
13472 }
13473 job_by_id.insert(id, node);
13474 }
13475 }
13476
13477 let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13478 for (idx, line) in lines.iter().enumerate() {
13479 for parsed in parse_worker_result_line(line, lookup) {
13480 let line_no = idx as i64 + 1;
13481 if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13482 continue;
13483 }
13484 let result =
13485 traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13486 graph.add_node(result.clone());
13487 graph.add_edge(
13488 &session.handle,
13489 &result.handle,
13490 "contains",
13491 Some("session worker result".to_string()),
13492 1,
13493 );
13494 if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13495 graph.add_edge(
13496 &backlog.handle,
13497 &result.handle,
13498 "has_result",
13499 Some(format!("worker result {}", parsed.status)),
13500 1,
13501 );
13502 }
13503 if let Some(job) = job_by_id.get(&parsed.id) {
13504 graph.add_edge(
13505 &job.handle,
13506 &result.handle,
13507 "has_result",
13508 Some(format!("queued worker result {}", parsed.status)),
13509 1,
13510 );
13511 }
13512 let mut result_text = line.to_string();
13513 if !parsed.touched_files.is_empty() {
13514 result_text.push(' ');
13515 result_text.push_str(&parsed.touched_files.join(" "));
13516 }
13517 link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13518 }
13519 }
13520 }
13521 Ok(())
13522}
13523
13524#[derive(Debug, Clone)]
13525struct AgentDocIndexGate {
13526 db_path: Option<PathBuf>,
13527 source_root: PathBuf,
13528 diagnostics: Vec<String>,
13529}
13530
13531#[derive(Clone, Hash, PartialEq, Eq)]
13532struct AgentDocIndexGateCacheKey {
13533 root: PathBuf,
13534 path_hint: PathBuf,
13535 scope: Option<String>,
13536 packet_label: String,
13537}
13538
13539fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13540 std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13541> {
13542 static CACHE: std::sync::OnceLock<
13543 std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13544 > = std::sync::OnceLock::new();
13545 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13546}
13547
13548fn prepare_agent_doc_index_gate_cached(
13549 root: &Path,
13550 path_hint: &Path,
13551 scope: Option<&str>,
13552 packet_label: &str,
13553) -> (AgentDocIndexGate, String) {
13554 let key = AgentDocIndexGateCacheKey {
13555 root: root.to_path_buf(),
13556 path_hint: path_hint.to_path_buf(),
13557 scope: scope.map(str::to_string),
13558 packet_label: packet_label.to_string(),
13559 };
13560 if let Ok(cache) = agent_doc_index_gate_cache().lock()
13561 && let Some(cached) = cache.get(&key)
13562 {
13563 return (
13564 cached.clone(),
13565 "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13566 );
13567 }
13568 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13569 if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13570 cache.insert(key, gate.clone());
13571 }
13572 (
13573 gate,
13574 "fresh inspection/refresh — cache miss on this preparation key".to_string(),
13575 )
13576}
13577
13578fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
13579 match state {
13580 SearchIndexState::Fresh => None,
13581 SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
13582 SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
13583 }
13584}
13585
13586fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
13587 rebuild_search_target_detail(&RebuildSearchTarget {
13588 label: target.label.clone(),
13589 reason,
13590 reindex_cmd: target.reindex_cmd.clone(),
13591 })
13592}
13593
13594fn index_refresh_diagnostic(
13595 target: &SearchIndexTarget,
13596 reason: RebuildSearchReason,
13597 summary: &index::IndexSummary,
13598 packet_label: &str,
13599) -> String {
13600 let changed = summary.new + summary.modified + summary.deleted;
13601 format!(
13602 "index refreshed: {}; updated {} changed file{} before {}",
13603 index_reason_detail(target, reason),
13604 changed,
13605 if changed == 1 { "" } else { "s" },
13606 packet_label
13607 )
13608}
13609
13610fn index_refresh_fallback_diagnostic(
13611 target: &SearchIndexTarget,
13612 reason: RebuildSearchReason,
13613 err: &anyhow::Error,
13614 packet_label: &str,
13615) -> String {
13616 format!(
13617 "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
13618 index_reason_detail(target, reason),
13619 packet_label
13620 )
13621}
13622
13623fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
13624 if let Some(scope_name) = scope
13625 && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
13626 {
13627 return scope.source_root;
13628 }
13629 if let Some(scope_name) = scope
13630 && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
13631 {
13632 return package.package_root;
13633 }
13634 if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
13635 return scope.source_root;
13636 }
13637 if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
13638 return package.package_root;
13639 }
13640 if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
13641 return scope.source_root;
13642 }
13643 root.to_path_buf()
13644}
13645
13646fn prepare_agent_doc_index_gate(
13647 root: &Path,
13648 path_hint: &Path,
13649 scope: Option<&str>,
13650 packet_label: &str,
13651) -> AgentDocIndexGate {
13652 let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
13653 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13654 Ok(targets) => targets,
13655 Err(err) => {
13656 return AgentDocIndexGate {
13657 db_path: None,
13658 source_root: fallback_source_root,
13659 diagnostics: vec![format!(
13660 "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13661 )],
13662 };
13663 }
13664 };
13665 let Some(target) = targets.into_iter().next() else {
13666 return AgentDocIndexGate {
13667 db_path: None,
13668 source_root: fallback_source_root,
13669 diagnostics: vec![format!(
13670 "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
13671 )],
13672 };
13673 };
13674
13675 let state = match inspect_search_index(&target) {
13676 Ok(state) => state,
13677 Err(err) => {
13678 return AgentDocIndexGate {
13679 db_path: None,
13680 source_root: target.source_root,
13681 diagnostics: vec![format!(
13682 "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13683 )],
13684 };
13685 }
13686 };
13687
13688 let Some(reason) = index_reason_for_state(state) else {
13689 return AgentDocIndexGate {
13690 db_path: Some(target.db_path),
13691 source_root: target.source_root,
13692 diagnostics: Vec::new(),
13693 };
13694 };
13695
13696 match apply_search_index_update(root, &target) {
13697 Ok(summary) => {
13698 index::inspect_scope_invalidate_all();
13704 let diagnostics = vec![index_refresh_diagnostic(
13705 &target,
13706 reason,
13707 &summary,
13708 packet_label,
13709 )];
13710 AgentDocIndexGate {
13711 db_path: Some(target.db_path),
13712 source_root: target.source_root,
13713 diagnostics,
13714 }
13715 }
13716 Err(err) => {
13717 let diagnostics = vec![index_refresh_fallback_diagnostic(
13718 &target,
13719 reason,
13720 &err,
13721 packet_label,
13722 )];
13723 AgentDocIndexGate {
13724 db_path: None,
13725 source_root: target.source_root,
13726 diagnostics,
13727 }
13728 }
13729 }
13730}
13731
13732fn add_raw_source_file_nodes(
13733 root: &Path,
13734 source_root: &Path,
13735 graph: &mut TraversalGraphBuild,
13736 file_entries: &mut Vec<TraversalFileIndexEntry>,
13737) -> Result<()> {
13738 let mut entries = walk::walk_files(source_root)?;
13739 entries.sort_by(|left, right| left.path.cmp(&right.path));
13740 for entry in entries {
13741 let file = entry.path.to_string_lossy();
13742 let node = traversal_raw_source_file_node(root, file.as_ref());
13743 let entry = TraversalFileIndexEntry {
13744 handle: node.handle.clone(),
13745 tokens: traversal_node_tokens(&node),
13746 node: node.clone(),
13747 };
13748 graph.add_node(node);
13749 file_entries.push(entry);
13750 }
13751 Ok(())
13752}
13753
13754fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
13755 if scope_root.is_empty() {
13756 return true;
13757 }
13758 path == scope_root || path.starts_with(&format!("{scope_root}/"))
13759}
13760
13761fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
13762 let path = Path::new(file);
13763 if path.is_absolute() {
13764 return path.to_path_buf();
13765 }
13766 let source_candidate = source_root.join(path);
13767 if source_candidate.exists() {
13768 source_candidate
13769 } else {
13770 root.join(path)
13771 }
13772}
13773
13774fn cargo_import_alias_from_line(line: &str) -> Option<String> {
13775 let trimmed = line.trim();
13776 let rest = trimmed
13777 .strip_prefix("pub use ")
13778 .or_else(|| trimmed.strip_prefix("use "))
13779 .or_else(|| trimmed.strip_prefix("extern crate "))?;
13780 let alias = rest
13781 .split([':', ';', ' ', '\t'])
13782 .next()
13783 .unwrap_or_default()
13784 .trim();
13785 (!alias.is_empty()).then(|| alias.to_string())
13786}
13787
13788fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
13789 let mut aliases = BTreeSet::new();
13790 for entry in walk::walk_files(&package.package_root)? {
13791 if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
13792 continue;
13793 }
13794 let content = fs::read_to_string(&entry.path)
13795 .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
13796 aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
13797 }
13798 Ok(aliases)
13799}
13800
13801fn load_multiplicity_traversal_nodes(
13802 root: &Path,
13803 source_root: &Path,
13804 graph: &mut TraversalGraphBuild,
13805 file_handle_by_path: &HashMap<String, String>,
13806 multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
13807) -> Result<()> {
13808 let inventory = multiplicity::discover_cargo_inventory(source_root)?;
13809 let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
13810 for workspace in &inventory.workspaces {
13811 let node = traversal_cargo_workspace_node(root, workspace);
13812 workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
13813 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13814 handle: node.handle.clone(),
13815 tokens: traversal_node_tokens(&node),
13816 node: node.clone(),
13817 });
13818 graph.add_node(node);
13819 }
13820
13821 let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
13822 let mut package_nodes = Vec::new();
13823 for package in &inventory.packages {
13824 let node = traversal_cargo_package_node(root, package);
13825 package_handle_by_name
13826 .entry(package.name.clone())
13827 .or_default()
13828 .push(node.handle.clone());
13829 package_handle_by_name
13830 .entry(package.normalized_name.clone())
13831 .or_default()
13832 .push(node.handle.clone());
13833 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13834 handle: node.handle.clone(),
13835 tokens: traversal_node_tokens(&node),
13836 node: node.clone(),
13837 });
13838 graph.add_node(node.clone());
13839 package_nodes.push((package, node));
13840 }
13841
13842 for (package, node) in &package_nodes {
13843 if let Some(workspace_handle) =
13844 workspace_handle_by_root.get(&package.relative_workspace_root)
13845 {
13846 graph.add_edge(
13847 workspace_handle,
13848 &node.handle,
13849 "contains_package",
13850 Some("Cargo workspace member package".to_string()),
13851 1,
13852 );
13853 }
13854 let package_root = relativize_pathbuf(&package.package_root, root)
13855 .to_string_lossy()
13856 .replace('\\', "/");
13857 for (file, handle) in file_handle_by_path {
13858 if relative_path_inside_scope(file, &package_root) {
13859 graph.add_edge(
13860 &node.handle,
13861 handle,
13862 "owns_file",
13863 Some("Cargo package owns source file".to_string()),
13864 1,
13865 );
13866 }
13867 }
13868 for dependency in &package.dependencies {
13869 if let Some(handles) = package_handle_by_name.get(&dependency.name)
13870 && handles.len() == 1
13871 {
13872 graph.add_edge(
13873 &node.handle,
13874 &handles[0],
13875 "declares_dependency",
13876 Some(format!("{} Cargo dependency", dependency.kind)),
13877 1,
13878 );
13879 }
13880 }
13881 for alias in cargo_import_aliases(package)? {
13882 if let Some(handles) = package_handle_by_name.get(&alias)
13883 && handles.len() == 1
13884 && handles[0] != node.handle
13885 {
13886 graph.add_edge(
13887 &node.handle,
13888 &handles[0],
13889 "uses_crate",
13890 Some("Rust use/extern crate reference".to_string()),
13891 1,
13892 );
13893 graph.add_edge(
13894 &node.handle,
13895 &handles[0],
13896 "imports",
13897 Some("Rust use/extern crate import".to_string()),
13898 1,
13899 );
13900 }
13901 }
13902 }
13903
13904 Ok(())
13905}
13906
13907fn build_traversal_graph_source_with_options(
13908 root: &Path,
13909 path_hint: &Path,
13910 scope: Option<&str>,
13911 session_only: bool,
13912) -> Result<TraversalGraphBuild> {
13913 let mut graph = TraversalGraphBuild::default();
13914 let mut symbol_entries = Vec::new();
13915 let mut file_entries = Vec::new();
13916 let mut route_entries = Vec::new();
13917 let mut multiplicity_entries = Vec::new();
13918 let mut file_handle_by_path = HashMap::<String, String>::new();
13919 let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
13920 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13921 let (gate, _cache_detail) =
13922 prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
13923 graph.warnings.extend(gate.diagnostics);
13924 let gate_source_root = gate.source_root.clone();
13925
13926 match gate.db_path {
13927 Some(db_path) if db_path.exists() => {
13928 let db = index::IndexDb::open_read_only_resilient(&db_path)?;
13929 let file_paths = db.file_paths()?;
13930 for file in file_paths {
13931 if traversal_path_is_generated_artifact(
13932 root,
13933 &gate_source_root,
13934 Path::new(&file),
13935 ) {
13936 continue;
13937 }
13938 let node = traversal_file_node(root, &file);
13939 let entry = TraversalFileIndexEntry {
13940 handle: node.handle.clone(),
13941 tokens: traversal_node_tokens(&node),
13942 node: node.clone(),
13943 };
13944 if let Some(path) = entry.node.path.as_ref() {
13945 file_handle_by_path.insert(path.clone(), entry.handle.clone());
13946 }
13947 graph.add_node(node);
13948 file_entries.push(entry);
13949 }
13950
13951 let symbols = db.all_symbols()?;
13952 let mut symbol_by_file_name_line = HashMap::new();
13953 let mut span_by_file_name_line = HashMap::new();
13954 let mut first_symbol_by_name = BTreeMap::<String, String>::new();
13955 let mut first_span_by_name = BTreeMap::<String, String>::new();
13956 let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
13957 let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
13958 for symbol in symbols.iter().filter(|symbol| {
13959 !traversal_path_is_generated_artifact(
13960 root,
13961 &gate_source_root,
13962 Path::new(&symbol.file),
13963 )
13964 }) {
13965 let node = traversal_symbol_node(root, symbol);
13966 let file = relativize(&symbol.file, root);
13967 symbol_by_file_name_line.insert(
13968 format!("{file}:{}:{}", symbol.line, symbol.name),
13969 node.handle.clone(),
13970 );
13971 first_symbol_by_name
13972 .entry(symbol.name.clone())
13973 .or_insert_with(|| node.handle.clone());
13974 let entry = TraversalSymbolIndexEntry {
13975 handle: node.handle.clone(),
13976 tokens: traversal_node_tokens(&node),
13977 node: node.clone(),
13978 };
13979 graph.add_node(node.clone());
13980 if let Some(file_handle) = file_handle_by_path.get(&file) {
13981 graph.add_edge(
13982 file_handle,
13983 &node.handle,
13984 "defines",
13985 Some("file defines symbol".to_string()),
13986 1,
13987 );
13988 }
13989 if !source_by_file.contains_key(&symbol.file) {
13990 let source_path =
13991 traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
13992 source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
13993 }
13994 if let Some(Some(source)) = source_by_file.get(&symbol.file)
13995 && let Some((ast_node, mut ast_entry)) =
13996 traversal_ast_span_node(root, symbol, source, &symbols)
13997 {
13998 ast_entry.symbol_handle = node.handle.clone();
13999 ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
14000 span_by_file_name_line.insert(
14001 format!("{file}:{}:{}", symbol.line, symbol.name),
14002 ast_node.handle.clone(),
14003 );
14004 first_span_by_name
14005 .entry(symbol.name.clone())
14006 .or_insert_with(|| ast_node.handle.clone());
14007 graph.add_node(ast_node.clone());
14008 graph.add_edge(
14009 &node.handle,
14010 &ast_node.handle,
14011 "has_ast_span",
14012 Some("symbol projects to indexed AST span".to_string()),
14013 1,
14014 );
14015 graph.add_edge(
14016 &ast_node.handle,
14017 &node.handle,
14018 "represents_symbol",
14019 Some("AST span represents indexed symbol".to_string()),
14020 1,
14021 );
14022 ast_entries.push(ast_entry);
14023 }
14024 symbol_entries.push(entry);
14025 }
14026 link_ast_navigation_edges(&mut graph, &ast_entries);
14027 link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
14028
14029 if !bounded_session_projection {
14030 for edge in db.all_stored_edges()? {
14031 if traversal_path_is_generated_artifact(
14032 root,
14033 &gate_source_root,
14034 Path::new(&edge.caller_file),
14035 ) {
14036 continue;
14037 }
14038 let caller_file = relativize(&edge.caller_file, root);
14039 let caller_key =
14040 format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
14041 let Some(caller_handle) =
14042 symbol_by_file_name_line.get(&caller_key).cloned()
14043 else {
14044 continue;
14045 };
14046 let callee_handle = if let Some(handle) =
14047 first_symbol_by_name.get(&edge.callee_name)
14048 {
14049 handle.clone()
14050 } else {
14051 let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
14052 let handle = node.handle.clone();
14053 graph.add_node(node);
14054 handle
14055 };
14056 graph.add_edge(
14057 &caller_handle,
14058 &callee_handle,
14059 "calls",
14060 Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
14061 1,
14062 );
14063 if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
14064 && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
14065 {
14066 graph.add_edge(
14067 caller_span,
14068 callee_span,
14069 "calls",
14070 Some(format!(
14071 "AST call site {}:{}",
14072 caller_file, edge.call_site_line
14073 )),
14074 1,
14075 );
14076 }
14077 }
14078 }
14079
14080 for route in db.all_routes()? {
14081 if traversal_path_is_generated_artifact(
14082 root,
14083 &gate_source_root,
14084 Path::new(&route.file),
14085 ) {
14086 continue;
14087 }
14088 let node = traversal_route_node(root, &route);
14089 let entry = TraversalRouteIndexEntry {
14090 handle: node.handle.clone(),
14091 tokens: traversal_node_tokens(&node),
14092 node: node.clone(),
14093 };
14094 graph.add_node(node.clone());
14095 if let Some(path) = node.path.as_ref()
14096 && let Some(file_handle) = file_handle_by_path.get(path)
14097 {
14098 graph.add_edge(
14099 file_handle,
14100 &node.handle,
14101 "defines_route",
14102 Some("file declares route".to_string()),
14103 1,
14104 );
14105 }
14106 let handler_handle =
14107 if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
14108 handle.clone()
14109 } else {
14110 let node = traversal_unresolved_symbol_node(root, &route.handler_name);
14111 let handle = node.handle.clone();
14112 graph.add_node(node);
14113 handle
14114 };
14115 graph.add_edge(
14116 &entry.handle,
14117 &handler_handle,
14118 "handled_by",
14119 Some("route handler reference".to_string()),
14120 1,
14121 );
14122 if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
14123 graph.add_edge(
14124 &entry.handle,
14125 handler_span,
14126 "handled_by",
14127 Some("route handler AST span".to_string()),
14128 1,
14129 );
14130 graph.add_edge(
14131 handler_span,
14132 &entry.handle,
14133 "handles_route",
14134 Some("AST span handles route".to_string()),
14135 1,
14136 );
14137 }
14138 route_entries.push(entry);
14139 }
14140 }
14141 _ => {
14142 add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
14143 .with_context(|| {
14144 format!(
14145 "loading raw source fallback nodes from {}",
14146 gate_source_root.display()
14147 )
14148 })?;
14149 for entry in &file_entries {
14150 if let Some(path) = entry.node.path.as_ref() {
14151 file_handle_by_path.insert(path.clone(), entry.handle.clone());
14152 }
14153 }
14154 }
14155 }
14156 load_multiplicity_traversal_nodes(
14157 root,
14158 &gate_source_root,
14159 &mut graph,
14160 &file_handle_by_path,
14161 &mut multiplicity_entries,
14162 )?;
14163 }
14164
14165 let code_lookup = TraversalCodeLookup::new(
14166 &symbol_entries,
14167 &file_entries,
14168 &route_entries,
14169 &multiplicity_entries,
14170 );
14171 load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14172 Ok(graph)
14173}
14174
14175#[cfg(test)]
14176fn build_traversal_graph_source(
14177 root: &Path,
14178 path_hint: &Path,
14179 scope: Option<&str>,
14180) -> Result<TraversalGraphBuild> {
14181 build_traversal_graph_source_with_options(root, path_hint, scope, false)
14182}
14183
14184pub(crate) fn write_traversal_graph_store_with_options(
14185 root: &Path,
14186 path_hint: &Path,
14187 scope: Option<&str>,
14188 session_only: bool,
14189) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14190 let source_graph =
14191 build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14192 let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14193 let graph_db = graph_substrate_db_path(root, scope);
14194 let mut store = SqliteGraphStore::open(&graph_db)?;
14195 let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14196 .ok()
14197 .flatten()
14198 .or_else(|| graph_projection_content_hash(&projection));
14199 let refresh = store.replace_projection_with_version(
14200 scope.unwrap_or("root"),
14201 &projection,
14202 Some(GRAPH_PROJECTION_VERSION),
14203 source_watermark,
14204 )?;
14205 Ok((source_graph, refresh))
14206}
14207
14208pub(crate) fn write_traversal_graph_store(
14209 root: &Path,
14210 path_hint: &Path,
14211 scope: Option<&str>,
14212) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14213 write_traversal_graph_store_with_options(root, path_hint, scope, false)
14214}
14215
14216fn refresh_traversal_graph_store_with_options(
14217 root: &Path,
14218 path_hint: &Path,
14219 scope: Option<&str>,
14220 session_only: bool,
14221) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14222 let (source_graph, refresh) =
14223 write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14224 let graph_db = graph_substrate_db_path(root, scope);
14225 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14226 let mut graph = traversal_graph_from_store(root, &store)?;
14227 graph.warnings = source_graph.warnings;
14228 Ok((graph, refresh))
14229}
14230
14231fn refresh_traversal_graph_store(
14232 root: &Path,
14233 path_hint: &Path,
14234 scope: Option<&str>,
14235) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14236 refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14237}
14238
14239pub(crate) fn build_traversal_graph(
14240 root: &Path,
14241 path_hint: &Path,
14242 scope: Option<&str>,
14243) -> Result<TraversalGraphBuild> {
14244 let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14245 Ok(graph)
14246}
14247
14248fn traversal_query_kind_priority(kind: &str) -> usize {
14249 match kind {
14250 "backlog" => 0,
14251 "job_packet" => 1,
14252 "worker_result" => 2,
14253 "symbol" => 3,
14254 "ast_span" => 4,
14255 "file" => 5,
14256 "route" => 6,
14257 "cargo_package" => 7,
14258 "cargo_workspace" => 8,
14259 "session" => 9,
14260 "semantic_concept" => 10,
14261 "semantic_entity" => 11,
14262 _ => 12,
14263 }
14264}
14265
14266fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14267 let trimmed = query.trim();
14268 if trimmed.is_empty() {
14269 return None;
14270 }
14271 let kind_priority = traversal_query_kind_priority(&node.kind);
14272 if node.handle == trimmed {
14273 return Some((0, kind_priority, node.handle.clone()));
14274 }
14275 if node.path.as_deref() == Some(trimmed) {
14276 let path_priority = if node.kind == "file" {
14277 0
14278 } else {
14279 kind_priority.saturating_add(1)
14280 };
14281 return Some((1, path_priority, node.handle.clone()));
14282 }
14283 let normalized_backlog = trimmed.trim_start_matches('#');
14284 if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14285 {
14286 return Some((2, kind_priority, node.handle.clone()));
14287 }
14288 if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14289 return Some((3, kind_priority, node.handle.clone()));
14290 }
14291 None
14292}
14293
14294fn resolve_traversal_node<'a>(
14295 graph: &'a TraversalGraphBuild,
14296 query: &str,
14297) -> Option<&'a TraversalNode> {
14298 graph
14299 .nodes
14300 .values()
14301 .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14302 .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14303 .map(|(_, node)| node)
14304}
14305
14306fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14307 let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14308 for edge in edges {
14309 adj.entry(edge.from.clone())
14310 .or_default()
14311 .insert(edge.to.clone());
14312 adj.entry(edge.to.clone())
14313 .or_default()
14314 .insert(edge.from.clone());
14315 }
14316 adj.into_iter()
14317 .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14318 .collect()
14319}
14320
14321fn traversal_shortest_handles(
14322 edges: &[TraversalEdge],
14323 from: &str,
14324 to: &str,
14325) -> Option<Vec<String>> {
14326 if from == to {
14327 return Some(vec![from.to_string()]);
14328 }
14329 let adj = traversal_adjacency(edges);
14330 if !adj.contains_key(from) || !adj.contains_key(to) {
14331 return None;
14332 }
14333 let mut visited = BTreeSet::new();
14334 let mut queue = VecDeque::new();
14335 let mut parent = BTreeMap::<String, String>::new();
14336 visited.insert(from.to_string());
14337 queue.push_back(from.to_string());
14338 while let Some(current) = queue.pop_front() {
14339 if let Some(neighbors) = adj.get(¤t) {
14340 for neighbor in neighbors {
14341 if visited.insert(neighbor.clone()) {
14342 parent.insert(neighbor.clone(), current.clone());
14343 if neighbor == to {
14344 let mut path = vec![to.to_string()];
14345 let mut cursor = to.to_string();
14346 while let Some(prev) = parent.get(&cursor) {
14347 path.push(prev.clone());
14348 cursor = prev.clone();
14349 }
14350 path.reverse();
14351 return Some(path);
14352 }
14353 queue.push_back(neighbor.clone());
14354 }
14355 }
14356 }
14357 }
14358 None
14359}
14360
14361fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14362 let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14363 for edge in edges {
14364 let neighbor = if edge.from == current {
14365 edge.to.as_str()
14366 } else if edge.to == current {
14367 edge.from.as_str()
14368 } else {
14369 continue;
14370 };
14371 let score = traversal_relation_score(edge, current);
14372 best_score_by_neighbor
14373 .entry(neighbor.to_string())
14374 .and_modify(|best| *best = (*best).max(score))
14375 .or_insert(score);
14376 }
14377 let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14378 ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14379 right_score
14380 .cmp(left_score)
14381 .then_with(|| left_handle.cmp(right_handle))
14382 });
14383 ranked.into_iter().map(|(handle, _)| handle).collect()
14384}
14385
14386fn traversal_neighborhood_handles(
14387 edges: &[TraversalEdge],
14388 origin: &str,
14389 depth: usize,
14390 limit: usize,
14391) -> BTreeSet<String> {
14392 let mut seen = BTreeSet::new();
14393 let mut queue = VecDeque::new();
14394 seen.insert(origin.to_string());
14395 queue.push_back((origin.to_string(), 0usize));
14396 while let Some((current, current_depth)) = queue.pop_front() {
14397 if current_depth >= depth {
14398 continue;
14399 }
14400 for neighbor in traversal_scored_neighbors(edges, ¤t) {
14401 if limit > 0 && seen.len() >= limit {
14402 return seen;
14403 }
14404 if seen.insert(neighbor.clone()) {
14405 queue.push_back((neighbor, current_depth + 1));
14406 }
14407 }
14408 }
14409 seen
14410}
14411
14412fn traversal_edges_between(
14413 handles: &BTreeSet<String>,
14414 edges: &[TraversalEdge],
14415) -> Vec<TraversalEdge> {
14416 edges
14417 .iter()
14418 .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14419 .cloned()
14420 .collect()
14421}
14422
14423fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14424 let mut result = Vec::new();
14425 for pair in path.windows(2) {
14426 if let Some(edge) = edges.iter().find(|edge| {
14427 (edge.from == pair[0] && edge.to == pair[1])
14428 || (edge.from == pair[1] && edge.to == pair[0])
14429 }) {
14430 result.push(edge.clone());
14431 }
14432 }
14433 result
14434}
14435
14436fn sorted_traversal_nodes<'a>(
14437 nodes: impl IntoIterator<Item = &'a TraversalNode>,
14438) -> Vec<TraversalNode> {
14439 let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14440 nodes.sort_by(|left, right| {
14441 left.kind
14442 .cmp(&right.kind)
14443 .then_with(|| left.label.cmp(&right.label))
14444 .then_with(|| left.path.cmp(&right.path))
14445 .then_with(|| left.handle.cmp(&right.handle))
14446 });
14447 nodes
14448}
14449
14450fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14451 let base = match edge.relation.as_str() {
14452 "mentions" => 100,
14453 "contains" => 80,
14454 "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14455 "contains_embedded_symbol" | "embedded_in_fence" => 77,
14456 "contains_markdown_block"
14457 | "contains_embedded_code"
14458 | "enclosing_module"
14459 | "enclosing_section" => 76,
14460 "calls" => {
14461 if edge.from == origin {
14462 70
14463 } else {
14464 65
14465 }
14466 }
14467 "handled_by" | "handles_route" => 68,
14468 "defines_route" => 62,
14469 "imports" => 62,
14470 "previous_sibling" | "next_sibling" => 54,
14471 "mentions_concept" | "mentions_entity" => 66,
14472 "semantic_relation" => 64,
14473 "tagged_concept" | "related_concept" => 58,
14474 "defines" => {
14475 if edge.from == origin {
14476 60
14477 } else {
14478 55
14479 }
14480 }
14481 _ => 10,
14482 };
14483 base + edge.weight
14484}
14485
14486fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
14487 match edge.relation.as_str() {
14488 "mentions" => "matched from backlog/session text".to_string(),
14489 "contains" => "contained in the selected session artifact".to_string(),
14490 "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
14491 "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
14492 "parent" => "parent AST span".to_string(),
14493 "child" => "child AST span".to_string(),
14494 "previous_sibling" => "previous AST sibling".to_string(),
14495 "next_sibling" => "next AST sibling".to_string(),
14496 "contains_markdown_block" => "Markdown section block".to_string(),
14497 "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
14498 "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
14499 "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
14500 "enclosing_module" => "nearest enclosing module".to_string(),
14501 "enclosing_section" => "nearest enclosing Markdown section".to_string(),
14502 "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
14503 "defines" => "file that defines the selected symbol".to_string(),
14504 "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
14505 "defines_route" => "file that declares the selected route".to_string(),
14506 "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
14507 "handled_by" => "route handled by the selected symbol".to_string(),
14508 "handles_route" => "route handled by the selected AST span".to_string(),
14509 "imports" => "import dependency from the selected package".to_string(),
14510 "mentions_concept" => "cached summary concept for the selected source".to_string(),
14511 "mentions_entity" => "cached summary entity for the selected source".to_string(),
14512 "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
14513 "tagged_concept" => "concept label attached to the selected entity".to_string(),
14514 "related_concept" => "co-occurring cached summary concept".to_string(),
14515 "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
14516 "calls" => "caller of the selected symbol".to_string(),
14517 other => format!("connected by {other}"),
14518 }
14519}
14520
14521fn traversal_recommendations(
14522 graph: &TraversalGraphBuild,
14523 origin: Option<&str>,
14524 shortest_path: Option<&[String]>,
14525 limit: usize,
14526) -> Vec<TraversalRecommendation> {
14527 let Some(origin) = origin else {
14528 return Vec::new();
14529 };
14530 let mut recommendations = Vec::new();
14531 let mut seen = BTreeSet::new();
14532
14533 if let Some(path) = shortest_path
14534 && path.len() > 1
14535 && path.first().is_some_and(|handle| handle == origin)
14536 && let Some(next) = graph.nodes.get(&path[1])
14537 {
14538 seen.insert(next.handle.clone());
14539 recommendations.push(TraversalRecommendation {
14540 handle: next.handle.clone(),
14541 kind: next.kind.clone(),
14542 label: next.label.clone(),
14543 reason: "next hop on shortest path".to_string(),
14544 score: 1_000,
14545 expand: next.expand.clone(),
14546 });
14547 }
14548
14549 let mut candidates = graph
14550 .edges
14551 .iter()
14552 .filter_map(|edge| {
14553 let neighbor = if edge.from == origin {
14554 edge.to.as_str()
14555 } else if edge.to == origin {
14556 edge.from.as_str()
14557 } else {
14558 return None;
14559 };
14560 let node = graph.nodes.get(neighbor)?;
14561 Some((traversal_relation_score(edge, origin), edge, node))
14562 })
14563 .collect::<Vec<_>>();
14564 candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
14565 right_score
14566 .cmp(left_score)
14567 .then_with(|| left.kind.cmp(&right.kind))
14568 .then_with(|| left.label.cmp(&right.label))
14569 .then_with(|| left.handle.cmp(&right.handle))
14570 });
14571
14572 let max = if limit == 0 { usize::MAX } else { limit };
14573 for (score, edge, node) in candidates {
14574 if recommendations.len() >= max {
14575 break;
14576 }
14577 if seen.insert(node.handle.clone()) {
14578 recommendations.push(TraversalRecommendation {
14579 handle: node.handle.clone(),
14580 kind: node.kind.clone(),
14581 label: node.label.clone(),
14582 reason: traversal_recommendation_reason(edge, origin),
14583 score,
14584 expand: node.expand.clone(),
14585 });
14586 }
14587 }
14588
14589 recommendations
14590}
14591
14592fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
14593 let scale = nodes.saturating_add(edges);
14594 if scale <= 80 {
14595 ExplorationBudget {
14596 project_size: "small".to_string(),
14597 max_source_windows: 8,
14598 lines_per_window: 96,
14599 relationship_limit: 40,
14600 }
14601 } else if scale <= 800 {
14602 ExplorationBudget {
14603 project_size: "medium".to_string(),
14604 max_source_windows: 6,
14605 lines_per_window: 80,
14606 relationship_limit: 32,
14607 }
14608 } else {
14609 ExplorationBudget {
14610 project_size: "large".to_string(),
14611 max_source_windows: 4,
14612 lines_per_window: 64,
14613 relationship_limit: 24,
14614 }
14615 }
14616}
14617
14618fn exploration_node_label(node: &TraversalNode) -> String {
14619 format!("{}:{}", node.kind, node.label)
14620}
14621
14622fn exploration_source_window_for_node(
14623 root: &Path,
14624 node: &TraversalNode,
14625 budget: &ExplorationBudget,
14626) -> Option<ExplorationSourceWindow> {
14627 let file = node.path.as_ref()?;
14628 let anchor = node
14629 .line
14630 .and_then(|line| usize::try_from(line).ok())
14631 .and_then(|line| line.checked_add(1))
14632 .unwrap_or(1);
14633 let context_before = budget.lines_per_window / 3;
14634 let start = anchor.saturating_sub(context_before).max(1);
14635 let end = start
14636 .saturating_add(budget.lines_per_window)
14637 .saturating_sub(1);
14638 let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
14639 Some(ExplorationSourceWindow {
14640 handle,
14641 file: file.clone(),
14642 start,
14643 end,
14644 reason: format!("cluster around {}", exploration_node_label(node)),
14645 expand: source_read_command(root, file, start, budget.lines_per_window),
14646 })
14647}
14648
14649fn build_exploration_packet(
14650 root: &Path,
14651 totals: &TraversalTotals,
14652 selected_nodes: &[TraversalNode],
14653 selected_edges: &[TraversalEdge],
14654) -> ExplorationPacket {
14655 let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
14656 let node_by_handle = selected_nodes
14657 .iter()
14658 .map(|node| (node.handle.as_str(), node))
14659 .collect::<BTreeMap<_, _>>();
14660 let relationship_map = selected_edges
14661 .iter()
14662 .take(budget.relationship_limit)
14663 .filter_map(|edge| {
14664 let from = node_by_handle.get(edge.from.as_str())?;
14665 let to = node_by_handle.get(edge.to.as_str())?;
14666 Some(ExplorationRelation {
14667 from: exploration_node_label(from),
14668 relation: edge.relation.clone(),
14669 to: exploration_node_label(to),
14670 label: edge.label.clone(),
14671 })
14672 })
14673 .collect::<Vec<_>>();
14674
14675 let mut seen_windows = BTreeSet::new();
14676 let mut source_windows = Vec::new();
14677 for node in selected_nodes {
14678 if source_windows.len() >= budget.max_source_windows {
14679 break;
14680 }
14681 let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
14682 continue;
14683 };
14684 let key = (window.file.clone(), window.start, window.end);
14685 if seen_windows.insert(key) {
14686 source_windows.push(window);
14687 }
14688 }
14689
14690 ExplorationPacket {
14691 budget,
14692 relationship_map,
14693 source_windows,
14694 worker_context: Vec::new(),
14695 no_reread_guidance:
14696 "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
14697 .to_string(),
14698 }
14699}
14700
14701pub(crate) fn traversal_report(
14702 root: &Path,
14703 scope: Option<&str>,
14704 graph: TraversalGraphBuild,
14705 query: Option<&str>,
14706 target: Option<&str>,
14707 depth: usize,
14708 limit: usize,
14709) -> Result<TraversalReport> {
14710 let totals = TraversalTotals {
14711 nodes: graph.nodes.len(),
14712 edges: graph.edges.len(),
14713 };
14714 let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
14715 let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
14716 if let Some(query) = query
14717 && origin_node.is_none()
14718 {
14719 bail!("traversal node not found: {}", query);
14720 }
14721 if let Some(target) = target
14722 && target_node.is_none()
14723 {
14724 bail!("traversal target not found: {}", target);
14725 }
14726
14727 let (mode, selected_nodes, selected_edges, shortest_path) =
14728 if let (Some(origin), Some(target)) = (origin_node, target_node) {
14729 if let Some(handles) =
14730 traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
14731 {
14732 let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
14733 let nodes = handles
14734 .iter()
14735 .filter_map(|handle| graph.nodes.get(handle).cloned())
14736 .collect::<Vec<_>>();
14737 let edges = traversal_path_edges(&handles, &graph.edges);
14738 let path = TraversalPathReport {
14739 from: origin.clone(),
14740 to: target.clone(),
14741 hops: handles.len().saturating_sub(1),
14742 nodes: nodes.clone(),
14743 edges: edges.clone(),
14744 };
14745 (
14746 "path".to_string(),
14747 nodes,
14748 traversal_edges_between(&handle_set, &graph.edges),
14749 Some(path),
14750 )
14751 } else {
14752 (
14753 "path".to_string(),
14754 vec![origin.clone(), target.clone()],
14755 Vec::new(),
14756 None,
14757 )
14758 }
14759 } else if let Some(origin) = origin_node {
14760 let handles =
14761 traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
14762 let nodes =
14763 sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
14764 let edges = traversal_edges_between(&handles, &graph.edges);
14765 ("neighborhood".to_string(), nodes, edges, None)
14766 } else {
14767 let mut nodes = sorted_traversal_nodes(graph.nodes.values());
14768 let truncated_nodes = limit > 0 && nodes.len() > limit;
14769 if truncated_nodes {
14770 nodes.truncate(limit);
14771 }
14772 let handles = nodes
14773 .iter()
14774 .map(|node| node.handle.clone())
14775 .collect::<BTreeSet<_>>();
14776 let mut edges = traversal_edges_between(&handles, &graph.edges);
14777 let truncated_edges = limit > 0 && edges.len() > limit;
14778 if truncated_edges {
14779 edges.truncate(limit);
14780 }
14781 ("export".to_string(), nodes, edges, None)
14782 };
14783
14784 let shortest_handles = shortest_path.as_ref().map(|path| {
14785 path.nodes
14786 .iter()
14787 .map(|node| node.handle.clone())
14788 .collect::<Vec<_>>()
14789 });
14790 let recommendations = traversal_recommendations(
14791 &graph,
14792 origin_node.map(|node| node.handle.as_str()),
14793 shortest_handles.as_deref(),
14794 if limit == 0 { 10 } else { limit.min(10) },
14795 );
14796 let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
14797 let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
14798
14799 Ok(TraversalReport {
14800 root: root.to_string_lossy().to_string(),
14801 scope: scope.map(str::to_string),
14802 mode,
14803 totals,
14804 query: query.map(str::to_string),
14805 target: target.map(str::to_string),
14806 nodes: selected_nodes,
14807 edges: selected_edges,
14808 shortest_path,
14809 recommendations,
14810 exploration,
14811 truncated,
14812 warnings: graph.warnings,
14813 })
14814}
14815
14816fn html_escape(input: &str) -> String {
14817 input
14818 .replace('&', "&")
14819 .replace('<', "<")
14820 .replace('>', ">")
14821 .replace('"', """)
14822 .replace('\'', "'")
14823}
14824
14825pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
14826 let json = serde_json::to_string(report)?.replace("</", "<\\/");
14827 let mut html = String::new();
14828 html.push_str(
14829 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
14830 );
14831 html.push_str(
14832 r#"<style>
14833:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
14834@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
14835*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif;line-height:1.4}.page{max-width:1280px;margin:0 auto;padding:20px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:14px}.top h1{font-size:22px;margin:0}.meta{color:var(--muted);font-size:13px}.toolbar{display:flex;gap:8px;align-items:center}.toolbar input{min-width:220px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--text);padding:8px 10px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:14px;min-height:650px}.graph-panel,.side{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.graph-panel{position:relative}.legend{position:absolute;left:12px;top:12px;display:flex;flex-wrap:wrap;gap:6px;max-width:calc(100% - 24px)}.legend span{font-size:12px;background:color-mix(in srgb,var(--panel) 86%,transparent);border:1px solid var(--line);border-radius:999px;padding:4px 8px}.side{padding:14px;overflow:auto}.side h2{font-size:15px;margin:0 0 8px}.selected{border-top:1px solid var(--line);margin-top:12px;padding-top:12px}.list{display:grid;gap:8px}.row{border:1px solid var(--line);border-radius:6px;padding:8px;cursor:pointer}.row:hover{border-color:var(--accent)}.kind{font-size:11px;text-transform:uppercase;color:var(--muted);letter-spacing:.04em}.label{font-weight:650;overflow-wrap:anywhere}.handle,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted)}svg{width:100%;height:650px;display:block}.edge{stroke:var(--edge);stroke-width:1.4;opacity:.72}.edge.semantic{stroke:var(--semantic);stroke-width:1.8}.node{stroke:var(--panel);stroke-width:2;cursor:pointer}.node.semantic{stroke:var(--semantic);stroke-width:2.5}.node-label{font-size:12px;paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round;fill:var(--text);pointer-events:none}.hidden{display:none}@media(max-width:900px){.top{display:block}.toolbar{margin-top:12px}.layout{grid-template-columns:1fr}.side{max-height:360px}svg{height:560px}}
14836</style>"#,
14837 );
14838 html.push_str("</head><body>");
14839 html.push_str("<div class=\"page\">");
14840 html.push_str(&format!(
14841 "<header class=\"top\"><div><h1>tsift traversal graph</h1><div class=\"meta\">mode <code>{}</code> | nodes <code>{}</code>/<code>{}</code> | edges <code>{}</code>/<code>{}</code></div></div><div class=\"toolbar\"><input id=\"filter\" type=\"search\" placeholder=\"Filter nodes\"></div></header>",
14842 html_escape(&report.mode),
14843 report.nodes.len(),
14844 report.totals.nodes,
14845 report.edges.len(),
14846 report.totals.edges
14847 ));
14848 html.push_str(
14849 r#"<main class="layout"><section class="graph-panel"><div id="legend" class="legend"></div><svg id="graph-canvas" role="img" aria-label="Traversal graph"></svg></section><aside class="side"><h2>Nodes</h2><div id="node-list" class="list"></div><div id="selected" class="selected"></div></aside></main>"#,
14850 );
14851 html.push_str("<script id=\"graph-data\" type=\"application/json\">");
14852 html.push_str(&json);
14853 html.push_str(
14854 r##"</script><script>
14855const report = JSON.parse(document.getElementById("graph-data").textContent);
14856const svg = document.getElementById("graph-canvas");
14857const list = document.getElementById("node-list");
14858const selected = document.getElementById("selected");
14859const filter = document.getElementById("filter");
14860const legend = document.getElementById("legend");
14861const nodes = report.nodes.map((node, index) => ({...node, index}));
14862const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
14863const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
14864const colorByKind = new Map([
14865 ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
14866 ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
14867 ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
14868 ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
14869]);
14870function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
14871function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
14872function text(value){ return value == null ? "" : String(value); }
14873function matches(node, query){
14874 if (!query) return true;
14875 const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
14876 return haystack.includes(query);
14877}
14878function layout(){
14879 const rect = svg.getBoundingClientRect();
14880 const width = rect.width || 900;
14881 const height = rect.height || 650;
14882 const cx = width / 2;
14883 const cy = height / 2;
14884 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14885 const counts = new Map();
14886 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
14887 const offsets = new Map();
14888 for (const node of nodes) {
14889 const group = kinds.indexOf(node.kind);
14890 const index = offsets.get(node.kind) || 0;
14891 offsets.set(node.kind, index + 1);
14892 const groupCount = counts.get(node.kind) || 1;
14893 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
14894 const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
14895 node.x = cx + Math.cos(angle) * ring;
14896 node.y = cy + Math.sin(angle) * ring;
14897 }
14898}
14899function draw(){
14900 const query = filter.value.trim().toLowerCase();
14901 const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
14902 svg.innerHTML = "";
14903 for (const edge of edges) {
14904 if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
14905 const from = nodeByHandle.get(edge.from);
14906 const to = nodeByHandle.get(edge.to);
14907 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
14908 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
14909 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
14910 line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
14911 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
14912 svg.appendChild(line);
14913 }
14914 for (const node of nodes) {
14915 if (!visible.has(node.handle)) continue;
14916 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
14917 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
14918 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
14919 circle.setAttribute("fill", color(node.kind));
14920 circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
14921 circle.addEventListener("click", () => selectNode(node));
14922 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
14923 svg.appendChild(circle);
14924 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
14925 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
14926 label.setAttribute("class", "node-label");
14927 label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
14928 svg.appendChild(label);
14929 }
14930 renderList(query);
14931}
14932function renderLegend(){
14933 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14934 legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">●</b> ${kind}</span>`).join("");
14935}
14936function renderList(query){
14937 const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
14938 list.innerHTML = rows.map(node => `<div class="row" data-handle="${node.handle}"><div class="kind">${node.kind}</div><div class="label">${escapeHtml(node.label)}</div><div class="handle">${node.handle}</div></div>`).join("");
14939 for (const row of list.querySelectorAll(".row")) {
14940 row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
14941 }
14942}
14943function selectNode(node){
14944 const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
14945 selected.innerHTML = `<h2>${escapeHtml(node.label)}</h2><div class="kind">${node.kind}</div><p class="handle">${node.handle}</p>${node.path ? `<p>${escapeHtml(node.path)}${node.line != null ? ":" + node.line : ""}</p>` : ""}${node.detail ? `<p>${escapeHtml(node.detail)}</p>` : ""}<p><code>${escapeHtml(node.expand)}</code></p><h2>Edges</h2><div class="list">${adjacent.map(edge => `<div class="row"><div class="kind">${edge.relation}</div><div>${escapeHtml(edge.from)} -> ${escapeHtml(edge.to)}</div>${edge.label ? `<div>${escapeHtml(edge.label)}</div>` : ""}</div>`).join("") || "<div class=\"meta\">No visible edges.</div>"}</div>`;
14946}
14947function escapeHtml(value){
14948 return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));
14949}
14950filter.addEventListener("input", draw);
14951window.addEventListener("resize", () => { layout(); draw(); });
14952renderLegend();
14953layout();
14954draw();
14955if (nodes.length) selectNode(nodes[0]);
14956</script></div></body></html>"##,
14957 );
14958 Ok(html)
14959}
14960
14961fn semantic_related_report_from_store(
14962 root: &Path,
14963 scope: Option<&str>,
14964 query: &str,
14965 limit: usize,
14966 kind: SemanticRelatedKind,
14967 store: &impl GraphStore,
14968) -> Result<SemanticRelatedReport> {
14969 if query.trim().is_empty() {
14970 bail!("semantic query cannot be empty");
14971 }
14972
14973 let query_embedding = semantic_embedding(query);
14974 let node_kinds: &[&str] = match kind {
14975 SemanticRelatedKind::Concept => &["semantic_concept"],
14976 SemanticRelatedKind::Entity => &["semantic_entity"],
14977 SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
14978 };
14979
14980 let mut items = Vec::new();
14981 for node_kind in node_kinds {
14982 for node in store.nodes_by_kind(node_kind)? {
14983 let Some(embedding) = node
14984 .properties
14985 .get("embedding")
14986 .and_then(|value| parse_semantic_embedding_property(value))
14987 else {
14988 continue;
14989 };
14990 let score = semantic_cosine(&query_embedding, &embedding);
14991 items.push(SemanticRelatedItem {
14992 handle: node
14993 .properties
14994 .get("handle")
14995 .cloned()
14996 .unwrap_or_else(|| node.id.clone()),
14997 kind: node.kind,
14998 label: node.label,
14999 score,
15000 file_path: node
15001 .properties
15002 .get("source_file")
15003 .or_else(|| node.properties.get("path"))
15004 .cloned(),
15005 source_symbol: node.properties.get("source_symbol").cloned(),
15006 detail: node
15007 .properties
15008 .get("description")
15009 .or_else(|| node.properties.get("detail"))
15010 .cloned(),
15011 expand: node
15012 .properties
15013 .get("expand")
15014 .cloned()
15015 .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
15016 });
15017 }
15018 }
15019
15020 items.sort_by(|left, right| {
15021 right
15022 .score
15023 .partial_cmp(&left.score)
15024 .unwrap_or(Ordering::Equal)
15025 .then_with(|| left.kind.cmp(&right.kind))
15026 .then_with(|| left.label.cmp(&right.label))
15027 .then_with(|| left.handle.cmp(&right.handle))
15028 });
15029 if limit > 0 && items.len() > limit {
15030 items.truncate(limit);
15031 }
15032
15033 let mut warnings = Vec::new();
15034 if items.is_empty() {
15035 warnings.push(
15036 "no semantic graph rows found; run `tsift summarize --extract <path>` first"
15037 .to_string(),
15038 );
15039 }
15040
15041 Ok(SemanticRelatedReport {
15042 root: root.to_string_lossy().to_string(),
15043 scope: scope.map(str::to_string),
15044 query: query.to_string(),
15045 embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
15046 count: items.len(),
15047 items,
15048 warnings,
15049 })
15050}
15051
15052fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
15053 Ok(store.nodes_by_kind("semantic_concept")?.len()
15054 + store.nodes_by_kind("semantic_entity")?.len())
15055}
15056
15057fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
15058 if limit == 0 {
15059 return 0;
15060 }
15061 limit.saturating_mul(4).clamp(
15062 GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
15063 GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
15064 )
15065}
15066
15067fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
15068 if limit == 0 {
15069 return usize::MAX;
15070 }
15071 limit.saturating_mul(3).max(limit).max(seed_count)
15072}
15073
15074fn graph_db_semantic_edge_other_id<'a>(
15075 edge: &'a SubstrateGraphEdge,
15076 current_id: &str,
15077) -> Option<&'a str> {
15078 if edge.from_id == current_id {
15079 Some(edge.to_id.as_str())
15080 } else if edge.to_id == current_id {
15081 Some(edge.from_id.as_str())
15082 } else {
15083 None
15084 }
15085}
15086
15087fn graph_db_semantic_edge_score(edge: &SubstrateGraphEdge, current_id: &str) -> i64 {
15088 let mut score = resolution::edge_kind_rank_score(&edge.kind).saturating_mul(10);
15089 score += if edge.from_id == current_id { 8 } else { 4 };
15090 score += match edge.kind.as_str() {
15091 "mentions_concept" | "mentions_entity" | "tagged_concept" | "tagged_entity"
15092 | "related_concept" => 30,
15093 "semantic_relation" => 28,
15094 "calls" => 24,
15095 "mentions" => 22,
15096 "requests_context" | "scopes_context" | "scopes_source" | "explains_result" => 18,
15097 "defines" | "contains" | "belongs_to" => 12,
15098 _ => 0,
15099 };
15100 score
15101}
15102
15103fn graph_db_semantic_seeded_neighborhood(
15104 store: &impl GraphStore,
15105 seed_ids: &[String],
15106 depth: usize,
15107 limit: usize,
15108) -> Result<GraphDbSemanticSeededSubgraph> {
15109 let seed_rank = seed_ids
15110 .iter()
15111 .enumerate()
15112 .map(|(idx, seed)| (seed.clone(), idx))
15113 .collect::<BTreeMap<_, _>>();
15114 let mut nodes = BTreeMap::<String, SubstrateGraphNode>::new();
15115 let mut edges = BTreeMap::<String, SubstrateGraphEdge>::new();
15116 let mut node_score_by_id = BTreeMap::<String, i64>::new();
15117 let mut queue = VecDeque::<(String, usize)>::new();
15118 let mut seen_at_depth = BTreeMap::<String, usize>::new();
15119 let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
15120 let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
15121 let mut skipped_by_edge_cap = 0usize;
15122 let mut skipped_by_node_cap = 0usize;
15123 let mut diagnostics = vec![
15124 "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
15125 "seed expansion traverses both outgoing and incident edges so code, markdown, conversation, and memory adapters can link into semantic rows without reversing their edge direction".to_string(),
15126 format!(
15127 "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
15128 if edge_scan_cap == 0 {
15129 "unbounded".to_string()
15130 } else {
15131 edge_scan_cap.to_string()
15132 },
15133 if node_discovery_cap == usize::MAX {
15134 "unbounded".to_string()
15135 } else {
15136 node_discovery_cap.to_string()
15137 }
15138 ),
15139 ];
15140
15141 for (idx, seed_id) in seed_ids.iter().enumerate() {
15142 if let Some(node) = store.node(seed_id)? {
15143 nodes.entry(seed_id.clone()).or_insert(node);
15144 node_score_by_id
15145 .entry(seed_id.clone())
15146 .or_insert(1_000_000i64.saturating_sub(idx as i64));
15147 queue.push_back((seed_id.clone(), 0));
15148 seen_at_depth.entry(seed_id.clone()).or_insert(0);
15149 } else {
15150 diagnostics.push(format!(
15151 "semantic seed {seed_id} was not present in the graph store"
15152 ));
15153 }
15154 }
15155
15156 while let Some((current_id, current_depth)) = queue.pop_front() {
15157 if current_depth >= depth {
15158 continue;
15159 }
15160
15161 let mut expansion_edges_by_key = BTreeMap::<String, SubstrateGraphEdge>::new();
15162 for edge in store.outgoing_edges(¤t_id, None)? {
15163 expansion_edges_by_key
15164 .entry(graph_db_edge_key(&edge))
15165 .or_insert(edge);
15166 }
15167 for edge in store.incident_edges(¤t_id, None)? {
15168 expansion_edges_by_key
15169 .entry(graph_db_edge_key(&edge))
15170 .or_insert(edge);
15171 }
15172 let mut expansion_edges = expansion_edges_by_key.into_values().collect::<Vec<_>>();
15173 expansion_edges.sort_by(|left, right| {
15174 graph_db_semantic_edge_score(right, ¤t_id)
15175 .cmp(&graph_db_semantic_edge_score(left, ¤t_id))
15176 .then_with(|| graph_db_edge_key(left).cmp(&graph_db_edge_key(right)))
15177 });
15178 if edge_scan_cap > 0 && expansion_edges.len() > edge_scan_cap {
15179 skipped_by_edge_cap += expansion_edges.len() - edge_scan_cap;
15180 expansion_edges.truncate(edge_scan_cap);
15181 }
15182
15183 for edge in expansion_edges {
15184 let Some(other_id) = graph_db_semantic_edge_other_id(&edge, ¤t_id) else {
15185 continue;
15186 };
15187 let other_known = nodes.contains_key(other_id);
15188 if !other_known && nodes.len() >= node_discovery_cap {
15189 skipped_by_node_cap += 1;
15190 continue;
15191 }
15192 let other_id = other_id.to_string();
15193 let edge_score = graph_db_semantic_edge_score(&edge, ¤t_id)
15194 .saturating_add((depth.saturating_sub(current_depth) as i64).saturating_mul(5));
15195 node_score_by_id
15196 .entry(other_id.clone())
15197 .and_modify(|score| *score = (*score).max(edge_score))
15198 .or_insert(edge_score);
15199 let edge_key = graph_db_edge_key(&edge);
15200 edges.entry(edge_key).or_insert_with(|| edge.clone());
15201 if let std::collections::btree_map::Entry::Vacant(entry) = nodes.entry(other_id.clone())
15202 && let Some(node) = store.node(&other_id)?
15203 {
15204 entry.insert(node);
15205 }
15206 if !nodes.contains_key(&other_id) {
15207 continue;
15208 }
15209 let next_depth = current_depth + 1;
15210 let should_queue = seen_at_depth
15211 .get(&other_id)
15212 .is_none_or(|seen_depth| next_depth < *seen_depth);
15213 if should_queue {
15214 seen_at_depth.insert(other_id.clone(), next_depth);
15215 queue.push_back((other_id, next_depth));
15216 }
15217 }
15218 }
15219
15220 if skipped_by_edge_cap > 0 {
15221 diagnostics.push(format!(
15222 "semantic-seeded expansion skipped {skipped_by_edge_cap} lower-scoring incident/outgoing edge(s) after per-node caps"
15223 ));
15224 }
15225 if skipped_by_node_cap > 0 {
15226 diagnostics.push(format!(
15227 "semantic-seeded expansion skipped {skipped_by_node_cap} lower-scoring node discovery edge(s) after the discovery cap"
15228 ));
15229 }
15230
15231 let mut nodes = nodes.into_values().collect::<Vec<_>>();
15232 nodes.sort_by(|left, right| {
15233 seed_rank
15234 .get(&left.id)
15235 .copied()
15236 .unwrap_or(usize::MAX)
15237 .cmp(&seed_rank.get(&right.id).copied().unwrap_or(usize::MAX))
15238 .then_with(|| {
15239 node_score_by_id
15240 .get(&right.id)
15241 .copied()
15242 .unwrap_or_default()
15243 .cmp(&node_score_by_id.get(&left.id).copied().unwrap_or_default())
15244 })
15245 .then(left.id.cmp(&right.id))
15246 });
15247
15248 let before_limit = nodes.len();
15249 let truncated = limit > 0 && nodes.len() > limit;
15250 if truncated {
15251 nodes.truncate(limit);
15252 diagnostics.push(format!(
15253 "semantic-seeded neighborhood truncated from {before_limit} to {limit} node(s)"
15254 ));
15255 }
15256
15257 let node_ids = nodes
15258 .iter()
15259 .map(|node| node.id.as_str())
15260 .collect::<BTreeSet<_>>();
15261 let mut edges = edges
15262 .into_values()
15263 .filter(|edge| {
15264 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
15265 })
15266 .collect::<Vec<_>>();
15267 edges.sort_by_key(graph_db_edge_key);
15268
15269 Ok(GraphDbSemanticSeededSubgraph {
15270 nodes,
15271 edges,
15272 truncated,
15273 diagnostics,
15274 })
15275}
15276
15277#[allow(clippy::too_many_arguments)]
15278fn cmd_semantic_related(
15279 query: &str,
15280 path: &Path,
15281 scope: Option<&str>,
15282 limit: usize,
15283 kind: SemanticRelatedKind,
15284 json_output: bool,
15285 compact: bool,
15286 pretty: bool,
15287 terse: bool,
15288 schema: bool,
15289) -> Result<()> {
15290 let root = lint::resolve_project_root_or_canonical_path(path)?;
15291 write_traversal_graph_store(&root, path, scope)?;
15292 let graph_db = graph_substrate_db_path(&root, scope);
15293 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15294 let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15295 if let Some(recovery) = store.read_only_recovery() {
15296 report
15297 .warnings
15298 .push(graph_db_read_recovery_diagnostic(recovery));
15299 }
15300
15301 if json_output {
15302 println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15303 } else if compact {
15304 for item in &report.items {
15305 println!(
15306 "{:.3}\t{}\t{}\t{}",
15307 item.score, item.kind, item.label, item.handle
15308 );
15309 }
15310 for warning in &report.warnings {
15311 eprintln!("warning: {warning}");
15312 }
15313 } else {
15314 println!(
15315 "Related semantic graph rows for {:?} ({})",
15316 report.query, report.embedding_model
15317 );
15318 for item in &report.items {
15319 println!(
15320 " {:.3} [{}] {} ({})",
15321 item.score, item.kind, item.label, item.handle
15322 );
15323 if let Some(detail) = &item.detail {
15324 println!(" {}", detail);
15325 }
15326 if let Some(file_path) = &item.file_path {
15327 println!(" file: {}", file_path);
15328 }
15329 println!(" expand: {}", item.expand);
15330 }
15331 for warning in &report.warnings {
15332 eprintln!("warning: {warning}");
15333 }
15334 }
15335
15336 Ok(())
15337}
15338
15339#[derive(Serialize)]
15340struct SourceLinePreview {
15341 line: usize,
15342 text: String,
15343}
15344
15345#[derive(Serialize)]
15346pub(crate) struct SourceRangePreview {
15347 start: usize,
15348 end: usize,
15349 total_lines: usize,
15350 truncated_before: bool,
15351 truncated_after: bool,
15352}
15353
15354#[derive(Serialize)]
15355struct SourceExpandCommands {
15356 #[serde(skip_serializing_if = "Option::is_none")]
15357 before: Option<String>,
15358 #[serde(skip_serializing_if = "Option::is_none")]
15359 after: Option<String>,
15360 #[serde(skip_serializing_if = "Option::is_none")]
15361 body: Option<String>,
15362 file: String,
15363 #[serde(skip_serializing_if = "Option::is_none")]
15364 markdown_ast: Option<String>,
15365}
15366
15367#[derive(Serialize)]
15368struct SourceSymbolRef {
15369 handle: String,
15370 name: String,
15371 kind: String,
15372 language: String,
15373 file: String,
15374 line: usize,
15375 #[serde(skip_serializing_if = "Option::is_none")]
15376 end_line: Option<usize>,
15377 #[serde(skip_serializing_if = "Option::is_none")]
15378 signature: Option<String>,
15379 #[serde(skip_serializing_if = "Option::is_none")]
15380 span: Option<AstSpanPreview>,
15381 expand: String,
15382}
15383
15384#[derive(Serialize)]
15385struct SourceSummaryRef {
15386 handle: String,
15387 symbol_name: String,
15388 file_path: String,
15389 summary: String,
15390 expand: String,
15391}
15392
15393#[derive(Serialize)]
15394struct SourceReadReport {
15395 handle: String,
15396 root: String,
15397 file: String,
15398 range: SourceRangePreview,
15399 preview: Vec<SourceLinePreview>,
15400 symbols: Vec<SourceSymbolRef>,
15401 summaries: Vec<SourceSummaryRef>,
15402 #[serde(skip_serializing_if = "Option::is_none")]
15403 markdown: Option<SourceReadMarkdownProjection>,
15404 expand: SourceExpandCommands,
15405 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15406 warnings: Vec<String>,
15407}
15408
15409#[derive(Serialize)]
15410struct SourceReadAstExpandCommands {
15411 window: String,
15412 file_window: String,
15413 #[serde(skip_serializing_if = "Option::is_none")]
15414 markdown_ast: Option<String>,
15415}
15416
15417#[derive(Serialize)]
15418struct SourceReadAstReport {
15419 handle: String,
15420 root: String,
15421 file: String,
15422 range: SourceRangePreview,
15423 symbols: Vec<SourceSymbolRef>,
15424 summaries: Vec<SourceSummaryRef>,
15425 #[serde(skip_serializing_if = "Option::is_none")]
15426 markdown: Option<SourceReadMarkdownProjection>,
15427 expand: SourceReadAstExpandCommands,
15428 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15429 warnings: Vec<String>,
15430}
15431
15432#[derive(Serialize)]
15433struct SymbolReadTarget {
15434 handle: String,
15435 name: String,
15436 kind: String,
15437 language: String,
15438 file: String,
15439 line: usize,
15440 #[serde(skip_serializing_if = "Option::is_none")]
15441 end_line: Option<usize>,
15442 #[serde(skip_serializing_if = "Option::is_none")]
15443 signature: Option<String>,
15444 #[serde(skip_serializing_if = "Option::is_none")]
15445 parent_module: Option<String>,
15446 #[serde(skip_serializing_if = "Option::is_none")]
15447 visibility: Option<String>,
15448 #[serde(skip_serializing_if = "Option::is_none")]
15449 span: Option<AstSpanPreview>,
15450}
15451
15452#[derive(Serialize)]
15453struct SymbolReadExpandCommands {
15454 source_window: String,
15455 #[serde(skip_serializing_if = "Option::is_none")]
15456 body: Option<String>,
15457 file: String,
15458 explain: String,
15459 callers: String,
15460 callees: String,
15461 #[serde(skip_serializing_if = "Option::is_none")]
15462 markdown_ast: Option<String>,
15463}
15464
15465#[derive(Serialize)]
15466struct SymbolReadReport {
15467 handle: String,
15468 root: String,
15469 query: String,
15470 symbol: SymbolReadTarget,
15471 range: SourceRangePreview,
15472 body: Vec<SourceLinePreview>,
15473 child_symbols: Vec<SourceSymbolRef>,
15474 summaries: Vec<SourceSummaryRef>,
15475 expand: SymbolReadExpandCommands,
15476 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15477 warnings: Vec<String>,
15478}
15479
15480#[derive(Clone)]
15481pub(crate) struct MarkdownAstRawNode {
15482 handle: String,
15483 span_handle: String,
15484 name: String,
15485 kind: String,
15486 block_kind: String,
15487 node_kind: String,
15488 start_byte: usize,
15489 end_byte: usize,
15490 body_start_byte: Option<usize>,
15491 body_end_byte: Option<usize>,
15492}
15493
15494#[derive(Clone)]
15495pub(crate) struct MarkdownAstProjection {
15496 source_hash: String,
15497 nodes: Vec<MarkdownAstRawNode>,
15498 parse_duration_micros: u128,
15499 cache_hit: bool,
15500}
15501
15502#[derive(Clone)]
15503struct MarkdownAstCacheEntry {
15504 source_hash: String,
15505 nodes: Vec<MarkdownAstRawNode>,
15506 parse_duration_micros: u128,
15507}
15508
15509static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15510 OnceLock::new();
15511
15512#[derive(Serialize, Clone)]
15513struct MarkdownAstNodeMetadata {
15514 #[serde(skip_serializing_if = "Option::is_none")]
15515 heading_level: Option<usize>,
15516 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15517 section_path: Vec<String>,
15518 #[serde(skip_serializing_if = "Option::is_none")]
15519 section_handle: Option<String>,
15520 #[serde(skip_serializing_if = "Option::is_none")]
15521 list_depth: Option<usize>,
15522 #[serde(skip_serializing_if = "Option::is_none")]
15523 list_marker: Option<String>,
15524 #[serde(skip_serializing_if = "Option::is_none")]
15525 list_order: Option<usize>,
15526 #[serde(skip_serializing_if = "Option::is_none")]
15527 fence_language: Option<String>,
15528 #[serde(skip_serializing_if = "Option::is_none")]
15529 fence_marker: Option<String>,
15530 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15531 embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15532}
15533
15534#[derive(Serialize, Clone)]
15535struct MarkdownAstNodeExpand {
15536 source_window: String,
15537 source_body: String,
15538 symbol_read: String,
15539 edit_intents: String,
15540}
15541
15542#[derive(Serialize, Clone)]
15543struct MarkdownAstCacheReport {
15544 source_hash: String,
15545 cache_hit: bool,
15546 parse_duration_micros: u128,
15547 node_count: usize,
15548 section_count: usize,
15549 list_item_count: usize,
15550 code_block_count: usize,
15551}
15552
15553#[derive(Serialize, Clone)]
15554struct MarkdownAstPhaseTiming {
15555 name: String,
15556 duration_micros: u128,
15557 detail: String,
15558}
15559
15560#[derive(Serialize, Clone)]
15561struct MarkdownAstOutlineEntry {
15562 handle: String,
15563 span_handle: String,
15564 name: String,
15565 kind: String,
15566 block_kind: String,
15567 line: usize,
15568 end_line: usize,
15569 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15570 section_path: Vec<String>,
15571 child_count: usize,
15572 expand: String,
15573}
15574
15575#[derive(Serialize, Clone)]
15576struct MarkdownAstProjectionPreview {
15577 mode: String,
15578 total_nodes: usize,
15579 returned_nodes: usize,
15580 omitted_nodes: usize,
15581 selected_node: Option<String>,
15582 cache: MarkdownAstCacheReport,
15583 outline: Vec<MarkdownAstOutlineEntry>,
15584 phase_timings: Vec<MarkdownAstPhaseTiming>,
15585}
15586
15587#[derive(Serialize)]
15588struct SourceReadMarkdownProjection {
15589 handle: String,
15590 mode: String,
15591 total_nodes: usize,
15592 visible_nodes: usize,
15593 outline: Vec<MarkdownAstOutlineEntry>,
15594 expand: String,
15595}
15596
15597#[derive(Serialize, Clone)]
15598struct SourceByteRangePreview {
15599 start: usize,
15600 end: usize,
15601}
15602
15603#[derive(Serialize, Clone)]
15604struct MarkdownAstNode {
15605 handle: String,
15606 span_handle: String,
15607 name: String,
15608 kind: String,
15609 block_kind: String,
15610 node_kind: String,
15611 line: usize,
15612 end_line: usize,
15613 byte_span: SourceByteRangePreview,
15614 #[serde(skip_serializing_if = "Option::is_none")]
15615 body_byte_span: Option<SourceByteRangePreview>,
15616 parent_handle: Option<String>,
15617 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15618 child_handles: Vec<String>,
15619 metadata: MarkdownAstNodeMetadata,
15620 expand: MarkdownAstNodeExpand,
15621}
15622
15623#[derive(Serialize)]
15624struct MarkdownAstExpandCommands {
15625 file: String,
15626 source_read: String,
15627 edit_intents: String,
15628}
15629
15630#[derive(Serialize)]
15631struct MarkdownAstReport {
15632 handle: String,
15633 root: String,
15634 file: String,
15635 range: SourceRangePreview,
15636 projection: MarkdownAstProjectionPreview,
15637 nodes: Vec<MarkdownAstNode>,
15638 expand: MarkdownAstExpandCommands,
15639 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15640 warnings: Vec<String>,
15641}
15642
15643pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
15644 let candidate = if file.is_absolute() {
15645 file.to_path_buf()
15646 } else {
15647 root.join(file)
15648 };
15649 let canonical = candidate
15650 .canonicalize()
15651 .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
15652 if !canonical.is_file() {
15653 bail!("source file is not a regular file: {}", canonical.display());
15654 }
15655 let canonical_root = root
15656 .canonicalize()
15657 .with_context(|| format!("canonicalizing project root {}", root.display()))?;
15658 if !canonical.starts_with(&canonical_root) {
15659 bail!(
15660 "source file {} is outside project root {}",
15661 canonical.display(),
15662 canonical_root.display()
15663 );
15664 }
15665 Ok(canonical)
15666}
15667
15668pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
15669 source_read_window_command(root, file, start, lines)
15670}
15671
15672pub(crate) fn source_read_window_command(
15673 root: &Path,
15674 file: &str,
15675 start: usize,
15676 lines: usize,
15677) -> String {
15678 format!(
15679 "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
15680 shell_quote(file),
15681 shell_quote(&root.to_string_lossy()),
15682 start,
15683 lines
15684 )
15685}
15686
15687pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
15688 format!(
15689 "tsift --envelope source-read {} --path {} --budget normal",
15690 shell_quote(file),
15691 shell_quote(&root.to_string_lossy())
15692 )
15693}
15694
15695pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
15696 format!(
15697 "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
15698 shell_quote(symbol),
15699 shell_quote(&root.to_string_lossy()),
15700 shell_quote(file)
15701 )
15702}
15703
15704fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
15705 format!(
15706 "tsift --envelope explain {} --path {} --budget normal",
15707 shell_quote(symbol),
15708 shell_quote(&root.to_string_lossy())
15709 )
15710}
15711
15712fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
15713 format!(
15714 "tsift graph {} --path {} --{} --json",
15715 shell_quote(symbol),
15716 shell_quote(&root.to_string_lossy()),
15717 relation
15718 )
15719}
15720
15721fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
15722 format!(
15723 "tsift summarize {} --path {} --json",
15724 shell_quote(symbol),
15725 shell_quote(&root.to_string_lossy())
15726 )
15727}
15728
15729pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
15730 let mut command = format!(
15731 "tsift --envelope markdown-ast {} --path {} --budget normal",
15732 shell_quote(file),
15733 shell_quote(&root.to_string_lossy())
15734 );
15735 if let Some(node) = node {
15736 command.push_str(" --node ");
15737 command.push_str(&shell_quote(node));
15738 }
15739 command
15740}
15741
15742fn markdown_edit_intents_command(root: &Path) -> String {
15743 format!(
15744 "tsift --envelope edit-intents --path {} --budget normal",
15745 shell_quote(&root.to_string_lossy())
15746 )
15747}
15748
15749pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
15750 usize::try_from(symbol.line)
15751 .ok()
15752 .and_then(|line| line.checked_add(1))
15753 .unwrap_or(1)
15754}
15755
15756fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
15757 symbol
15758 .end_line
15759 .and_then(|line| usize::try_from(line).ok())
15760 .and_then(|line| line.checked_add(1))
15761}
15762
15763fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
15764 value.and_then(|byte| usize::try_from(byte).ok())
15765}
15766
15767fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
15768 let byte = byte.min(source.len());
15769 source[..byte]
15770 .iter()
15771 .filter(|value| **value == b'\n')
15772 .count()
15773 .saturating_add(1)
15774}
15775
15776fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
15777 source_line_for_byte(source, end_byte.saturating_sub(1))
15778}
15779
15780fn ast_span_handle(
15781 file: &str,
15782 name: &str,
15783 kind: &str,
15784 start_byte: usize,
15785 end_byte: usize,
15786) -> String {
15787 stable_handle(
15788 "span",
15789 &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
15790 )
15791}
15792
15793pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
15794 Some((
15795 symbol_span_byte(symbol.start_byte)?,
15796 symbol_span_byte(symbol.end_byte)?,
15797 ))
15798}
15799
15800pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
15801 Some((
15802 symbol_span_byte(symbol.start_byte)?,
15803 symbol_span_byte(symbol.end_byte)?,
15804 ))
15805}
15806
15807pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
15808 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15809 Some(ast_span_handle(
15810 &symbol.file,
15811 &symbol.name,
15812 &symbol.kind,
15813 start_byte,
15814 end_byte,
15815 ))
15816}
15817
15818fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
15819 left.file == right.file
15820 && left.name == right.name
15821 && left.kind == right.kind
15822 && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
15823}
15824
15825fn stored_symbol_parent_span_handle(
15826 symbol: &index::StoredSymbol,
15827 symbols: &[index::StoredSymbol],
15828) -> Option<String> {
15829 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15830 symbols
15831 .iter()
15832 .filter(|candidate| {
15833 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15834 return false;
15835 }
15836 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15837 else {
15838 return false;
15839 };
15840 candidate_start <= start_byte && candidate_end >= end_byte
15841 })
15842 .min_by_key(|candidate| {
15843 stored_symbol_span_bounds(candidate)
15844 .map(|(start, end)| end.saturating_sub(start))
15845 .unwrap_or(usize::MAX)
15846 })
15847 .and_then(stored_symbol_span_handle)
15848}
15849
15850fn stored_symbol_child_span_handles(
15851 symbol: &index::StoredSymbol,
15852 symbols: &[index::StoredSymbol],
15853 limit: usize,
15854) -> Vec<String> {
15855 let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
15856 return Vec::new();
15857 };
15858 symbols
15859 .iter()
15860 .filter(|candidate| {
15861 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15862 return false;
15863 }
15864 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15865 else {
15866 return false;
15867 };
15868 candidate_start >= start_byte && candidate_end <= end_byte
15869 })
15870 .take(limit)
15871 .filter_map(stored_symbol_span_handle)
15872 .collect()
15873}
15874
15875fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
15876 let start = start_byte.min(source.len());
15877 let line_end = source[start..]
15878 .iter()
15879 .position(|value| *value == b'\n')
15880 .map(|pos| start + pos)
15881 .unwrap_or(source.len());
15882 let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
15883 let marker = line.trim_start();
15884 let level = marker.chars().take_while(|ch| *ch == '#').count();
15885 (1..=6).contains(&level).then_some(level)
15886}
15887
15888fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
15889 let start = start_byte.min(source.len());
15890 let line_start = source[..start]
15891 .iter()
15892 .rposition(|value| *value == b'\n')
15893 .map(|pos| pos + 1)
15894 .unwrap_or(0);
15895 source[line_start..start]
15896 .iter()
15897 .map(|byte| match byte {
15898 b'\t' => 4,
15899 b' ' => 1,
15900 _ => 0,
15901 })
15902 .sum::<usize>()
15903 / 2
15904}
15905
15906fn markdown_enclosing_heading_symbols<'a>(
15907 file: &str,
15908 start_byte: usize,
15909 end_byte: usize,
15910 symbols: &'a [index::StoredSymbol],
15911) -> Vec<&'a index::StoredSymbol> {
15912 let mut headings = symbols
15913 .iter()
15914 .filter(|candidate| candidate.file == file && candidate.kind == "heading")
15915 .filter(|candidate| {
15916 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15917 else {
15918 return false;
15919 };
15920 candidate_start <= start_byte && candidate_end >= end_byte
15921 })
15922 .collect::<Vec<_>>();
15923 headings.sort_by(|left, right| {
15924 stored_symbol_span_bounds(left)
15925 .map(|(start, _)| start)
15926 .unwrap_or(usize::MAX)
15927 .cmp(
15928 &stored_symbol_span_bounds(right)
15929 .map(|(start, _)| start)
15930 .unwrap_or(usize::MAX),
15931 )
15932 .then(left.name.cmp(&right.name))
15933 });
15934 headings
15935}
15936
15937fn markdown_stored_symbol_metadata(
15938 symbol: &index::StoredSymbol,
15939 source: &[u8],
15940 symbols: &[index::StoredSymbol],
15941) -> Option<MarkdownSpanMetadata> {
15942 if symbol.language != "markdown" {
15943 return None;
15944 }
15945 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15946 let section_symbols =
15947 markdown_enclosing_heading_symbols(&symbol.file, start_byte, end_byte, symbols);
15948 let section_path = section_symbols
15949 .iter()
15950 .map(|heading| heading.name.clone())
15951 .collect::<Vec<_>>();
15952 let section_handle = section_symbols
15953 .last()
15954 .and_then(|heading| stored_symbol_span_handle(heading));
15955 let heading_level = (symbol.kind == "heading")
15956 .then(|| markdown_heading_level(source, start_byte))
15957 .flatten();
15958 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15959 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15960 let embedded_symbols = if symbol.kind == "code_block" {
15961 markdown_embedded_symbols(
15962 &symbol.file,
15963 source,
15964 symbol_span_byte(symbol.body_start_byte),
15965 symbol_span_byte(symbol.body_end_byte),
15966 fence_language.as_deref(),
15967 )
15968 } else {
15969 Vec::new()
15970 };
15971
15972 (heading_level.is_some()
15973 || !section_path.is_empty()
15974 || section_handle.is_some()
15975 || list_depth.is_some()
15976 || fence_language.is_some()
15977 || !embedded_symbols.is_empty())
15978 .then_some(MarkdownSpanMetadata {
15979 heading_level,
15980 section_path,
15981 section_handle,
15982 list_depth,
15983 fence_language,
15984 embedded_symbols,
15985 })
15986}
15987
15988fn markdown_symbol_hit_metadata(
15989 symbol: &index::SymbolHit,
15990 source: &[u8],
15991 start_byte: usize,
15992) -> Option<MarkdownSpanMetadata> {
15993 if symbol.language != "markdown" {
15994 return None;
15995 }
15996 let heading_level = (symbol.kind == "heading")
15997 .then(|| markdown_heading_level(source, start_byte))
15998 .flatten();
15999 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16000 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16001 let embedded_symbols = if symbol.kind == "code_block" {
16002 markdown_embedded_symbols(
16003 &symbol.file,
16004 source,
16005 symbol_span_byte(symbol.body_start_byte),
16006 symbol_span_byte(symbol.body_end_byte),
16007 fence_language.as_deref(),
16008 )
16009 } else {
16010 Vec::new()
16011 };
16012 (heading_level.is_some()
16013 || list_depth.is_some()
16014 || fence_language.is_some()
16015 || !embedded_symbols.is_empty())
16016 .then_some(MarkdownSpanMetadata {
16017 heading_level,
16018 section_path: Vec::new(),
16019 section_handle: None,
16020 list_depth,
16021 fence_language,
16022 embedded_symbols,
16023 })
16024}
16025
16026fn is_markdown_path(path: &Path) -> bool {
16027 path.extension()
16028 .and_then(|ext| ext.to_str())
16029 .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
16030 .unwrap_or(false)
16031}
16032
16033fn markdown_ast_block_kind(kind: &str) -> String {
16034 match kind {
16035 "heading" => "section",
16036 "code_block" => "fenced_code_block",
16037 "list_item" => "list_item",
16038 other => other,
16039 }
16040 .to_string()
16041}
16042
16043fn markdown_embedded_language_key(language: &str) -> Option<String> {
16044 let key = language
16045 .split_whitespace()
16046 .next()
16047 .unwrap_or("")
16048 .trim()
16049 .trim_start_matches("language-")
16050 .trim_start_matches("lang-")
16051 .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
16052 .to_ascii_lowercase();
16053 (!key.is_empty()).then_some(key)
16054}
16055
16056fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
16057 let key = markdown_embedded_language_key(language)?;
16058 let extension = match key.as_str() {
16059 "rust" => "rs",
16060 "python" => "py",
16061 "typescript" => "ts",
16062 "javascript" => "js",
16063 "kotlin" => "kt",
16064 "shell" | "sh" | "zsh" => "bash",
16065 other => other,
16066 };
16067 let lang = graph::Lang::from_extension(extension)?;
16068 (lang.name() != "markdown").then_some(lang)
16069}
16070
16071fn markdown_embedded_ast_span_handle(
16072 file: &str,
16073 language: &str,
16074 name: &str,
16075 kind: &str,
16076 start_byte: usize,
16077 end_byte: usize,
16078) -> String {
16079 stable_handle(
16080 "span",
16081 &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
16082 )
16083}
16084
16085fn markdown_embedded_symbols(
16086 file: &str,
16087 source: &[u8],
16088 body_start_byte: Option<usize>,
16089 body_end_byte: Option<usize>,
16090 fence_language: Option<&str>,
16091) -> Vec<MarkdownEmbeddedSymbol> {
16092 let Some(fence_language) = fence_language else {
16093 return Vec::new();
16094 };
16095 let Some(lang) = markdown_embedded_lang(fence_language) else {
16096 return Vec::new();
16097 };
16098 let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
16099 return Vec::new();
16100 };
16101 let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
16102 else {
16103 return Vec::new();
16104 };
16105 if body.is_empty() {
16106 return Vec::new();
16107 }
16108
16109 let Ok(symbols) = lang.extract_symbols(body) else {
16110 return Vec::new();
16111 };
16112 let language = lang.name().to_string();
16113 symbols
16114 .into_iter()
16115 .map(|symbol| {
16116 let start_byte = body_start_byte.saturating_add(symbol.start_byte);
16117 let end_byte = body_start_byte.saturating_add(symbol.end_byte);
16118 let body_start = symbol
16119 .body_start_byte
16120 .map(|byte| body_start_byte.saturating_add(byte));
16121 let body_end = symbol
16122 .body_end_byte
16123 .map(|byte| body_start_byte.saturating_add(byte));
16124 let start_line = source_line_for_byte(source, start_byte);
16125 let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
16126 MarkdownEmbeddedSymbol {
16127 handle: markdown_embedded_ast_span_handle(
16128 file,
16129 &language,
16130 &symbol.name,
16131 &symbol.kind,
16132 start_byte,
16133 end_byte,
16134 ),
16135 name: symbol.name,
16136 kind: symbol.kind,
16137 language: language.clone(),
16138 node_kind: symbol.node_kind,
16139 start_byte,
16140 end_byte,
16141 start_line,
16142 end_line,
16143 body_start_byte: body_start,
16144 body_end_byte: body_end,
16145 body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
16146 body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
16147 }
16148 })
16149 .collect()
16150}
16151
16152fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
16153 let start = start_byte.min(source.len());
16154 let line_start = source[..start]
16155 .iter()
16156 .rposition(|value| *value == b'\n')
16157 .map(|pos| pos + 1)
16158 .unwrap_or(0);
16159 let line_end = source[start..]
16160 .iter()
16161 .position(|value| *value == b'\n')
16162 .map(|pos| start + pos)
16163 .unwrap_or(source.len());
16164 std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
16165}
16166
16167fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
16168 let line = markdown_source_line(source, start_byte);
16169 let trimmed = line.trim_start();
16170 for marker in ["-", "*", "+"] {
16171 if trimmed
16172 .strip_prefix(marker)
16173 .and_then(|rest| rest.strip_prefix(' '))
16174 .is_some()
16175 {
16176 return (Some(marker.to_string()), None);
16177 }
16178 }
16179
16180 let digit_end = trimmed
16181 .find(|ch: char| !ch.is_ascii_digit())
16182 .unwrap_or(trimmed.len());
16183 let (digits, rest) = trimmed.split_at(digit_end);
16184 if !digits.is_empty() {
16185 for marker in [".", ")"] {
16186 if rest
16187 .strip_prefix(marker)
16188 .and_then(|value| value.strip_prefix(' '))
16189 .is_some()
16190 {
16191 return (
16192 Some(format!("{digits}{marker}")),
16193 digits.parse::<usize>().ok(),
16194 );
16195 }
16196 }
16197 }
16198 (None, None)
16199}
16200
16201fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
16202 let line = markdown_source_line(source, start_byte);
16203 let trimmed = line.trim_start();
16204 ["```", "~~~"]
16205 .into_iter()
16206 .find(|marker| trimmed.starts_with(marker))
16207 .map(str::to_string)
16208}
16209
16210fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16211 let mut nodes = graph::Lang::Markdown
16212 .extract_symbols(source)
16213 .context("extracting Markdown AST nodes")?
16214 .into_iter()
16215 .map(|symbol| {
16216 let body_start_byte = symbol.body_start_byte;
16217 let body_end_byte = symbol.body_end_byte;
16218 let span_handle = ast_span_handle(
16219 file,
16220 &symbol.name,
16221 &symbol.kind,
16222 symbol.start_byte,
16223 symbol.end_byte,
16224 );
16225 MarkdownAstRawNode {
16226 handle: stable_handle(
16227 "mdast",
16228 &format!(
16229 "{}:{}:{}:{}:{}",
16230 file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16231 ),
16232 ),
16233 span_handle,
16234 name: symbol.name,
16235 kind: symbol.kind.clone(),
16236 block_kind: markdown_ast_block_kind(&symbol.kind),
16237 node_kind: symbol.node_kind,
16238 start_byte: symbol.start_byte,
16239 end_byte: symbol.end_byte,
16240 body_start_byte,
16241 body_end_byte,
16242 }
16243 })
16244 .collect::<Vec<_>>();
16245 nodes.sort_by(|left, right| {
16246 left.start_byte
16247 .cmp(&right.start_byte)
16248 .then(left.end_byte.cmp(&right.end_byte))
16249 .then(left.kind.cmp(&right.kind))
16250 .then(left.name.cmp(&right.name))
16251 });
16252 Ok(nodes)
16253}
16254
16255pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16256 let source_hash = blake3::hash(source).to_hex().to_string();
16257 let cache_key = format!("{file}:{source_hash}");
16258 let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16259 if let Some(entry) = cache
16260 .lock()
16261 .expect("markdown ast cache poisoned")
16262 .get(&cache_key)
16263 {
16264 return Ok(MarkdownAstProjection {
16265 source_hash: entry.source_hash.clone(),
16266 nodes: entry.nodes.clone(),
16267 parse_duration_micros: entry.parse_duration_micros,
16268 cache_hit: true,
16269 });
16270 }
16271
16272 let started = Instant::now();
16273 let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16274 let parse_duration_micros = started.elapsed().as_micros();
16275 cache.lock().expect("markdown ast cache poisoned").insert(
16276 cache_key,
16277 MarkdownAstCacheEntry {
16278 source_hash: source_hash.clone(),
16279 nodes: nodes.clone(),
16280 parse_duration_micros,
16281 },
16282 );
16283 Ok(MarkdownAstProjection {
16284 source_hash,
16285 nodes,
16286 parse_duration_micros,
16287 cache_hit: false,
16288 })
16289}
16290
16291fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16292 MarkdownAstCacheReport {
16293 source_hash: projection.source_hash.clone(),
16294 cache_hit: projection.cache_hit,
16295 parse_duration_micros: projection.parse_duration_micros,
16296 node_count: projection.nodes.len(),
16297 section_count: projection
16298 .nodes
16299 .iter()
16300 .filter(|node| node.kind == "heading")
16301 .count(),
16302 list_item_count: projection
16303 .nodes
16304 .iter()
16305 .filter(|node| node.kind == "list_item")
16306 .count(),
16307 code_block_count: projection
16308 .nodes
16309 .iter()
16310 .filter(|node| node.kind == "code_block")
16311 .count(),
16312 }
16313}
16314
16315fn markdown_ast_node_direct_child_count(
16316 node: &MarkdownAstRawNode,
16317 nodes: &[MarkdownAstRawNode],
16318) -> usize {
16319 nodes
16320 .iter()
16321 .filter(|candidate| {
16322 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16323 })
16324 .count()
16325}
16326
16327fn markdown_ast_outline_entry(
16328 root: &Path,
16329 file: &str,
16330 source: &[u8],
16331 nodes: &[MarkdownAstRawNode],
16332 node: &MarkdownAstRawNode,
16333 max_bytes: usize,
16334) -> MarkdownAstOutlineEntry {
16335 let line = source_line_for_byte(source, node.start_byte);
16336 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16337 MarkdownAstOutlineEntry {
16338 handle: node.handle.clone(),
16339 span_handle: node.span_handle.clone(),
16340 name: truncate_for_budget(&node.name, max_bytes),
16341 kind: node.kind.clone(),
16342 block_kind: node.block_kind.clone(),
16343 line,
16344 end_line,
16345 section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16346 child_count: markdown_ast_node_direct_child_count(node, nodes),
16347 expand: markdown_ast_command(root, file, Some(&node.handle)),
16348 }
16349}
16350
16351fn markdown_ast_outline_entries(
16352 root: &Path,
16353 file: &str,
16354 source: &[u8],
16355 nodes: &[MarkdownAstRawNode],
16356 limit: usize,
16357 max_bytes: usize,
16358) -> Vec<MarkdownAstOutlineEntry> {
16359 let mut headings = nodes
16360 .iter()
16361 .filter(|node| node.kind == "heading")
16362 .collect::<Vec<_>>();
16363 let mut blocks = nodes
16364 .iter()
16365 .filter(|node| node.kind != "heading")
16366 .collect::<Vec<_>>();
16367 headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16368 blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16369 headings
16370 .into_iter()
16371 .chain(blocks)
16372 .take(limit)
16373 .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16374 .collect()
16375}
16376
16377fn markdown_ast_node_intersects_lines(
16378 source: &[u8],
16379 node: &MarkdownAstRawNode,
16380 start: usize,
16381 end: usize,
16382) -> bool {
16383 let line = source_line_for_byte(source, node.start_byte);
16384 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16385 line <= end && end_line >= start
16386}
16387
16388fn source_read_markdown_projection(
16389 root: &Path,
16390 file: &str,
16391 source: &[u8],
16392 start: usize,
16393 end: usize,
16394 budget: ResponseBudget,
16395) -> Result<SourceReadMarkdownProjection> {
16396 let projection = markdown_ast_projection(file, source)?;
16397 let visible_nodes = projection
16398 .nodes
16399 .iter()
16400 .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16401 .collect::<Vec<_>>();
16402 let mut outline_nodes = visible_nodes.clone();
16403 outline_nodes.sort_by_key(|node| {
16404 (
16405 node.kind != "heading",
16406 node.start_byte,
16407 node.end_byte,
16408 node.name.as_str(),
16409 )
16410 });
16411 let outline = outline_nodes
16412 .into_iter()
16413 .take(budget.preview_items())
16414 .map(|node| {
16415 markdown_ast_outline_entry(
16416 root,
16417 file,
16418 source,
16419 &projection.nodes,
16420 node,
16421 budget.preview_bytes(),
16422 )
16423 })
16424 .collect::<Vec<_>>();
16425 Ok(SourceReadMarkdownProjection {
16426 handle: stable_handle(
16427 "mdproj",
16428 &format!("{file}:{start}:{end}:{}", projection.source_hash),
16429 ),
16430 mode: "window_outline".to_string(),
16431 total_nodes: projection.nodes.len(),
16432 visible_nodes: visible_nodes.len(),
16433 outline,
16434 expand: markdown_ast_command(root, file, None),
16435 })
16436}
16437
16438fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16439 if parent.handle == child.handle {
16440 return false;
16441 }
16442 parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16443}
16444
16445fn markdown_ast_parent_handle(
16446 node: &MarkdownAstRawNode,
16447 nodes: &[MarkdownAstRawNode],
16448) -> Option<String> {
16449 nodes
16450 .iter()
16451 .filter(|candidate| markdown_ast_contains(candidate, node))
16452 .min_by_key(|candidate| {
16453 (
16454 candidate.end_byte.saturating_sub(candidate.start_byte),
16455 candidate.start_byte,
16456 )
16457 })
16458 .map(|candidate| candidate.handle.clone())
16459}
16460
16461fn markdown_ast_child_handles(
16462 node: &MarkdownAstRawNode,
16463 nodes: &[MarkdownAstRawNode],
16464 limit: usize,
16465) -> Vec<String> {
16466 nodes
16467 .iter()
16468 .filter(|candidate| {
16469 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16470 })
16471 .take(limit)
16472 .map(|candidate| candidate.handle.clone())
16473 .collect()
16474}
16475
16476fn markdown_ast_section_nodes<'a>(
16477 node: &MarkdownAstRawNode,
16478 nodes: &'a [MarkdownAstRawNode],
16479) -> Vec<&'a MarkdownAstRawNode> {
16480 let mut headings = nodes
16481 .iter()
16482 .filter(|candidate| candidate.kind == "heading")
16483 .filter(|candidate| {
16484 candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16485 })
16486 .collect::<Vec<_>>();
16487 headings.sort_by(|left, right| {
16488 left.start_byte
16489 .cmp(&right.start_byte)
16490 .then(left.end_byte.cmp(&right.end_byte))
16491 .then(left.name.cmp(&right.name))
16492 });
16493 headings
16494}
16495
16496fn markdown_ast_node_metadata(
16497 file: &str,
16498 node: &MarkdownAstRawNode,
16499 source: &[u8],
16500 nodes: &[MarkdownAstRawNode],
16501) -> MarkdownAstNodeMetadata {
16502 let section_nodes = markdown_ast_section_nodes(node, nodes);
16503 let section_path = section_nodes
16504 .iter()
16505 .map(|heading| heading.name.clone())
16506 .collect::<Vec<_>>();
16507 let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16508 let heading_level = (node.kind == "heading")
16509 .then(|| markdown_heading_level(source, node.start_byte))
16510 .flatten();
16511 let (list_marker, list_order) = if node.kind == "list_item" {
16512 markdown_list_attributes(source, node.start_byte)
16513 } else {
16514 (None, None)
16515 };
16516 let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16517 let embedded_symbols = if node.kind == "code_block" {
16518 markdown_embedded_symbols(
16519 file,
16520 source,
16521 node.body_start_byte,
16522 node.body_end_byte,
16523 fence_language.as_deref(),
16524 )
16525 } else {
16526 Vec::new()
16527 };
16528 MarkdownAstNodeMetadata {
16529 heading_level,
16530 section_path,
16531 section_handle,
16532 list_depth: (node.kind == "list_item")
16533 .then(|| markdown_list_depth(source, node.start_byte)),
16534 list_marker,
16535 list_order,
16536 fence_language,
16537 fence_marker: (node.kind == "code_block")
16538 .then(|| markdown_fence_marker(source, node.start_byte))
16539 .flatten(),
16540 embedded_symbols,
16541 }
16542}
16543
16544fn markdown_ast_node_expand(
16545 root: &Path,
16546 file: &str,
16547 node: &MarkdownAstRawNode,
16548 source: &[u8],
16549) -> MarkdownAstNodeExpand {
16550 let start_line = source_line_for_byte(source, node.start_byte);
16551 let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16552 let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16553 let body_start_line = node
16554 .body_start_byte
16555 .map(|byte| source_line_for_byte(source, byte))
16556 .unwrap_or(start_line);
16557 let body_end_line = node
16558 .body_end_byte
16559 .map(|byte| source_line_for_end_byte(source, byte))
16560 .unwrap_or(end_line)
16561 .max(body_start_line);
16562 let body_line_count = body_end_line
16563 .saturating_sub(body_start_line)
16564 .saturating_add(1)
16565 .max(1);
16566 MarkdownAstNodeExpand {
16567 source_window: source_read_command(root, file, start_line, line_count),
16568 source_body: source_read_command(root, file, body_start_line, body_line_count),
16569 symbol_read: source_symbol_read_command(root, &node.name, file),
16570 edit_intents: markdown_edit_intents_command(root),
16571 }
16572}
16573
16574fn markdown_ast_node(
16575 root: &Path,
16576 file: &str,
16577 node: &MarkdownAstRawNode,
16578 source: &[u8],
16579 nodes: &[MarkdownAstRawNode],
16580 child_limit: usize,
16581) -> MarkdownAstNode {
16582 let line = source_line_for_byte(source, node.start_byte);
16583 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16584 let body_byte_span = node
16585 .body_start_byte
16586 .zip(node.body_end_byte)
16587 .map(|(start, end)| SourceByteRangePreview { start, end });
16588 MarkdownAstNode {
16589 handle: node.handle.clone(),
16590 span_handle: node.span_handle.clone(),
16591 name: node.name.clone(),
16592 kind: node.kind.clone(),
16593 block_kind: node.block_kind.clone(),
16594 node_kind: node.node_kind.clone(),
16595 line,
16596 end_line,
16597 byte_span: SourceByteRangePreview {
16598 start: node.start_byte,
16599 end: node.end_byte,
16600 },
16601 body_byte_span,
16602 parent_handle: markdown_ast_parent_handle(node, nodes),
16603 child_handles: markdown_ast_child_handles(node, nodes, child_limit),
16604 metadata: markdown_ast_node_metadata(file, node, source, nodes),
16605 expand: markdown_ast_node_expand(root, file, node, source),
16606 }
16607}
16608
16609pub(crate) fn stored_symbol_ast_span(
16610 symbol: &index::StoredSymbol,
16611 source: &[u8],
16612 symbols: &[index::StoredSymbol],
16613 child_limit: usize,
16614) -> Option<AstSpanPreview> {
16615 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16616 let node_kind = symbol.node_kind.clone()?;
16617 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16618 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16619 Some(AstSpanPreview {
16620 handle: ast_span_handle(
16621 &symbol.file,
16622 &symbol.name,
16623 &symbol.kind,
16624 start_byte,
16625 end_byte,
16626 ),
16627 node_kind,
16628 start_byte,
16629 end_byte,
16630 start_line: source_line_for_byte(source, start_byte),
16631 end_line: source_line_for_end_byte(source, end_byte),
16632 body_start_byte,
16633 body_end_byte,
16634 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16635 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16636 parent_handle: stored_symbol_parent_span_handle(symbol, symbols),
16637 child_handles: stored_symbol_child_span_handles(symbol, symbols, child_limit),
16638 markdown: markdown_stored_symbol_metadata(symbol, source, symbols),
16639 })
16640}
16641
16642pub(crate) fn symbol_hit_ast_span(symbol: &index::SymbolHit, source: &[u8]) -> Option<AstSpanPreview> {
16643 let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
16644 let node_kind = symbol.node_kind.clone()?;
16645 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16646 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16647 Some(AstSpanPreview {
16648 handle: ast_span_handle(
16649 &symbol.file,
16650 &symbol.name,
16651 &symbol.kind,
16652 start_byte,
16653 end_byte,
16654 ),
16655 node_kind,
16656 start_byte,
16657 end_byte,
16658 start_line: source_line_for_byte(source, start_byte),
16659 end_line: source_line_for_end_byte(source, end_byte),
16660 body_start_byte,
16661 body_end_byte,
16662 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16663 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16664 parent_handle: None,
16665 child_handles: Vec::new(),
16666 markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
16667 })
16668}
16669
16670pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
16671 usize::try_from(symbol.line)
16672 .ok()
16673 .and_then(|line| line.checked_add(1))
16674 .unwrap_or(1)
16675}
16676
16677pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
16678 symbol
16679 .end_line
16680 .and_then(|line| usize::try_from(line).ok())
16681 .and_then(|line| line.checked_add(1))
16682}
16683
16684fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
16685 if end == 0 {
16686 return false;
16687 }
16688 let symbol_start = source_symbol_line(symbol);
16689 let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
16690 symbol_start <= end && symbol_end >= start
16691}
16692
16693#[allow(clippy::too_many_arguments)]
16694fn load_source_symbols(
16695 root: &Path,
16696 file_abs: &Path,
16697 file_display: &str,
16698 source: &[u8],
16699 scope: Option<&str>,
16700 start: usize,
16701 end: usize,
16702 limit: usize,
16703 max_bytes: usize,
16704 warnings: &mut Vec<String>,
16705) -> Vec<SourceSymbolRef> {
16706 let db_path = match resolve_query_db_path(root, file_abs, scope) {
16707 Ok(path) => path,
16708 Err(err) => {
16709 warnings.push(format!("index refs unavailable: {err:#}"));
16710 return Vec::new();
16711 }
16712 };
16713 if !db_path.exists() {
16714 warnings.push(format!(
16715 "index refs unavailable: no index found at {}",
16716 db_path.display()
16717 ));
16718 return Vec::new();
16719 }
16720
16721 let db = match index::IndexDb::open_read_only_resilient(&db_path) {
16722 Ok(db) => db,
16723 Err(err) => {
16724 warnings.push(format!("index refs unavailable: {err:#}"));
16725 return Vec::new();
16726 }
16727 };
16728
16729 let file_key = file_abs.to_string_lossy().to_string();
16730 let symbols = match db.symbols_for_file(&file_key) {
16731 Ok(symbols) => symbols,
16732 Err(err) => {
16733 warnings.push(format!("symbol refs unavailable: {err:#}"));
16734 return Vec::new();
16735 }
16736 };
16737
16738 symbols
16739 .iter()
16740 .filter(|symbol| source_symbol_intersects(symbol, start, end))
16741 .take(limit)
16742 .map(|symbol| {
16743 let line = source_symbol_line(symbol);
16744 let end_line = source_symbol_end_line(symbol);
16745 let handle = stable_handle(
16746 "ssym",
16747 &format!("{}:{}:{}", file_display, symbol.name, line),
16748 );
16749 SourceSymbolRef {
16750 handle,
16751 name: truncate_for_budget(&symbol.name, max_bytes),
16752 kind: symbol.kind.clone(),
16753 language: symbol.language.clone(),
16754 file: file_display.to_string(),
16755 line,
16756 end_line,
16757 signature: symbol
16758 .signature
16759 .clone()
16760 .map(|signature| truncate_for_budget(&signature, max_bytes)),
16761 span: stored_symbol_ast_span(symbol, source, &symbols, limit),
16762 expand: source_symbol_read_command(root, &symbol.name, file_display),
16763 }
16764 })
16765 .collect()
16766}
16767
16768fn load_source_summaries(
16769 root: &Path,
16770 file_display: &str,
16771 limit: usize,
16772 max_bytes: usize,
16773 warnings: &mut Vec<String>,
16774) -> Vec<SourceSummaryRef> {
16775 let db_path = root.join(".tsift/summaries.db");
16776 if !db_path.exists() {
16777 return Vec::new();
16778 }
16779 let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
16780 Ok(db) => db,
16781 Err(err) => {
16782 warnings.push(format!("summary refs unavailable: {err:#}"));
16783 return Vec::new();
16784 }
16785 };
16786 let summaries = match db.get_by_file(file_display) {
16787 Ok(summaries) => summaries,
16788 Err(err) => {
16789 warnings.push(format!("summary refs unavailable: {err:#}"));
16790 return Vec::new();
16791 }
16792 };
16793
16794 summaries
16795 .into_iter()
16796 .take(limit)
16797 .map(|summary| SourceSummaryRef {
16798 handle: stable_handle(
16799 "sum",
16800 &format!(
16801 "{}:{}:{}",
16802 summary.file_path, summary.symbol_name, summary.id
16803 ),
16804 ),
16805 symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
16806 file_path: summary.file_path,
16807 summary: truncate_for_budget(&summary.summary, max_bytes),
16808 expand: source_summary_expand_command(root, &summary.symbol_name),
16809 })
16810 .collect()
16811}
16812
16813fn cmd_markdown_ast(
16814 file: &Path,
16815 path: &Path,
16816 node: Option<&str>,
16817 format: OutputFormat,
16818 absolute: bool,
16819 budget: ResponseBudget,
16820) -> Result<()> {
16821 let root = lint::resolve_project_root_or_canonical_path(path)?;
16822 let file_abs = resolve_source_file(&root, file)?;
16823 if !is_markdown_path(&file_abs) {
16824 bail!(
16825 "markdown-ast only supports Markdown files (.md/.mdx): {}",
16826 file_abs.display()
16827 );
16828 }
16829 let file_display = if absolute {
16830 file_abs.to_string_lossy().to_string()
16831 } else {
16832 relativize_pathbuf(&file_abs, &root)
16833 .to_string_lossy()
16834 .to_string()
16835 };
16836 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16837 let text = String::from_utf8_lossy(&source);
16838 let total_lines = text.lines().count();
16839 let projection = markdown_ast_projection(&file_display, &source)?;
16840 let raw_nodes = &projection.nodes;
16841 let max_items = budget.preview_items();
16842 let max_bytes = budget.preview_bytes();
16843
16844 let selected_nodes = if let Some(handle) = node {
16845 let matches = raw_nodes
16846 .iter()
16847 .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
16848 .collect::<Vec<_>>();
16849 if matches.is_empty() {
16850 bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
16851 }
16852 matches
16853 } else {
16854 raw_nodes.iter().take(max_items).collect::<Vec<_>>()
16855 };
16856 let nodes = selected_nodes
16857 .into_iter()
16858 .map(|raw| {
16859 let mut node =
16860 markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
16861 node.name = truncate_for_budget(&node.name, max_bytes);
16862 node
16863 })
16864 .collect::<Vec<_>>();
16865 let outline_started = Instant::now();
16866 let outline = markdown_ast_outline_entries(
16867 &root,
16868 &file_display,
16869 &source,
16870 raw_nodes,
16871 max_items,
16872 max_bytes,
16873 );
16874 let outline_duration_micros = outline_started.elapsed().as_micros();
16875 let projection_preview = MarkdownAstProjectionPreview {
16876 mode: if node.is_some() {
16877 "selected_node".to_string()
16878 } else {
16879 "outline_first".to_string()
16880 },
16881 total_nodes: raw_nodes.len(),
16882 returned_nodes: nodes.len(),
16883 omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
16884 selected_node: node.map(str::to_string),
16885 cache: markdown_ast_cache_report(&projection),
16886 outline,
16887 phase_timings: vec![
16888 MarkdownAstPhaseTiming {
16889 name: "parse_extract".to_string(),
16890 duration_micros: projection.parse_duration_micros,
16891 detail: if projection.cache_hit {
16892 "reused cached tree-sitter Markdown symbol extraction".to_string()
16893 } else {
16894 "tree-sitter Markdown symbol extraction".to_string()
16895 },
16896 },
16897 MarkdownAstPhaseTiming {
16898 name: "outline_projection".to_string(),
16899 duration_micros: outline_duration_micros,
16900 detail: "outline-first section/block preview construction".to_string(),
16901 },
16902 ],
16903 };
16904 let report = MarkdownAstReport {
16905 handle: stable_handle("mdastrep", &file_display),
16906 root: root.to_string_lossy().to_string(),
16907 file: file_display.clone(),
16908 range: SourceRangePreview {
16909 start: 1,
16910 end: total_lines,
16911 total_lines,
16912 truncated_before: false,
16913 truncated_after: false,
16914 },
16915 projection: projection_preview,
16916 nodes,
16917 expand: MarkdownAstExpandCommands {
16918 file: markdown_ast_command(&root, &file_display, None),
16919 source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
16920 edit_intents: markdown_edit_intents_command(&root),
16921 },
16922 warnings: Vec::new(),
16923 };
16924
16925 if format.json_output {
16926 let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
16927 let mut follow_up = vec![
16928 report.expand.file.clone(),
16929 report.expand.source_read.clone(),
16930 report.expand.edit_intents.clone(),
16931 ];
16932 follow_up.extend(
16933 report
16934 .nodes
16935 .iter()
16936 .map(|node| node.expand.source_window.clone()),
16937 );
16938 print_json_or_envelope(
16939 &report,
16940 &format,
16941 "markdown-ast",
16942 "ast",
16943 ToolEnvelopeSummary {
16944 text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
16945 metrics: vec![
16946 envelope_metric("nodes", report.nodes.len()),
16947 envelope_metric("total_nodes", report.projection.total_nodes),
16948 envelope_metric(
16949 "parse_duration_micros",
16950 report.projection.cache.parse_duration_micros,
16951 ),
16952 envelope_metric("total_lines", report.range.total_lines),
16953 ],
16954 },
16955 truncated,
16956 follow_up,
16957 )?;
16958 } else if format.compact {
16959 println!(
16960 "markdown-ast {} nodes:{} handle:{}",
16961 report.file,
16962 report.nodes.len(),
16963 report.handle
16964 );
16965 for node in &report.nodes {
16966 println!(
16967 " {} {} {}:{}-{}",
16968 node.handle, node.kind, node.name, node.line, node.end_line
16969 );
16970 }
16971 if node.is_none() && raw_nodes.len() > report.nodes.len() {
16972 println!("expand: {}", report.expand.file);
16973 }
16974 } else {
16975 println!(
16976 "Markdown AST `{}` nodes {} of {} ({})",
16977 report.file,
16978 report.nodes.len(),
16979 raw_nodes.len(),
16980 report.handle
16981 );
16982 for node in &report.nodes {
16983 println!(
16984 " {} `{}` {}:{}-{} — {}",
16985 node.handle,
16986 node.name,
16987 node.kind,
16988 node.line,
16989 node.end_line,
16990 node.expand.source_window
16991 );
16992 }
16993 if node.is_none() && raw_nodes.len() > report.nodes.len() {
16994 println!();
16995 println!("Expand:");
16996 println!(" file: {}", report.expand.file);
16997 }
16998 }
16999
17000 Ok(())
17001}
17002
17003#[allow(clippy::too_many_arguments)]
17004fn cmd_source_read(
17005 file: &Path,
17006 path: &Path,
17007 style: SourceReadStyle,
17008 start: usize,
17009 lines: usize,
17010 end: Option<usize>,
17011 scope: Option<&str>,
17012 format: OutputFormat,
17013 absolute: bool,
17014 budget: ResponseBudget,
17015) -> Result<()> {
17016 if start == 0 {
17017 bail!("--start is 1-based and must be greater than zero");
17018 }
17019 if lines == 0 {
17020 bail!("--lines must be greater than zero");
17021 }
17022 if let Some(end) = end
17023 && end < start
17024 {
17025 bail!("--end must be greater than or equal to --start");
17026 }
17027
17028 let root = lint::resolve_project_root_or_canonical_path(path)?;
17029 let file_abs = resolve_source_file(&root, file)?;
17030 let file_display = if absolute {
17031 file_abs.to_string_lossy().to_string()
17032 } else {
17033 relativize_pathbuf(&file_abs, &root)
17034 .to_string_lossy()
17035 .to_string()
17036 };
17037
17038 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17039 let text = String::from_utf8_lossy(&source);
17040 let all_lines: Vec<&str> = text.lines().collect();
17041 let total_lines = all_lines.len();
17042 if total_lines > 0 && start > total_lines {
17043 bail!(
17044 "--start {} is beyond end of {} ({} lines)",
17045 start,
17046 file_display,
17047 total_lines
17048 );
17049 }
17050 let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
17051 let end_line = requested_end.min(total_lines);
17052 let mut warnings = Vec::new();
17053 let max_items = budget.preview_items();
17054 let max_bytes = budget.preview_bytes();
17055 if style == SourceReadStyle::Ast {
17056 let symbols = load_source_symbols(
17057 &root,
17058 &file_abs,
17059 &file_display,
17060 &source,
17061 scope,
17062 start,
17063 end_line,
17064 max_items,
17065 max_bytes,
17066 &mut warnings,
17067 );
17068 let summaries =
17069 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17070 let markdown = if is_markdown_path(&file_abs) {
17071 match source_read_markdown_projection(
17072 &root,
17073 &file_display,
17074 &source,
17075 start,
17076 end_line,
17077 budget,
17078 ) {
17079 Ok(markdown) => Some(markdown),
17080 Err(err) => {
17081 warnings.push(format!("markdown projection unavailable: {err:#}"));
17082 None
17083 }
17084 }
17085 } else {
17086 None
17087 };
17088 let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
17089 let report = SourceReadAstReport {
17090 handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
17091 root: root.to_string_lossy().to_string(),
17092 file: file_display.clone(),
17093 range: SourceRangePreview {
17094 start,
17095 end: end_line,
17096 total_lines,
17097 truncated_before: start > 1,
17098 truncated_after: end_line < total_lines,
17099 },
17100 symbols,
17101 summaries,
17102 markdown,
17103 expand: SourceReadAstExpandCommands {
17104 window: source_read_window_command(&root, &file_display, start, window_lines),
17105 file_window: source_read_window_command(
17106 &root,
17107 &file_display,
17108 1,
17109 total_lines.max(window_lines),
17110 ),
17111 markdown_ast: is_markdown_path(&file_abs)
17112 .then(|| markdown_ast_command(&root, &file_display, None)),
17113 },
17114 warnings,
17115 };
17116
17117 if format.json_output {
17118 let truncated = report.range.truncated_before
17119 || report.range.truncated_after
17120 || report.symbols.len() >= max_items
17121 || report.summaries.len() >= max_items;
17122 let follow_up = [
17123 Some(report.expand.window.clone()),
17124 Some(report.expand.file_window.clone()),
17125 report.expand.markdown_ast.clone(),
17126 ]
17127 .into_iter()
17128 .flatten()
17129 .collect::<Vec<_>>();
17130 print_json_or_envelope(
17131 &report,
17132 &format,
17133 "source-read",
17134 "ast",
17135 ToolEnvelopeSummary {
17136 text: format!(
17137 "source ast {}:{}-{}",
17138 report.file, report.range.start, report.range.end
17139 ),
17140 metrics: vec![
17141 envelope_metric("symbols", report.symbols.len()),
17142 envelope_metric("summaries", report.summaries.len()),
17143 envelope_metric(
17144 "markdown_nodes",
17145 report
17146 .markdown
17147 .as_ref()
17148 .map_or(0, |markdown| markdown.visible_nodes),
17149 ),
17150 ],
17151 },
17152 truncated,
17153 follow_up,
17154 )?;
17155 } else if format.compact {
17156 println!(
17157 "source-ast {}:{}-{} / {} handle:{}",
17158 report.file,
17159 report.range.start,
17160 report.range.end,
17161 report.range.total_lines,
17162 report.handle
17163 );
17164 for symbol in &report.symbols {
17165 println!(
17166 " {} {}:{} {}",
17167 symbol.name, symbol.file, symbol.line, symbol.expand
17168 );
17169 }
17170 if !report.summaries.is_empty() {
17171 println!("summaries[{}]", report.summaries.len());
17172 }
17173 for warning in &report.warnings {
17174 eprintln!("warning: {warning}");
17175 }
17176 } else {
17177 println!(
17178 "Source AST `{}` lines {}-{} of {} ({})",
17179 report.file,
17180 report.range.start,
17181 report.range.end,
17182 report.range.total_lines,
17183 report.handle
17184 );
17185 if !report.symbols.is_empty() {
17186 println!();
17187 println!("Symbol refs:");
17188 for symbol in &report.symbols {
17189 println!(
17190 " {} `{}` {}:{} — {}",
17191 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17192 );
17193 }
17194 }
17195 if !report.summaries.is_empty() {
17196 println!();
17197 println!("Summary refs:");
17198 for summary in &report.summaries {
17199 println!(
17200 " {} `{}` — {}",
17201 summary.handle, summary.symbol_name, summary.expand
17202 );
17203 }
17204 }
17205 println!();
17206 println!("Expand:");
17207 println!(" window: {}", report.expand.window);
17208 println!(" file window: {}", report.expand.file_window);
17209 if let Some(markdown_ast) = &report.expand.markdown_ast {
17210 println!(" markdown: {}", markdown_ast);
17211 }
17212 for warning in &report.warnings {
17213 eprintln!("warning: {warning}");
17214 }
17215 }
17216
17217 return Ok(());
17218 }
17219 let max_bytes = budget.preview_bytes();
17220 let token_cap = budget.body_token_cap();
17221 let (preview, preview_end, body_truncated) = if total_lines == 0 {
17222 (Vec::new(), end_line, false)
17223 } else {
17224 let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17225 (capped.preview, capped.capped_end, capped.was_capped)
17226 };
17227 let effective_end = if body_truncated { preview_end } else { end_line };
17228
17229 if body_truncated {
17230 warnings.push(format!(
17231 "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17232 ));
17233 }
17234 let symbols = load_source_symbols(
17235 &root,
17236 &file_abs,
17237 &file_display,
17238 &source,
17239 scope,
17240 start,
17241 effective_end,
17242 max_items,
17243 max_bytes,
17244 &mut warnings,
17245 );
17246 let summaries =
17247 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17248 let markdown = if is_markdown_path(&file_abs) {
17249 match source_read_markdown_projection(
17250 &root,
17251 &file_display,
17252 &source,
17253 start,
17254 effective_end,
17255 budget,
17256 ) {
17257 Ok(markdown) => Some(markdown),
17258 Err(err) => {
17259 warnings.push(format!("markdown projection unavailable: {err:#}"));
17260 None
17261 }
17262 }
17263 } else {
17264 None
17265 };
17266
17267 let expand = SourceExpandCommands {
17268 before: (start > 1).then(|| {
17269 let before_start = start.saturating_sub(lines).max(1);
17270 source_read_window_command(&root, &file_display, before_start, start - before_start)
17271 }),
17272 after: (effective_end < total_lines)
17273 .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17274 body: body_truncated.then(|| {
17275 let remaining = end_line.saturating_sub(effective_end);
17276 source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17277 }),
17278 file: source_read_ast_command(&root, &file_display),
17279 markdown_ast: is_markdown_path(&file_abs)
17280 .then(|| markdown_ast_command(&root, &file_display, None)),
17281 };
17282
17283 let report = SourceReadReport {
17284 handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17285 root: root.to_string_lossy().to_string(),
17286 file: file_display,
17287 range: SourceRangePreview {
17288 start,
17289 end: effective_end,
17290 total_lines,
17291 truncated_before: start > 1,
17292 truncated_after: effective_end < total_lines,
17293 },
17294 preview,
17295 symbols,
17296 summaries,
17297 markdown,
17298 expand,
17299 warnings,
17300 };
17301
17302 if format.json_output {
17303 let truncated = report.range.truncated_before || report.range.truncated_after;
17304 let follow_up = [
17305 report.expand.before.clone(),
17306 report.expand.after.clone(),
17307 report.expand.body.clone(),
17308 Some(report.expand.file.clone()),
17309 report.expand.markdown_ast.clone(),
17310 ]
17311 .into_iter()
17312 .flatten()
17313 .collect::<Vec<_>>();
17314 print_json_or_envelope(
17315 &report,
17316 &format,
17317 "source-read",
17318 "window",
17319 ToolEnvelopeSummary {
17320 text: format!(
17321 "source window {}:{}-{}",
17322 report.file, report.range.start, report.range.end
17323 ),
17324 metrics: vec![
17325 envelope_metric("lines", report.preview.len()),
17326 envelope_metric("symbols", report.symbols.len()),
17327 envelope_metric("summaries", report.summaries.len()),
17328 envelope_metric(
17329 "markdown_nodes",
17330 report
17331 .markdown
17332 .as_ref()
17333 .map_or(0, |markdown| markdown.visible_nodes),
17334 ),
17335 ],
17336 },
17337 truncated,
17338 follow_up,
17339 )?;
17340 } else if format.compact {
17341 println!(
17342 "source {}:{}-{} / {} handle:{}",
17343 report.file,
17344 report.range.start,
17345 report.range.end,
17346 report.range.total_lines,
17347 report.handle
17348 );
17349 for line in &report.preview {
17350 println!("{:>5} {}", line.line, line.text);
17351 }
17352 if !report.symbols.is_empty() {
17353 println!("syms[{}]:", report.symbols.len());
17354 for symbol in &report.symbols {
17355 println!(" {} {}:{}", symbol.name, symbol.file, symbol.line);
17356 }
17357 }
17358 if report.range.truncated_before || report.range.truncated_after {
17359 println!("expand: {}", report.expand.file);
17360 }
17361 } else {
17362 println!(
17363 "Source window `{}` lines {}-{} of {} ({})",
17364 report.file,
17365 report.range.start,
17366 report.range.end,
17367 report.range.total_lines,
17368 report.handle
17369 );
17370 for line in &report.preview {
17371 println!("{:>5} | {}", line.line, line.text);
17372 }
17373 if !report.symbols.is_empty() {
17374 println!();
17375 println!("Symbol refs:");
17376 for symbol in &report.symbols {
17377 println!(
17378 " {} `{}` {}:{} — {}",
17379 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17380 );
17381 }
17382 }
17383 if !report.summaries.is_empty() {
17384 println!();
17385 println!("Summary refs:");
17386 for summary in &report.summaries {
17387 println!(
17388 " {} `{}` — {}",
17389 summary.handle, summary.symbol_name, summary.expand
17390 );
17391 }
17392 }
17393 if report.range.truncated_before || report.range.truncated_after {
17394 println!();
17395 println!("Expand:");
17396 if let Some(before) = &report.expand.before {
17397 println!(" before: {}", before);
17398 }
17399 if let Some(after) = &report.expand.after {
17400 println!(" after: {}", after);
17401 }
17402 println!(" file: {}", report.expand.file);
17403 }
17404 for warning in &report.warnings {
17405 eprintln!("warning: {warning}");
17406 }
17407 }
17408
17409 Ok(())
17410}
17411
17412#[allow(clippy::too_many_arguments)]
17413fn cmd_symbol_read(
17414 symbol: &str,
17415 file_hint: Option<&Path>,
17416 path: &Path,
17417 scope: Option<&str>,
17418 format: OutputFormat,
17419 absolute: bool,
17420 budget: ResponseBudget,
17421) -> Result<()> {
17422 let root = lint::resolve_project_root_or_canonical_path(path)?;
17423 let hinted_file_abs = file_hint
17424 .map(|file| resolve_source_file(&root, file))
17425 .transpose()?;
17426 let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17427 let db_path = resolve_query_db_path(&root, path_hint, scope)?;
17428 if !db_path.exists() {
17429 bail!(
17430 "index refs unavailable: no index found at {}",
17431 db_path.display()
17432 );
17433 }
17434 let db = index::IndexDb::open_read_only_resilient(&db_path)
17435 .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17436 let search_limit = budget.follow_up_items().max(10);
17437 let hits = db
17438 .symbol_search(symbol, search_limit)
17439 .with_context(|| format!("searching symbols for {symbol:?}"))?;
17440 let selected = hits
17441 .into_iter()
17442 .find(|hit| {
17443 let Some(hinted_file_abs) = &hinted_file_abs else {
17444 return true;
17445 };
17446 resolve_source_file(&root, Path::new(&hit.file))
17447 .map(|hit_file| hit_file == *hinted_file_abs)
17448 .unwrap_or(false)
17449 })
17450 .with_context(|| {
17451 let hint = file_hint
17452 .map(|file| format!(" in {}", file.display()))
17453 .unwrap_or_default();
17454 format!("no indexed symbol matched {symbol:?}{hint}")
17455 })?;
17456
17457 let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17458 let file_display = if absolute {
17459 file_abs.to_string_lossy().to_string()
17460 } else {
17461 relativize_pathbuf(&file_abs, &root)
17462 .to_string_lossy()
17463 .to_string()
17464 };
17465 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17466 let content_hash = blake3::hash(&source).to_hex().to_string();
17467 let text = String::from_utf8_lossy(&source);
17468 let all_lines: Vec<&str> = text.lines().collect();
17469 let total_lines = all_lines.len();
17470 let file_symbols = db
17471 .symbols_for_file(&file_abs.to_string_lossy())
17472 .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17473 let max_items = budget.preview_items();
17474 let max_bytes = budget.preview_bytes();
17475 let selected_start = symbol_hit_line(&selected);
17476 let selected_end = symbol_hit_end_line(&selected)
17477 .unwrap_or(selected_start)
17478 .max(selected_start);
17479 let stored_target = file_symbols.iter().find(|candidate| {
17480 candidate.name == selected.name
17481 && candidate.kind == selected.kind
17482 && source_symbol_line(candidate) == selected_start
17483 });
17484 let target_span = stored_target
17485 .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17486 .or_else(|| symbol_hit_ast_span(&selected, &source));
17487 let target_start = target_span
17488 .as_ref()
17489 .map(|span| span.start_line)
17490 .unwrap_or(selected_start);
17491 let target_end = target_span
17492 .as_ref()
17493 .map(|span| span.end_line)
17494 .or_else(|| stored_target.and_then(source_symbol_end_line))
17495 .unwrap_or(selected_end)
17496 .max(target_start);
17497 let target_bounds = stored_target
17498 .and_then(stored_symbol_span_bounds)
17499 .or_else(|| symbol_hit_span_bounds(&selected));
17500 let target_end = stored_target
17501 .and_then(source_symbol_end_line)
17502 .unwrap_or(target_end)
17503 .max(target_start);
17504 let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17505 let line_capped_end = target_start
17506 .saturating_add(body_line_budget)
17507 .saturating_sub(1)
17508 .min(target_end)
17509 .min(total_lines.max(target_start));
17510 let token_cap = budget.body_token_cap();
17511 let (body, effective_preview_end, body_truncated) = if total_lines == 0 || target_start > total_lines {
17512 (Vec::new(), line_capped_end, false)
17513 } else {
17514 let capped = build_token_capped_preview(&all_lines, target_start, line_capped_end, max_bytes, token_cap);
17515 (capped.preview, capped.capped_end, capped.was_capped)
17516 };
17517 let preview_end = if body_truncated { effective_preview_end } else { line_capped_end };
17518 let child_symbols = file_symbols
17519 .iter()
17520 .filter(|candidate| {
17521 if let Some((target_start_byte, target_end_byte)) = target_bounds {
17522 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17523 else {
17524 return false;
17525 };
17526 return candidate_start >= target_start_byte
17527 && candidate_end <= target_end_byte
17528 && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17529 }
17530 let line = source_symbol_line(candidate);
17531 line > target_start && line <= target_end
17532 })
17533 .take(max_items)
17534 .map(|symbol| {
17535 let line = source_symbol_line(symbol);
17536 let end_line = source_symbol_end_line(symbol);
17537 SourceSymbolRef {
17538 handle: stable_handle(
17539 "ssym",
17540 &format!("{}:{}:{}", file_display, symbol.name, line),
17541 ),
17542 name: truncate_for_budget(&symbol.name, max_bytes),
17543 kind: symbol.kind.clone(),
17544 language: symbol.language.clone(),
17545 file: file_display.clone(),
17546 line,
17547 end_line,
17548 signature: symbol
17549 .signature
17550 .clone()
17551 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17552 span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
17553 expand: source_symbol_read_command(&root, &symbol.name, &file_display),
17554 }
17555 })
17556 .collect::<Vec<_>>();
17557 let mut warnings = Vec::new();
17558 if body_truncated {
17559 warnings.push(format!(
17560 "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
17561 ));
17562 }
17563 let summaries =
17564 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17565 let symbol_handle = stable_handle(
17566 "sread",
17567 &format!("{}:{}:{}", file_display, selected.name, target_start),
17568 );
17569 let source_lines = preview_end
17570 .saturating_sub(target_start)
17571 .saturating_add(1)
17572 .max(1);
17573 let expand = SymbolReadExpandCommands {
17574 source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
17575 body: body_truncated.then(|| {
17576 let remaining = target_end.saturating_sub(preview_end);
17577 source_read_window_command(&root, &file_display, preview_end + 1, remaining)
17578 }),
17579 file: source_read_ast_command(&root, &file_display),
17580 explain: source_symbol_expand_command(&root, &selected.name),
17581 callers: source_symbol_graph_command(&root, &selected.name, "callers"),
17582 callees: source_symbol_graph_command(&root, &selected.name, "callees"),
17583 markdown_ast: (selected.language == "markdown").then(|| {
17584 markdown_ast_command(
17585 &root,
17586 &file_display,
17587 target_span.as_ref().map(|span| span.handle.as_str()),
17588 )
17589 }),
17590 };
17591 let report = SymbolReadReport {
17592 handle: symbol_handle.clone(),
17593 root: root.to_string_lossy().to_string(),
17594 query: symbol.to_string(),
17595 symbol: SymbolReadTarget {
17596 handle: symbol_handle,
17597 name: selected.name.clone(),
17598 kind: selected.kind.clone(),
17599 language: selected.language.clone(),
17600 file: file_display.clone(),
17601 line: target_start,
17602 end_line: Some(target_end),
17603 signature: stored_target
17604 .and_then(|stored| stored.signature.clone())
17605 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17606 parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
17607 visibility: stored_target.and_then(|stored| stored.visibility.clone()),
17608 span: target_span,
17609 },
17610 range: SourceRangePreview {
17611 start: target_start,
17612 end: preview_end,
17613 total_lines,
17614 truncated_before: false,
17615 truncated_after: preview_end < target_end,
17616 },
17617 body,
17618 child_symbols,
17619 summaries,
17620 expand,
17621 warnings,
17622 };
17623
17624 if format.json_output {
17625 let truncated = report.range.truncated_after
17626 || report.body.iter().any(|line| line.text.len() >= max_bytes)
17627 || report.child_symbols.len() >= max_items;
17628 let follow_up = [
17629 Some(report.expand.source_window.clone()),
17630 report.expand.body.clone(),
17631 Some(report.expand.file.clone()),
17632 Some(report.expand.explain.clone()),
17633 Some(report.expand.callers.clone()),
17634 Some(report.expand.callees.clone()),
17635 ]
17636 .into_iter()
17637 .flatten()
17638 .chain(report.expand.markdown_ast.clone())
17639 .collect::<Vec<_>>();
17640 print_json_or_envelope(
17641 &report,
17642 &format,
17643 "symbol-read",
17644 "symbol",
17645 ToolEnvelopeSummary {
17646 text: format!(
17647 "symbol {} {}:{}-{}",
17648 report.symbol.name, report.symbol.file, report.range.start, report.range.end
17649 ),
17650 metrics: vec![
17651 envelope_metric("body_lines", report.body.len()),
17652 envelope_metric("child_symbols", report.child_symbols.len()),
17653 envelope_metric("summaries", report.summaries.len()),
17654 ],
17655 },
17656 truncated,
17657 follow_up,
17658 )?;
17659 } else if format.compact {
17660 println!(
17661 "symbol {} {}:{}-{} handle:{} hash:{}",
17662 report.symbol.name,
17663 report.symbol.file,
17664 report.range.start,
17665 report.range.end,
17666 report.handle,
17667 content_hash
17668 );
17669 for line in &report.body {
17670 println!("{:>5} {}", line.line, line.text);
17671 }
17672 if !report.child_symbols.is_empty() {
17673 println!("children[{}]:", report.child_symbols.len());
17674 for child in &report.child_symbols {
17675 println!(" {} {}:{}", child.name, child.file, child.line);
17676 }
17677 }
17678 } else {
17679 println!(
17680 "Symbol `{}` in `{}` lines {}-{} ({})",
17681 report.symbol.name,
17682 report.symbol.file,
17683 report.range.start,
17684 report.range.end,
17685 report.handle
17686 );
17687 for line in &report.body {
17688 println!("{:>5} | {}", line.line, line.text);
17689 }
17690 if !report.child_symbols.is_empty() {
17691 println!();
17692 println!("Child symbols:");
17693 for child in &report.child_symbols {
17694 println!(
17695 " {} `{}` {}:{} — {}",
17696 child.handle, child.name, child.file, child.line, child.expand
17697 );
17698 }
17699 }
17700 println!();
17701 println!("Expand:");
17702 println!(" source: {}", report.expand.source_window);
17703 println!(" file: {}", report.expand.file);
17704 println!(" explain: {}", report.expand.explain);
17705 println!(" callers: {}", report.expand.callers);
17706 println!(" callees: {}", report.expand.callees);
17707 for warning in &report.warnings {
17708 eprintln!("warning: {warning}");
17709 }
17710 }
17711
17712 Ok(())
17713}
17714
17715#[allow(clippy::too_many_arguments)]
17716#[derive(Serialize)]
17717struct ExplainBudgetDefinitionPreview {
17718 handle: String,
17719 #[serde(skip_serializing_if = "Option::is_none")]
17720 tag_alias: Option<String>,
17721 kind: String,
17722 name: String,
17723 file: String,
17724 line: i64,
17725 expand: String,
17726}
17727
17728#[derive(Serialize)]
17729struct ExplainBudgetEdgePreview {
17730 handle: String,
17731 #[serde(skip_serializing_if = "Option::is_none")]
17732 tag_alias: Option<String>,
17733 name: String,
17734 file: String,
17735 line: i64,
17736 expand: String,
17737}
17738
17739#[derive(Serialize)]
17740struct ExplainBudgetCommunityPreview {
17741 size: usize,
17742 members: Vec<String>,
17743}
17744
17745#[derive(Serialize)]
17746struct ExplainBudgetReport {
17747 symbol: String,
17748 max_items: usize,
17749 max_bytes: usize,
17750 definition_total: usize,
17751 callers_total: usize,
17752 callers_truncated_by_limit: bool,
17753 callees_total: usize,
17754 callees_truncated_by_limit: bool,
17755 truncated: bool,
17756 definitions: Vec<ExplainBudgetDefinitionPreview>,
17757 callers: Vec<ExplainBudgetEdgePreview>,
17758 callees: Vec<ExplainBudgetEdgePreview>,
17759 #[serde(skip_serializing_if = "Option::is_none")]
17760 community: Option<ExplainBudgetCommunityPreview>,
17761}
17762
17763#[allow(clippy::too_many_arguments)]
17764pub(crate) fn build_explain_budget_report(
17765 symbol: &str,
17766 _root: &Path,
17767 symbols: &[index::StoredSymbol],
17768 callers: &[index::StoredEdge],
17769 callers_total: usize,
17770 callers_truncated_by_limit: bool,
17771 callees: &[index::StoredEdge],
17772 callees_total: usize,
17773 callees_truncated_by_limit: bool,
17774 community: Option<&graph::Community>,
17775 budget: ResponseBudget,
17776) -> ExplainBudgetReport {
17777 let max_items = budget.preview_items();
17778 let max_bytes = budget.preview_bytes();
17779 let definitions = symbols
17780 .iter()
17781 .take(max_items)
17782 .map(|entry| {
17783 let symbol_ref = build_compact_symbol_ref(
17784 "edef",
17785 &format!(
17786 "{}:{}:{}:{}",
17787 entry.kind, entry.name, entry.file, entry.line
17788 ),
17789 &entry.name,
17790 entry.tags.as_deref(),
17791 max_bytes,
17792 );
17793 ExplainBudgetDefinitionPreview {
17794 handle: symbol_ref.handle,
17795 tag_alias: symbol_ref.tag_alias,
17796 kind: entry.kind.clone(),
17797 name: symbol_ref.name,
17798 file: truncate_for_budget(&entry.file, max_bytes),
17799 line: entry.line,
17800 expand: format!(
17801 "tsift search {} --exact --path {} --limit 20",
17802 shell_quote(&entry.name),
17803 shell_quote(&entry.file)
17804 ),
17805 }
17806 })
17807 .collect();
17808 let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
17809 .iter()
17810 .take(max_items)
17811 .map(|entry| {
17812 let symbol_ref = build_compact_symbol_ref(
17813 "ecall",
17814 &format!(
17815 "{}:{}:{}:{}",
17816 entry.caller_name, entry.caller_file, entry.call_site_line, symbol
17817 ),
17818 &entry.caller_name,
17819 None,
17820 max_bytes,
17821 );
17822 ExplainBudgetEdgePreview {
17823 handle: symbol_ref.handle,
17824 tag_alias: symbol_ref.tag_alias,
17825 name: symbol_ref.name,
17826 file: truncate_for_budget(&entry.caller_file, max_bytes),
17827 line: entry.call_site_line,
17828 expand: format!(
17829 "tsift explain {} --path {} --limit 0",
17830 shell_quote(&entry.caller_name),
17831 shell_quote(&entry.caller_file)
17832 ),
17833 }
17834 })
17835 .collect();
17836 let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
17837 .iter()
17838 .take(max_items)
17839 .map(|entry| {
17840 let symbol_ref = build_compact_symbol_ref(
17841 "eces",
17842 &format!(
17843 "{}:{}:{}:{}",
17844 entry.callee_name, entry.caller_file, entry.call_site_line, symbol
17845 ),
17846 &entry.callee_name,
17847 None,
17848 max_bytes,
17849 );
17850 ExplainBudgetEdgePreview {
17851 handle: symbol_ref.handle,
17852 tag_alias: symbol_ref.tag_alias,
17853 name: symbol_ref.name,
17854 file: truncate_for_budget(&entry.caller_file, max_bytes),
17855 line: entry.call_site_line,
17856 expand: format!(
17857 "tsift explain {} --path {} --limit 0",
17858 shell_quote(&entry.callee_name),
17859 shell_quote(&entry.caller_file)
17860 ),
17861 }
17862 })
17863 .collect();
17864 let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
17865 size: entry.members.len(),
17866 members: entry
17867 .members
17868 .iter()
17869 .take(max_items)
17870 .map(|member| truncate_for_budget(&member.name, max_bytes))
17871 .collect(),
17872 });
17873
17874 ExplainBudgetReport {
17875 symbol: symbol.to_string(),
17876 max_items,
17877 max_bytes,
17878 definition_total: symbols.len(),
17879 callers_total,
17880 callers_truncated_by_limit,
17881 callees_total,
17882 callees_truncated_by_limit,
17883 truncated: symbols.len() > max_items
17884 || callers_total > callers_preview.len()
17885 || callees_total > callees_preview.len()
17886 || community
17887 .map(|entry| entry.members.len() > max_items)
17888 .unwrap_or(false),
17889 definitions,
17890 callers: callers_preview,
17891 callees: callees_preview,
17892 community: community_preview,
17893 }
17894}
17895
17896pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
17897 println!(
17898 "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
17899 shell_quote(&report.symbol),
17900 report.definitions.len(),
17901 report.definition_total,
17902 report.callers.len(),
17903 report.callers_total,
17904 report.callees.len(),
17905 report.callees_total
17906 );
17907 for entry in &report.definitions {
17908 println!(
17909 "def {} {} {}:{} expand:{}",
17910 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17911 entry.kind,
17912 entry.file,
17913 entry.line,
17914 entry.expand
17915 );
17916 }
17917 for entry in &report.callers {
17918 println!(
17919 "caller {} {}:{} expand:{}",
17920 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17921 entry.file,
17922 entry.line,
17923 entry.expand
17924 );
17925 }
17926 for entry in &report.callees {
17927 println!(
17928 "callee {} {}:{} expand:{}",
17929 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17930 entry.file,
17931 entry.line,
17932 entry.expand
17933 );
17934 }
17935 if let Some(community) = &report.community {
17936 println!(
17937 "community size:{} members:{}",
17938 community.size,
17939 community.members.join(", ")
17940 );
17941 }
17942 if report.truncated {
17943 println!(
17944 "budget truncated items:{} bytes:{}",
17945 report.max_items, report.max_bytes
17946 );
17947 }
17948}
17949
17950const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
17960 ".git",
17961 "node_modules",
17962 "target",
17963 "__pycache__",
17964 ".venv",
17965 "vendor",
17966];
17967
17968const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
17969 "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
17970 "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
17971 "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
17972 "sh", "bash", "zsh", "sql", "css", "tsx",
17973];
17974
17975pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
17976 let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
17977 .iter()
17978 .map(|ext| (*ext).to_string())
17979 .collect::<BTreeSet<_>>();
17980
17981 let config_path = root.join(".naming.toml");
17982 if !config_path.exists() {
17983 return extensions;
17984 }
17985
17986 match tagpath::config::resolve(&config_path) {
17987 Ok(config) => {
17988 if let Some(grammars) = config.grammars {
17989 for grammar in grammars.languages.values() {
17990 for ext in &grammar.extensions {
17991 if let Some(normalized) = normalize_extension(ext) {
17992 extensions.insert(normalized);
17993 }
17994 }
17995 }
17996 }
17997 }
17998 Err(err) => {
17999 eprintln!("tagpath_policy_hint_config_unreadable: {err}");
18000 }
18001 }
18002 extensions
18003}
18004
18005pub(crate) fn tagpath_audit_policy_hints(
18006 rel_path: &str,
18007 supported_extensions: &BTreeSet<String>,
18008) -> Vec<String> {
18009 let path = Path::new(rel_path);
18010 let mut hints = BTreeSet::new();
18011 if let Some(parent) = path.parent() {
18012 for component in parent.components() {
18013 if let std::path::Component::Normal(name) = component {
18014 let name = name.to_string_lossy();
18015 if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
18016 hints.insert(format!("skip_dir:{name}"));
18017 }
18018 }
18019 }
18020 }
18021 if path
18022 .extension()
18023 .and_then(|ext| ext.to_str())
18024 .and_then(normalize_extension)
18025 .is_some_and(|ext| !supported_extensions.contains(&ext))
18026 {
18027 hints.insert("extension_unsupported".to_string());
18028 }
18029 hints.into_iter().collect()
18030}
18031
18032fn normalize_extension(ext: &str) -> Option<String> {
18033 let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
18034 if normalized.is_empty() {
18035 None
18036 } else {
18037 Some(normalized)
18038 }
18039}
18040
18041pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
18042 match status {
18043 diff_digest::DiffDigestFileStatus::Added => "added",
18044 diff_digest::DiffDigestFileStatus::Modified => "modified",
18045 diff_digest::DiffDigestFileStatus::Deleted => "deleted",
18046 }
18047}
18048
18049pub(crate) fn diff_digest_summary_label(
18050 state: diff_digest::DiffDigestSummaryState,
18051) -> &'static str {
18052 match state {
18053 diff_digest::DiffDigestSummaryState::Current => "current",
18054 diff_digest::DiffDigestSummaryState::Stale => "stale",
18055 diff_digest::DiffDigestSummaryState::Missing => "missing",
18056 diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
18057 }
18058}
18059
18060fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
18061 match state {
18062 test_digest::TestDigestSummaryState::Current => "current",
18063 test_digest::TestDigestSummaryState::Stale => "stale",
18064 test_digest::TestDigestSummaryState::Missing => "missing",
18065 test_digest::TestDigestSummaryState::Unavailable => "unavailable",
18066 }
18067}
18068
18069fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
18070 match state {
18071 log_digest::LogDigestSummaryState::Current => "current",
18072 log_digest::LogDigestSummaryState::Stale => "stale",
18073 log_digest::LogDigestSummaryState::Missing => "missing",
18074 log_digest::LogDigestSummaryState::Unavailable => "unavailable",
18075 }
18076}
18077
18078pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
18079 match mode {
18080 diff_digest::DiffDigestMode::WorkingTree => "worktree",
18081 diff_digest::DiffDigestMode::Cached => "cached",
18082 diff_digest::DiffDigestMode::Revision => "revision",
18083 }
18084}
18085
18086pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
18087 match (&report.mode, &report.revision) {
18088 (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
18089 (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
18090 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18091 format!("revision {revision}")
18092 }
18093 (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
18094 }
18095}
18096
18097pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
18098 match (&report.mode, &report.revision) {
18099 (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
18100 (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
18101 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18102 format!("No diff found for revision {revision}.")
18103 }
18104 (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
18105 }
18106}
18107
18108fn cmd_impact(
18109 path: &Path,
18110 cached: bool,
18111 revision: Option<&str>,
18112 scope: Option<&str>,
18113 limit: usize,
18114 format: OutputFormat,
18115) -> Result<()> {
18116 let report = impact::compute(
18117 path,
18118 impact::ImpactOptions {
18119 cached,
18120 revision,
18121 scope,
18122 limit,
18123 },
18124 )?;
18125 if format.json_output {
18126 println!(
18127 "{}",
18128 to_json_schema(
18129 &report,
18130 format.pretty,
18131 format.terse,
18132 format.ultra_terse,
18133 format.schema
18134 )?
18135 );
18136 return Ok(());
18137 }
18138
18139 if format.compact {
18140 println!(
18141 "impact mode:{} changed:{} symbols:{} tests:{}/{}",
18142 diff_digest_mode_label(report.mode),
18143 report.changed_files.len(),
18144 report.changed_symbols.len(),
18145 report.affected_tests.len(),
18146 report.affected_tests_total
18147 );
18148 for target in &report.affected_tests {
18149 println!(
18150 "{} reasons:{} command:{}",
18151 target.path,
18152 target.reasons.len(),
18153 target.commands.join(" && ")
18154 );
18155 }
18156 for warning in &report.warnings {
18157 println!("warning {warning}");
18158 }
18159 return Ok(());
18160 }
18161
18162 println!("Impact ({})", diff_digest_mode_label(report.mode));
18163 println!(" changed files: {}", report.changed_files.len());
18164 println!(" changed symbols: {}", report.changed_symbols.len());
18165 println!(
18166 " affected tests: {}/{}",
18167 report.affected_tests.len(),
18168 report.affected_tests_total
18169 );
18170 for target in &report.affected_tests {
18171 println!();
18172 println!("{}", target.path);
18173 for reason in &target.reasons {
18174 println!(" - {reason}");
18175 }
18176 if !target.symbols.is_empty() {
18177 println!(" symbols: {}", target.symbols.join(", "));
18178 }
18179 for command in &target.commands {
18180 println!(" run: {}", command);
18181 }
18182 }
18183 for warning in &report.warnings {
18184 println!("warning: {warning}");
18185 }
18186 Ok(())
18187}
18188
18189pub(crate) fn render_test_digest_from_input(
18190 path: &Path,
18191 input: &str,
18192 runner: Option<&str>,
18193 format: OutputFormat,
18194) -> Result<()> {
18195 let report = test_digest::compute(path, input, runner)?;
18196 if format.json_output {
18197 println!(
18198 "{}",
18199 to_json_schema(
18200 &report,
18201 format.pretty,
18202 format.terse,
18203 format.ultra_terse,
18204 format.schema
18205 )?
18206 );
18207 return Ok(());
18208 }
18209
18210 if report.failure_groups.is_empty() {
18211 println!("No failures detected (runner: {}).", report.runner);
18212 for warning in &report.warnings {
18213 println!("warning: {warning}");
18214 }
18215 return Ok(());
18216 }
18217
18218 if format.compact {
18219 println!(
18220 "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18221 report.runner,
18222 report.failures,
18223 report.grouped_failures,
18224 report.counts.passed.unwrap_or(0),
18225 report.counts.failed.unwrap_or(report.grouped_failures),
18226 report.counts.skipped.unwrap_or(0),
18227 );
18228 for failure in &report.failure_groups {
18229 let tests = truncate_for_compact(&failure.tests.join(","), 60);
18230 let location = match (&failure.path, failure.line) {
18231 (Some(path), Some(line)) => format!("{path}:{line}"),
18232 (Some(path), None) => path.clone(),
18233 _ => "-".to_string(),
18234 };
18235 println!(
18236 "{} tests:{} count:{} summaries:{} msg:{}",
18237 location,
18238 tests,
18239 failure.occurrences,
18240 test_digest_summary_label(failure.summary_state),
18241 truncate_for_compact(&failure.message, 80)
18242 );
18243 }
18244 for warning in &report.warnings {
18245 println!("warning: {warning}");
18246 }
18247 return Ok(());
18248 }
18249
18250 println!("Test digest ({})", report.runner);
18251 println!(" failures: {}", report.failures);
18252 println!(" failure groups: {}", report.grouped_failures);
18253 if let Some(passed) = report.counts.passed {
18254 println!(" passed: {}", passed);
18255 }
18256 if let Some(failed) = report.counts.failed {
18257 println!(" failed: {}", failed);
18258 }
18259 if let Some(skipped) = report.counts.skipped {
18260 println!(" skipped: {}", skipped);
18261 }
18262
18263 for failure in &report.failure_groups {
18264 println!();
18265 match (&failure.path, failure.line, failure.column) {
18266 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18267 (Some(path), Some(line), None) => println!("{path}:{line}"),
18268 (Some(path), None, _) => println!("{path}"),
18269 (None, _, _) => println!("(no file anchor)"),
18270 }
18271 println!(" tests: {}", failure.tests.join(", "));
18272 println!(" occurrences: {}", failure.occurrences);
18273 println!(" message: {}", failure.message);
18274 println!(
18275 " cached summaries: {}",
18276 test_digest_summary_label(failure.summary_state)
18277 );
18278 for summary in &failure.current_summaries {
18279 println!(
18280 " - {}: {}",
18281 summary.symbol,
18282 truncate_for_compact(&summary.summary, 160)
18283 );
18284 }
18285 }
18286 for warning in &report.warnings {
18287 println!("warning: {warning}");
18288 }
18289 Ok(())
18290}
18291
18292#[derive(Clone, Serialize, Deserialize)]
18293struct DispatchTraceSummary {
18294 backlog: usize,
18295 job_packet: usize,
18296 worker_result: usize,
18297 worker_context: usize,
18298 source_handle: usize,
18299 semantic_rows: usize,
18300}
18301
18302#[derive(Clone, Serialize, Deserialize)]
18303struct DispatchTraceReport {
18304 contract_version: String,
18305 root: String,
18306 #[serde(skip_serializing_if = "Option::is_none")]
18307 scope: Option<String>,
18308 targets: Vec<String>,
18309 projection_freshness: GraphDbFreshnessReport,
18310 projection_hashes: Vec<String>,
18311 evidence_packet_ids: Vec<String>,
18312 shared_preparation: ConflictMatrixSharedPreparationSummary,
18313 worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18314 worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18315 summary: DispatchTraceSummary,
18316 nodes: Vec<SubstrateTerseGraphNode>,
18317 edges: Vec<SubstrateTerseGraphEdge>,
18318 conflict_matrix_decisions: Vec<String>,
18319 replay_commands: Vec<String>,
18320 repair_commands: Vec<String>,
18321 truncated: bool,
18322 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18323 warnings: Vec<String>,
18324}
18325
18326fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18327 matches!(
18328 kind,
18329 "session"
18330 | "backlog"
18331 | "job_packet"
18332 | "worker_result"
18333 | "worker_context"
18334 | "source_handle"
18335 | "semantic_concept"
18336 | "semantic_entity"
18337 | "file"
18338 | "symbol"
18339 | "route"
18340 )
18341}
18342
18343fn dispatch_trace_kind_rank(kind: &str) -> usize {
18344 match kind {
18345 "backlog" => 0,
18346 "job_packet" => 1,
18347 "worker_result" => 2,
18348 "worker_context" => 3,
18349 "source_handle" => 4,
18350 "file" => 5,
18351 "symbol" => 6,
18352 "route" => 7,
18353 "semantic_concept" => 8,
18354 "semantic_entity" => 9,
18355 "session" => 10,
18356 _ => 99,
18357 }
18358}
18359
18360fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18361 DispatchTraceSummary {
18362 backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18363 job_packet: nodes
18364 .iter()
18365 .filter(|node| node.kind == "job_packet")
18366 .count(),
18367 worker_result: nodes
18368 .iter()
18369 .filter(|node| node.kind == "worker_result")
18370 .count(),
18371 worker_context: nodes
18372 .iter()
18373 .filter(|node| node.kind == "worker_context")
18374 .count(),
18375 source_handle: nodes
18376 .iter()
18377 .filter(|node| node.kind == "source_handle")
18378 .count(),
18379 semantic_rows: nodes
18380 .iter()
18381 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18382 .count(),
18383 }
18384}
18385
18386fn dispatch_trace_shared_preparation_summary(
18387 graph_nodes: &[SubstrateGraphNode],
18388 graph_edges: &[SubstrateGraphEdge],
18389 conflict: &ConflictMatrixReport,
18390) -> ConflictMatrixSharedPreparationSummary {
18391 ConflictMatrixSharedPreparationSummary {
18392 evidence_cache_status: conflict
18393 .inputs
18394 .shared_preparation
18395 .evidence_cache_status
18396 .clone(),
18397 graph_nodes: graph_nodes.len(),
18398 graph_edges: graph_edges.len(),
18399 evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18400 source_handles: conflict
18401 .candidates
18402 .iter()
18403 .map(|candidate| candidate.source_handles.len())
18404 .sum(),
18405 worker_context: conflict
18406 .candidates
18407 .iter()
18408 .map(|candidate| candidate.worker_context_handles.len())
18409 .sum(),
18410 worker_results: conflict
18411 .candidates
18412 .iter()
18413 .map(|candidate| candidate.worker_feedback.total)
18414 .sum(),
18415 semantic_rows: conflict
18416 .candidates
18417 .iter()
18418 .map(|candidate| candidate.semantic_related.len())
18419 .sum(),
18420 dispatch_trace_snapshot_nodes: graph_nodes.len(),
18421 dispatch_trace_snapshot_edges: graph_edges.len(),
18422 }
18423}
18424
18425fn dispatch_trace_collect_ids(
18426 targets: &[String],
18427 candidates: &[ConflictMatrixCandidate],
18428 graph_nodes: &[SubstrateGraphNode],
18429 graph_edges: &[SubstrateGraphEdge],
18430 depth: usize,
18431 limit: usize,
18432) -> (BTreeSet<String>, bool) {
18433 let target_refs = targets
18434 .iter()
18435 .map(|target| target.trim_start_matches('#').to_string())
18436 .collect::<BTreeSet<_>>();
18437 let mut ids = BTreeSet::new();
18438 for candidate in candidates {
18439 ids.insert(candidate.target_node_id.clone());
18440 for source in &candidate.source_handles {
18441 ids.insert(source.handle.clone());
18442 }
18443 for handle in &candidate.worker_context_handles {
18444 ids.insert(handle.clone());
18445 }
18446 for semantic in &candidate.semantic_related {
18447 ids.insert(semantic.handle.clone());
18448 }
18449 }
18450 for node in graph_nodes {
18451 if !dispatch_trace_allowed_node_kind(&node.kind) {
18452 continue;
18453 }
18454 if node
18455 .properties
18456 .get("ref_id")
18457 .is_some_and(|ref_id| target_refs.contains(ref_id))
18458 {
18459 ids.insert(node.id.clone());
18460 }
18461 }
18462
18463 let node_by_id = graph_nodes
18464 .iter()
18465 .map(|node| (node.id.as_str(), node))
18466 .collect::<BTreeMap<_, _>>();
18467 let max_nodes = if limit == 0 {
18468 usize::MAX
18469 } else {
18470 limit
18471 .saturating_mul(targets.len().max(1))
18472 .saturating_mul(12)
18473 .max(64)
18474 };
18475 let mut truncated = false;
18476 for _ in 0..depth.max(1) {
18477 let before = ids.len();
18478 let current_ids = ids.clone();
18479 for edge in graph_edges {
18480 if ids.len() >= max_nodes {
18481 truncated = true;
18482 break;
18483 }
18484 let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18485 if !touches {
18486 continue;
18487 }
18488 for endpoint in [&edge.from_id, &edge.to_id] {
18489 let Some(node) = node_by_id.get(endpoint.as_str()) else {
18490 continue;
18491 };
18492 if dispatch_trace_allowed_node_kind(&node.kind) {
18493 ids.insert(endpoint.clone());
18494 }
18495 }
18496 }
18497 if ids.len() == before || truncated {
18498 break;
18499 }
18500 }
18501 (ids, truncated)
18502}
18503
18504#[allow(clippy::too_many_arguments)]
18505fn build_dispatch_trace_report_from_conflict_snapshot(
18506 root: &Path,
18507 scope: Option<&str>,
18508 conflict: ConflictMatrixReport,
18509 graph_nodes: Vec<SubstrateGraphNode>,
18510 graph_edges: Vec<SubstrateGraphEdge>,
18511 depth: usize,
18512 limit: usize,
18513 extra_warnings: Vec<String>,
18514) -> Result<DispatchTraceReport> {
18515 let shared_preparation =
18516 dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18517 let (ids, truncated) = dispatch_trace_collect_ids(
18518 &conflict.targets,
18519 &conflict.candidates,
18520 &graph_nodes,
18521 &graph_edges,
18522 depth,
18523 limit,
18524 );
18525 let mut nodes = graph_nodes
18526 .into_iter()
18527 .filter(|node| ids.contains(&node.id))
18528 .collect::<Vec<_>>();
18529 nodes.sort_by(|left, right| {
18530 dispatch_trace_kind_rank(&left.kind)
18531 .cmp(&dispatch_trace_kind_rank(&right.kind))
18532 .then(left.id.cmp(&right.id))
18533 });
18534 let node_ids = nodes
18535 .iter()
18536 .map(|node| node.id.as_str())
18537 .collect::<BTreeSet<_>>();
18538 let mut edges = graph_edges
18539 .into_iter()
18540 .filter(|edge| {
18541 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18542 })
18543 .collect::<Vec<_>>();
18544 edges.sort_by(|left, right| {
18545 left.from_id
18546 .cmp(&right.from_id)
18547 .then(left.kind.cmp(&right.kind))
18548 .then(left.to_id.cmp(&right.to_id))
18549 });
18550 let mut warnings = conflict.warnings;
18551 warnings.extend(extra_warnings);
18552
18553 Ok(DispatchTraceReport {
18554 contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
18555 root: conflict.root,
18556 scope: conflict.scope,
18557 targets: conflict.targets,
18558 projection_freshness: conflict.orchestration.projection_freshness,
18559 projection_hashes: conflict.orchestration.projection_hashes,
18560 evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
18561 shared_preparation,
18562 worker_prompt_packets: conflict.worker_prompt_packets,
18563 worker_feedback: conflict
18564 .candidates
18565 .iter()
18566 .map(|candidate| candidate.worker_feedback.clone())
18567 .collect(),
18568 summary: dispatch_trace_summary(&nodes),
18569 nodes: nodes.into_iter().map(Into::into).collect(),
18570 edges: edges.into_iter().map(Into::into).collect(),
18571 conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
18572 replay_commands: conflict.next_commands,
18573 repair_commands: graph_db_repair_commands(root, scope),
18574 truncated,
18575 warnings,
18576 })
18577}
18578
18579fn build_dispatch_trace_report(
18580 path: &Path,
18581 scope: Option<&str>,
18582 raw_targets: &[String],
18583 depth: usize,
18584 limit: usize,
18585 impact_limit: usize,
18586) -> Result<DispatchTraceReport> {
18587 let root = lint::resolve_project_root_or_canonical_path(path)?;
18588 let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
18589 if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
18590 write_traversal_graph_store(&root, path, scope)
18591 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
18592 }
18593 let graph_db = graph_substrate_db_path(&root, scope);
18594 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
18595 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
18596 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
18597 let extra_warnings = store
18598 .read_only_recovery()
18599 .map(graph_db_read_recovery_diagnostic)
18600 .into_iter()
18601 .collect::<Vec<_>>();
18602 let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
18603 let graph_prepared = prepare_conflict_matrix_graph_orchestration(
18604 &root,
18605 scope,
18606 "sqlite",
18607 raw_targets,
18608 &prepared,
18609 depth,
18610 limit,
18611 &store,
18612 freshness.clone(),
18613 )?;
18614 let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
18615 &prepared.preparation_cache.source_watermark,
18616 &prepared.preparation_cache.document_watermark,
18617 &prepared.preparation_cache.staged_diff_watermark,
18618 &[
18619 &format!("targets:{}", raw_targets.join(",")),
18620 &format!("depth:{depth}"),
18621 &format!("limit:{limit}"),
18622 ],
18623 );
18624 if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
18625 &root,
18626 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18627 &dt_cache_key,
18628 ) {
18629 return Ok(cached_report);
18630 }
18631 let conflict = build_conflict_matrix_report_from_prepared_graph(
18632 &root,
18633 path,
18634 scope,
18635 depth,
18636 limit,
18637 impact_limit,
18638 freshness,
18639 extra_warnings.clone(),
18640 &prepared,
18641 &graph_prepared,
18642 )?;
18643 let report = build_dispatch_trace_report_from_conflict_snapshot(
18644 &root,
18645 scope,
18646 conflict,
18647 graph_prepared.graph.nodes,
18648 graph_prepared.graph.edges,
18649 depth,
18650 limit,
18651 extra_warnings,
18652 )?;
18653 cycle_packet_cache::cycle_packet_write_cache(
18654 &root,
18655 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18656 &dt_cache_key,
18657 &report,
18658 );
18659 Ok(report)
18660}
18661
18662fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
18663 let json = serde_json::to_string(report)?.replace("</", "<\\/");
18664 let mut html = String::new();
18665 html.push_str(
18666 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
18667 );
18668 html.push_str(
18669 r#"<style>
18670:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
18671@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
18672*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif;line-height:1.4}.page{max-width:1280px;margin:0 auto;padding:20px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:14px}.top h1{font-size:22px;margin:0}.meta{color:var(--muted);font-size:13px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:14px}.panel,.side{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.side{padding:14px;overflow:auto;max-height:720px}.side h2{font-size:15px;margin:12px 0 8px}.side h2:first-child{margin-top:0}.list{display:grid;gap:8px}.row{border:1px solid var(--line);border-radius:6px;padding:8px}.kind{font-size:11px;text-transform:uppercase;color:var(--muted);letter-spacing:.04em}.label{font-weight:650;overflow-wrap:anywhere}.handle,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);overflow-wrap:anywhere}svg{width:100%;height:680px;display:block}.edge{stroke:var(--edge);stroke-width:1.4;opacity:.72}.node{stroke:var(--panel);stroke-width:2}.node-label{font-size:12px;paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round;fill:var(--text)}@media(max-width:900px){.top{display:block}.layout{grid-template-columns:1fr}.side{max-height:none}svg{height:560px}}
18673</style>"#,
18674 );
18675 html.push_str("</head><body><div class=\"page\">");
18676 html.push_str(&format!(
18677 "<header class=\"top\"><div><h1>tsift dispatch trace</h1><div class=\"meta\">targets <code>{}</code> | evidence <code>{}</code> | nodes <code>{}</code> | worker_prompt_packets <code>{}</code></div></div><div class=\"meta\"><code>{}</code></div></header>",
18678 html_escape(&report.targets.join(", ")),
18679 report.evidence_packet_ids.len(),
18680 report.nodes.len(),
18681 report.worker_prompt_packets.len(),
18682 html_escape(&report.contract_version)
18683 ));
18684 html.push_str(
18685 r#"<main class="layout"><section class="panel"><svg id="graph-canvas" role="img" aria-label="Dispatch trace graph"></svg></section><aside class="side"><h2>Worker Prompt Packets</h2><div id="packets" class="list"></div><h2>Worker Feedback</h2><div id="feedback" class="list"></div><h2>Nodes</h2><div id="nodes" class="list"></div></aside></main>"#,
18686 );
18687 html.push_str("<script id=\"trace-data\" type=\"application/json\">");
18688 html.push_str(&json);
18689 html.push_str(
18690 r##"</script><script>
18691const report = JSON.parse(document.getElementById("trace-data").textContent);
18692const svg = document.getElementById("graph-canvas");
18693const nodeList = document.getElementById("nodes");
18694const packets = document.getElementById("packets");
18695const feedback = document.getElementById("feedback");
18696const nodes = report.nodes.map((node, index) => ({...node, index}));
18697const nodeById = new Map(nodes.map(node => [node.id, node]));
18698const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
18699const colorByKind = new Map([["backlog","#dc2626"],["job_packet","#ea580c"],["worker_result","#15803d"],["worker_context","#475569"],["source_handle","#64748b"],["semantic_concept","#9a3412"],["semantic_entity","#b45309"],["file","#2563eb"],["symbol","#16a34a"],["route","#7c3aed"],["session","#0891b2"]]);
18700function color(kind){return colorByKind.get(kind)||"#6b7280";}
18701function text(value){return value == null ? "" : String(value);}
18702function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));}
18703function layout(){
18704 const rect = svg.getBoundingClientRect();
18705 const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
18706 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
18707 const counts = new Map();
18708 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
18709 const offsets = new Map();
18710 for (const node of nodes) {
18711 const group = kinds.indexOf(node.kind);
18712 const index = offsets.get(node.kind) || 0;
18713 offsets.set(node.kind, index + 1);
18714 const total = counts.get(node.kind) || 1;
18715 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
18716 const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
18717 node.x = cx + Math.cos(angle) * ring;
18718 node.y = cy + Math.sin(angle) * ring;
18719 }
18720}
18721function draw(){
18722 svg.innerHTML = "";
18723 for (const edge of edges) {
18724 const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
18725 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
18726 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
18727 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
18728 line.setAttribute("class", "edge");
18729 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
18730 svg.appendChild(line);
18731 }
18732 for (const node of nodes) {
18733 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
18734 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
18735 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
18736 circle.setAttribute("fill", color(node.kind));
18737 circle.setAttribute("class", "node");
18738 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
18739 svg.appendChild(circle);
18740 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
18741 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
18742 label.setAttribute("class", "node-label");
18743 label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
18744 svg.appendChild(label);
18745 }
18746}
18747packets.innerHTML = report.worker_prompt_packets.map(packet => `<div class="row"><div class="kind">${escapeHtml(packet.contract_version)} - ${escapeHtml(packet.risk)} - parallel_safe ${packet.parallel_safe ? "true" : "false"} - closure ${packet.worker_feedback ? packet.worker_feedback.closure_rank_score : 0}</div><div class="label">${escapeHtml(packet.title)}</div><div class="handle">${escapeHtml(packet.packet_id)}</div><div class="handle">blocks ${escapeHtml((packet.blocks||[]).join(", ") || "none")} | blocked_by ${escapeHtml((packet.blocked_by||[]).join(", ") || "none")}</div></div>`).join("") || "<div class=\"meta\">No packets.</div>";
18748feedback.innerHTML = report.worker_feedback.map(item => `<div class="row"><div class="kind">completed ${item.completed} - blocked ${item.blocked} - closure ${item.closure_rank_score}</div><div>files ${escapeHtml((item.touched_files||[]).join(", ") || "none")}</div><div>tests ${escapeHtml((item.expected_tests||[]).join(" && ") || "none")}</div>${item.repeated_blockage ? "<div class=\"label\">Repeated blockage</div>" : ""}${(item.stale_expected_tests||[]).length ? `<div class="label">Stale tests: ${escapeHtml(item.stale_expected_tests.join(", "))}</div>` : ""}${(item.follow_up_debt||[]).length ? `<div class="label">Follow-up debt: ${escapeHtml(item.follow_up_debt.join(", "))}</div>` : ""}</div>`).join("") || "<div class=\"meta\">No worker results.</div>";
18749nodeList.innerHTML = nodes.map(node => `<div class="row"><div class="kind">${escapeHtml(node.kind)}</div><div class="label">${escapeHtml(node.label)}</div><div class="handle">${escapeHtml(node.id)}</div></div>`).join("");
18750window.addEventListener("resize", () => { layout(); draw(); });
18751layout(); draw();
18752</script></div></body></html>"##,
18753 );
18754 Ok(html)
18755}
18756
18757struct DispatchTraceOptions<'a> {
18758 path: &'a Path,
18759 scope: Option<&'a str>,
18760 raw_targets: &'a [String],
18761 depth: usize,
18762 limit: usize,
18763 impact_limit: usize,
18764 trace_format: DispatchTraceFormat,
18765}
18766
18767fn cmd_dispatch_trace(
18768 options: DispatchTraceOptions<'_>,
18769 output_format: OutputFormat,
18770) -> Result<()> {
18771 let report = build_dispatch_trace_report(
18772 options.path,
18773 options.scope,
18774 options.raw_targets,
18775 options.depth,
18776 options.limit,
18777 options.impact_limit,
18778 )?;
18779 match options.trace_format {
18780 DispatchTraceFormat::Json => {
18781 if output_format.envelope {
18782 print_json_or_envelope(
18783 &report,
18784 &output_format,
18785 "dispatch-trace",
18786 "operator-review",
18787 ToolEnvelopeSummary {
18788 text: format!(
18789 "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
18790 report.targets.len(),
18791 report.nodes.len(),
18792 report.worker_prompt_packets.len()
18793 ),
18794 metrics: vec![
18795 envelope_metric("targets", report.targets.len()),
18796 envelope_metric("nodes", report.nodes.len()),
18797 envelope_metric("edges", report.edges.len()),
18798 envelope_metric(
18799 "worker_prompt_packets",
18800 report.worker_prompt_packets.len(),
18801 ),
18802 ],
18803 },
18804 report.truncated,
18805 report.replay_commands.clone(),
18806 )
18807 } else {
18808 println!(
18809 "{}",
18810 to_json_schema(
18811 &report,
18812 output_format.pretty,
18813 output_format.terse,
18814 output_format.ultra_terse,
18815 output_format.schema
18816 )?
18817 );
18818 Ok(())
18819 }
18820 }
18821 DispatchTraceFormat::Html => {
18822 println!("{}", dispatch_trace_html(&report)?);
18823 Ok(())
18824 }
18825 }
18826}
18827
18828#[derive(Clone, Debug)]
18829struct DependencyDagProfile {
18830 id: String,
18831 graph_node_id: String,
18832 label: String,
18833 path: Option<String>,
18834 line: Option<i64>,
18835 detail: Option<String>,
18836 source_files: BTreeSet<String>,
18837 source_symbols: BTreeSet<String>,
18838 config_files: BTreeSet<String>,
18839 expected_tests: BTreeSet<String>,
18840 semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
18841 worker_feedback: ConflictMatrixWorkerFeedback,
18842}
18843
18844#[derive(Clone, Debug, Serialize)]
18845struct DependencyDagNode {
18846 id: String,
18847 graph_node_id: String,
18848 label: String,
18849 #[serde(skip_serializing_if = "Option::is_none")]
18850 path: Option<String>,
18851 #[serde(skip_serializing_if = "Option::is_none")]
18852 line: Option<i64>,
18853 #[serde(skip_serializing_if = "Option::is_none")]
18854 detail: Option<String>,
18855 source_files: Vec<String>,
18856 source_symbols: Vec<String>,
18857 config_files: Vec<String>,
18858 expected_tests: Vec<String>,
18859 semantic_refs: Vec<ConflictMatrixSemanticRef>,
18860 worker_feedback: ConflictMatrixWorkerFeedback,
18861}
18862
18863#[derive(Clone, Debug, Serialize)]
18864struct DependencyDagEdge {
18865 from: String,
18866 to: String,
18867 kind: String,
18868 weight: usize,
18869 reasons: Vec<String>,
18870 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18871 shared_files: Vec<String>,
18872 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18873 shared_symbols: Vec<String>,
18874 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18875 shared_tests: Vec<String>,
18876 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18877 shared_config_files: Vec<String>,
18878 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18879 shared_semantic_refs: Vec<String>,
18880}
18881
18882#[derive(Clone, Debug, Serialize)]
18883struct DependencyDagTopoBatch {
18884 batch: usize,
18885 targets: Vec<String>,
18886}
18887
18888#[derive(Clone, Debug, Serialize)]
18889struct DependencyDagCycleDiagnostics {
18890 has_cycles: bool,
18891 blocked_nodes: Vec<String>,
18892 cycle_edges: Vec<DependencyDagEdge>,
18893}
18894
18895#[derive(Serialize)]
18896struct DependencyDagSummary {
18897 nodes: usize,
18898 edges: usize,
18899 topo_batches: usize,
18900 has_cycles: bool,
18901}
18902
18903#[derive(Serialize)]
18904struct DependencyDagReport {
18905 contract_version: &'static str,
18906 root: String,
18907 #[serde(skip_serializing_if = "Option::is_none")]
18908 scope: Option<String>,
18909 path: String,
18910 targets: Vec<String>,
18911 projection_freshness: GraphDbFreshnessReport,
18912 projection_hashes: Vec<String>,
18913 nodes: Vec<DependencyDagNode>,
18914 edges: Vec<DependencyDagEdge>,
18915 topo_batches: Vec<DependencyDagTopoBatch>,
18916 cycle_diagnostics: DependencyDagCycleDiagnostics,
18917 summary: DependencyDagSummary,
18918 replay_commands: Vec<String>,
18919 repair_commands: Vec<String>,
18920 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18921 warnings: Vec<String>,
18922}
18923
18924fn dependency_dag_backlog_node_for_target(
18925 store: &impl GraphStore,
18926 target: &str,
18927) -> Result<SubstrateGraphNode> {
18928 let resolved = graph_db_resolve_evidence_target(store, target)?
18929 .with_context(|| format!("dependency-dag target not found: {target}"))?;
18930 if resolved.kind == "backlog" {
18931 return Ok(resolved);
18932 }
18933 let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
18934 bail!(
18935 "dependency-dag target {} resolved to {} without a backlog ref_id",
18936 target,
18937 resolved.kind
18938 );
18939 };
18940 store
18941 .nodes_by_kind("backlog")?
18942 .into_iter()
18943 .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
18944 .min_by(|left, right| {
18945 left.properties
18946 .get("line")
18947 .and_then(|value| value.parse::<i64>().ok())
18948 .cmp(
18949 &right
18950 .properties
18951 .get("line")
18952 .and_then(|value| value.parse::<i64>().ok()),
18953 )
18954 .then(left.id.cmp(&right.id))
18955 })
18956 .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
18957}
18958
18959fn dependency_dag_resolve_backlog_nodes(
18960 root: &Path,
18961 path: &Path,
18962 store: &impl GraphStore,
18963 raw_targets: &[String],
18964) -> Result<Vec<SubstrateGraphNode>> {
18965 let mut nodes = Vec::new();
18966 let mut seen = BTreeSet::new();
18967 if raw_targets.is_empty() {
18968 let hinted_path = if path.is_absolute() {
18969 path.to_path_buf()
18970 } else {
18971 root.join(path)
18972 };
18973 let hinted_markdown = hinted_path
18974 .extension()
18975 .and_then(|ext| ext.to_str())
18976 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
18977 let hinted_rel = hinted_markdown.then(|| {
18978 relativize_pathbuf(&hinted_path, root)
18979 .to_string_lossy()
18980 .replace('\\', "/")
18981 });
18982 for node in store.nodes_by_kind("backlog")? {
18983 if let Some(expected_path) = &hinted_rel
18984 && node.properties.get("path") != Some(expected_path)
18985 {
18986 continue;
18987 }
18988 if seen.insert(node.id.clone()) {
18989 nodes.push(node);
18990 }
18991 }
18992 if nodes.is_empty() && hinted_rel.is_some() {
18993 for node in store.nodes_by_kind("backlog")? {
18994 if seen.insert(node.id.clone()) {
18995 nodes.push(node);
18996 }
18997 }
18998 }
18999 } else {
19000 for target in raw_targets {
19001 let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
19002 let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
19003 if seen.insert(node.id.clone()) {
19004 nodes.push(node);
19005 }
19006 }
19007 }
19008 if nodes.is_empty() {
19009 bail!("dependency-dag needs at least one resolvable backlog id");
19010 }
19011 nodes.sort_by(|left, right| {
19012 left.properties
19013 .get("line")
19014 .and_then(|value| value.parse::<i64>().ok())
19015 .cmp(
19016 &right
19017 .properties
19018 .get("line")
19019 .and_then(|value| value.parse::<i64>().ok()),
19020 )
19021 .then(left.id.cmp(&right.id))
19022 });
19023 Ok(nodes)
19024}
19025
19026fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
19027 node.properties
19028 .get("ref_id")
19029 .cloned()
19030 .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
19031}
19032
19033fn dependency_dag_node_profile(
19034 root: &Path,
19035 store: &impl GraphStore,
19036 node: &SubstrateGraphNode,
19037 graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
19038 graph_edges: &[SubstrateGraphEdge],
19039 depth: usize,
19040 limit: usize,
19041) -> Result<DependencyDagProfile> {
19042 let id = dependency_dag_node_id(node);
19043 let mut source_files = BTreeSet::new();
19044 let mut source_symbols = BTreeSet::new();
19045 for edge in graph_edges
19046 .iter()
19047 .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
19048 {
19049 let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
19050 continue;
19051 };
19052 match target.kind.as_str() {
19053 "file" | "route" => {
19054 if let Some(path) = target.properties.get("path") {
19055 source_files.insert(path.clone());
19056 }
19057 }
19058 "symbol" => {
19059 source_symbols.insert(target.label.clone());
19060 if let Some(path) = target.properties.get("path") {
19061 source_files.insert(path.clone());
19062 }
19063 }
19064 _ => {}
19065 }
19066 }
19067
19068 let max_rows = if limit == 0 { usize::MAX } else { limit };
19069 for (source, _) in
19070 graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
19071 {
19072 let terse: SubstrateTerseGraphNode = (&source).into();
19073 if let Some(handle) = conflict_matrix_source_handle(&terse) {
19074 source_files.insert(handle.file);
19075 }
19076 }
19077
19078 let worker_results = graph_nodes_by_id
19079 .values()
19080 .filter(|candidate| {
19081 candidate.kind == "worker_result"
19082 && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
19083 })
19084 .map(SubstrateTerseGraphNode::from)
19085 .collect::<Vec<_>>();
19086 let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
19087 let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
19088 let config_files = source_files
19089 .iter()
19090 .filter(|file| is_planner_config_path(file))
19091 .cloned()
19092 .collect();
19093
19094 let mut semantic_refs = BTreeMap::new();
19095 for kind in ["semantic_concept", "semantic_entity"] {
19096 for (semantic, _) in
19097 graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
19098 {
19099 let terse: SubstrateTerseGraphNode = (&semantic).into();
19100 let item = conflict_matrix_semantic_ref(root, &terse);
19101 semantic_refs
19102 .entry(format!("{}:{}", item.kind, item.label))
19103 .or_insert(item);
19104 }
19105 }
19106
19107 Ok(DependencyDagProfile {
19108 id,
19109 graph_node_id: node.id.clone(),
19110 label: node.label.clone(),
19111 path: node.properties.get("path").cloned(),
19112 line: node
19113 .properties
19114 .get("line")
19115 .and_then(|value| value.parse::<i64>().ok()),
19116 detail: node.properties.get("detail").cloned(),
19117 source_files,
19118 source_symbols,
19119 config_files,
19120 expected_tests,
19121 semantic_refs,
19122 worker_feedback,
19123 })
19124}
19125
19126fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
19127 let lower = text.to_ascii_lowercase();
19128 let mut refs = Vec::new();
19129 for marker in markers {
19130 let mut offset = 0usize;
19131 while let Some(pos) = lower[offset..].find(marker) {
19132 let start = offset + pos + marker.len();
19133 let segment = text[start..]
19134 .split(['\n', '.'])
19135 .next()
19136 .unwrap_or(&text[start..]);
19137 refs.extend(extract_conflict_target_refs(segment));
19138 offset = start;
19139 }
19140 }
19141 dedupe_preserve_order(refs)
19142}
19143
19144fn dependency_dag_push_edge(
19145 edges: &mut Vec<DependencyDagEdge>,
19146 seen: &mut BTreeSet<(String, String, String)>,
19147 edge: DependencyDagEdge,
19148) {
19149 if edge.from == edge.to {
19150 return;
19151 }
19152 if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
19153 edges.push(edge);
19154 }
19155}
19156
19157fn dependency_dag_explicit_edges(
19158 profiles: &[DependencyDagProfile],
19159 target_ids: &BTreeSet<String>,
19160 edges: &mut Vec<DependencyDagEdge>,
19161 seen: &mut BTreeSet<(String, String, String)>,
19162) {
19163 for profile in profiles {
19164 let detail = profile.detail.as_deref().unwrap_or_default();
19165 for dep in dependency_dag_marker_refs(
19166 detail,
19167 &[
19168 "depends on",
19169 "depends-on",
19170 "deps:",
19171 "after",
19172 "blocked by",
19173 "requires",
19174 ],
19175 ) {
19176 if target_ids.contains(&dep) {
19177 dependency_dag_push_edge(
19178 edges,
19179 seen,
19180 DependencyDagEdge {
19181 from: dep.clone(),
19182 to: profile.id.clone(),
19183 kind: "explicit_depends_on".to_string(),
19184 weight: 1000,
19185 reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
19186 shared_files: Vec::new(),
19187 shared_symbols: Vec::new(),
19188 shared_tests: Vec::new(),
19189 shared_config_files: Vec::new(),
19190 shared_semantic_refs: Vec::new(),
19191 },
19192 );
19193 }
19194 }
19195 for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
19196 if target_ids.contains(&downstream) {
19197 dependency_dag_push_edge(
19198 edges,
19199 seen,
19200 DependencyDagEdge {
19201 from: profile.id.clone(),
19202 to: downstream.clone(),
19203 kind: "explicit_before".to_string(),
19204 weight: 900,
19205 reasons: vec![format!(
19206 "{} declares it should run before #{downstream}",
19207 profile.id
19208 )],
19209 shared_files: Vec::new(),
19210 shared_symbols: Vec::new(),
19211 shared_tests: Vec::new(),
19212 shared_config_files: Vec::new(),
19213 shared_semantic_refs: Vec::new(),
19214 },
19215 );
19216 }
19217 }
19218 }
19219}
19220
19221fn dependency_dag_worker_follow_up_edges(
19222 profiles: &[DependencyDagProfile],
19223 target_ids: &BTreeSet<String>,
19224 edges: &mut Vec<DependencyDagEdge>,
19225 seen: &mut BTreeSet<(String, String, String)>,
19226) {
19227 for profile in profiles {
19228 for follow_up in &profile.worker_feedback.follow_up_ids {
19229 if target_ids.contains(follow_up) {
19230 dependency_dag_push_edge(
19231 edges,
19232 seen,
19233 DependencyDagEdge {
19234 from: profile.id.clone(),
19235 to: follow_up.clone(),
19236 kind: "worker_result_follow_up".to_string(),
19237 weight: 700,
19238 reasons: vec![format!(
19239 "worker_result for #{} references follow-up #{}",
19240 profile.id, follow_up
19241 )],
19242 shared_files: Vec::new(),
19243 shared_symbols: Vec::new(),
19244 shared_tests: Vec::new(),
19245 shared_config_files: Vec::new(),
19246 shared_semantic_refs: Vec::new(),
19247 },
19248 );
19249 }
19250 }
19251 }
19252}
19253
19254fn dependency_dag_overlap_edges(
19255 profiles: &[DependencyDagProfile],
19256 edges: &mut Vec<DependencyDagEdge>,
19257 seen: &mut BTreeSet<(String, String, String)>,
19258) {
19259 for left_idx in 0..profiles.len() {
19260 for right_idx in (left_idx + 1)..profiles.len() {
19261 let left = &profiles[left_idx];
19262 let right = &profiles[right_idx];
19263 let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19264 let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19265 let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19266 let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19267 let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19268 let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19269 let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19270 if shared_files.is_empty()
19271 && shared_symbols.is_empty()
19272 && shared_tests.is_empty()
19273 && shared_config_files.is_empty()
19274 && shared_semantic_refs.is_empty()
19275 {
19276 continue;
19277 }
19278 let kind = if shared_files.is_empty()
19279 && shared_symbols.is_empty()
19280 && shared_tests.is_empty()
19281 && shared_config_files.is_empty()
19282 {
19283 "semantic_relation"
19284 } else {
19285 "shared_resource"
19286 };
19287 let mut reasons = Vec::new();
19288 if !shared_files.is_empty() {
19289 reasons.push(format!("shared files: {}", shared_files.join(", ")));
19290 }
19291 if !shared_symbols.is_empty() {
19292 reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19293 }
19294 if !shared_tests.is_empty() {
19295 reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19296 }
19297 if !shared_config_files.is_empty() {
19298 reasons.push(format!(
19299 "shared config files: {}",
19300 shared_config_files.join(", ")
19301 ));
19302 }
19303 if !shared_semantic_refs.is_empty() {
19304 reasons.push(format!(
19305 "shared semantic refs: {}",
19306 shared_semantic_refs.join(", ")
19307 ));
19308 }
19309 let weight = shared_files.len() * 100
19310 + shared_config_files.len() * 100
19311 + shared_symbols.len() * 40
19312 + shared_tests.len() * 10
19313 + shared_semantic_refs.len() * 5;
19314 dependency_dag_push_edge(
19315 edges,
19316 seen,
19317 DependencyDagEdge {
19318 from: left.id.clone(),
19319 to: right.id.clone(),
19320 kind: kind.to_string(),
19321 weight,
19322 reasons,
19323 shared_files,
19324 shared_symbols,
19325 shared_tests,
19326 shared_config_files,
19327 shared_semantic_refs,
19328 },
19329 );
19330 }
19331 }
19332}
19333
19334fn dependency_dag_topo_batches(
19335 targets: &[String],
19336 edges: &[DependencyDagEdge],
19337) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19338 let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19339 let order = targets
19340 .iter()
19341 .enumerate()
19342 .map(|(idx, id)| (id.clone(), idx))
19343 .collect::<BTreeMap<_, _>>();
19344 let mut indegree = targets
19345 .iter()
19346 .map(|id| (id.clone(), 0usize))
19347 .collect::<BTreeMap<_, _>>();
19348 let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19349 let mut seen_pairs = BTreeSet::<(String, String)>::new();
19350 for edge in edges {
19351 if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19352 continue;
19353 }
19354 if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19355 continue;
19356 }
19357 *indegree.entry(edge.to.clone()).or_default() += 1;
19358 outgoing
19359 .entry(edge.from.clone())
19360 .or_default()
19361 .push(edge.to.clone());
19362 }
19363 for values in outgoing.values_mut() {
19364 values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19365 values.dedup();
19366 }
19367
19368 let mut processed = BTreeSet::new();
19369 let mut batches = Vec::new();
19370 loop {
19371 let mut ready = targets
19372 .iter()
19373 .filter(|id| !processed.contains(*id))
19374 .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19375 .cloned()
19376 .collect::<Vec<_>>();
19377 ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19378 if ready.is_empty() {
19379 break;
19380 }
19381 for id in &ready {
19382 processed.insert(id.clone());
19383 for next in outgoing.get(id).into_iter().flatten() {
19384 if let Some(value) = indegree.get_mut(next) {
19385 *value = value.saturating_sub(1);
19386 }
19387 }
19388 }
19389 batches.push(DependencyDagTopoBatch {
19390 batch: batches.len() + 1,
19391 targets: ready,
19392 });
19393 }
19394
19395 let blocked_nodes = targets
19396 .iter()
19397 .filter(|id| !processed.contains(*id))
19398 .cloned()
19399 .collect::<Vec<_>>();
19400 let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19401 let cycle_edges = edges
19402 .iter()
19403 .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19404 .cloned()
19405 .collect::<Vec<_>>();
19406 (
19407 batches,
19408 DependencyDagCycleDiagnostics {
19409 has_cycles: !blocked_nodes.is_empty(),
19410 blocked_nodes,
19411 cycle_edges,
19412 },
19413 )
19414}
19415
19416fn dependency_dag_replay_commands(
19417 path: &Path,
19418 scope: Option<&str>,
19419 targets: &[String],
19420 depth: usize,
19421 limit: usize,
19422) -> Vec<String> {
19423 let target_args = targets
19424 .iter()
19425 .map(|target| shell_quote(target))
19426 .collect::<Vec<_>>()
19427 .join(" ");
19428 let mut command = format!(
19429 "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19430 shell_quote(path.to_string_lossy().as_ref()),
19431 scope
19432 .map(|scope| format!(" --scope {}", shell_quote(scope)))
19433 .unwrap_or_default(),
19434 depth,
19435 limit
19436 );
19437 if !target_args.is_empty() {
19438 command.push(' ');
19439 command.push_str(&target_args);
19440 }
19441 vec![command]
19442}
19443
19444fn build_dependency_dag_report(
19445 path: &Path,
19446 scope: Option<&str>,
19447 raw_targets: &[String],
19448 depth: usize,
19449 limit: usize,
19450) -> Result<DependencyDagReport> {
19451 let root = lint::resolve_project_root_or_canonical_path(path)?;
19452 write_traversal_graph_store(&root, path, scope)
19453 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19454 let graph_db = graph_substrate_db_path(&root, scope);
19455 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19456 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19457 let mut warnings = Vec::new();
19458 if let Some(recovery) = store.read_only_recovery() {
19459 warnings.push(graph_db_read_recovery_diagnostic(recovery));
19460 }
19461 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19462 if freshness.fail_closed {
19463 bail!(
19464 "dependency-dag graph projection failed closed: {}; repair: {}",
19465 freshness.diagnostics.join("; "),
19466 graph_db_repair_commands(&root, scope).join("; ")
19467 );
19468 }
19469
19470 let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19471 let graph_nodes = store.all_nodes()?;
19472 let graph_edges = store.all_edges()?;
19473 let graph_nodes_by_id = graph_nodes
19474 .into_iter()
19475 .map(|node| (node.id.clone(), node))
19476 .collect::<BTreeMap<_, _>>();
19477 let profiles = target_nodes
19478 .iter()
19479 .map(|node| {
19480 dependency_dag_node_profile(
19481 &root,
19482 &store,
19483 node,
19484 &graph_nodes_by_id,
19485 &graph_edges,
19486 depth,
19487 limit,
19488 )
19489 })
19490 .collect::<Result<Vec<_>>>()?;
19491 let targets = profiles
19492 .iter()
19493 .map(|profile| profile.id.clone())
19494 .collect::<Vec<_>>();
19495 let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19496
19497 let mut edges = Vec::new();
19498 let mut seen_edges = BTreeSet::new();
19499 dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19500 dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19501 dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19502 edges.sort_by(|left, right| {
19503 left.from
19504 .cmp(&right.from)
19505 .then(left.to.cmp(&right.to))
19506 .then(left.kind.cmp(&right.kind))
19507 });
19508 let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19509
19510 let nodes = profiles
19511 .into_iter()
19512 .map(|profile| DependencyDagNode {
19513 id: profile.id,
19514 graph_node_id: profile.graph_node_id,
19515 label: profile.label,
19516 path: profile.path,
19517 line: profile.line,
19518 detail: profile.detail,
19519 source_files: sorted_set(&profile.source_files),
19520 source_symbols: sorted_set(&profile.source_symbols),
19521 config_files: sorted_set(&profile.config_files),
19522 expected_tests: sorted_set(&profile.expected_tests),
19523 semantic_refs: profile.semantic_refs.into_values().collect(),
19524 worker_feedback: profile.worker_feedback,
19525 })
19526 .collect::<Vec<_>>();
19527 let projection_hashes = freshness
19528 .content_hash
19529 .clone()
19530 .into_iter()
19531 .collect::<Vec<_>>();
19532 let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19533 let repair_commands = graph_db_repair_commands(&root, scope);
19534 let summary = DependencyDagSummary {
19535 nodes: nodes.len(),
19536 edges: edges.len(),
19537 topo_batches: topo_batches.len(),
19538 has_cycles: cycle_diagnostics.has_cycles,
19539 };
19540
19541 Ok(DependencyDagReport {
19542 contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19543 root: root.to_string_lossy().to_string(),
19544 scope: scope.map(str::to_string),
19545 path: path.to_string_lossy().to_string(),
19546 targets,
19547 projection_freshness: freshness,
19548 projection_hashes,
19549 nodes,
19550 edges,
19551 topo_batches,
19552 cycle_diagnostics,
19553 summary,
19554 replay_commands,
19555 repair_commands,
19556 warnings,
19557 })
19558}
19559
19560fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
19561 if compact {
19562 println!(
19563 "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
19564 report.targets.len(),
19565 report.edges.len(),
19566 report.topo_batches.len(),
19567 report.cycle_diagnostics.has_cycles
19568 );
19569 } else {
19570 println!("Dependency DAG");
19571 println!(" targets: {}", report.targets.join(", "));
19572 println!(" edges: {}", report.edges.len());
19573 println!(" cycles: {}", report.cycle_diagnostics.has_cycles);
19574 }
19575 for batch in &report.topo_batches {
19576 println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
19577 }
19578 for edge in &report.edges {
19579 println!(
19580 "edge {} -> {} kind:{} weight:{}",
19581 edge.from, edge.to, edge.kind, edge.weight
19582 );
19583 for reason in &edge.reasons {
19584 println!(" reason: {reason}");
19585 }
19586 }
19587 if report.cycle_diagnostics.has_cycles {
19588 println!(
19589 "cycle blocked nodes: {}",
19590 report.cycle_diagnostics.blocked_nodes.join(", ")
19591 );
19592 }
19593 for command in &report.replay_commands {
19594 println!("replay: {command}");
19595 }
19596 for command in &report.repair_commands {
19597 println!("repair: {command}");
19598 }
19599 for warning in &report.warnings {
19600 println!("warning: {warning}");
19601 }
19602}
19603
19604fn cmd_dependency_dag(
19605 path: &Path,
19606 scope: Option<&str>,
19607 raw_targets: &[String],
19608 depth: usize,
19609 limit: usize,
19610 format: OutputFormat,
19611) -> Result<()> {
19612 let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
19613 if format.json_output {
19614 print_json_or_envelope(
19615 &report,
19616 &format,
19617 "dependency-dag",
19618 "topological-planning",
19619 ToolEnvelopeSummary {
19620 text: format!(
19621 "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
19622 report.targets.len(),
19623 report.edges.len(),
19624 report.topo_batches.len(),
19625 report.cycle_diagnostics.has_cycles
19626 ),
19627 metrics: vec![
19628 envelope_metric("targets", report.targets.len()),
19629 envelope_metric("edges", report.edges.len()),
19630 envelope_metric("topo_batches", report.topo_batches.len()),
19631 envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
19632 ],
19633 },
19634 report.cycle_diagnostics.has_cycles,
19635 report.replay_commands.clone(),
19636 )
19637 } else {
19638 print_dependency_dag_human(&report, format.compact);
19639 Ok(())
19640 }
19641}
19642
19643pub(crate) fn render_log_digest_from_input(
19644 path: &Path,
19645 input: &str,
19646 format: OutputFormat,
19647) -> Result<()> {
19648 let report = log_digest::compute(path, input)?;
19649 if format.json_output {
19650 println!(
19651 "{}",
19652 to_json_schema(
19653 &report,
19654 format.pretty,
19655 format.terse,
19656 format.ultra_terse,
19657 format.schema
19658 )?
19659 );
19660 return Ok(());
19661 }
19662
19663 if format.compact {
19664 println!(
19665 "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
19666 report.non_empty_lines,
19667 report.signal_groups,
19668 report.repeated_line_groups,
19669 report.file_ref_groups,
19670 report.symbol_ref_groups,
19671 report.stack_groups
19672 );
19673 for signal in &report.signals {
19674 let location = match (&signal.path, signal.line) {
19675 (Some(path), Some(line)) => format!("{path}:{line}"),
19676 (Some(path), None) => path.clone(),
19677 _ => "-".to_string(),
19678 };
19679 println!(
19680 "{} sev:{} count:{} sums:{} msg:{}",
19681 location,
19682 signal.severity,
19683 signal.occurrences,
19684 log_digest_summary_label(signal.summary_state),
19685 truncate_for_compact(&signal.message, 80)
19686 );
19687 }
19688 for repeated in &report.repeated_lines {
19689 println!(
19690 "repeat count:{} line:{}",
19691 repeated.occurrences,
19692 truncate_for_compact(&repeated.line, 80)
19693 );
19694 }
19695 for symbol in &report.symbol_refs {
19696 println!(
19697 "sym:{} count:{} sums:{}",
19698 symbol.symbol,
19699 symbol.occurrences,
19700 log_digest_summary_label(symbol.summary_state)
19701 );
19702 }
19703 for warning in &report.warnings {
19704 println!("warning: {warning}");
19705 }
19706 return Ok(());
19707 }
19708
19709 println!("Log digest");
19710 println!(" lines: {}", report.total_lines);
19711 println!(" non-empty lines: {}", report.non_empty_lines);
19712 println!(" signal groups: {}", report.signal_groups);
19713 println!(
19714 " repeated lines: {}",
19715 report.repeated_line_groups
19716 );
19717 println!(
19718 " repeated line instances: {}",
19719 report.repeated_line_occurrences
19720 );
19721 println!(" file refs: {}", report.file_ref_groups);
19722 println!(" symbol refs: {}", report.symbol_ref_groups);
19723 println!(" stack groups: {}", report.stack_groups);
19724
19725 if !report.signals.is_empty() {
19726 println!();
19727 println!("Signals:");
19728 for signal in &report.signals {
19729 match (&signal.path, signal.line, signal.column) {
19730 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
19731 (Some(path), Some(line), None) => println!("{path}:{line}"),
19732 (Some(path), None, _) => println!("{path}"),
19733 (None, _, _) => println!("(no file anchor)"),
19734 }
19735 println!(" severity: {}", signal.severity);
19736 println!(" occurrences: {}", signal.occurrences);
19737 println!(" message: {}", signal.message);
19738 println!(
19739 " cached summaries: {}",
19740 log_digest_summary_label(signal.summary_state)
19741 );
19742 for summary in &signal.current_summaries {
19743 println!(
19744 " - {}: {}",
19745 summary.symbol,
19746 truncate_for_compact(&summary.summary, 160)
19747 );
19748 }
19749 }
19750 }
19751
19752 if !report.repeated_lines.is_empty() {
19753 println!();
19754 println!("Repeated lines:");
19755 for repeated in &report.repeated_lines {
19756 println!(
19757 " {}x {}",
19758 repeated.occurrences,
19759 truncate_for_compact(&repeated.line, 180)
19760 );
19761 }
19762 }
19763
19764 if !report.file_refs.is_empty() {
19765 println!();
19766 println!("Anchored files:");
19767 for file_ref in &report.file_refs {
19768 match (file_ref.line, file_ref.column) {
19769 (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
19770 (Some(line), None) => println!("{}:{}", file_ref.path, line),
19771 (None, _) => println!("{}", file_ref.path),
19772 }
19773 println!(" occurrences: {}", file_ref.occurrences);
19774 println!(
19775 " cached summaries: {}",
19776 log_digest_summary_label(file_ref.summary_state)
19777 );
19778 for summary in &file_ref.current_summaries {
19779 println!(
19780 " - {}: {}",
19781 summary.symbol,
19782 truncate_for_compact(&summary.summary, 160)
19783 );
19784 }
19785 }
19786 }
19787
19788 if !report.symbol_refs.is_empty() {
19789 println!();
19790 println!("Symbol candidates:");
19791 for symbol in &report.symbol_refs {
19792 println!("{}", symbol.symbol);
19793 println!(" occurrences: {}", symbol.occurrences);
19794 println!(
19795 " cached summaries: {}",
19796 log_digest_summary_label(symbol.summary_state)
19797 );
19798 for summary in &symbol.current_summaries {
19799 println!(
19800 " - {}: {}",
19801 summary.symbol,
19802 truncate_for_compact(&summary.summary, 160)
19803 );
19804 }
19805 }
19806 }
19807
19808 if !report.stack_traces.is_empty() {
19809 println!();
19810 println!("Stack groups:");
19811 for stack in &report.stack_traces {
19812 println!(" occurrences: {}", stack.occurrences);
19813 for frame in &stack.frames {
19814 println!(" - {}", frame);
19815 }
19816 }
19817 }
19818
19819 for warning in &report.warnings {
19820 println!("warning: {warning}");
19821 }
19822 Ok(())
19823}
19824
19825pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
19826 match trend {
19827 metric_digest::MetricDigestTrend::Improved => "improved",
19828 metric_digest::MetricDigestTrend::Regressed => "regressed",
19829 metric_digest::MetricDigestTrend::Flat => "flat",
19830 metric_digest::MetricDigestTrend::Unknown => "changed",
19831 }
19832}
19833
19834pub(crate) fn metric_digest_gate_label(
19835 decision: metric_digest::CommunitySearchGateDecision,
19836) -> &'static str {
19837 match decision {
19838 metric_digest::CommunitySearchGateDecision::Pass => "pass",
19839 metric_digest::CommunitySearchGateDecision::Block => "block",
19840 }
19841}
19842
19843fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
19844 let input = fs::read_to_string(fixture_path)
19845 .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
19846 let report = dci_benchmark::compute(&input)?;
19847
19848 if format.json_output {
19849 println!(
19850 "{}",
19851 to_json_schema(
19852 &report,
19853 format.pretty,
19854 format.terse,
19855 format.ultra_terse,
19856 format.schema
19857 )?
19858 );
19859 return Ok(());
19860 }
19861
19862 if format.compact {
19863 println!(
19864 "dci tasks:{} strategies:{} warnings:{}",
19865 report.tasks_loaded,
19866 report.strategies_compared,
19867 report.warnings.len()
19868 );
19869 for summary in &report.strategy_summaries {
19870 println!(
19871 "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
19872 summary.strategy,
19873 summary.rank,
19874 summary.localized,
19875 summary.task_runs,
19876 dci_benchmark::format_number(summary.localization_rate * 100.0),
19877 dci_benchmark::format_number(summary.avg_useful_hits),
19878 dci_benchmark::format_number(summary.zero_output_rate * 100.0),
19879 dci_benchmark::format_number(summary.avg_tool_calls),
19880 dci_benchmark::format_number(summary.avg_latency_ms),
19881 dci_benchmark::format_number(summary.avg_estimated_tokens),
19882 dci_benchmark::format_number(summary.avg_output_tokens)
19883 );
19884 }
19885 if let Some(gate) = &report.memory_retrieval_gate {
19886 println!(
19887 "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
19888 gate.decision,
19889 gate.baseline_strategy,
19890 dci_benchmark::format_number(gate.min_avg_useful_hits),
19891 gate.max_zero_output_failures,
19892 gate.diagnostics.len()
19893 );
19894 }
19895 for warning in &report.warnings {
19896 println!("warning: {warning}");
19897 }
19898 return Ok(());
19899 }
19900
19901 println!("DCI benchmark");
19902 if let Some(description) = &report.description {
19903 println!(" description: {}", description);
19904 }
19905 println!(" tasks loaded: {}", report.tasks_loaded);
19906 println!(" strategies compared: {}", report.strategies_compared);
19907
19908 println!();
19909 println!("Strategy summary:");
19910 for summary in &report.strategy_summaries {
19911 println!(
19912 " #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
19913 summary.rank,
19914 summary.strategy,
19915 summary.localized,
19916 summary.task_runs,
19917 summary.localization_rate * 100.0,
19918 dci_benchmark::format_number(summary.avg_useful_hits),
19919 summary.zero_output_rate * 100.0,
19920 dci_benchmark::format_number(summary.avg_tool_calls),
19921 dci_benchmark::format_number(summary.avg_latency_ms),
19922 dci_benchmark::format_number(summary.avg_estimated_tokens),
19923 dci_benchmark::format_number(summary.avg_output_tokens)
19924 );
19925 }
19926
19927 if let Some(gate) = &report.memory_retrieval_gate {
19928 println!();
19929 println!("Memory retrieval gate:");
19930 println!(" decision: {}", gate.decision);
19931 println!(
19932 " baseline: {}, min avg useful hits {}, max zero-output failures {}",
19933 gate.baseline_strategy,
19934 dci_benchmark::format_number(gate.min_avg_useful_hits),
19935 gate.max_zero_output_failures
19936 );
19937 for row in &gate.rows {
19938 println!(
19939 " {}: status {}, avg useful hits {}, zero-output failures {}",
19940 row.strategy,
19941 row.status,
19942 dci_benchmark::format_number(row.avg_useful_hits),
19943 row.zero_output_failures
19944 );
19945 }
19946 for diagnostic in &gate.diagnostics {
19947 println!(" diagnostic: {diagnostic}");
19948 }
19949 }
19950
19951 println!();
19952 println!("Task winners:");
19953 for row in &report.task_rows {
19954 let label = row
19955 .label
19956 .as_ref()
19957 .map(|value| format!(" ({value})"))
19958 .unwrap_or_default();
19959 println!(" {}{}", row.task_id, label);
19960 println!(" localized: {}", row.best_localization.join(", "));
19961 println!(" most useful hits: {}", row.most_useful_hits.join(", "));
19962 println!(
19963 " lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
19964 row.lowest_tool_calls.as_deref().unwrap_or("-"),
19965 row.lowest_latency.as_deref().unwrap_or("-"),
19966 row.lowest_token_budget.as_deref().unwrap_or("-"),
19967 row.lowest_output_tokens.as_deref().unwrap_or("-")
19968 );
19969 if !row.zero_output_failures.is_empty() {
19970 println!(" zero output: {}", row.zero_output_failures.join(", "));
19971 }
19972 }
19973
19974 for warning in &report.warnings {
19975 println!("warning: {warning}");
19976 }
19977 Ok(())
19978}
19979
19980pub(crate) fn format_compact_count(value: u64) -> String {
19981 if value >= 1_000_000 {
19982 format!("{:.1}M", value as f64 / 1_000_000.0)
19983 } else if value >= 1_000 {
19984 format!("{:.1}K", value as f64 / 1_000.0)
19985 } else {
19986 value.to_string()
19987 }
19988}
19989
19990fn cmd_digest_runner(
19991 kind: &str,
19992 path: &Path,
19993 runner: Option<&str>,
19994 shell_command: &str,
19995 format: OutputFormat,
19996) -> Result<()> {
19997 let digest_kind = DigestRunnerKind::parse(kind)?;
19998 let root = transcript_artifact_root(path)?;
19999 let execution = run_digest_runner_command(shell_command)?;
20000 let output = &execution.output;
20001 let captured = String::from_utf8_lossy(&output.stdout).into_owned();
20002 let exit_code = output.status.code().unwrap_or(-1);
20003 if format.json_output && format.envelope {
20004 let artifact_key = format!(
20005 "{}:{}:{}:{}",
20006 digest_kind.as_str(),
20007 shell_command,
20008 execution.executed_command,
20009 captured
20010 );
20011 let artifact = if captured.trim().is_empty() {
20012 None
20013 } else {
20014 let (suffix, expand) = match digest_kind {
20015 DigestRunnerKind::Test => (
20016 "test.log",
20017 format!(
20018 "tsift test-digest --path {} --input {}{} --json",
20019 shell_quote(root.to_string_lossy().as_ref()),
20020 shell_quote(
20021 root.join(".tsift/artifacts")
20022 .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
20023 .to_string_lossy()
20024 .as_ref()
20025 ),
20026 runner
20027 .map(|value| format!(" --runner {}", shell_quote(value)))
20028 .unwrap_or_default()
20029 ),
20030 ),
20031 DigestRunnerKind::Log => (
20032 "log",
20033 format!(
20034 "tsift log-digest --path {} --input {} --json",
20035 shell_quote(root.to_string_lossy().as_ref()),
20036 shell_quote(
20037 root.join(".tsift/artifacts")
20038 .join(format!("{}.log", stable_handle("tart", &artifact_key)))
20039 .to_string_lossy()
20040 .as_ref()
20041 )
20042 ),
20043 ),
20044 };
20045 Some(persist_transcript_artifact(
20046 &root,
20047 "tart",
20048 suffix,
20049 &artifact_key,
20050 &captured,
20051 expand,
20052 )?)
20053 };
20054 let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
20055
20056 match digest_kind {
20057 DigestRunnerKind::Test => {
20058 let digest_report = test_digest::compute(path, &captured, runner)?;
20059 let report = serde_json::json!({
20060 "kind": digest_kind.as_str(),
20061 "command": shell_command,
20062 "executed_command": execution.executed_command,
20063 "exit_code": exit_code,
20064 "success": output.status.success(),
20065 "filter": filter_report,
20066 "artifact": artifact,
20067 "digest": digest_report,
20068 });
20069 let mut follow_up = artifact
20070 .as_ref()
20071 .map(|entry| vec![entry.expand.clone()])
20072 .unwrap_or_default();
20073 follow_up.push(format!(
20074 "tsift rewrite --run {}",
20075 shell_quote(shell_command)
20076 ));
20077 let summary_text = if output.status.success() && digest_report.failures == 0 {
20078 format!("test run passed for {}", runner.unwrap_or("auto"))
20079 } else {
20080 format!("test run captured {} failure(s)", digest_report.failures)
20081 };
20082 print_json_or_envelope(
20083 &report,
20084 &format,
20085 "digest-runner",
20086 "test-run",
20087 ToolEnvelopeSummary {
20088 text: summary_text,
20089 metrics: vec![
20090 envelope_metric("runner", &digest_report.runner),
20091 envelope_metric("exit_code", exit_code),
20092 envelope_metric("filter", execution.filter_label()),
20093 envelope_metric("failures", digest_report.failures),
20094 envelope_metric("groups", digest_report.grouped_failures),
20095 envelope_metric(
20096 "artifact",
20097 artifact
20098 .as_ref()
20099 .map(|entry| entry.handle.as_str())
20100 .unwrap_or("-"),
20101 ),
20102 ],
20103 },
20104 false,
20105 follow_up,
20106 )?;
20107 }
20108 DigestRunnerKind::Log => {
20109 let digest_report = log_digest::compute(path, &captured)?;
20110 let report = serde_json::json!({
20111 "kind": digest_kind.as_str(),
20112 "command": shell_command,
20113 "executed_command": execution.executed_command,
20114 "exit_code": exit_code,
20115 "success": output.status.success(),
20116 "filter": filter_report,
20117 "artifact": artifact,
20118 "digest": digest_report,
20119 });
20120 let mut follow_up = artifact
20121 .as_ref()
20122 .map(|entry| vec![entry.expand.clone()])
20123 .unwrap_or_default();
20124 follow_up.push(format!(
20125 "tsift rewrite --run {}",
20126 shell_quote(shell_command)
20127 ));
20128 let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
20129 "command finished without log signals".to_string()
20130 } else {
20131 format!(
20132 "command emitted {} log signal group(s)",
20133 digest_report.signal_groups
20134 )
20135 };
20136 print_json_or_envelope(
20137 &report,
20138 &format,
20139 "digest-runner",
20140 "command-run",
20141 ToolEnvelopeSummary {
20142 text: summary_text,
20143 metrics: vec![
20144 envelope_metric("exit_code", exit_code),
20145 envelope_metric("filter", execution.filter_label()),
20146 envelope_metric("signals", digest_report.signal_groups),
20147 envelope_metric("file_refs", digest_report.file_ref_groups),
20148 envelope_metric(
20149 "artifact",
20150 artifact
20151 .as_ref()
20152 .map(|entry| entry.handle.as_str())
20153 .unwrap_or("-"),
20154 ),
20155 ],
20156 },
20157 false,
20158 follow_up,
20159 )?;
20160 }
20161 }
20162
20163 if output.status.success() {
20164 return Ok(());
20165 }
20166 if let Some(code) = output.status.code() {
20167 std::process::exit(code);
20168 }
20169 bail!("digest-wrapped command terminated by signal: {shell_command}");
20170 }
20171
20172 if captured.trim().is_empty() {
20173 let label = match digest_kind {
20174 DigestRunnerKind::Test => "test",
20175 DigestRunnerKind::Log => "log",
20176 };
20177 println!("No {label} output captured.");
20178 } else {
20179 match digest_kind {
20180 DigestRunnerKind::Test => {
20181 render_test_digest_from_input(path, &captured, runner, format)?
20182 }
20183 DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20184 }
20185 }
20186
20187 if output.status.success() {
20188 return Ok(());
20189 }
20190 if let Some(code) = output.status.code() {
20191 std::process::exit(code);
20192 }
20193 bail!("digest-wrapped command terminated by signal: {shell_command}");
20194}
20195
20196struct DigestRunnerExecution {
20197 output: std::process::Output,
20198 executed_command: String,
20199 filter: Option<DigestRunnerFilter>,
20200}
20201
20202impl DigestRunnerExecution {
20203 fn filter_label(&self) -> &'static str {
20204 self.filter
20205 .as_ref()
20206 .map(|filter| filter.tool)
20207 .unwrap_or("none")
20208 }
20209}
20210
20211struct DigestRunnerFilter {
20212 tool: &'static str,
20213 command: String,
20214}
20215
20216impl DigestRunnerFilter {
20217 fn to_json(&self) -> serde_json::Value {
20218 serde_json::json!({
20219 "tool": self.tool,
20220 "command": self.command,
20221 })
20222 }
20223}
20224
20225fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20226 let filter = rtk_rewrite_for_digest_runner(shell_command);
20227 let executed_command = filter
20228 .as_ref()
20229 .map(|filter| filter.command.as_str())
20230 .unwrap_or(shell_command);
20231 let output = Command::new("sh")
20232 .arg("-lc")
20233 .arg(format!("({executed_command}) 2>&1"))
20234 .stdout(Stdio::piped())
20235 .output()
20236 .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20237
20238 Ok(DigestRunnerExecution {
20239 output,
20240 executed_command: executed_command.to_string(),
20241 filter,
20242 })
20243}
20244
20245fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20246 if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20247 return None;
20248 }
20249 let output = Command::new("rtk")
20250 .arg("rewrite")
20251 .arg(shell_command)
20252 .output()
20253 .ok()?;
20254 if !output.status.success() {
20255 return None;
20256 }
20257 let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20258 if rewritten.is_empty() || rewritten == shell_command {
20259 return None;
20260 }
20261 Some(DigestRunnerFilter {
20262 tool: "rtk",
20263 command: rewritten,
20264 })
20265}
20266
20267fn find_command_on_path(command: &str) -> Option<PathBuf> {
20268 let path_var = std::env::var_os("PATH")?;
20269 std::env::split_paths(&path_var)
20270 .map(|dir| dir.join(command))
20271 .find(|candidate| candidate.is_file())
20272}
20273
20274pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20275 if !db_path.exists() {
20276 bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20277 }
20278 summarize::SummaryDb::open_read_only_resilient(db_path)
20279}
20280
20281fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20282 !matches!(report.index, status::IndexStatus::Fresh { .. })
20283}
20284
20285fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20286 !matches!(report.instructions, init::InstructionStatus::Current { .. })
20287}
20288
20289pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20290 if status_instructions_need_fix(report) {
20291 eprintln!("status fix: refreshing tsift instructions");
20292 init::init(root, false, false)?;
20293 }
20294
20295 let eviction = cycle_packet_cache::cycle_packet_cache_evict(
20296 root,
20297 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
20298 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
20299 );
20300 if eviction.evicted_entries > 0 {
20301 eprintln!(
20302 "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
20303 eviction.evicted_entries,
20304 eviction.evicted_bytes,
20305 eviction.remaining_entries
20306 );
20307 }
20308
20309 if !status_index_needs_fix(report) {
20310 return Ok(());
20311 }
20312
20313 let scopes = config::Config::submodule_dirs(root)?;
20314 if scopes.is_empty() {
20315 eprintln!("status fix: refreshing index");
20316 run_index_update(
20317 &root.join(".tsift/index.db"),
20318 root,
20319 "status --fix refreshing index".to_string(),
20320 root,
20321 None,
20322 false,
20323 false,
20324 )?;
20325 return Ok(());
20326 }
20327
20328 let cfg = config::Config::load(root)?;
20329 for scope in scopes {
20330 if !scope.source_root.exists() {
20331 eprintln!(
20332 "status fix: skipping missing submodule `{}` ({})",
20333 scope.id,
20334 scope.source_root.display()
20335 );
20336 continue;
20337 }
20338 eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20339 run_index_update(
20340 &cfg.db_path_for(root, &scope.id),
20341 &scope.source_root,
20342 format!("status --fix refreshing submodule `{}` index", scope.id),
20343 root,
20344 Some(scope.id.as_str()),
20345 false,
20346 false,
20347 )?;
20348 }
20349
20350 Ok(())
20351}
20352
20353pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20354 match &report.index {
20355 status::IndexStatus::Fresh { missing_scopes, .. }
20356 | status::IndexStatus::Stale { missing_scopes, .. }
20357 | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20358 }
20359}
20360
20361pub(crate) fn autoindex_missing_workspace_scopes(
20362 root: &Path,
20363 report: &status::StatusReport,
20364) -> Result<()> {
20365 let missing_scopes = match &report.index {
20366 status::IndexStatus::Fresh { missing_scopes, .. }
20367 | status::IndexStatus::Stale { missing_scopes, .. }
20368 | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20369 };
20370 if missing_scopes.is_empty() {
20371 return Ok(());
20372 }
20373
20374 let missing_scope_ids = missing_scopes
20375 .iter()
20376 .map(|scope| scope.scope.as_str())
20377 .collect::<std::collections::HashSet<_>>();
20378 let cfg = config::Config::load(root)?;
20379 for scope in config::Config::submodule_dirs(root)? {
20380 if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20381 continue;
20382 }
20383 let db_path = cfg.db_path_for(root, &scope.id);
20384 run_index_update(
20385 &db_path,
20386 &scope.source_root,
20387 format!(
20388 "autoindexing missing submodule `{}` during status",
20389 scope.id
20390 ),
20391 root,
20392 Some(scope.id.as_str()),
20393 false,
20394 false,
20395 )?;
20396 }
20397 Ok(())
20398}
20399
20400pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
20401 for warning in &stats.warnings {
20402 let rel_path = relativize_pathbuf(&warning.path, root);
20403 eprintln!(
20404 "warning: summarize stats {}: {}",
20405 rel_path.display(),
20406 warning.message
20407 );
20408 }
20409}
20410
20411fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
20412 Result::<(), anyhow::Error>::Err(err)
20413 .context(context)
20414 .unwrap_err()
20415}
20416
20417fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
20418 let message = err.to_string();
20419 message.contains("another tsift index writer is already active")
20420 || substrate::error_mentions_locked_db(err)
20421}
20422
20423fn add_write_lock_context(
20424 err: anyhow::Error,
20425 action: String,
20426 root: &std::path::Path,
20427 scope: Option<&str>,
20428) -> anyhow::Error {
20429 if !should_attach_lock_diagnostics(&err) {
20430 return contextualize_error(err, action);
20431 }
20432
20433 let Ok(report) = status::check_locks(root, None, scope) else {
20434 return contextualize_error(err, action);
20435 };
20436
20437 contextualize_error(
20438 err,
20439 format!(
20440 "{}\n\nlock diagnostics:\n{}",
20441 action,
20442 status::format_locks_human(&report, false).trim_end()
20443 ),
20444 )
20445}
20446
20447pub(crate) fn run_index_update(
20448 db_path: &std::path::Path,
20449 source_root: &std::path::Path,
20450 action: String,
20451 root: &std::path::Path,
20452 scope: Option<&str>,
20453 rebuild: bool,
20454 prune: bool,
20455) -> Result<index::IndexSummary> {
20456 let result = (|| {
20457 let db = index::IndexDb::open(db_path)?;
20458 if rebuild {
20459 db.rebuild(source_root)
20460 } else if prune {
20461 db.apply_changes_pruned(source_root)
20462 } else {
20463 db.apply_changes(source_root)
20464 }
20465 })();
20466
20467 let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
20468 emit_index_warnings(&summary, source_root, scope);
20469 Ok(summary)
20470}
20471
20472pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
20473 for change in &mut summary.changes {
20474 change.path = relativize_pathbuf(&change.path, root);
20475 }
20476 for warning in &mut summary.warnings {
20477 warning.path = relativize_pathbuf(&warning.path, root);
20478 }
20479}
20480
20481fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
20482 for warning in &summary.warnings {
20483 let rel_path = relativize_pathbuf(&warning.path, root);
20484 let stage = match warning.stage {
20485 index::IndexWarningStage::ReadSource => "read failed",
20486 index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
20487 index::IndexWarningStage::ExtractCallSites => "call extraction failed",
20488 index::IndexWarningStage::ExtractRoutes => "route extraction failed",
20489 };
20490 let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
20491 let lang_suffix = warning
20492 .language
20493 .as_deref()
20494 .map(|lang| format!(" [{}]", lang))
20495 .unwrap_or_default();
20496 eprintln!(
20497 "warning: {}{}{}: {}: {}",
20498 scope_prefix,
20499 rel_path.display(),
20500 lang_suffix,
20501 stage,
20502 warning.message
20503 );
20504 }
20505}
20506
20507pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
20508 let config_path = root.join(".tsift/config.toml");
20509 if !config_path.exists() {
20510 return summarize::SummarizeConfig::default();
20511 }
20512 #[derive(serde::Deserialize, Default)]
20513 struct RawConfig {
20514 #[serde(default)]
20515 summarize: Option<RawSummarize>,
20516 }
20517 #[derive(serde::Deserialize)]
20518 struct RawSummarize {
20519 model: Option<String>,
20520 max_file_tokens: Option<usize>,
20521 api_key_env: Option<String>,
20522 }
20523 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
20524 let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
20525 let defaults = summarize::SummarizeConfig::default();
20526 match raw.summarize {
20527 Some(s) => summarize::SummarizeConfig {
20528 model: s.model.unwrap_or(defaults.model),
20529 max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
20530 api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
20531 },
20532 None => defaults,
20533 }
20534}
20535
20536#[derive(Debug, Clone, PartialEq, Eq)]
20537struct ExtractSymbolContext {
20538 db_path: PathBuf,
20539 source_root: PathBuf,
20540}
20541
20542pub(crate) fn find_symbols_db_for_file(
20543 root: &Path,
20544 file_path: &Path,
20545) -> Result<Option<ExtractSymbolContext>> {
20546 let cfg = config::Config::load(root)?;
20547 let mut submodules = config::Config::submodule_dirs(root)?;
20548 submodules.sort_by(|left, right| {
20549 right
20550 .source_root
20551 .components()
20552 .count()
20553 .cmp(&left.source_root.components().count())
20554 });
20555
20556 for scope in submodules {
20557 if !file_path.starts_with(&scope.source_root) {
20558 continue;
20559 }
20560 let db_path = cfg.db_path_for(root, &scope.id);
20561 if db_path.exists() {
20562 return Ok(Some(ExtractSymbolContext {
20563 db_path,
20564 source_root: scope.source_root,
20565 }));
20566 }
20567 }
20568
20569 let single = root.join(".tsift/index.db");
20570 if single.exists() && file_path.starts_with(root) {
20571 return Ok(Some(ExtractSymbolContext {
20572 db_path: single,
20573 source_root: root.to_path_buf(),
20574 }));
20575 }
20576
20577 Ok(None)
20578}
20579
20580pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
20581 let canonical = path
20582 .canonicalize()
20583 .with_context(|| format!("canonicalizing {}", path.display()))?;
20584
20585 Ok(if canonical.is_dir() {
20586 canonical
20587 } else {
20588 canonical
20589 .parent()
20590 .map(Path::to_path_buf)
20591 .unwrap_or(canonical)
20592 })
20593}
20594
20595fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
20596 if path.exists() {
20597 return path
20598 .canonicalize()
20599 .with_context(|| format!("canonicalizing extract scope {}", path.display()));
20600 }
20601
20602 Ok(summarize::normalize_lexical_path(path))
20603}
20604
20605pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
20606 let scope = if extract_path.is_absolute() {
20607 extract_path.to_path_buf()
20608 } else {
20609 root.join(extract_path)
20610 };
20611 normalize_extract_scope_path(&scope)
20612}
20613
20614pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
20615 normalize_extract_scope_path(changed_path)
20616 .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
20617 .starts_with(extract_scope)
20618}
20619
20620pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
20621 summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
20622}
20623
20624pub(crate) fn summarize_full_extract_deleted_summary_paths(
20625 summary_db: &summarize::SummaryDb,
20626 root: &Path,
20627 extract_scope: &Path,
20628 files_to_extract: &[PathBuf],
20629) -> Result<BTreeSet<String>> {
20630 let live_paths = files_to_extract
20631 .iter()
20632 .map(|file_path| summarize_relative_file_path(root, file_path))
20633 .collect::<BTreeSet<_>>();
20634 let mut deleted = BTreeSet::new();
20635
20636 for cached_path in summary_db.cached_file_paths()? {
20637 if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
20638 continue;
20639 }
20640 if !live_paths.contains(&cached_path) {
20641 deleted.insert(cached_path);
20642 }
20643 }
20644
20645 Ok(deleted)
20646}
20647
20648#[derive(Debug, Clone)]
20649struct SearchIndexTarget {
20650 label: String,
20651 db_path: PathBuf,
20652 source_root: PathBuf,
20653 scope_name: Option<String>,
20654 reindex_cmd: String,
20655}
20656
20657fn cargo_package_index_target(
20658 root: &Path,
20659 package: multiplicity::CargoPackageInfo,
20660) -> SearchIndexTarget {
20661 SearchIndexTarget {
20662 label: format!("cargo package `{}` index", package.scope_id),
20663 db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
20664 source_root: package.package_root.clone(),
20665 scope_name: Some(package.scope_id.clone()),
20666 reindex_cmd: format!(
20667 "tsift index --submodule {} {}",
20668 package.scope_id,
20669 root.display()
20670 ),
20671 }
20672}
20673
20674#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20675enum SearchIndexState {
20676 Missing,
20677 Fresh,
20678 Stale { stale_files: usize },
20679}
20680
20681fn resolve_search_index_targets(
20682 root: &Path,
20683 path_hint: &Path,
20684 scope: Option<&str>,
20685 federated: bool,
20686) -> Result<Vec<SearchIndexTarget>> {
20687 if let Some(scope_name) = scope {
20688 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
20689 let cfg = config::Config::load(root)?;
20690 return Ok(vec![SearchIndexTarget {
20691 label: format!("submodule `{}` index", scope.id),
20692 db_path: cfg.db_path_for(root, &scope.id),
20693 source_root: scope.source_root.clone(),
20694 scope_name: Some(scope.id.clone()),
20695 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20696 }]);
20697 }
20698 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
20699 return Ok(vec![cargo_package_index_target(root, package)]);
20700 }
20701 config::Config::resolve_submodule(root, scope_name)?;
20702 }
20703
20704 if federated {
20705 let cfg = config::Config::load(root)?;
20706 let mut targets = Vec::new();
20707 for scope in config::Config::submodule_dirs(root)? {
20708 if !cfg.federation_for_scope(&scope) {
20709 continue;
20710 }
20711 targets.push(SearchIndexTarget {
20712 label: format!("submodule `{}` index", scope.id),
20713 db_path: cfg.db_path_for(root, &scope.id),
20714 source_root: scope.source_root.clone(),
20715 scope_name: Some(scope.id.clone()),
20716 reindex_cmd: format!("tsift index --workspace {}", root.display()),
20717 });
20718 }
20719 return Ok(targets);
20720 }
20721
20722 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
20723 let cfg = config::Config::load(root)?;
20724 return Ok(vec![SearchIndexTarget {
20725 label: format!("submodule `{}` index", scope.id),
20726 db_path: cfg.db_path_for(root, &scope.id),
20727 source_root: scope.source_root.clone(),
20728 scope_name: Some(scope.id.clone()),
20729 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20730 }]);
20731 }
20732
20733 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
20734 return Ok(vec![cargo_package_index_target(root, package)]);
20735 }
20736
20737 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
20738 let cfg = config::Config::load(root)?;
20739 return Ok(vec![SearchIndexTarget {
20740 label: format!("submodule `{}` index", scope.id),
20741 db_path: cfg.db_path_for(root, &scope.id),
20742 source_root: scope.source_root.clone(),
20743 scope_name: Some(scope.id.clone()),
20744 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20745 }]);
20746 }
20747
20748 let scopes = config::Config::submodule_dirs(root)?;
20749 if !scopes.is_empty() {
20750 let root_db = root.join(".tsift/index.db");
20751 if !root_db.exists() {
20752 let available_scopes = scopes
20753 .iter()
20754 .map(|scope| scope.id.as_str())
20755 .collect::<Vec<_>>()
20756 .join(", ");
20757 let cfg = config::Config::load(root)?;
20758 let indexed_scopes = scopes
20759 .iter()
20760 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
20761 .map(|scope| scope.id.as_str())
20762 .collect::<Vec<_>>();
20763 let indexed_label = if indexed_scopes.is_empty() {
20764 "none".to_string()
20765 } else {
20766 indexed_scopes.join(", ")
20767 };
20768 bail!(
20769 "workspace root {} has no shared root index at {}. Default search requires `--scope <scope>` or `--federated` when the workspace uses scoped `.tsift/indexes/*/index.db` files. Available scopes: {}. Indexed scopes: {}.",
20770 root.display(),
20771 root_db.display(),
20772 available_scopes,
20773 indexed_label,
20774 );
20775 }
20776 }
20777
20778 Ok(vec![SearchIndexTarget {
20779 label: "index".to_string(),
20780 db_path: root.join(".tsift/index.db"),
20781 source_root: root.to_path_buf(),
20782 scope_name: None,
20783 reindex_cmd: format!("tsift index {}", root.display()),
20784 }])
20785}
20786
20787fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
20788 if !target.source_root.exists() || !target.db_path.exists() {
20789 return Ok(SearchIndexState::Missing);
20790 }
20791
20792 let inspection =
20793 index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
20794 let stale_files =
20795 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
20796 if stale_files == 0 {
20797 Ok(SearchIndexState::Fresh)
20798 } else {
20799 Ok(SearchIndexState::Stale { stale_files })
20800 }
20801}
20802
20803#[derive(Debug, Clone, PartialEq, Eq)]
20804struct RebuildSearchTarget {
20805 label: String,
20806 reason: RebuildSearchReason,
20807 reindex_cmd: String,
20808}
20809
20810#[derive(Debug, Clone, PartialEq, Eq)]
20811enum RebuildSearchReason {
20812 Missing,
20813 Stale { stale_files: usize },
20814}
20815
20816#[derive(Debug, Clone, PartialEq, Eq)]
20817struct DegradedSearchTarget {
20818 label: String,
20819 reason: RebuildSearchReason,
20820 reindex_cmd: String,
20821}
20822
20823#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20824pub(crate) enum DegradedSearchMode {
20825 ReadOnly,
20826 Exact,
20827}
20828
20829#[derive(Debug)]
20830struct SearchPrecheck {
20831 targets: Vec<SearchIndexTarget>,
20832 degraded_targets: Vec<DegradedSearchTarget>,
20833}
20834
20835fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
20836 err.chain().any(|cause| {
20837 cause
20838 .to_string()
20839 .contains("another tsift index writer is already active")
20840 })
20841}
20842
20843fn infer_agent_doc_task_submodule(
20844 root: &Path,
20845 path_hint: &Path,
20846) -> Result<Option<config::WorkspaceScope>> {
20847 let hinted_path = if path_hint.is_absolute() {
20848 path_hint.to_path_buf()
20849 } else {
20850 root.join(path_hint)
20851 };
20852 let Ok(relative) = hinted_path.strip_prefix(root) else {
20853 return Ok(None);
20854 };
20855 let mut components = relative.components();
20856 let Some(std::path::Component::Normal(first)) = components.next() else {
20857 return Ok(None);
20858 };
20859 if first != "tasks" {
20860 return Ok(None);
20861 }
20862 let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
20863 return Ok(None);
20864 };
20865 config::Config::find_submodule(root, file_stem)
20866}
20867
20868fn degraded_search_target(
20869 target: &SearchIndexTarget,
20870 reason: RebuildSearchReason,
20871) -> DegradedSearchTarget {
20872 DegradedSearchTarget {
20873 label: target.label.clone(),
20874 reason,
20875 reindex_cmd: target.reindex_cmd.clone(),
20876 }
20877}
20878
20879fn apply_search_index_update(
20880 root: &Path,
20881 target: &SearchIndexTarget,
20882) -> Result<index::IndexSummary> {
20883 run_index_update(
20884 &target.db_path,
20885 &target.source_root,
20886 format!("autoindexing {}", target.label),
20887 root,
20888 target.scope_name.as_deref(),
20889 false,
20890 false,
20891 )
20892}
20893
20894fn collect_rebuild_search_targets(
20895 targets: &[SearchIndexTarget],
20896) -> Result<Vec<RebuildSearchTarget>> {
20897 let mut rebuild_targets = Vec::new();
20898 for target in targets {
20899 let reason = match inspect_search_index(target)? {
20900 SearchIndexState::Missing => RebuildSearchReason::Missing,
20901 SearchIndexState::Fresh => continue,
20902 SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
20903 };
20904 rebuild_targets.push(RebuildSearchTarget {
20905 label: target.label.clone(),
20906 reason,
20907 reindex_cmd: target.reindex_cmd.clone(),
20908 });
20909 }
20910 Ok(rebuild_targets)
20911}
20912
20913fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
20914 match target.reason {
20915 RebuildSearchReason::Missing => format!("{} is missing", target.label),
20916 RebuildSearchReason::Stale { stale_files } => {
20917 let file_suffix = if stale_files == 1 { "" } else { "s" };
20918 format!(
20919 "{} is stale ({} file{})",
20920 target.label, stale_files, file_suffix
20921 )
20922 }
20923 }
20924}
20925
20926fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
20927 if rebuild_targets.len() == 1 {
20928 let target = &rebuild_targets[0];
20929 return format!(
20930 "{}. Run `{}` to rebuild before retrying.",
20931 rebuild_search_target_detail(target),
20932 target.reindex_cmd
20933 );
20934 }
20935
20936 let summary: Vec<String> = rebuild_targets
20937 .iter()
20938 .take(3)
20939 .map(rebuild_search_target_detail)
20940 .collect();
20941 let overflow = rebuild_targets.len().saturating_sub(summary.len());
20942 let mut details = summary.join(", ");
20943 if overflow > 0 {
20944 details.push_str(&format!(", +{} more", overflow));
20945 }
20946 let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
20947 format!(
20948 "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
20949 rebuild_targets.len(),
20950 details,
20951 reindex_cmd
20952 )
20953}
20954
20955pub(crate) fn precheck_search_indexes(
20956 root: &Path,
20957 path_hint: &Path,
20958 scope: Option<&str>,
20959 federated: bool,
20960 autoindex: bool,
20961) -> Result<SearchPrecheck> {
20962 let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
20963 let mut stale_targets = Vec::new();
20964 let mut degraded_targets = Vec::new();
20965
20966 for target in &targets {
20967 match inspect_search_index(target)? {
20968 SearchIndexState::Missing => {
20969 if autoindex && let Err(err) = apply_search_index_update(root, target) {
20970 if is_active_writer_lock_error(&err) {
20971 degraded_targets
20972 .push(degraded_search_target(target, RebuildSearchReason::Missing));
20973 } else {
20974 return Err(err);
20975 }
20976 }
20977 }
20978 SearchIndexState::Fresh => {}
20979 SearchIndexState::Stale { stale_files } => {
20980 if autoindex {
20981 if let Err(err) = apply_search_index_update(root, target) {
20982 if is_active_writer_lock_error(&err) {
20983 degraded_targets.push(degraded_search_target(
20984 target,
20985 RebuildSearchReason::Stale { stale_files },
20986 ));
20987 } else {
20988 return Err(err);
20989 }
20990 }
20991 } else {
20992 stale_targets.push(RebuildSearchTarget {
20993 label: target.label.clone(),
20994 reason: RebuildSearchReason::Stale { stale_files },
20995 reindex_cmd: target.reindex_cmd.clone(),
20996 });
20997 }
20998 }
20999 }
21000 }
21001
21002 if stale_targets.is_empty() {
21003 return Ok(SearchPrecheck {
21004 targets,
21005 degraded_targets,
21006 });
21007 }
21008
21009 bail!(
21010 "tsift search aborted: {} \
21011 or re-run without `--no-autoindex`.",
21012 rebuild_search_targets_message(&stale_targets),
21013 );
21014}
21015
21016pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
21017 if targets.is_empty() {
21018 return None;
21019 }
21020
21021 if targets
21022 .iter()
21023 .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
21024 {
21025 Some(DegradedSearchMode::Exact)
21026 } else {
21027 Some(DegradedSearchMode::ReadOnly)
21028 }
21029}
21030
21031fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
21032 if targets.len() == 1 {
21033 let target = &targets[0];
21034 return match target.reason {
21035 RebuildSearchReason::Missing => format!("{} is missing", target.label),
21036 RebuildSearchReason::Stale { stale_files } => {
21037 let file_suffix = if stale_files == 1 { "" } else { "s" };
21038 format!(
21039 "{} is stale ({} file{})",
21040 target.label, stale_files, file_suffix
21041 )
21042 }
21043 };
21044 }
21045
21046 let missing = targets
21047 .iter()
21048 .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
21049 .count();
21050 let stale = targets.len().saturating_sub(missing);
21051 let mut parts = Vec::new();
21052 if stale > 0 {
21053 let suffix = if stale == 1 { "" } else { "es" };
21054 parts.push(format!("{stale} stale index{suffix}"));
21055 }
21056 if missing > 0 {
21057 let suffix = if missing == 1 { "" } else { "es" };
21058 parts.push(format!("{missing} missing index{suffix}"));
21059 }
21060 parts.join(", ")
21061}
21062
21063pub(crate) fn emit_degraded_search_note(
21064 targets: &[DegradedSearchTarget],
21065 mode: DegradedSearchMode,
21066) {
21067 let summary = degraded_search_targets_summary(targets);
21068 let reindex_cmd = &targets[0].reindex_cmd;
21069 match mode {
21070 DegradedSearchMode::ReadOnly => eprintln!(
21071 "note: active tsift writer detected; skipping autoindex because {}. \
21072 Continuing with read-only search and the current index snapshot; symbol hits may lag. \
21073 Retry `{}` after the active writer finishes for fresh index results.",
21074 summary, reindex_cmd
21075 ),
21076 DegradedSearchMode::Exact => eprintln!(
21077 "note: active tsift writer detected; skipping autoindex because {}. \
21078 Continuing with exact live-file search. Retry `{}` after the active writer finishes \
21079 for indexed symbol hits.",
21080 summary, reindex_cmd
21081 ),
21082 }
21083}
21084
21085fn search_timeout_message(
21086 timeout_secs: u64,
21087 strategy: &str,
21088 targets: &[SearchIndexTarget],
21089) -> Result<String> {
21090 let rebuild_targets = collect_rebuild_search_targets(targets)?;
21091 if rebuild_targets.is_empty() {
21092 return Ok(format!(
21093 "tsift search timed out after {}s (strategy: {}). \
21094 The search root looks fresh, so reindexing is unlikely to help. \
21095 Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
21096 or try a different strategy.",
21097 timeout_secs, strategy,
21098 ));
21099 }
21100
21101 Ok(format!(
21102 "tsift search timed out after {}s (strategy: {}). {}",
21103 timeout_secs,
21104 strategy,
21105 rebuild_search_targets_message(&rebuild_targets),
21106 ))
21107}
21108
21109fn is_exact_preferring_query_char(ch: char) -> bool {
21110 matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
21111}
21112
21113fn query_prefers_exact_search(query: &str) -> bool {
21114 let trimmed = query.trim();
21115 !trimmed.is_empty()
21116 && !trimmed.chars().any(char::is_whitespace)
21117 && trimmed.chars().any(|ch| ch.is_alphanumeric())
21118 && trimmed.chars().any(is_exact_preferring_query_char)
21119 && trimmed
21120 .chars()
21121 .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
21122}
21123
21124pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
21125 strategy.unwrap_or_else(|| {
21126 if query_prefers_exact_search(query) {
21127 "exact".to_string()
21128 } else {
21129 "lexical".to_string()
21130 }
21131 })
21132}
21133
21134
21135pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21136 let mut files = Vec::new();
21137 if path.is_file() {
21138 files.push(path.to_path_buf());
21139 return Ok(files);
21140 }
21141 let walker = ignore::WalkBuilder::new(path)
21142 .hidden(true)
21143 .git_ignore(true)
21144 .build();
21145 for entry in walker {
21146 let entry = entry?;
21147 if entry.file_type().is_some_and(|ft| ft.is_file()) {
21148 let p = entry.path();
21149 if let Some(ext) = p.extension() {
21150 let ext = ext.to_string_lossy();
21151 if matches!(
21152 ext.as_ref(),
21153 "rs" | "py"
21154 | "ts"
21155 | "tsx"
21156 | "js"
21157 | "jsx"
21158 | "kt"
21159 | "kts"
21160 | "zig"
21161 | "sh"
21162 | "bash"
21163 | "zsh"
21164 ) {
21165 files.push(p.to_path_buf());
21166 }
21167 }
21168 }
21169 }
21170 Ok(files)
21171}
21172
21173#[cfg(test)]
21174 mod tests {
21175 use super::*;
21176 use super::semantic_edit::{
21177 EditOp,
21178 apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21179 markdown_section_spans,
21180 };
21181 use tsift_memory::{MemoryEventKind, MemoryStore};
21182
21183 use std::cell::RefCell;
21184 use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21185 fn parse_cli<I, T>(itr: I) -> Cli
21186 where
21187 I: IntoIterator<Item = T> + Send + 'static,
21188 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21189 {
21190 std::thread::Builder::new()
21191 .name("cli-parse".to_string())
21192 .stack_size(16 * 1024 * 1024)
21193 .spawn(move || Cli::parse_from(itr))
21194 .unwrap()
21195 .join()
21196 .unwrap()
21197 }
21198
21199 fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21200 where
21201 I: IntoIterator<Item = T> + Send + 'static,
21202 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21203 {
21204 std::thread::Builder::new()
21205 .name("cli-try-parse".to_string())
21206 .stack_size(16 * 1024 * 1024)
21207 .spawn(move || Cli::try_parse_from(itr))
21208 .unwrap()
21209 .join()
21210 .unwrap()
21211 }
21212
21213 fn build_relative_search_budget_report(
21214 query: &str,
21215 strategy: &str,
21216 root: &Path,
21217 response: &sift::SearchResponse,
21218 symbol_hits: &[index::SymbolHit],
21219 budget: ResponseBudget,
21220 filters: &SearchFacetFilters,
21221 ) -> SearchBudgetReport {
21222 build_search_budget_report(SearchBudgetReportInput {
21223 query,
21224 strategy,
21225 root,
21226 response,
21227 symbol_hits,
21228 absolute: false,
21229 budget,
21230 filters,
21231 })
21232 }
21233
21234 #[derive(Default)]
21235 struct MemoryConvexGraphClient {
21236 nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
21237 edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
21238 }
21239
21240 impl ConvexGraphClient for MemoryConvexGraphClient {
21241 fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
21242 self.nodes
21243 .borrow_mut()
21244 .insert(row.external_id.clone(), row.clone());
21245 Ok(())
21246 }
21247
21248 fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
21249 self.edges
21250 .borrow_mut()
21251 .insert(row.edge_key.clone(), row.clone());
21252 Ok(())
21253 }
21254
21255 fn delete_node_row(&self, external_id: &str) -> Result<usize> {
21256 Ok(usize::from(
21257 self.nodes.borrow_mut().remove(external_id).is_some(),
21258 ))
21259 }
21260
21261 fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
21262 Ok(usize::from(
21263 self.edges.borrow_mut().remove(edge_key).is_some(),
21264 ))
21265 }
21266
21267 fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
21268 Ok(self.nodes.borrow().get(external_id).cloned())
21269 }
21270
21271 fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
21272 Ok(self.nodes.borrow().values().cloned().collect())
21273 }
21274
21275 fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
21276 Ok(self.edges.borrow().values().cloned().collect())
21277 }
21278
21279 fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
21280 Ok(self
21281 .nodes
21282 .borrow()
21283 .values()
21284 .filter(|row| row.kind == kind)
21285 .cloned()
21286 .collect())
21287 }
21288
21289 fn outgoing_edge_rows(
21290 &self,
21291 from_external_id: &str,
21292 kind: Option<&str>,
21293 ) -> Result<Vec<ConvexEdgeRow>> {
21294 Ok(self
21295 .edges
21296 .borrow()
21297 .values()
21298 .filter(|row| row.from_external_id == from_external_id)
21299 .filter(|row| kind.is_none_or(|kind| row.kind == kind))
21300 .cloned()
21301 .collect())
21302 }
21303 }
21304
21305 fn init_git_repo(path: &Path) {
21306 let status = std::process::Command::new("git")
21307 .args(["init"])
21308 .current_dir(path)
21309 .status()
21310 .unwrap();
21311 assert!(status.success(), "git init failed");
21312
21313 let status = std::process::Command::new("git")
21314 .args(["add", "."])
21315 .current_dir(path)
21316 .status()
21317 .unwrap();
21318 assert!(status.success(), "git add failed");
21319
21320 let status = std::process::Command::new("git")
21321 .args([
21322 "-c",
21323 "user.name=tsift-tests",
21324 "-c",
21325 "user.email=tsift-tests@example.com",
21326 "commit",
21327 "--quiet",
21328 "-m",
21329 "init",
21330 ])
21331 .current_dir(path)
21332 .status()
21333 .unwrap();
21334 assert!(status.success(), "git commit failed");
21335 }
21336
21337 fn write_empty_root_index(root: &Path) {
21338 let index_dir = root.join(".tsift");
21339 fs::create_dir_all(&index_dir).unwrap();
21340 fs::write(index_dir.join("index.db"), "").unwrap();
21341 }
21342
21343 fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
21344 if let Some(parent) = path.parent() {
21345 fs::create_dir_all(parent).unwrap();
21346 }
21347 let body = std::iter::repeat_n(line, lines)
21348 .collect::<Vec<_>>()
21349 .join("\n");
21350 fs::write(path, format!("{body}\n")).unwrap();
21351 path.to_path_buf()
21352 }
21353
21354 #[test]
21357 fn token_capped_preview_returns_all_lines_when_under_cap() {
21358 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 1", "}"];
21359 let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
21360 assert!(!result.was_capped);
21361 assert_eq!(result.preview.len(), 3);
21362 assert_eq!(result.capped_end, 3);
21363 }
21364
21365 #[test]
21366 fn token_capped_preview_truncates_when_over_cap() {
21367 let lines: Vec<&str> = (0..200).map(|_| " let x = some_very_long_expression_here();").collect();
21368 let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
21369 assert!(result.was_capped);
21370 assert!(result.preview.len() < 200);
21371 assert!(result.capped_end < 200);
21372 }
21373
21374 #[test]
21375 fn token_capped_preview_keeps_at_least_one_line() {
21376 let long_line: String = "x".repeat(8000);
21377 let lines: Vec<&str> = vec![&long_line];
21378 let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
21379 assert!(!result.was_capped);
21380 assert_eq!(result.preview.len(), 1);
21381 }
21382
21383 #[test]
21384 fn token_capped_preview_cap_at_boundary() {
21385 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21386 let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
21387 assert!(!result.was_capped);
21388 assert_eq!(result.preview.len(), 4);
21389 }
21390
21391 #[test]
21392 fn token_capped_preview_cap_just_over_boundary() {
21393 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21394 let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
21395 assert!(result.was_capped);
21396 assert_eq!(result.preview.len(), 3);
21397 assert_eq!(result.capped_end, 3);
21398 }
21399
21400 #[test]
21401 fn token_capped_preview_empty_lines() {
21402 let lines: Vec<&str> = vec![];
21403 let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
21404 assert!(!result.was_capped);
21405 assert!(result.preview.is_empty());
21406 }
21407
21408 #[test]
21409 fn token_capped_preview_per_line_truncation_applied() {
21410 let long_line = "x".repeat(500);
21411 let lines: Vec<&str> = vec![&long_line, "short"];
21412 let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
21413 assert!(!result.was_capped);
21414 assert_eq!(result.preview.len(), 2);
21415 assert!(result.preview[0].text.len() <= 23);
21416 assert!(result.preview[0].text.ends_with("..."));
21417 }
21418
21419 #[test]
21422 fn route_search_defaults_to_haiku() {
21423 let (tier, model) = classify_task("find all uses of authenticate");
21424 assert_eq!(tier, "haiku");
21425 assert!(
21426 model.contains("haiku"),
21427 "expected haiku model, got {}",
21428 model
21429 );
21430 }
21431
21432 #[test]
21433 fn route_edit_keywords_to_sonnet() {
21434 for kw in &[
21435 "edit the file",
21436 "fix the bug",
21437 "update the config",
21438 "remove dead code",
21439 "create a new module",
21440 ] {
21441 let (tier, _) = classify_task(kw);
21442 assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
21443 }
21444 }
21445
21446 #[test]
21447 fn route_architecture_keywords_to_opus() {
21448 for kw in &[
21449 "design the API",
21450 "architecture review",
21451 "plan the migration",
21452 "analyze the system",
21453 "evaluate trade-offs",
21454 ] {
21455 let (tier, _) = classify_task(kw);
21456 assert_eq!(tier, "opus", "expected opus for {:?}", kw);
21457 }
21458 }
21459
21460 #[test]
21461 fn route_architecture_beats_edit() {
21462 let (tier, _) = classify_task("design and implement the new auth service");
21464 assert_eq!(tier, "opus");
21465 }
21466
21467 #[test]
21468 fn cli_accepts_global_compact_flag() {
21469 let cli = parse_cli(["tsift", "--compact", "status"]);
21470 assert!(cli.compact);
21471 assert!(matches!(cli.command, Some(Commands::Status { .. })));
21472 }
21473
21474 #[test]
21475 fn summarize_diff_scope_matches_relative_directory() {
21476 let root = Path::new("/repo");
21477 let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
21478
21479 assert!(summarize_diff_matches_scope(
21480 Path::new("/repo/src/feature/main.rs"),
21481 &extract_scope
21482 ));
21483 assert!(!summarize_diff_matches_scope(
21484 Path::new("/repo/src/other/main.rs"),
21485 &extract_scope
21486 ));
21487 }
21488
21489 #[test]
21490 fn summarize_diff_scope_matches_relative_file() {
21491 let root = Path::new("/repo");
21492 let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
21493
21494 assert!(summarize_diff_matches_scope(
21495 Path::new("/repo/src/feature/main.rs"),
21496 &extract_scope
21497 ));
21498 assert!(!summarize_diff_matches_scope(
21499 Path::new("/repo/src/feature/lib.rs"),
21500 &extract_scope
21501 ));
21502 }
21503
21504 #[test]
21505 fn summarize_extract_scope_walks_relative_paths_from_root() {
21506 let dir = tempfile::tempdir().unwrap();
21507 let source_dir = dir.path().join("src");
21508 std::fs::create_dir_all(&source_dir).unwrap();
21509 let main_rs = source_dir.join("main.rs");
21510 std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
21511
21512 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
21513 let files = collect_source_files(&extract_scope).unwrap();
21514
21515 assert_eq!(files, vec![main_rs]);
21516 }
21517
21518 #[test]
21519 fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
21520 let dir = tempfile::tempdir().unwrap();
21521 let nested = dir.path().join("src/nested");
21522 std::fs::create_dir_all(&nested).unwrap();
21523 std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
21524 let nested_file = nested.join("main.rs");
21525 std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
21526
21527 let extract_base = resolve_extract_base(&nested).unwrap();
21528 let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
21529 let files = collect_source_files(&extract_scope).unwrap();
21530
21531 assert_eq!(extract_scope, nested);
21532 assert_eq!(files, vec![nested_file]);
21533 }
21534
21535 #[test]
21536 fn summarize_extract_base_uses_parent_of_file_path() {
21537 let dir = tempfile::tempdir().unwrap();
21538 let nested = dir.path().join("src/nested");
21539 std::fs::create_dir_all(&nested).unwrap();
21540 let file_path = nested.join("main.rs");
21541 std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
21542
21543 let extract_base = resolve_extract_base(&file_path).unwrap();
21544
21545 assert_eq!(extract_base, nested);
21546 }
21547
21548 #[test]
21549 fn summarize_extract_scope_normalizes_dotdot_segments() {
21550 let dir = tempfile::tempdir().unwrap();
21551 let source_dir = dir.path().join("src");
21552 std::fs::create_dir_all(&source_dir).unwrap();
21553
21554 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
21555
21556 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21557 assert!(summarize_diff_matches_scope(
21558 &source_dir.join("main.rs"),
21559 &extract_scope
21560 ));
21561 }
21562
21563 #[cfg(unix)]
21564 #[test]
21565 fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
21566 use std::os::unix::fs::symlink;
21567
21568 let dir = tempfile::tempdir().unwrap();
21569 let real_root = dir.path().join("real");
21570 let source_dir = real_root.join("src");
21571 std::fs::create_dir_all(&source_dir).unwrap();
21572 let symlink_scope = dir.path().join("scope-link");
21573 symlink(&source_dir, &symlink_scope).unwrap();
21574
21575 let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
21576
21577 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21578 assert!(summarize_diff_matches_scope(
21579 &source_dir.join("lib.rs"),
21580 &extract_scope
21581 ));
21582 }
21583
21584 #[test]
21585 fn summarize_diff_extract_includes_untracked_files() {
21586 let dir = tempfile::tempdir().unwrap();
21587 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21588 init_git_repo(dir.path());
21589
21590 let source_dir = dir.path().join("src");
21591 std::fs::create_dir_all(&source_dir).unwrap();
21592 let new_file = source_dir.join("new.rs");
21593 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21594
21595 let files = summarize::git_changed_files(dir.path()).unwrap();
21596
21597 assert_eq!(files.existing, vec![new_file]);
21598 assert!(files.deleted.is_empty());
21599 }
21600
21601 #[test]
21602 fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
21603 let dir = tempfile::tempdir().unwrap();
21604 let status = std::process::Command::new("git")
21605 .args(["init"])
21606 .current_dir(dir.path())
21607 .status()
21608 .unwrap();
21609 assert!(status.success(), "git init failed");
21610
21611 let source_dir = dir.path().join("src");
21612 std::fs::create_dir_all(&source_dir).unwrap();
21613 let new_file = source_dir.join("new.rs");
21614 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21615
21616 let files = summarize::git_changed_files(dir.path()).unwrap();
21617
21618 assert_eq!(files.existing, vec![new_file]);
21619 assert!(files.deleted.is_empty());
21620 }
21621
21622 #[test]
21623 fn summarize_diff_extract_tracks_deleted_files() {
21624 let dir = tempfile::tempdir().unwrap();
21625 let source_dir = dir.path().join("src");
21626 std::fs::create_dir_all(&source_dir).unwrap();
21627 let deleted_file = source_dir.join("gone.rs");
21628 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21629 init_git_repo(dir.path());
21630
21631 std::fs::remove_file(&deleted_file).unwrap();
21632
21633 let files = summarize::git_changed_files(dir.path()).unwrap();
21634
21635 assert!(files.existing.is_empty());
21636 assert_eq!(files.deleted, vec![deleted_file]);
21637 }
21638
21639 #[test]
21640 fn summarize_diff_extract_tracks_git_renames() {
21641 let dir = tempfile::tempdir().unwrap();
21642 let source_dir = dir.path().join("src");
21643 std::fs::create_dir_all(&source_dir).unwrap();
21644 let old_file = source_dir.join("old.rs");
21645 let new_file = source_dir.join("new.rs");
21646 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21647 init_git_repo(dir.path());
21648
21649 let status = std::process::Command::new("git")
21650 .args(["mv", "src/old.rs", "src/new.rs"])
21651 .current_dir(dir.path())
21652 .status()
21653 .unwrap();
21654 assert!(status.success(), "git mv failed");
21655
21656 let files = summarize::git_changed_files(dir.path()).unwrap();
21657
21658 assert_eq!(files.existing, vec![new_file]);
21659 assert_eq!(files.deleted, vec![old_file]);
21660 }
21661
21662 #[test]
21663 fn summarize_diff_extract_deletes_removed_summary_rows() {
21664 let dir = tempfile::tempdir().unwrap();
21665 let source_dir = dir.path().join("src");
21666 std::fs::create_dir_all(&source_dir).unwrap();
21667 let deleted_file = source_dir.join("gone.rs");
21668 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21669 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21670 init_git_repo(dir.path());
21671
21672 let summary_db =
21673 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21674 summary_db
21675 .insert(&summarize::Summary {
21676 id: 0,
21677 symbol_name: "stale".to_string(),
21678 file_path: "src/gone.rs".to_string(),
21679 content_hash: "hash1".to_string(),
21680 summary: "stale summary".to_string(),
21681 entities: None,
21682 relationships: None,
21683 concept_labels: None,
21684 extracted_at: "1700000000".to_string(),
21685 model: "test".to_string(),
21686 tokens_input: Some(100),
21687 tokens_output: Some(50),
21688 })
21689 .unwrap();
21690
21691 std::fs::remove_file(&deleted_file).unwrap();
21692
21693 cmd_summarize(
21694 None,
21695 None,
21696 Some(PathBuf::from("src")),
21697 true,
21698 false,
21699 dir.path(),
21700 false,
21701 true,
21702 false,
21703 false,
21704 false,
21705 )
21706 .unwrap();
21707
21708 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21709 }
21710
21711 #[test]
21712 fn summarize_diff_extract_deletes_renamed_summary_rows() {
21713 let dir = tempfile::tempdir().unwrap();
21714 let source_dir = dir.path().join("src");
21715 std::fs::create_dir_all(&source_dir).unwrap();
21716 let old_file = source_dir.join("old.rs");
21717 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21718 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21719 init_git_repo(dir.path());
21720
21721 let summary_db =
21722 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21723 summary_db
21724 .insert(&summarize::Summary {
21725 id: 0,
21726 symbol_name: "stale".to_string(),
21727 file_path: "src/old.rs".to_string(),
21728 content_hash: "hash1".to_string(),
21729 summary: "stale summary".to_string(),
21730 entities: None,
21731 relationships: None,
21732 concept_labels: None,
21733 extracted_at: "1700000000".to_string(),
21734 model: "test".to_string(),
21735 tokens_input: Some(100),
21736 tokens_output: Some(50),
21737 })
21738 .unwrap();
21739
21740 let status = std::process::Command::new("git")
21741 .args(["mv", "src/old.rs", "src/new.rs"])
21742 .current_dir(dir.path())
21743 .status()
21744 .unwrap();
21745 assert!(status.success(), "git mv failed");
21746
21747 cmd_summarize(
21748 None,
21749 None,
21750 Some(PathBuf::from("src")),
21751 true,
21752 false,
21753 dir.path(),
21754 false,
21755 true,
21756 false,
21757 false,
21758 false,
21759 )
21760 .unwrap();
21761
21762 assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
21763 }
21764
21765 #[test]
21766 fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
21767 let dir = tempfile::tempdir().unwrap();
21768 let source_dir = dir.path().join("src");
21769 std::fs::create_dir_all(&source_dir).unwrap();
21770 let deleted_file = source_dir.join("gone.rs");
21771 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21772
21773 let summary_db =
21774 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21775 summary_db
21776 .insert(&summarize::Summary {
21777 id: 0,
21778 symbol_name: "stale".to_string(),
21779 file_path: "src/gone.rs".to_string(),
21780 content_hash: "hash1".to_string(),
21781 summary: "stale summary".to_string(),
21782 entities: None,
21783 relationships: None,
21784 concept_labels: None,
21785 extracted_at: "1700000000".to_string(),
21786 model: "test".to_string(),
21787 tokens_input: Some(100),
21788 tokens_output: Some(50),
21789 })
21790 .unwrap();
21791
21792 std::fs::remove_file(&deleted_file).unwrap();
21793
21794 cmd_summarize(
21795 None,
21796 None,
21797 Some(PathBuf::from("src")),
21798 false,
21799 false,
21800 dir.path(),
21801 false,
21802 true,
21803 false,
21804 false,
21805 false,
21806 )
21807 .unwrap();
21808
21809 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21810 }
21811
21812 #[test]
21813 fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
21814 let dir = tempfile::tempdir().unwrap();
21815 let source_dir = dir.path().join("src");
21816 std::fs::create_dir_all(&source_dir).unwrap();
21817 let file = source_dir.join("lib.rs");
21818 std::fs::write(&file, "fn helper() {}\n").unwrap();
21819
21820 let content = std::fs::read(&file).unwrap();
21821 let summary_db =
21822 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21823 summary_db
21824 .insert(&summarize::Summary {
21825 id: 0,
21826 symbol_name: "lib.rs".to_string(),
21827 file_path: "src/lib.rs".to_string(),
21828 content_hash: summarize::content_hash(&content),
21829 summary: "cached summary".to_string(),
21830 entities: None,
21831 relationships: None,
21832 concept_labels: None,
21833 extracted_at: "1700000000".to_string(),
21834 model: "test".to_string(),
21835 tokens_input: Some(100),
21836 tokens_output: Some(50),
21837 })
21838 .unwrap();
21839 drop(summary_db);
21840
21841 let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
21842 let _lock = hold_writer_lock(&lock_path);
21843
21844 let err = cmd_summarize(
21845 None,
21846 None,
21847 Some(PathBuf::from("src")),
21848 false,
21849 false,
21850 dir.path(),
21851 false,
21852 true,
21853 false,
21854 false,
21855 false,
21856 )
21857 .unwrap_err();
21858 let message = err.to_string();
21859
21860 assert!(message.contains("another tsift summarize extractor is already active"));
21861 assert!(message.contains("tsift summarize --extract"));
21862 }
21863
21864 #[test]
21865 fn summarize_stats_fails_closed_when_cache_missing() {
21866 let dir = tempfile::tempdir().unwrap();
21867 let err = cmd_summarize(
21868 None,
21869 None,
21870 None,
21871 false,
21872 true,
21873 dir.path(),
21874 false,
21875 false,
21876 false,
21877 false,
21878 false,
21879 )
21880 .unwrap_err();
21881
21882 assert!(
21883 err.to_string().contains("no summaries.db found"),
21884 "got: {err}"
21885 );
21886 assert!(!dir.path().join(".tsift/summaries.db").exists());
21887 }
21888
21889 #[test]
21890 fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21891 let dir = tempfile::tempdir().unwrap();
21892 let summary_db =
21893 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21894 summary_db
21895 .insert(&summarize::Summary {
21896 id: 0,
21897 symbol_name: "alpha_helper".to_string(),
21898 file_path: "src/lib.rs".to_string(),
21899 content_hash: "hash1".to_string(),
21900 summary: "cached summary".to_string(),
21901 entities: None,
21902 relationships: None,
21903 concept_labels: None,
21904 extracted_at: "1700000000".to_string(),
21905 model: "claude-haiku-4-5-20251001".to_string(),
21906 tokens_input: Some(100),
21907 tokens_output: Some(40),
21908 })
21909 .unwrap();
21910 drop(summary_db);
21911 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21912
21913 let result = cmd_summarize(
21914 None,
21915 None,
21916 None,
21917 false,
21918 true,
21919 dir.path(),
21920 false,
21921 false,
21922 false,
21923 false,
21924 false,
21925 );
21926
21927 assert!(result.is_ok());
21928 }
21929
21930 #[test]
21931 fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21932 let dir = tempfile::tempdir().unwrap();
21933 let summary_db =
21934 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21935 summary_db
21936 .insert(&summarize::Summary {
21937 id: 0,
21938 symbol_name: "alpha_helper".to_string(),
21939 file_path: "src/lib.rs".to_string(),
21940 content_hash: "hash1".to_string(),
21941 summary: "cached summary".to_string(),
21942 entities: None,
21943 relationships: None,
21944 concept_labels: None,
21945 extracted_at: "1700000000".to_string(),
21946 model: "claude-haiku-4-5-20251001".to_string(),
21947 tokens_input: Some(100),
21948 tokens_output: Some(40),
21949 })
21950 .unwrap();
21951 drop(summary_db);
21952 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21953
21954 let result = cmd_summarize(
21955 Some("alpha_helper".to_string()),
21956 None,
21957 None,
21958 false,
21959 false,
21960 dir.path(),
21961 false,
21962 true,
21963 false,
21964 false,
21965 false,
21966 );
21967
21968 assert!(result.is_ok());
21969 }
21970
21971 #[test]
21972 fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
21973 let dir = tempfile::tempdir().unwrap();
21974 let nested = dir.path().join("src/nested");
21975 std::fs::create_dir_all(&nested).unwrap();
21976
21977 let summary_db =
21978 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21979 summary_db
21980 .insert(&summarize::Summary {
21981 id: 0,
21982 symbol_name: "alpha_helper".to_string(),
21983 file_path: "src/lib.rs".to_string(),
21984 content_hash: "hash1".to_string(),
21985 summary: "cached summary".to_string(),
21986 entities: None,
21987 relationships: None,
21988 concept_labels: None,
21989 extracted_at: "1700000000".to_string(),
21990 model: "claude-haiku-4-5-20251001".to_string(),
21991 tokens_input: Some(100),
21992 tokens_output: Some(40),
21993 })
21994 .unwrap();
21995
21996 let result = cmd_summarize(
21997 Some("alpha_helper".to_string()),
21998 None,
21999 None,
22000 false,
22001 false,
22002 &nested,
22003 false,
22004 true,
22005 false,
22006 false,
22007 false,
22008 );
22009
22010 assert!(result.is_ok());
22011 assert!(!nested.join(".tsift/summaries.db").exists());
22012 }
22013
22014 #[test]
22015 fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
22016 let dir = tempfile::tempdir().unwrap();
22017 std::fs::write(
22018 dir.path().join(".gitmodules"),
22019 r#"[submodule "src/alpha"]
22020 path = src/alpha
22021 url = https://example.com/alpha
22022[submodule "src/beta"]
22023 path = src/beta
22024 url = https://example.com/beta
22025"#,
22026 )
22027 .unwrap();
22028
22029 let alpha_root = dir.path().join("src/alpha");
22030 let beta_root = dir.path().join("src/beta");
22031 std::fs::create_dir_all(alpha_root.join("src")).unwrap();
22032 std::fs::create_dir_all(beta_root.join("src")).unwrap();
22033 std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
22034 std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
22035 std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
22036 let beta_file = beta_root.join("src/lib.rs");
22037 std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
22038 std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
22039 std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
22040
22041 let context = find_symbols_db_for_file(dir.path(), &beta_file)
22042 .unwrap()
22043 .expect("expected matching scoped index");
22044
22045 assert_eq!(
22046 context.db_path,
22047 dir.path().join(".tsift/indexes/beta/index.db")
22048 );
22049 assert_eq!(context.source_root, beta_root);
22050 }
22051
22052 fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
22055 EditOp {
22056 file: PathBuf::from("dummy.txt"),
22057 old: old.to_string(),
22058 new: new.to_string(),
22059 replace_all,
22060 }
22061 }
22062
22063 #[test]
22064 fn edit_replaces_single_occurrence() {
22065 let content = "hello world";
22066 let op = make_op("world", "rust", false);
22067 let (result, count) = apply_edit_op(content, &op).unwrap();
22068 assert_eq!(result, "hello rust");
22069 assert_eq!(count, 1);
22070 }
22071
22072 #[test]
22073 fn edit_replace_all_replaces_every_occurrence() {
22074 let content = "foo foo foo";
22075 let op = make_op("foo", "bar", true);
22076 let (result, count) = apply_edit_op(content, &op).unwrap();
22077 assert_eq!(result, "bar bar bar");
22078 assert_eq!(count, 3);
22079 }
22080
22081 #[test]
22082 fn edit_fails_when_old_not_found() {
22083 let content = "hello world";
22084 let op = make_op("missing", "x", false);
22085 assert!(apply_edit_op(content, &op).is_err());
22086 }
22087
22088 #[test]
22089 fn edit_fails_when_ambiguous_without_replace_all() {
22090 let content = "foo foo";
22091 let op = make_op("foo", "bar", false);
22092 let err = apply_edit_op(content, &op).unwrap_err();
22093 assert!(err.to_string().contains("2 times"), "got: {}", err);
22094 }
22095
22096 #[test]
22097 fn edit_fails_when_old_equals_new() {
22098 let content = "hello";
22099 let op = make_op("hello", "hello", false);
22100 assert!(apply_edit_op(content, &op).is_err());
22101 }
22102
22103 #[test]
22104 fn edit_batch_rolls_back_when_later_swap_fails() {
22105 let dir = tempfile::tempdir().unwrap();
22106 let alpha = dir.path().join("alpha.txt");
22107 let beta = dir.path().join("beta.txt");
22108 fs::write(&alpha, "alpha old\n").unwrap();
22109 fs::write(&beta, "beta old\n").unwrap();
22110
22111 let batch = EditBatch {
22112 edits: vec![
22113 EditOp {
22114 file: alpha.clone(),
22115 old: "old".to_string(),
22116 new: "new".to_string(),
22117 replace_all: false,
22118 },
22119 EditOp {
22120 file: beta.clone(),
22121 old: "old".to_string(),
22122 new: "new".to_string(),
22123 replace_all: false,
22124 },
22125 ],
22126 };
22127
22128 let plan = build_edit_plan(&batch).unwrap();
22129 let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
22130 if commit_index == 1 {
22131 bail!("simulated swap failure");
22132 }
22133 Ok(())
22134 }) {
22135 Ok(_) => panic!("expected simulated swap failure"),
22136 Err(err) => err,
22137 };
22138
22139 assert!(err.to_string().contains("simulated swap failure"));
22140 assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22141 assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22142 }
22143
22144 fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22147 let tmp = tempfile::NamedTempFile::new().unwrap();
22148 let conn = Connection::open(tmp.path()).unwrap();
22149 conn.execute_batch(
22150 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22151 INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22152 INSERT INTO users VALUES (2, 'Bob', NULL);
22153 CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22154 FOREIGN KEY(user_id) REFERENCES users(id));
22155 INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22156 INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22157 INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22158 ).unwrap();
22159 (tmp, conn)
22160 }
22161
22162 #[test]
22165 fn rewrite_rg_simple_pattern() {
22166 let result = rewrite_command("rg authenticate");
22167 assert_eq!(
22168 result,
22169 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22170 );
22171 }
22172
22173 #[test]
22174 fn rewrite_rg_with_path() {
22175 let result = rewrite_command("rg authenticate src/");
22176 assert_eq!(
22177 result,
22178 Some(
22179 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22180 .to_string()
22181 )
22182 );
22183 }
22184
22185 #[test]
22186 fn rewrite_rg_with_flags_ignored() {
22187 let result = rewrite_command("rg -i authenticate src/");
22188 assert_eq!(
22189 result,
22190 Some(
22191 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22192 .to_string()
22193 )
22194 );
22195 }
22196
22197 #[test]
22198 fn rewrite_rg_with_type_flag() {
22199 let result = rewrite_command("rg -t rs authenticate");
22201 assert_eq!(
22202 result,
22203 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
22204 );
22205 }
22206
22207 #[test]
22208 fn rewrite_rg_pipe_passthrough() {
22209 let result = rewrite_command("rg authenticate | head -5");
22211 assert_eq!(result, None);
22212 }
22213
22214 #[test]
22215 fn rewrite_rg_files_passthrough() {
22216 let result = rewrite_command("rg --files src/tsift .agent-doc logs");
22217 assert_eq!(result, None);
22218 }
22219
22220 #[test]
22221 fn rewrite_find_passthrough() {
22222 let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
22223 assert_eq!(result, None);
22224 }
22225
22226 #[test]
22227 fn rewrite_grep_recursive() {
22228 let result = rewrite_command("grep -r authenticate src/");
22229 assert_eq!(
22230 result,
22231 Some(
22232 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22233 .to_string()
22234 )
22235 );
22236 }
22237
22238 #[test]
22239 fn rewrite_grep_non_recursive_passthrough() {
22240 let result = rewrite_command("grep authenticate file.txt");
22241 assert_eq!(result, None);
22242 }
22243
22244 #[test]
22245 fn rewrite_tsift_passthrough() {
22246 let result = rewrite_command("tsift search \"foo\"");
22247 assert_eq!(result, Some("tsift search \"foo\"".to_string()));
22248 }
22249
22250 #[test]
22251 fn rewrite_run_tsift_search_disables_timeout_by_default() {
22252 let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
22253 assert_eq!(
22254 result,
22255 "tsift search hookcaps --exact --path /tmp/x --timeout 0"
22256 );
22257 }
22258
22259 #[test]
22260 fn rewrite_run_preserves_explicit_search_timeout() {
22261 let result = effective_rewrite_run_command(
22262 "tsift search hookcaps --exact --path /tmp/x --timeout 5",
22263 );
22264 assert_eq!(
22265 result,
22266 "tsift search hookcaps --exact --path /tmp/x --timeout 5"
22267 );
22268 }
22269
22270 #[test]
22271 fn rewrite_unrelated_passthrough() {
22272 let result = rewrite_command("echo cargo build");
22273 assert_eq!(result, None);
22274 }
22275
22276 #[test]
22277 fn rewrite_rg_quoted_pattern() {
22278 let result = rewrite_command("rg \"fn main\"");
22279 assert_eq!(
22280 result,
22281 Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
22282 );
22283 }
22284
22285 #[test]
22286 fn rewrite_git_diff_to_diff_digest() {
22287 let result = rewrite_command("git diff");
22288 assert_eq!(result, Some("tsift diff-digest .".to_string()));
22289 }
22290
22291 #[test]
22292 fn rewrite_git_diff_cached_to_diff_digest() {
22293 let result = rewrite_command("git diff --cached");
22294 assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
22295 }
22296
22297 #[test]
22298 fn rewrite_git_diff_with_path_to_diff_digest() {
22299 let result = rewrite_command("git diff -- src/");
22300 assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
22301 }
22302
22303 #[test]
22304 fn rewrite_git_diff_with_revision_passthrough() {
22305 let result = rewrite_command("git diff HEAD~1");
22306 assert_eq!(result, None);
22307 }
22308
22309 #[test]
22310 fn rewrite_git_show_to_revision_diff_digest() {
22311 let result = rewrite_command("git show HEAD~1");
22312 assert_eq!(
22313 result,
22314 Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
22315 );
22316 }
22317
22318 #[test]
22319 fn rewrite_git_log_patch_history_to_revision_diff_digest() {
22320 let result = rewrite_command("git log -p -1 HEAD~2");
22321 assert_eq!(
22322 result,
22323 Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
22324 );
22325 }
22326
22327 #[test]
22328 fn rewrite_cat_long_agent_doc_session_to_session_digest() {
22329 let dir = tempfile::tempdir().unwrap();
22330 let session = dir.path().join("tsift.md");
22331 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22332 for index in 0..90 {
22333 body.push_str(&format!("❯ prompt {index}?\n"));
22334 }
22335 fs::write(&session, body).unwrap();
22336
22337 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22338 assert_eq!(
22339 result,
22340 Some(format!(
22341 "tsift session-digest --path {} --input {} --source markdown",
22342 shell_quote(&resolve_digest_context_path(&session)),
22343 shell_quote(session.to_str().unwrap())
22344 ))
22345 );
22346 }
22347
22348 #[test]
22349 fn rewrite_head_long_claude_jsonl_to_session_digest() {
22350 let dir = tempfile::tempdir().unwrap();
22351 let session = dir.path().join("session.jsonl");
22352 let line =
22353 r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
22354 let body = std::iter::repeat_n(line, 120)
22355 .collect::<Vec<_>>()
22356 .join("\n");
22357 fs::write(&session, format!("{body}\n")).unwrap();
22358
22359 let result = rewrite_command(&format!(
22360 "head -n 120 {}",
22361 shell_quote(session.to_str().unwrap())
22362 ));
22363 assert_eq!(
22364 result,
22365 Some(format!(
22366 "tsift session-digest --path {} --input {} --source claude-jsonl",
22367 shell_quote(&resolve_digest_context_path(&session)),
22368 shell_quote(session.to_str().unwrap())
22369 ))
22370 );
22371 }
22372
22373 #[test]
22374 fn rewrite_head_long_codex_jsonl_to_session_digest() {
22375 let dir = tempfile::tempdir().unwrap();
22376 let session = dir.path().join("codex.jsonl");
22377 let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
22378 let body = std::iter::repeat_n(line, 120)
22379 .collect::<Vec<_>>()
22380 .join("\n");
22381 fs::write(&session, format!("{body}\n")).unwrap();
22382
22383 let result = rewrite_command(&format!(
22384 "head -n 120 {}",
22385 shell_quote(session.to_str().unwrap())
22386 ));
22387 assert_eq!(
22388 result,
22389 Some(format!(
22390 "tsift session-digest --path {} --input {} --source codex-jsonl",
22391 shell_quote(&resolve_digest_context_path(&session)),
22392 shell_quote(session.to_str().unwrap())
22393 ))
22394 );
22395 }
22396
22397 #[test]
22398 fn rewrite_small_transcript_window_passthrough() {
22399 let dir = tempfile::tempdir().unwrap();
22400 let session = dir.path().join("session.jsonl");
22401 let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
22402 let body = std::iter::repeat_n(line, 120)
22403 .collect::<Vec<_>>()
22404 .join("\n");
22405 fs::write(&session, format!("{body}\n")).unwrap();
22406
22407 let result = rewrite_command(&format!(
22408 "tail -n 20 {}",
22409 shell_quote(session.to_str().unwrap())
22410 ));
22411 assert_eq!(result, None);
22412 }
22413
22414 #[test]
22415 fn rewrite_sed_large_agent_doc_range_to_session_digest() {
22416 let dir = tempfile::tempdir().unwrap();
22417 let session = dir.path().join("tsift.md");
22418 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22419 for index in 0..120 {
22420 body.push_str(&format!("### Re: topic {index}\n"));
22421 }
22422 fs::write(&session, body).unwrap();
22423
22424 let result = rewrite_command(&format!(
22425 "sed -n '1,120p' {}",
22426 shell_quote(session.to_str().unwrap())
22427 ));
22428 assert_eq!(
22429 result,
22430 Some(format!(
22431 "tsift session-digest --path {} --input {} --source markdown",
22432 shell_quote(&resolve_digest_context_path(&session)),
22433 shell_quote(session.to_str().unwrap())
22434 ))
22435 );
22436 }
22437
22438 #[test]
22439 fn rewrite_cat_large_agent_doc_log_to_session_digest() {
22440 let dir = tempfile::tempdir().unwrap();
22441 let session = dir.path().join("tsift.log");
22442 let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
22443 let body = std::iter::repeat_n(line, 120)
22444 .collect::<Vec<_>>()
22445 .join("\n");
22446 fs::write(&session, format!("{body}\n")).unwrap();
22447
22448 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22449 assert_eq!(
22450 result,
22451 Some(format!(
22452 "tsift session-digest --path {} --input {} --source agent-doc-log",
22453 shell_quote(&resolve_digest_context_path(&session)),
22454 shell_quote(session.to_str().unwrap())
22455 ))
22456 );
22457 }
22458
22459 #[test]
22460 fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
22461 let dir = tempfile::tempdir().unwrap();
22462 fs::write(
22463 dir.path().join(".gitmodules"),
22464 r#"[submodule "src/tsift"]
22465 path = src/tsift
22466 url = https://example.com/tsift
22467"#,
22468 )
22469 .unwrap();
22470 let submodule = dir.path().join("src/tsift");
22471 fs::create_dir_all(submodule.join("tasks")).unwrap();
22472 fs::write(
22473 submodule.join(".git"),
22474 "gitdir: ../../.git/modules/src/tsift\n",
22475 )
22476 .unwrap();
22477 let session = submodule.join("tasks/plan.md");
22478 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22479 for index in 0..90 {
22480 body.push_str(&format!("❯ prompt {index}?\n"));
22481 }
22482 fs::write(&session, body).unwrap();
22483
22484 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22485
22486 assert_eq!(
22487 result,
22488 Some(format!(
22489 "tsift session-digest --path {} --input {} --source markdown",
22490 shell_quote(submodule.to_str().unwrap()),
22491 shell_quote(session.to_str().unwrap())
22492 ))
22493 );
22494 }
22495
22496 #[test]
22497 fn rewrite_regular_markdown_read_passthrough() {
22498 let dir = tempfile::tempdir().unwrap();
22499 let readme = dir.path().join("README.md");
22500 let body = std::iter::repeat_n("plain markdown", 120)
22501 .collect::<Vec<_>>()
22502 .join("\n");
22503 fs::write(&readme, format!("{body}\n")).unwrap();
22504
22505 let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
22506 assert_eq!(result, None);
22507 }
22508
22509 #[test]
22510 fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
22511 let dir = tempfile::tempdir().unwrap();
22512 write_empty_root_index(dir.path());
22513 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22514
22515 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22516
22517 assert_eq!(
22518 result,
22519 Some(format!(
22520 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
22521 shell_quote(&dir.path().to_string_lossy())
22522 ))
22523 );
22524 }
22525
22526 #[test]
22527 fn rewrite_head_small_source_window_passthrough() {
22528 let dir = tempfile::tempdir().unwrap();
22529 write_empty_root_index(dir.path());
22530 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22531
22532 let result = rewrite_command(&format!(
22533 "head -n 20 {}",
22534 shell_quote(source.to_str().unwrap())
22535 ));
22536
22537 assert_eq!(result, None);
22538 }
22539
22540 #[test]
22541 fn rewrite_sed_large_source_range_to_source_read() {
22542 let dir = tempfile::tempdir().unwrap();
22543 write_empty_root_index(dir.path());
22544 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22545
22546 let result = rewrite_command(&format!(
22547 "sed -n '40,160p' {}",
22548 shell_quote(source.to_str().unwrap())
22549 ));
22550
22551 assert_eq!(
22552 result,
22553 Some(format!(
22554 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
22555 shell_quote(&dir.path().to_string_lossy())
22556 ))
22557 );
22558 }
22559
22560 #[test]
22561 fn rewrite_tail_large_source_window_preserves_tail_anchor() {
22562 let dir = tempfile::tempdir().unwrap();
22563 write_empty_root_index(dir.path());
22564 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22565
22566 let result = rewrite_command(&format!(
22567 "tail -n 120 {}",
22568 shell_quote(source.to_str().unwrap())
22569 ));
22570
22571 assert_eq!(
22572 result,
22573 Some(format!(
22574 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
22575 shell_quote(&dir.path().to_string_lossy())
22576 ))
22577 );
22578 }
22579
22580 #[test]
22581 fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
22582 let dir = tempfile::tempdir().unwrap();
22583 write_empty_root_index(dir.path());
22584 let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
22585
22586 let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
22587
22588 assert_eq!(result, None);
22589 }
22590
22591 #[test]
22592 fn rewrite_large_source_read_passthrough_without_index() {
22593 let dir = tempfile::tempdir().unwrap();
22594 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22595
22596 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22597
22598 assert_eq!(result, None);
22599 }
22600
22601 #[test]
22602 fn rewrite_cargo_test_to_digest_runner() {
22603 let result = rewrite_command("cargo test --lib");
22604 assert_eq!(
22605 result,
22606 Some(
22607 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
22608 )
22609 );
22610 }
22611
22612 #[test]
22613 fn rewrite_pytest_to_digest_runner() {
22614 let result = rewrite_command("pytest -q tests/test_cli.py");
22615 assert_eq!(
22616 result,
22617 Some(
22618 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
22619 )
22620 );
22621 }
22622
22623 #[test]
22624 fn rewrite_python_m_pytest_to_digest_runner() {
22625 let result = rewrite_command("python -m pytest tests/test_cli.py");
22626 assert_eq!(
22627 result,
22628 Some(
22629 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
22630 )
22631 );
22632 }
22633
22634 #[test]
22635 fn rewrite_cargo_build_to_log_digest_runner() {
22636 let result = rewrite_command("cargo build --release");
22637 assert_eq!(
22638 result,
22639 Some(
22640 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
22641 )
22642 );
22643 }
22644
22645 #[test]
22646 fn rewrite_cargo_install_to_log_digest_runner() {
22647 let result = rewrite_command("cargo install --path . --force");
22648 assert_eq!(
22649 result,
22650 Some(
22651 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
22652 )
22653 );
22654 }
22655
22656 #[test]
22657 fn rewrite_metacharacter_command_passthrough() {
22658 let result = rewrite_command("cargo test | head");
22659 assert_eq!(result, None);
22660 }
22661
22662 #[test]
22663 fn rewrite_output_cap_detects_search_even_with_global_flag() {
22664 let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
22665 assert_eq!(cap.max_lines, 50);
22666 assert_eq!(cap.strip_prefix, Some("Strategy:"));
22667 }
22668
22669 #[test]
22670 fn rewrite_output_cap_skips_structured_output() {
22671 assert!(rewrite_output_cap("tsift search foo --json").is_none());
22672 assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
22673 assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
22674 }
22675
22676 #[test]
22677 fn rewrite_output_format_forwards_envelope_to_digest_runner() {
22678 let command = rewrite_command("cargo test --lib").expect("rewrite");
22679 let forwarded = apply_rewrite_output_format(
22680 &command,
22681 OutputFormat {
22682 json_output: true,
22683 compact: false,
22684 pretty: false,
22685 terse: false,
22686 ultra_terse: false,
22687 schema: false,
22688 envelope: true,
22689 },
22690 );
22691 assert_eq!(
22692 forwarded,
22693 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
22694 );
22695 }
22696
22697 #[test]
22698 fn rewrite_output_format_forwards_json_when_requested() {
22699 let command = rewrite_command("cargo build --release").expect("rewrite");
22700 let forwarded = apply_rewrite_output_format(
22701 &command,
22702 OutputFormat {
22703 json_output: true,
22704 compact: false,
22705 pretty: true,
22706 terse: false,
22707 ultra_terse: false,
22708 schema: false,
22709 envelope: false,
22710 },
22711 );
22712 assert_eq!(
22713 forwarded,
22714 "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
22715 );
22716 }
22717
22718 #[test]
22719 fn output_cap_strips_search_header_and_truncates() {
22720 let capped = apply_output_cap(
22721 b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
22722 OutputCap {
22723 max_lines: 2,
22724 strip_prefix: Some("Strategy:"),
22725 },
22726 );
22727 assert_eq!(
22728 capped,
22729 "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
22730 );
22731 }
22732
22733 #[test]
22734 fn sql_schema_overview_lists_tables() {
22735 let (_tmp, conn) = setup_test_db();
22736 let tables = schema_overview(&conn).unwrap();
22737 let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
22738 assert_eq!(names, &["posts", "users"]);
22739 }
22740
22741 #[test]
22742 fn sql_schema_overview_row_counts() {
22743 let (_tmp, conn) = setup_test_db();
22744 let tables = schema_overview(&conn).unwrap();
22745 let users = tables.iter().find(|t| t.name == "users").unwrap();
22746 let posts = tables.iter().find(|t| t.name == "posts").unwrap();
22747 assert_eq!(users.row_count, 2);
22748 assert_eq!(posts.row_count, 3);
22749 }
22750
22751 #[test]
22752 fn sql_table_columns_metadata() {
22753 let (_tmp, conn) = setup_test_db();
22754 let cols = table_columns(&conn, "users").unwrap();
22755 assert_eq!(cols.len(), 3);
22756 assert_eq!(cols[0].name, "id");
22757 assert!(cols[0].pk);
22758 assert_eq!(cols[1].name, "name");
22759 assert!(cols[1].notnull);
22760 assert_eq!(cols[2].name, "email");
22761 assert!(!cols[2].notnull);
22762 }
22763
22764 #[test]
22765 fn sql_execute_query_returns_rows() {
22766 let (_tmp, conn) = setup_test_db();
22767 let (columns, rows) =
22768 execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
22769 assert_eq!(columns, &["name", "email"]);
22770 assert_eq!(rows.len(), 2);
22771 assert_eq!(rows[0][0], serde_json::json!("Alice"));
22772 assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
22773 assert_eq!(rows[1][1], serde_json::Value::Null);
22774 }
22775
22776 #[test]
22777 fn sql_execute_query_aggregate() {
22778 let (_tmp, conn) = setup_test_db();
22779 let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
22780 assert_eq!(columns, &["cnt"]);
22781 assert_eq!(rows[0][0], serde_json::json!(3));
22782 }
22783
22784 #[test]
22785 fn sql_execute_query_join() {
22786 let (_tmp, conn) = setup_test_db();
22787 let (_cols, rows) = execute_query(
22788 &conn,
22789 "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
22790 )
22791 .unwrap();
22792 assert_eq!(rows.len(), 3);
22793 assert_eq!(rows[0][0], serde_json::json!("Alice"));
22794 assert_eq!(rows[2][0], serde_json::json!("Bob"));
22795 }
22796
22797 #[test]
22798 fn sql_open_db_read_only() {
22799 let (tmp, _conn) = setup_test_db();
22800 drop(_conn);
22801 let ro_conn = open_db(tmp.path()).unwrap();
22802 let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
22803 assert!(result.is_err(), "read-only connection should reject writes");
22804 }
22805
22806 #[test]
22807 fn sql_empty_table_schema() {
22808 let tmp = tempfile::NamedTempFile::new().unwrap();
22809 let conn = Connection::open(tmp.path()).unwrap();
22810 conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
22811 .unwrap();
22812 let tables = schema_overview(&conn).unwrap();
22813 assert_eq!(tables[0].row_count, 0);
22814 assert_eq!(tables[0].columns.len(), 2);
22815 }
22816
22817 fn setup_graph_index() -> tempfile::TempDir {
22820 let dir = tempfile::tempdir().unwrap();
22821 std::fs::write(
22822 dir.path().join("main.rs"),
22823 "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
22824 )
22825 .unwrap();
22826 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22827 db.apply_changes(dir.path()).unwrap();
22828 dir
22829 }
22830
22831 fn setup_traversal_project() -> tempfile::TempDir {
22832 let dir = setup_graph_index();
22833 let task_dir = dir.path().join("tasks/software");
22834 std::fs::create_dir_all(&task_dir).unwrap();
22835 std::fs::write(
22836 task_dir.join("tsift.md"),
22837 r#"---
22838agent_doc_session: tsift-v0.1
22839agent_doc_format: template
22840---
22841
22842## Exchange
22843
22844<!-- agent:exchange patch=append -->
22845❯ do [#kgnv]
22846Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
22847<!-- /agent:exchange -->
22848
22849<!-- agent:queue -->
22850dispatch #spec-test-build-install-commit-push
22851- do [#kgnv]
22852<!-- /agent:queue -->
22853
22854## Backlog
22855
22856<!-- agent:backlog -->
22857- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
22858<!-- /agent:backlog -->
22859"#,
22860 )
22861 .unwrap();
22862 dir
22863 }
22864
22865 fn resolve_ast_span_node<'a>(
22866 graph: &'a TraversalGraphBuild,
22867 label: &str,
22868 symbol_kind: &str,
22869 ) -> &'a TraversalNode {
22870 graph
22871 .nodes
22872 .values()
22873 .find(|node| {
22874 node.kind == "ast_span"
22875 && node.label == label
22876 && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
22877 })
22878 .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
22879 }
22880
22881 fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
22882 let dir = tempfile::tempdir().unwrap();
22883 std::fs::write(
22884 dir.path().join("rust.rs"),
22885 r#"mod fixture_nav_rust_mod {
22886 pub fn fixture_nav_rust_helper() {}
22887 pub fn fixture_nav_rust_entry() {
22888 fixture_nav_rust_helper();
22889 }
22890}
22891"#,
22892 )
22893 .unwrap();
22894 std::fs::write(
22895 dir.path().join("python.py"),
22896 r#"def fixture_nav_python_helper():
22897 return 1
22898
22899def fixture_nav_python_entry():
22900 return fixture_nav_python_helper()
22901"#,
22902 )
22903 .unwrap();
22904 std::fs::write(
22905 dir.path().join("typescript.ts"),
22906 r#"export function fixture_nav_typescript_entry(): number {
22907 return fixtureNavTsHelper();
22908}
22909
22910function fixtureNavTsHelper(): number {
22911 return 1;
22912}
22913"#,
22914 )
22915 .unwrap();
22916 std::fs::write(
22917 dir.path().join("javascript.js"),
22918 r#"function fixture_nav_javascript_entry() {
22919 return fixtureNavJsHelper();
22920}
22921
22922function fixtureNavJsHelper() {
22923 return 1;
22924}
22925"#,
22926 )
22927 .unwrap();
22928 std::fs::write(
22929 dir.path().join("kotlin.kt"),
22930 r#"fun fixture_nav_kotlin_entry(): Int {
22931 return fixtureNavKotlinHelper()
22932}
22933
22934fun fixtureNavKotlinHelper(): Int = 1
22935"#,
22936 )
22937 .unwrap();
22938 std::fs::write(
22939 dir.path().join("zig.zig"),
22940 r#"pub fn fixture_nav_zig_entry() i32 {
22941 return fixtureNavZigHelper();
22942}
22943
22944fn fixtureNavZigHelper() i32 {
22945 return 1;
22946}
22947"#,
22948 )
22949 .unwrap();
22950 std::fs::write(
22951 dir.path().join("bash.sh"),
22952 r#"#!/usr/bin/env bash
22953fixture_nav_bash_entry() {
22954 fixture_nav_bash_helper
22955}
22956
22957fixture_nav_bash_helper() {
22958 echo ok
22959}
22960
22961alias fixture_nav_bash_alias='echo alias'
22962"#,
22963 )
22964 .unwrap();
22965 std::fs::write(
22966 dir.path().join("README.md"),
22967 r#"# Fixture Guide
22968
22969## Fixture Section
22970
22971- Fixture step
22972 - Nested fixture step
22973
22974```python
22975def fixture_nav_markdown_embedded():
22976 return 1
22977```
22978"#,
22979 )
22980 .unwrap();
22981
22982 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22983 db.apply_changes(dir.path()).unwrap();
22984 dir
22985 }
22986
22987 fn assert_cli_expand_command_parses(command: &str) {
22988 let args = shell_split(command)
22989 .into_iter()
22990 .map(str::to_string)
22991 .collect::<Vec<_>>();
22992 assert!(
22993 try_parse_cli(args).is_ok(),
22994 "expand command should parse as a tsift CLI command: {command}"
22995 );
22996 }
22997
22998 fn setup_multiplicity_project() -> tempfile::TempDir {
22999 let dir = tempfile::tempdir().unwrap();
23000 std::fs::write(
23001 dir.path().join("Cargo.toml"),
23002 r#"[workspace]
23003members = ["crates/core-lib", "crates/cli-app"]
23004"#,
23005 )
23006 .unwrap();
23007 std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
23008 std::fs::write(
23009 dir.path().join("crates/core-lib/Cargo.toml"),
23010 r#"[package]
23011name = "core-lib"
23012
23013[lib]
23014name = "core_lib"
23015
23016[features]
23017default = []
23018"#,
23019 )
23020 .unwrap();
23021 std::fs::write(
23022 dir.path().join("crates/core-lib/src/lib.rs"),
23023 "pub fn run() {}\n",
23024 )
23025 .unwrap();
23026 std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
23027 std::fs::write(
23028 dir.path().join("crates/cli-app/Cargo.toml"),
23029 r#"[package]
23030name = "cli-app"
23031
23032[[bin]]
23033name = "cli-app"
23034
23035[dependencies]
23036core-lib = { path = "../core-lib" }
23037"#,
23038 )
23039 .unwrap();
23040 std::fs::write(
23041 dir.path().join("crates/cli-app/src/main.rs"),
23042 "use core_lib::run;\nfn main() { run(); }\n",
23043 )
23044 .unwrap();
23045 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23046 db.apply_changes(dir.path()).unwrap();
23047
23048 let task_dir = dir.path().join("tasks/software");
23049 std::fs::create_dir_all(&task_dir).unwrap();
23050 std::fs::write(
23051 task_dir.join("tsift.md"),
23052 r#"---
23053agent_doc_session: tsift-multiplicity
23054agent_doc_format: template
23055---
23056
23057## Backlog
23058
23059<!-- agent:backlog -->
23060- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
23061<!-- /agent:backlog -->
23062"#,
23063 )
23064 .unwrap();
23065 init_git_repo(dir.path());
23066 dir
23067 }
23068
23069 fn setup_dependency_dag_project() -> tempfile::TempDir {
23070 let dir = tempfile::tempdir().unwrap();
23071 std::fs::write(
23072 dir.path().join("main.rs"),
23073 "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
23074 )
23075 .unwrap();
23076 std::fs::write(
23077 dir.path().join("Cargo.toml"),
23078 "[package]\nname = \"dag-fixture\"\n",
23079 )
23080 .unwrap();
23081 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23082 db.apply_changes(dir.path()).unwrap();
23083
23084 let task_dir = dir.path().join("tasks/software");
23085 std::fs::create_dir_all(&task_dir).unwrap();
23086 std::fs::write(
23087 task_dir.join("tsift.md"),
23088 r#"---
23089agent_doc_session: tsift-dag
23090agent_doc_format: template
23091---
23092
23093## Exchange
23094
23095<!-- agent:exchange patch=append -->
23096Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
23097<!-- /agent:exchange -->
23098
23099## Backlog
23100
23101<!-- agent:backlog -->
23102- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
23103- [ ] [#alpha] Update shared_helper in main.rs after #prep.
23104- [ ] [#beta] Refactor shared_helper tests in main.rs.
23105- [ ] [#gamma] Follow-up review for graph navigation.
23106<!-- /agent:backlog -->
23107"#,
23108 )
23109 .unwrap();
23110 dir
23111 }
23112
23113 fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
23114 let dir = setup_graph_index();
23115 let task_dir = dir.path().join("tasks/software");
23116 std::fs::create_dir_all(&task_dir).unwrap();
23117 std::fs::write(
23118 task_dir.join("tsift.md"),
23119 r#"---
23120agent_doc_session: tsift-dag-cycle
23121agent_doc_format: template
23122---
23123
23124## Backlog
23125
23126<!-- agent:backlog -->
23127- [ ] [#left] Left side depends on #right.
23128- [ ] [#right] Right side depends on #left.
23129<!-- /agent:backlog -->
23130"#,
23131 )
23132 .unwrap();
23133 dir
23134 }
23135
23136 fn seed_traversal_semantic_summaries(dir: &Path) {
23137 let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23138 summary_db
23139 .insert(&summarize::Summary {
23140 id: 0,
23141 symbol_name: "helper".to_string(),
23142 file_path: "main.rs".to_string(),
23143 content_hash: "hash-main".to_string(),
23144 summary: "helper builds graph navigation handles for traversal.".to_string(),
23145 entities: Some(vec![
23146 summarize::Entity {
23147 name: "helper".to_string(),
23148 kind: "function".to_string(),
23149 description: "Builds graph navigation handles.".to_string(),
23150 },
23151 summarize::Entity {
23152 name: "TraversalGraph".to_string(),
23153 kind: "type".to_string(),
23154 description: "Carries GraphStore-backed traversal rows.".to_string(),
23155 },
23156 ]),
23157 relationships: Some(vec![summarize::Relationship {
23158 from: "helper".to_string(),
23159 to: "TraversalGraph".to_string(),
23160 kind: "uses".to_string(),
23161 }]),
23162 concept_labels: Some(vec![
23163 "graph navigation".to_string(),
23164 "semantic extraction".to_string(),
23165 ]),
23166 extracted_at: "1700000000".to_string(),
23167 model: "test-model".to_string(),
23168 tokens_input: Some(10),
23169 tokens_output: Some(5),
23170 })
23171 .unwrap();
23172 }
23173
23174 fn seed_tsift_memory_graph_db(dir: &Path) {
23175 let db = dir.join(".tsift").join("memory.db");
23176 let store = MemoryStore::open_or_create(&db).unwrap();
23177 let project = dir.to_string_lossy().to_string();
23178 let observation = MemoryEvent::new(
23179 MemoryEventKind::ImportedObservation,
23180 "claude-mem:observations:1",
23181 [
23182 "Graph memory adapter",
23183 "read-only projection",
23184 "graph-db should retrieve tsift memory observations",
23185 "Project memory is queried from .tsift/memory.db",
23186 "graph memory, tsift memory, semantic query",
23187 ]
23188 .join("\n\n"),
23189 )
23190 .with_session_id("claude-session-a")
23191 .with_observed_at_unix(1_700_000_000)
23192 .with_import("claude-mem", "observations:1")
23193 .with_metadata("project", project.clone())
23194 .with_metadata("observation_type", "fact")
23195 .with_metadata("prompt_number", "7")
23196 .with_metadata("discovery_tokens", "42")
23197 .with_metadata("content_hash", "hash-observation-1");
23198 store.insert_event(&observation).unwrap();
23199
23200 let summary = MemoryEvent::new(
23201 MemoryEventKind::ImportedSessionSummary,
23202 "claude-mem:session_summaries:2",
23203 [
23204 "Query old memory from graph-db",
23205 "Read-only tsift memory SQLite projection",
23206 "Semantic graph rows can point at existing memory",
23207 "Projected source and session nodes",
23208 "Keep capture ownership inside tsift-memory",
23209 "summary note",
23210 ]
23211 .join("\n\n"),
23212 )
23213 .with_session_id("claude-session-a")
23214 .with_observed_at_unix(1_700_000_010)
23215 .with_import("claude-mem", "session_summaries:2")
23216 .with_metadata("project", project)
23217 .with_metadata("prompt_number", "8")
23218 .with_metadata("discovery_tokens", "36");
23219 store.insert_event(&summary).unwrap();
23220
23221 let prompt = MemoryEvent::new(
23222 MemoryEventKind::ImportedUserPrompt,
23223 "claude-mem:user_prompts:3",
23224 "How can graph-db query tsift memory semantic history?",
23225 )
23226 .with_session_id("claude-session-a")
23227 .with_observed_at_unix(1_700_000_020)
23228 .with_import("claude-mem", "user_prompts:3")
23229 .with_metadata("prompt_number", "9");
23230 store.insert_event(&prompt).unwrap();
23231 }
23232
23233 #[test]
23234 fn graph_callers_query() {
23235 let dir = setup_graph_index();
23236 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23237 let callers = db.callers_of("helper").unwrap();
23238 assert_eq!(callers.len(), 1);
23239 assert_eq!(callers[0].caller_name, "main");
23240 }
23241
23242 #[test]
23243 fn graph_callees_query() {
23244 let dir = setup_graph_index();
23245 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23246 let callees = db.callees_of("main").unwrap();
23247 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
23248 assert!(names.contains(&"helper"));
23249 assert!(names.contains(&"new"));
23250 }
23251
23252 #[test]
23253 fn graph_no_callers_returns_empty() {
23254 let dir = setup_graph_index();
23255 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23256 let callers = db.callers_of("nonexistent").unwrap();
23257 assert!(callers.is_empty());
23258 }
23259
23260 #[test]
23261 fn graph_cmd_autoindexes_missing_index_by_default() {
23262 let dir = tempfile::tempdir().unwrap();
23263 std::fs::write(
23264 dir.path().join("main.rs"),
23265 "fn helper() {}\nfn main() { helper(); }\n",
23266 )
23267 .unwrap();
23268 let result = cmd_graph(
23269 "helper",
23270 dir.path(),
23271 true,
23272 false,
23273 None,
23274 20,
23275 false,
23276 true,
23277 false,
23278 false,
23279 false,
23280 false,
23281 false,
23282 TagpathSearchOpts::default(),
23283 );
23284
23285 assert!(result.is_ok());
23286 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
23287 let summary = db.compute_changes(dir.path()).unwrap();
23288 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
23289 }
23290
23291 #[test]
23292 fn traversal_graph_has_stable_typed_handles() {
23293 let dir = setup_traversal_project();
23294 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23295 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23296
23297 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
23298 let symbol = resolve_traversal_node(&graph, "helper").unwrap();
23299 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23300 let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
23301
23302 assert!(file.handle.starts_with("gfil-"));
23303 assert!(symbol.handle.starts_with("gsym-"));
23304 assert!(backlog.handle.starts_with("gbak-"));
23305 assert!(session.handle.starts_with("gses-"));
23306
23307 assert_eq!(
23308 symbol.handle,
23309 resolve_traversal_node(&graph_again, "helper")
23310 .unwrap()
23311 .handle
23312 );
23313 assert_eq!(
23314 backlog.handle,
23315 resolve_traversal_node(&graph_again, "#kgnv")
23316 .unwrap()
23317 .handle
23318 );
23319 }
23320
23321 #[test]
23322 fn traversal_graph_links_backlog_items_to_code_tokens() {
23323 let dir = setup_traversal_project();
23324 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23325 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23326 let helper = resolve_traversal_node(&graph, "helper").unwrap();
23327
23328 assert!(graph.edges.iter().any(|edge| {
23329 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23330 }));
23331 }
23332
23333 #[test]
23334 fn session_hinted_traversal_skips_global_call_edges() {
23335 let dir = setup_traversal_project();
23336 let session = dir.path().join("tasks/software/tsift.md");
23337 let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
23338 let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
23339 let helper = resolve_traversal_node(&bounded, "helper").unwrap();
23340
23341 assert!(bounded.edges.iter().any(|edge| {
23342 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23343 }));
23344 assert!(
23345 !bounded.edges.iter().any(|edge| edge.relation == "calls"),
23346 "session-hinted graph-db projections should not materialize unrelated global call edges"
23347 );
23348
23349 let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
23350 assert!(
23351 full.edges.iter().any(|edge| edge.relation == "calls"),
23352 "root/full projections still carry the complete indexed call graph"
23353 );
23354 }
23355
23356 #[test]
23357 fn agent_doc_task_path_infers_matching_workspace_scope() {
23358 let dir = tempfile::tempdir().unwrap();
23359 std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
23360 std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
23361 std::fs::write(
23362 dir.path().join(".gitmodules"),
23363 "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
23364 )
23365 .unwrap();
23366 let task = dir.path().join("tasks/software/tsift.md");
23367 std::fs::write(&task, "# tsift\n").unwrap();
23368
23369 let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
23370 let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
23371 let cfg = config::Config::load(dir.path()).unwrap();
23372
23373 assert_eq!(targets.len(), 1);
23374 assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
23375 assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
23376 assert!(
23377 targets[0]
23378 .db_path
23379 .ends_with(".tsift/indexes/tsift/index.db")
23380 );
23381 assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
23382 }
23383
23384 #[test]
23385 fn cargo_package_scope_selector_indexes_package_db() {
23386 let dir = setup_multiplicity_project();
23387 let targets =
23388 resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
23389
23390 assert_eq!(targets.len(), 1);
23391 assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
23392 assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
23393 assert!(
23394 targets[0]
23395 .db_path
23396 .ends_with(".tsift/indexes/cargo/core-lib/index.db")
23397 );
23398
23399 cmd_index(
23400 dir.path(),
23401 false,
23402 false,
23403 false,
23404 false,
23405 true,
23406 false,
23407 Some("core_lib"),
23408 false,
23409 true,
23410 false,
23411 false,
23412 false,
23413 false,
23414 )
23415 .unwrap();
23416 assert!(targets[0].db_path.exists());
23417 }
23418
23419 #[test]
23420 fn path_inference_prefers_nested_cargo_package_without_submodule() {
23421 let dir = setup_multiplicity_project();
23422 let source = dir.path().join("crates/cli-app/src/main.rs");
23423 let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
23424
23425 assert_eq!(targets.len(), 1);
23426 assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
23427 assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
23428 }
23429
23430 #[test]
23431 fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
23432 let dir = setup_multiplicity_project();
23433 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23434 let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
23435 let core = resolve_traversal_node(&graph, "core-lib").unwrap();
23436 let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
23437 let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
23438
23439 assert_eq!(workspace.kind, "cargo_workspace");
23440 assert_eq!(core.kind, "cargo_package");
23441 assert_eq!(
23442 core.properties.get("features"),
23443 Some(&"default".to_string())
23444 );
23445 assert!(graph.edges.iter().any(|edge| {
23446 edge.from == workspace.handle
23447 && edge.to == core.handle
23448 && edge.relation == "contains_package"
23449 }));
23450 assert!(graph.edges.iter().any(|edge| {
23451 edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
23452 }));
23453 assert!(graph.edges.iter().any(|edge| {
23454 edge.from == cli.handle
23455 && edge.to == core.handle
23456 && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
23457 }));
23458 }
23459
23460 #[test]
23461 fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
23462 let dir = setup_multiplicity_project();
23463 let session = dir.path().join("tasks/software/tsift.md");
23464 let report =
23465 build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
23466 .unwrap();
23467
23468 assert!(report.per_target_fail_closed.is_empty());
23469 let candidate = report
23470 .candidates
23471 .iter()
23472 .find(|candidate| candidate.target == "corepkg")
23473 .unwrap();
23474 assert!(
23475 candidate
23476 .owned_files
23477 .iter()
23478 .any(|file| file == "crates/core-lib/Cargo.toml"),
23479 "{:?}",
23480 candidate.owned_files
23481 );
23482 }
23483
23484 #[test]
23485 fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
23486 let dir = setup_traversal_project();
23487 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23488 let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
23489 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23490
23491 assert_eq!(job.kind, "job_packet");
23492 assert!(job.handle.starts_with("gjob-"));
23493 assert!(graph.edges.iter().any(|edge| {
23494 edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
23495 }));
23496
23497 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23498 let jobs = store.nodes_by_kind("job_packet").unwrap();
23499 assert!(
23500 jobs.iter()
23501 .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23502 "expected queued job packet in graph store, got {jobs:?}"
23503 );
23504 }
23505
23506 #[test]
23507 fn traversal_graph_includes_routes_and_handler_edges() {
23508 let dir = tempfile::tempdir().unwrap();
23509 std::fs::write(
23510 dir.path().join("api.py"),
23511 r#"@router.get("/items")
23512def list_items():
23513 return []
23514"#,
23515 )
23516 .unwrap();
23517 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23518 db.apply_changes(dir.path()).unwrap();
23519
23520 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23521 let route = resolve_traversal_node(&graph, "/items").unwrap();
23522 let handler = resolve_traversal_node(&graph, "list_items").unwrap();
23523
23524 assert_eq!(route.kind, "route");
23525 assert!(graph.edges.iter().any(|edge| {
23526 edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
23527 }));
23528 }
23529
23530 #[test]
23531 fn traversal_graph_projects_rust_ast_navigation_edges() {
23532 let dir = tempfile::tempdir().unwrap();
23533 std::fs::write(
23534 dir.path().join("main.rs"),
23535 r#"mod api {
23536 pub fn helper() {}
23537 pub fn handler() { helper(); }
23538}
23539
23540fn main() { api::handler(); }
23541"#,
23542 )
23543 .unwrap();
23544 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23545 db.apply_changes(dir.path()).unwrap();
23546
23547 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23548 let api = resolve_ast_span_node(&graph, "api", "mod");
23549 let helper = resolve_ast_span_node(&graph, "helper", "function");
23550 let handler = resolve_ast_span_node(&graph, "handler", "function");
23551
23552 assert_eq!(helper.kind, "ast_span");
23553 assert!(helper.handle.starts_with("span-"));
23554 assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
23555 assert!(graph.edges.iter().any(|edge| {
23556 edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
23557 }));
23558 assert!(graph.edges.iter().any(|edge| {
23559 edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
23560 }));
23561 assert!(graph.edges.iter().any(|edge| {
23562 edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
23563 }));
23564 assert!(graph.edges.iter().any(|edge| {
23565 edge.from == helper.handle
23566 && edge.to == handler.handle
23567 && edge.relation == "next_sibling"
23568 }));
23569 assert!(graph.edges.iter().any(|edge| {
23570 edge.from == handler.handle
23571 && edge.to == helper.handle
23572 && edge.relation == "previous_sibling"
23573 }));
23574 assert!(graph.edges.iter().any(|edge| {
23575 edge.from == helper.handle
23576 && edge.to == api.handle
23577 && edge.relation == "enclosing_module"
23578 }));
23579 assert!(graph.edges.iter().any(|edge| {
23580 edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
23581 }));
23582
23583 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23584 let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
23585 assert!(
23586 ast_nodes.iter().any(|node| node.id == helper.handle
23587 && node.properties.get("symbol_kind") == Some(&"function".to_string())),
23588 "expected helper AST span in graph store, got {ast_nodes:?}"
23589 );
23590 assert!(
23591 store
23592 .outgoing_edges(&helper.handle, Some("parent"))
23593 .unwrap()
23594 .iter()
23595 .any(|edge| edge.to_id == api.handle),
23596 "expected persisted AST parent edge"
23597 );
23598 }
23599
23600 #[test]
23601 fn traversal_graph_projects_markdown_section_block_edges() {
23602 let dir = tempfile::tempdir().unwrap();
23603 std::fs::write(
23604 dir.path().join("README.md"),
23605 "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
23606 )
23607 .unwrap();
23608 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23609 db.apply_changes(dir.path()).unwrap();
23610
23611 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23612 let guide = resolve_ast_span_node(&graph, "Guide", "heading");
23613 let code = resolve_ast_span_node(&graph, "rust", "code_block");
23614 let embedded = resolve_ast_span_node(&graph, "demo", "function");
23615 let list_item = graph
23616 .nodes
23617 .values()
23618 .find(|node| {
23619 node.kind == "ast_span"
23620 && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
23621 && node.properties.get("section_handle") == Some(&guide.handle)
23622 })
23623 .expect("missing Markdown list item AST span");
23624
23625 assert_eq!(
23626 code.properties.get("markdown_block_kind"),
23627 Some(&"fenced_code_block".to_string())
23628 );
23629 assert_eq!(
23630 guide.properties.get("heading_level"),
23631 Some(&"1".to_string())
23632 );
23633 assert_eq!(
23634 embedded.properties.get("embedded"),
23635 Some(&"true".to_string())
23636 );
23637 assert_eq!(
23638 embedded.properties.get("language"),
23639 Some(&"rust".to_string())
23640 );
23641 assert_eq!(
23642 embedded.properties.get("markdown_block_handle"),
23643 Some(&code.handle)
23644 );
23645 assert!(graph.edges.iter().any(|edge| {
23646 edge.from == guide.handle
23647 && edge.to == code.handle
23648 && edge.relation == "contains_markdown_block"
23649 }));
23650 assert!(graph.edges.iter().any(|edge| {
23651 edge.from == code.handle
23652 && edge.to == guide.handle
23653 && edge.relation == "enclosing_section"
23654 }));
23655 assert!(graph.edges.iter().any(|edge| {
23656 edge.from == guide.handle
23657 && edge.to == list_item.handle
23658 && edge.relation == "contains_markdown_block"
23659 }));
23660 assert!(graph.edges.iter().any(|edge| {
23661 edge.from == code.handle
23662 && edge.to == embedded.handle
23663 && edge.relation == "contains_embedded_symbol"
23664 }));
23665 assert!(graph.edges.iter().any(|edge| {
23666 edge.from == embedded.handle
23667 && edge.to == code.handle
23668 && edge.relation == "embedded_in_fence"
23669 }));
23670 assert!(graph.edges.iter().any(|edge| {
23671 edge.from == guide.handle
23672 && edge.to == embedded.handle
23673 && edge.relation == "contains_embedded_code"
23674 }));
23675
23676 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23677 assert!(
23678 store
23679 .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
23680 .unwrap()
23681 .iter()
23682 .any(|edge| edge.to_id == code.handle),
23683 "expected persisted Markdown section/block edge"
23684 );
23685 assert!(
23686 store
23687 .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
23688 .unwrap()
23689 .iter()
23690 .any(|edge| edge.to_id == embedded.handle),
23691 "expected persisted Markdown fence/embedded symbol edge"
23692 );
23693 }
23694
23695 #[test]
23696 fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
23697 let dir = setup_multilingual_ast_navigation_project();
23698 let db =
23699 index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
23700 let symbols = db.all_symbols().unwrap();
23701 let expected_symbols = [
23702 ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
23703 (
23704 "python",
23705 "fixture_nav_python_entry",
23706 "function",
23707 "python.py",
23708 ),
23709 (
23710 "typescript",
23711 "fixture_nav_typescript_entry",
23712 "function",
23713 "typescript.ts",
23714 ),
23715 (
23716 "javascript",
23717 "fixture_nav_javascript_entry",
23718 "function",
23719 "javascript.js",
23720 ),
23721 (
23722 "kotlin",
23723 "fixture_nav_kotlin_entry",
23724 "function",
23725 "kotlin.kt",
23726 ),
23727 ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
23728 ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
23729 ("markdown", "Fixture Section", "heading", "README.md"),
23730 ("markdown", "Fixture step", "list_item", "README.md"),
23731 ("markdown", "python", "code_block", "README.md"),
23732 ];
23733
23734 for (language, name, kind, file) in expected_symbols {
23735 let symbol = symbols
23736 .iter()
23737 .find(|symbol| {
23738 symbol.language == language
23739 && symbol.name == name
23740 && symbol.kind == kind
23741 && symbol.file.ends_with(file)
23742 })
23743 .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
23744 assert!(
23745 symbol.start_byte.is_some() && symbol.end_byte.is_some(),
23746 "{language} {name} should carry AST byte spans"
23747 );
23748 }
23749
23750 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23751 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23752 let expected_ast_nodes = [
23753 ("fixture_nav_rust_entry", "function", "rust"),
23754 ("fixture_nav_python_entry", "function", "python"),
23755 ("fixture_nav_typescript_entry", "function", "typescript"),
23756 ("fixture_nav_javascript_entry", "function", "javascript"),
23757 ("fixture_nav_kotlin_entry", "function", "kotlin"),
23758 ("fixture_nav_zig_entry", "function", "zig"),
23759 ("fixture_nav_bash_entry", "function", "bash"),
23760 ("Fixture Section", "heading", "markdown"),
23761 ("Fixture step", "list_item", "markdown"),
23762 ("python", "code_block", "markdown"),
23763 ("fixture_nav_markdown_embedded", "function", "python"),
23764 ];
23765
23766 for (name, kind, language) in expected_ast_nodes {
23767 let node = resolve_ast_span_node(&graph, name, kind);
23768 let repeated = resolve_ast_span_node(&graph_again, name, kind);
23769 assert!(
23770 node.handle.starts_with("span-"),
23771 "{name} handle: {}",
23772 node.handle
23773 );
23774 assert_eq!(
23775 node.handle, repeated.handle,
23776 "{language} {name} handle drifted"
23777 );
23778 assert_eq!(
23779 node.properties.get("language"),
23780 Some(&language.to_string()),
23781 "{name} should keep its language label"
23782 );
23783 }
23784
23785 let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
23786 let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
23787 let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
23788 assert!(graph.edges.iter().any(|edge| {
23789 edge.from == markdown_section.handle
23790 && edge.to == markdown_code.handle
23791 && edge.relation == "contains_markdown_block"
23792 }));
23793 assert!(graph.edges.iter().any(|edge| {
23794 edge.from == markdown_code.handle
23795 && edge.to == embedded.handle
23796 && edge.relation == "contains_embedded_symbol"
23797 }));
23798 assert!(
23799 graph.nodes.len() <= 80,
23800 "multilingual AST fixture should stay bounded, got {} nodes",
23801 graph.nodes.len()
23802 );
23803 assert!(
23804 graph.edges.len() <= 180,
23805 "multilingual AST fixture should stay bounded, got {} edges",
23806 graph.edges.len()
23807 );
23808
23809 let response = empty_search_response(dir.path(), "lexical");
23810 let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
23811 let report = build_relative_search_budget_report(
23812 "fixture_nav_python_entry",
23813 "lexical",
23814 dir.path(),
23815 &response,
23816 &symbol_hits,
23817 ResponseBudget::new(Some(8), Some(120)),
23818 &SearchFacetFilters::default(),
23819 );
23820 let report_again = build_relative_search_budget_report(
23821 "fixture_nav_python_entry",
23822 "lexical",
23823 dir.path(),
23824 &response,
23825 &symbol_hits,
23826 ResponseBudget::new(Some(8), Some(120)),
23827 &SearchFacetFilters::default(),
23828 );
23829
23830 let top = report
23831 .ranked
23832 .first()
23833 .expect("ranked preview should not be empty");
23834 assert_eq!(top.source, "symbol_span");
23835 assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
23836 assert!(top.handle.starts_with("srnk-"));
23837 assert_eq!(top.handle, report_again.ranked[0].handle);
23838 assert!(
23839 top.reasons.iter().any(|reason| reason == "ast_span"),
23840 "expected AST span ranking reason, got {:?}",
23841 top.reasons
23842 );
23843 assert!(report.ranked.len() <= 8);
23844 assert!(report.symbols.len() <= 8);
23845
23846 let symbol = report
23847 .symbols
23848 .iter()
23849 .find(|symbol| symbol.name == "fixture_nav_python_entry")
23850 .expect("missing search preview symbol");
23851 assert_cli_expand_command_parses(&symbol.expand);
23852 let ast = symbol
23853 .ast
23854 .as_ref()
23855 .expect("search symbol should expose AST");
23856 assert_cli_expand_command_parses(&ast.expand.source_window);
23857 assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
23858 assert_cli_expand_command_parses(&ast.expand.symbol_read);
23859
23860 let markdown_hits = db.symbol_search("python", 20).unwrap();
23861 let markdown_report = build_relative_search_budget_report(
23862 "python",
23863 "lexical",
23864 dir.path(),
23865 &response,
23866 &markdown_hits,
23867 ResponseBudget::new(Some(8), Some(120)),
23868 &SearchFacetFilters::default(),
23869 );
23870 let markdown_symbol = markdown_report
23871 .symbols
23872 .iter()
23873 .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
23874 .expect("missing Markdown code-block symbol");
23875 let markdown_ast = markdown_symbol
23876 .ast
23877 .as_ref()
23878 .expect("Markdown code block should expose AST");
23879 assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
23880 assert_eq!(
23881 markdown_ast
23882 .span
23883 .markdown
23884 .as_ref()
23885 .unwrap()
23886 .embedded_symbols[0]
23887 .name,
23888 "fixture_nav_markdown_embedded"
23889 );
23890 }
23891
23892 #[test]
23893 fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
23894 let edges = vec![
23895 TraversalEdge {
23896 from: "origin".to_string(),
23897 to: "aaa_low".to_string(),
23898 relation: "unknown".to_string(),
23899 label: None,
23900 weight: 1,
23901 },
23902 TraversalEdge {
23903 from: "origin".to_string(),
23904 to: "zzz_high".to_string(),
23905 relation: "mentions".to_string(),
23906 label: None,
23907 weight: 1,
23908 },
23909 ];
23910
23911 let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
23912
23913 assert!(handles.contains("origin"));
23914 assert!(handles.contains("zzz_high"), "{handles:?}");
23915 assert!(!handles.contains("aaa_low"), "{handles:?}");
23916 }
23917
23918 #[test]
23919 fn traversal_materializes_provider_neutral_sqlite_graph() {
23920 let dir = setup_traversal_project();
23921 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23922 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23923
23924 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23925 let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
23926 assert!(
23927 backlog_nodes.iter().any(|node| node.id == backlog.handle
23928 && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23929 "expected materialized backlog node, got {backlog_nodes:?}"
23930 );
23931 assert!(
23932 store
23933 .all_nodes()
23934 .unwrap()
23935 .iter()
23936 .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
23937 && node.properties.get("projection_version")
23938 == Some(&GRAPH_PROJECTION_VERSION.to_string())),
23939 "expected projection metadata node"
23940 );
23941 let source_handles = store.nodes_by_kind("source_handle").unwrap();
23942 assert!(
23943 source_handles
23944 .iter()
23945 .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
23946 "expected bounded source_handle rows, got {source_handles:?}"
23947 );
23948 let worker_context = store.nodes_by_kind("worker_context").unwrap();
23949 assert!(
23950 worker_context
23951 .iter()
23952 .any(|node| node.properties.get("target")
23953 == Some(&"tasks/software/tsift.md".to_string())),
23954 "expected bounded worker_context rows, got {worker_context:?}"
23955 );
23956 let worker_results = store.nodes_by_kind("worker_result").unwrap();
23957 assert!(
23958 worker_results.iter().any(|node| {
23959 node.properties.get("ref_id") == Some(&"kgnv".to_string())
23960 && node.properties.get("status") == Some(&"completed".to_string())
23961 && node.properties.get("touched_files") == Some(&"main.rs".to_string())
23962 && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
23963 }),
23964 "expected worker_result rows, got {worker_results:?}"
23965 );
23966 }
23967
23968 #[test]
23969 fn traversal_projection_materializes_cached_semantic_rows() {
23970 let dir = setup_traversal_project();
23971 seed_traversal_semantic_summaries(dir.path());
23972 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23973 let helper = resolve_traversal_node(&graph, "helper").unwrap();
23974 let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
23975 let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
23976
23977 assert_eq!(concept.kind, "semantic_concept");
23978 assert_eq!(entity.kind, "semantic_entity");
23979 assert!(concept.handle.starts_with("gcon-"));
23980 assert!(entity.handle.starts_with("gent-"));
23981
23982 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23983 assert!(
23984 store
23985 .nodes_by_kind("semantic_concept")
23986 .unwrap()
23987 .iter()
23988 .any(|node| node.label == "semantic extraction"
23989 && node.properties.contains_key("embedding")),
23990 "expected persisted concept embeddings"
23991 );
23992 assert!(
23993 store
23994 .outgoing_edges(&helper.handle, Some("mentions_concept"))
23995 .unwrap()
23996 .iter()
23997 .any(|edge| edge.to_id == concept.handle),
23998 "expected helper symbol to link to cached summary concept"
23999 );
24000 assert!(
24001 store
24002 .outgoing_edges(
24003 &semantic_entity_handle("helper", "function"),
24004 Some("semantic_relation")
24005 )
24006 .unwrap()
24007 .iter()
24008 .any(|edge| edge.to_id == entity.handle
24009 && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
24010 "expected LLM relationship rows projected into GraphStore"
24011 );
24012 }
24013
24014 #[test]
24015 fn traversal_projection_materializes_tsift_memory_rows() {
24016 let dir = setup_traversal_project();
24017 seed_tsift_memory_graph_db(dir.path());
24018 let memory_db = dir.path().join(".tsift").join("memory.db");
24019 let store = MemoryStore::open_or_create(&memory_db).unwrap();
24020 for summary in ["first closeout", "second closeout"] {
24021 let event = MemoryEvent::new(
24022 MemoryEventKind::ResponseSummary,
24023 "tasks/software/tsift.md",
24024 summary,
24025 )
24026 .with_session_id("tasks/software/tsift.md")
24027 .with_observed_at_unix(1_700_000_100);
24028 store.insert_event(&event).unwrap();
24029 }
24030 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24031 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24032
24033 let native_sources = store
24034 .nodes_by_kind("source_handle")
24035 .unwrap()
24036 .into_iter()
24037 .filter(|node| {
24038 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24039 && node.properties.get("source_ref")
24040 == Some(&"tasks/software/tsift.md".to_string())
24041 })
24042 .collect::<Vec<_>>();
24043 assert_eq!(
24044 native_sources.len(),
24045 2,
24046 "same-source native memory events must get distinct source handles"
24047 );
24048
24049 let source = store
24050 .nodes_by_kind("source_handle")
24051 .unwrap()
24052 .into_iter()
24053 .find(|node| {
24054 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24055 })
24056 .expect("expected tsift-memory source handle");
24057 let session = store
24058 .nodes_by_kind("memory_session")
24059 .unwrap()
24060 .into_iter()
24061 .find(|node| {
24062 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24063 && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
24064 })
24065 .expect("expected tsift-memory session node");
24066 let event = store
24067 .nodes_by_kind("memory_event")
24068 .unwrap()
24069 .into_iter()
24070 .find(|node| {
24071 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24072 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24073 && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
24074 })
24075 .expect("expected tsift-memory event node");
24076 let concept = store
24077 .nodes_by_kind("semantic_concept")
24078 .unwrap()
24079 .into_iter()
24080 .find(|node| {
24081 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24082 && node.label.contains("Graph memory adapter")
24083 && node.properties.contains_key("embedding")
24084 })
24085 .expect("expected tsift-memory semantic concept");
24086
24087 assert!(
24088 store
24089 .outgoing_edges(&session.id, Some("records_memory_source"))
24090 .unwrap()
24091 .iter()
24092 .any(|edge| edge.to_id == source.id),
24093 "expected session to link to source handle"
24094 );
24095 assert!(
24096 store
24097 .outgoing_edges(&session.id, Some("records_memory_event"))
24098 .unwrap()
24099 .iter()
24100 .any(|edge| edge.to_id == event.id),
24101 "expected session to link to memory event"
24102 );
24103 assert!(
24104 store
24105 .outgoing_edges(&event.id, Some("projects_source"))
24106 .unwrap()
24107 .iter()
24108 .any(|edge| edge.to_id == source.id),
24109 "expected memory event to project source handle"
24110 );
24111 assert!(
24112 store
24113 .outgoing_edges(&source.id, Some("mentions_concept"))
24114 .unwrap()
24115 .iter()
24116 .any(|edge| edge.to_id == concept.id),
24117 "expected source handle to seed semantic concept"
24118 );
24119
24120 let related = semantic_related_report_from_store(
24121 dir.path(),
24122 None,
24123 "tsift memory graph adapter",
24124 5,
24125 SemanticRelatedKind::Concept,
24126 &store,
24127 )
24128 .unwrap();
24129 assert!(
24130 related
24131 .items
24132 .iter()
24133 .any(|item| item.handle == concept.id && item.score > 0.0),
24134 "expected semantic query to retrieve tsift-memory concept, got {:?}",
24135 related.items
24136 );
24137
24138 let graph_related = graph_db_report_from_store(
24139 dir.path(),
24140 None,
24141 "sqlite",
24142 GraphDbQuery::Related {
24143 query: "tsift memory graph adapter".to_string(),
24144 kind: SemanticRelatedKind::Concept,
24145 depth: 1,
24146 seed_limit: 5,
24147 limit: 20,
24148 },
24149 &store,
24150 sqlite_graph_freshness(&store, "root").unwrap(),
24151 Vec::new(),
24152 )
24153 .unwrap();
24154 assert_eq!(
24155 graph_related
24156 .readiness
24157 .as_ref()
24158 .map(|readiness| readiness.status.as_str()),
24159 Some("ready"),
24160 "tsift-memory semantic rows should satisfy graph-db related readiness"
24161 );
24162 assert!(
24163 graph_related.nodes.iter().any(|node| {
24164 node.kind == "semantic_concept"
24165 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24166 }),
24167 "expected related graph output to include tsift-memory semantic rows"
24168 );
24169 }
24170
24171 #[test]
24172 fn semantic_related_query_uses_persisted_graph_embeddings() {
24173 let dir = setup_traversal_project();
24174 seed_traversal_semantic_summaries(dir.path());
24175 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24176 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24177
24178 let report = semantic_related_report_from_store(
24179 dir.path(),
24180 None,
24181 "graph navigation",
24182 5,
24183 SemanticRelatedKind::Concept,
24184 &store,
24185 )
24186 .unwrap();
24187
24188 assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
24189 assert!(
24190 report
24191 .items
24192 .iter()
24193 .any(|item| item.label == "graph navigation"
24194 && item.kind == "semantic_concept"
24195 && item.score > 0.9),
24196 "expected nearest concept match from graph embeddings, got {:?}",
24197 report.items
24198 );
24199 }
24200
24201 #[test]
24202 fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
24203 let dir = setup_traversal_project();
24204 seed_traversal_semantic_summaries(dir.path());
24205 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24206 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24207
24208 let report = graph_db_report_from_store(
24209 dir.path(),
24210 None,
24211 "sqlite",
24212 GraphDbQuery::Related {
24213 query: "graph navigation".to_string(),
24214 kind: SemanticRelatedKind::All,
24215 depth: 1,
24216 seed_limit: 2,
24217 limit: 20,
24218 },
24219 &store,
24220 sqlite_graph_freshness(&store, "root").unwrap(),
24221 Vec::new(),
24222 )
24223 .unwrap();
24224
24225 let knowledge = report.knowledge_retrieval.as_ref().unwrap();
24226 assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
24227 assert_eq!(knowledge.seed_kind, "all");
24228 assert_eq!(knowledge.depth, 1);
24229 assert_eq!(
24230 report
24231 .readiness
24232 .as_ref()
24233 .map(|readiness| readiness.status.as_str()),
24234 Some("ready")
24235 );
24236 assert!(
24237 knowledge
24238 .diagnostics
24239 .iter()
24240 .any(|diagnostic| diagnostic.contains("incident"))
24241 );
24242 assert!(
24243 report
24244 .semantic_related
24245 .iter()
24246 .any(|item| item.label == "graph navigation"
24247 && item.kind == "semantic_concept"
24248 && item.score > 0.9),
24249 "expected natural-language query to seed the graph navigation concept, got {:?}",
24250 report.semantic_related
24251 );
24252 assert!(
24253 report
24254 .nodes
24255 .iter()
24256 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
24257 );
24258 assert!(
24259 report
24260 .nodes
24261 .iter()
24262 .any(|node| node.kind == "symbol" && node.label == "helper"),
24263 "incident expansion from semantic seed should recover source symbols, got {:?}",
24264 report
24265 .nodes
24266 .iter()
24267 .map(|node| (&node.kind, &node.label))
24268 .collect::<Vec<_>>()
24269 );
24270 assert!(
24271 report
24272 .edges
24273 .iter()
24274 .any(|edge| edge.kind == "mentions_concept")
24275 );
24276 assert!(
24277 report.output_budget.as_ref().is_some_and(|budget| budget
24278 .diagnostics
24279 .iter()
24280 .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
24281 "expected related output budget diagnostics, got {:?}",
24282 report.output_budget
24283 );
24284 }
24285
24286 #[test]
24287 fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
24288 let dir = setup_graph_index();
24289 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24290 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24291
24292 let report = graph_db_report_from_store(
24293 dir.path(),
24294 None,
24295 "sqlite",
24296 GraphDbQuery::Related {
24297 query: "graph navigation".to_string(),
24298 kind: SemanticRelatedKind::All,
24299 depth: 1,
24300 seed_limit: 2,
24301 limit: 20,
24302 },
24303 &store,
24304 sqlite_graph_freshness(&store, "root").unwrap(),
24305 Vec::new(),
24306 )
24307 .unwrap();
24308
24309 let readiness = report.readiness.as_ref().unwrap();
24310 assert_eq!(readiness.status, "blocked");
24311 assert_eq!(readiness.reason, "summary_cache_empty");
24312 assert!(readiness.fail_closed);
24313 assert_eq!(
24314 readiness.next_commands,
24315 vec![
24316 "tsift summarize --extract .".to_string(),
24317 graph_db_refresh_command(dir.path(), None)
24318 ]
24319 );
24320 assert!(
24321 report
24322 .knowledge_retrieval
24323 .as_ref()
24324 .unwrap()
24325 .diagnostics
24326 .iter()
24327 .any(|diagnostic| diagnostic.contains("summary cache empty")
24328 && diagnostic.contains("graph-db materialized code/session rows")),
24329 "expected related diagnostics to carry readiness gate, got {:?}",
24330 report.knowledge_retrieval.as_ref().unwrap().diagnostics
24331 );
24332 }
24333
24334 #[test]
24335 fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
24336 let mut nodes = vec![
24337 SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
24338 SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
24339 ];
24340 let mut edges = vec![SubstrateGraphEdge::new(
24341 "zzz_high",
24342 "seed",
24343 "mentions_concept",
24344 )];
24345 for idx in 0..24 {
24346 let id = format!("aaa_low_{idx:02}");
24347 nodes.push(SubstrateGraphNode::new(
24348 id.clone(),
24349 "note",
24350 format!("low {idx}"),
24351 ));
24352 edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
24353 }
24354 let mut store = SqliteGraphStore::in_memory().unwrap();
24355 store
24356 .replace_projection(&GraphProjection { nodes, edges })
24357 .unwrap();
24358
24359 let subgraph =
24360 graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
24361
24362 assert_eq!(subgraph.nodes.len(), 3);
24363 assert_eq!(subgraph.nodes[0].id, "seed");
24364 assert_eq!(
24365 subgraph.nodes[1].id, "zzz_high",
24366 "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
24367 subgraph.nodes
24368 );
24369 assert!(subgraph.truncated);
24370 assert!(
24371 subgraph
24372 .diagnostics
24373 .iter()
24374 .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
24375 "{:?}",
24376 subgraph.diagnostics
24377 );
24378 assert!(
24379 subgraph
24380 .diagnostics
24381 .iter()
24382 .any(|diagnostic| diagnostic.contains("skipped")),
24383 "{:?}",
24384 subgraph.diagnostics
24385 );
24386 }
24387
24388 #[test]
24389 fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
24390 let dir = setup_traversal_project();
24391 seed_traversal_semantic_summaries(dir.path());
24392 init_git_repo(dir.path());
24393 let session = dir.path().join("tasks/software/tsift.md");
24394 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24395 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24396 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24397 let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24398 root: dir.path(),
24399 scope: None,
24400 backend: "sqlite",
24401 target: "kgnv",
24402 depth: 4,
24403 limit: 8,
24404 cursor: None,
24405 store: &store,
24406 freshness,
24407 warnings: Vec::new(),
24408 })
24409 .unwrap();
24410 assert!(
24411 evidence
24412 .semantic_related
24413 .iter()
24414 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
24415 "expected semantic evidence rows, got {:?}",
24416 evidence
24417 .semantic_related
24418 .iter()
24419 .map(|node| (&node.kind, &node.label))
24420 .collect::<Vec<_>>()
24421 );
24422 assert!(
24423 evidence
24424 .output_budget
24425 .as_ref()
24426 .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
24427 diagnostic.contains("semantic_match")
24428 && diagnostic.contains("source_handle_coverage")
24429 })),
24430 "expected evidence output budget diagnostics, got {:?}",
24431 evidence.output_budget
24432 );
24433
24434 let cached_diff = diff_digest::compute(
24435 dir.path(),
24436 diff_digest::DiffDigestOptions {
24437 cached: true,
24438 revision: None,
24439 max_parsed_files: None,
24440 },
24441 )
24442 .unwrap();
24443 let impact_report = impact::compute(
24444 dir.path(),
24445 impact::ImpactOptions {
24446 cached: true,
24447 revision: None,
24448 scope: None,
24449 limit: 10,
24450 },
24451 )
24452 .unwrap();
24453 let graph_nodes = store.all_nodes().unwrap();
24454 let graph_index = conflict_matrix_graph_index(&graph_nodes);
24455 let semantic_candidate = conflict_matrix_candidate_from_evidence(
24456 dir.path(),
24457 &evidence,
24458 &graph_index,
24459 &cached_diff,
24460 &impact_report,
24461 );
24462 assert!(semantic_candidate.semantic_dispatch_score > 0);
24463 assert!(
24464 semantic_candidate
24465 .semantic_dispatch_reasons
24466 .iter()
24467 .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
24468 "expected semantic ranking explanations, got {:?}",
24469 semantic_candidate.semantic_dispatch_reasons
24470 );
24471 assert!(
24472 semantic_candidate
24473 .semantic_related
24474 .iter()
24475 .any(|item| item.label == "graph navigation")
24476 );
24477
24478 let mut plain_candidate = semantic_candidate.clone();
24479 plain_candidate.target = "plain".to_string();
24480 plain_candidate.semantic_related.clear();
24481 plain_candidate.semantic_dispatch_score = 0;
24482 plain_candidate.semantic_dispatch_reasons.clear();
24483 let mut ranked = [plain_candidate, semantic_candidate];
24484 ranked.sort_by(|left, right| {
24485 left.risk
24486 .cmp(&right.risk)
24487 .then_with(|| left.risk_score.cmp(&right.risk_score))
24488 .then_with(|| {
24489 right
24490 .semantic_dispatch_score
24491 .cmp(&left.semantic_dispatch_score)
24492 })
24493 .then_with(|| left.target.cmp(&right.target))
24494 });
24495 assert_eq!(ranked[0].target, "kgnv");
24496 }
24497
24498 #[test]
24499 fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
24500 let dir = setup_dependency_dag_project();
24501 let session = dir.path().join("tasks/software/tsift.md");
24502 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24503
24504 assert_eq!(report.contract_version, "dependency-dag-v1");
24505 assert_eq!(
24506 report.targets,
24507 vec![
24508 "prep".to_string(),
24509 "alpha".to_string(),
24510 "beta".to_string(),
24511 "gamma".to_string()
24512 ]
24513 );
24514 assert!(report.edges.iter().any(|edge| {
24515 edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
24516 }));
24517 assert!(report.edges.iter().any(|edge| {
24518 edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
24519 }));
24520 assert!(report.edges.iter().any(|edge| {
24521 edge.from == "alpha"
24522 && edge.to == "beta"
24523 && edge.kind == "shared_resource"
24524 && edge.shared_files.contains(&"main.rs".to_string())
24525 && edge.shared_symbols.contains(&"shared_helper".to_string())
24526 }));
24527 assert!(
24528 !report.cycle_diagnostics.has_cycles,
24529 "{:?}",
24530 report.cycle_diagnostics
24531 );
24532 assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
24533 assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
24534 assert!(
24535 report.replay_commands[0].contains("dependency-dag"),
24536 "{:?}",
24537 report.replay_commands
24538 );
24539
24540 cmd_dependency_dag(
24541 &session,
24542 None,
24543 &["alpha".to_string(), "beta".to_string()],
24544 4,
24545 12,
24546 OutputFormat {
24547 json_output: true,
24548 compact: false,
24549 pretty: false,
24550 terse: false,
24551 ultra_terse: false,
24552 schema: false,
24553 envelope: false,
24554 },
24555 )
24556 .unwrap();
24557 }
24558
24559 #[test]
24560 fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
24561 let dir = setup_dependency_dag_cycle_project();
24562 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24563
24564 assert!(report.cycle_diagnostics.has_cycles);
24565 assert_eq!(
24566 report.cycle_diagnostics.blocked_nodes,
24567 vec!["left".to_string(), "right".to_string()]
24568 );
24569 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24570 edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
24571 }));
24572 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24573 edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
24574 }));
24575 }
24576
24577 #[test]
24578 fn traversal_projection_queries_match_sqlite_and_convex_stores() {
24579 let dir = setup_traversal_project();
24580 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24581 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
24582
24583 let mut sqlite = SqliteGraphStore::in_memory().unwrap();
24584 sqlite.replace_projection(&projection).unwrap();
24585 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24586 projection.upsert_into(&convex).unwrap();
24587
24588 let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
24589 let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
24590 assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
24591 assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
24592
24593 let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
24594 let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
24595 assert!(convex_graph.edges.iter().any(|edge| {
24596 edge.from == sqlite_backlog.handle
24597 && edge.to == convex_helper.handle
24598 && edge.relation == "mentions"
24599 }));
24600 }
24601
24602 #[test]
24603 fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
24604 let dir = setup_traversal_project();
24605 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24606 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24607 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24608 assert_eq!(freshness.status, "current");
24609
24610 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24611 let report = graph_db_report_from_store(
24612 dir.path(),
24613 None,
24614 "sqlite",
24615 GraphDbQuery::Neighborhood {
24616 id: backlog.handle.clone(),
24617 depth: 1,
24618 edge_kind: Some("mentions".to_string()),
24619 cursor: None,
24620 limit: None,
24621 property_filters: Vec::new(),
24622 },
24623 &store,
24624 freshness,
24625 Vec::new(),
24626 )
24627 .unwrap();
24628 assert!(
24629 report
24630 .edges
24631 .iter()
24632 .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
24633 "expected backlog mention edge, got {:?}",
24634 report.edges
24635 );
24636 assert!(
24637 report.ranked_neighbors.iter().any(|neighbor| {
24638 neighbor.depth == Some(1)
24639 && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
24640 && neighbor.node_id != backlog.handle
24641 && neighbor.handle_coverage_pct >= 95.0
24642 && neighbor.duplicate_name_precision >= 0.99
24643 }),
24644 "expected ranked neighborhood neighbors with quality scores, got {:?}",
24645 report.ranked_neighbors
24646 );
24647 assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
24648 let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
24649 assert!(!ranking_gate.ranked_output_default);
24650 assert_eq!(ranking_gate.default_order, "stable_node_id");
24651 assert!(
24652 ranking_gate
24653 .diagnostics
24654 .iter()
24655 .any(|diagnostic| diagnostic.contains("score-capped")),
24656 "{ranking_gate:?}"
24657 );
24658 assert!(
24659 ranking_gate
24660 .required_metrics
24661 .iter()
24662 .any(|metric| metric == "handle_coverage_pct")
24663 );
24664 assert!(
24665 ranking_gate
24666 .required_metrics
24667 .iter()
24668 .any(|metric| metric == "duplicate_name_precision")
24669 );
24670 assert!(
24671 report
24672 .page
24673 .as_ref()
24674 .unwrap()
24675 .diagnostics
24676 .iter()
24677 .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
24678 "expected SQLite neighborhood query plan diagnostics, got {:?}",
24679 report.page.as_ref().unwrap().diagnostics
24680 );
24681 let edges_report = graph_db_report_from_store(
24682 dir.path(),
24683 None,
24684 "sqlite",
24685 GraphDbQuery::Edges {
24686 edge_kind: Some("mentions".to_string()),
24687 cursor: None,
24688 limit: Some(2),
24689 property_filters: Vec::new(),
24690 },
24691 &store,
24692 sqlite_graph_freshness(&store, "root").unwrap(),
24693 Vec::new(),
24694 )
24695 .unwrap();
24696 let edge_id = edges_report
24697 .edges
24698 .first()
24699 .map(|edge| edge.id.clone())
24700 .expect("expected at least one paged mentions edge");
24701 assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
24702 assert_eq!(
24703 edges_report.page.as_ref().unwrap().returned_edges,
24704 edges_report.edges.len()
24705 );
24706
24707 let edge_report = graph_db_report_from_store(
24708 dir.path(),
24709 None,
24710 "sqlite",
24711 GraphDbQuery::Edge {
24712 id: edge_id.clone(),
24713 },
24714 &store,
24715 sqlite_graph_freshness(&store, "root").unwrap(),
24716 Vec::new(),
24717 )
24718 .unwrap();
24719 assert_eq!(
24720 edge_report.edge.as_ref().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
24721 Some(edge_id.clone())
24722 );
24723
24724 let incident_report = graph_db_report_from_store(
24725 dir.path(),
24726 None,
24727 "sqlite",
24728 GraphDbQuery::Incident {
24729 id: backlog.handle.clone(),
24730 edge_kind: Some("mentions".to_string()),
24731 cursor: None,
24732 limit: Some(1),
24733 property_filters: Vec::new(),
24734 },
24735 &store,
24736 sqlite_graph_freshness(&store, "root").unwrap(),
24737 Vec::new(),
24738 )
24739 .unwrap();
24740 assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
24741 assert!(
24742 incident_report
24743 .edges
24744 .iter()
24745 .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
24746 "{:?}",
24747 incident_report.edges
24748 );
24749
24750 let schema_report = graph_db_report_from_store(
24751 dir.path(),
24752 None,
24753 "sqlite",
24754 GraphDbQuery::Schema,
24755 &store,
24756 sqlite_graph_freshness(&store, "root").unwrap(),
24757 Vec::new(),
24758 )
24759 .unwrap();
24760 assert!(
24761 schema_report
24762 .schema
24763 .unwrap()
24764 .operations
24765 .iter()
24766 .any(|operation| operation.command.starts_with("neighborhood"))
24767 );
24768 }
24769
24770 #[test]
24771 fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
24772 let mut nodes = vec![SubstrateGraphNode::new(
24773 "origin",
24774 "backlog",
24775 "#budgeted-neighborhood",
24776 )];
24777 let mut edges = Vec::new();
24778 for idx in 0..32 {
24779 let id = format!("src-{idx:02}");
24780 nodes.push(
24781 SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
24782 .with_property("source_ref", format!("fixture:{idx}"))
24783 .with_property("detail", "x".repeat(600)),
24784 );
24785 edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
24786 }
24787 let store = SqliteGraphStore::in_memory().unwrap();
24788 GraphProjection { nodes, edges }
24789 .upsert_into(&store)
24790 .unwrap();
24791
24792 let report = graph_db_report_from_store(
24793 Path::new("."),
24794 None,
24795 "fixture",
24796 GraphDbQuery::Neighborhood {
24797 id: "origin".to_string(),
24798 depth: 1,
24799 edge_kind: None,
24800 cursor: None,
24801 limit: None,
24802 property_filters: Vec::new(),
24803 },
24804 &store,
24805 current_graph_db_freshness(),
24806 Vec::new(),
24807 )
24808 .unwrap();
24809 let budget = report.output_budget.as_ref().unwrap();
24810 assert!(budget.selected_nodes < budget.candidate_nodes);
24811 assert!(
24812 budget.dropped_by_budget.iter().any(|drop| {
24813 drop.item == "node"
24814 && drop.kind == "source_handle"
24815 && drop.reason == "per_kind_quota"
24816 }),
24817 "expected source_handle budget drops, got {:?}",
24818 budget.dropped_by_budget
24819 );
24820 assert!(report.page.as_ref().unwrap().truncated);
24821 assert!(
24822 report
24823 .page
24824 .as_ref()
24825 .unwrap()
24826 .diagnostics
24827 .iter()
24828 .any(|diagnostic| diagnostic.contains("budget ranking signals")),
24829 "{:?}",
24830 report.page
24831 );
24832 }
24833
24834 #[test]
24835 fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
24836 let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
24837 let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
24838 for idx in 0..8 {
24839 let id = format!("far-{idx:02}");
24840 nodes.push(SubstrateGraphNode::new(
24841 id.clone(),
24842 "note",
24843 format!("aaa deeper row {idx}"),
24844 ));
24845 depth_by_id.insert(id, 6);
24846 }
24847
24848 let origin_ids = vec!["target".to_string()];
24849 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
24850 &origin_ids,
24851 &BTreeMap::new(),
24852 nodes,
24853 Vec::new(),
24854 Some(3),
24855 Some(&depth_by_id),
24856 None,
24857 );
24858
24859 assert!(
24860 budgeted.nodes.iter().any(|node| node.id == "near"),
24861 "expected the shallow evidence row to outrank deeper rows, got {:?}",
24862 budgeted
24863 .nodes
24864 .iter()
24865 .map(|node| (&node.id, &node.label))
24866 .collect::<Vec<_>>()
24867 );
24868 assert!(
24869 budgeted.report.dropped_by_budget.iter().any(|drop| {
24870 drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
24871 }),
24872 "expected node quota drops, got {:?}",
24873 budgeted.report.dropped_by_budget
24874 );
24875 assert!(
24876 budgeted
24877 .report
24878 .diagnostics
24879 .iter()
24880 .any(|diagnostic| diagnostic.contains("depth")),
24881 "{:?}",
24882 budgeted.report.diagnostics
24883 );
24884 }
24885
24886 #[test]
24887 fn evidence_pagination_returns_next_cursor_when_truncated() {
24888 let mut nodes = vec![SubstrateGraphNode::new(
24889 "target".to_string(),
24890 "backlog_item",
24891 "target item".to_string(),
24892 )];
24893 let mut depth_by_id = BTreeMap::new();
24894 depth_by_id.insert("target".to_string(), 0);
24895 for idx in 0..20 {
24896 let id = format!("ev-{idx}");
24897 nodes.push(SubstrateGraphNode::new(
24898 id.clone(),
24899 "source_handle",
24900 format!("evidence row {idx}"),
24901 ).with_property("detail", "x".repeat(400)));
24902 depth_by_id.insert(id, 1);
24903 }
24904 let origin_ids = vec!["target".to_string()];
24905 let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
24906 &origin_ids,
24907 &BTreeMap::new(),
24908 nodes.clone(),
24909 Vec::new(),
24910 Some(3),
24911 Some(&depth_by_id),
24912 None,
24913 );
24914 assert!(
24915 first_page.truncated,
24916 "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
24917 first_page.nodes.len(),
24918 first_page.report.candidate_nodes
24919 );
24920 assert!(
24921 first_page.next_cursor.is_some(),
24922 "expected next_cursor when truncated"
24923 );
24924 let cursor = first_page.next_cursor.unwrap();
24925 assert!(
24926 !cursor.is_empty(),
24927 "cursor should be a non-empty node id"
24928 );
24929 let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
24930 let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
24931 &origin_ids,
24932 &BTreeMap::new(),
24933 nodes.clone(),
24934 Vec::new(),
24935 Some(3),
24936 Some(&depth_by_id),
24937 Some(&cursor),
24938 );
24939 let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
24940 let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
24941 assert!(
24942 overlap.is_empty(),
24943 "pages should not overlap, but found shared ids: {overlap:?}"
24944 );
24945 assert!(
24946 second_page.report.diagnostics.iter().any(|d| d.contains("cursor skipped")),
24947 "expected cursor skip diagnostic, got {:?}",
24948 second_page.report.diagnostics
24949 );
24950 }
24951
24952 #[test]
24953 fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
24954 let mut nodes = vec![SubstrateGraphNode::new(
24955 "target".to_string(),
24956 "backlog_item",
24957 "target item".to_string(),
24958 )];
24959 let mut depth_by_id = BTreeMap::new();
24960 depth_by_id.insert("target".to_string(), 0);
24961 for idx in 0..3 {
24962 let id = format!("ev-{idx}");
24963 nodes.push(SubstrateGraphNode::new(
24964 id.clone(),
24965 "source_handle",
24966 format!("evidence row {idx}"),
24967 ));
24968 depth_by_id.insert(id, 1);
24969 }
24970 let origin_ids = vec!["target".to_string()];
24971 let result = graph_db_apply_output_budget_with_depths_and_cursor(
24972 &origin_ids,
24973 &BTreeMap::new(),
24974 nodes,
24975 Vec::new(),
24976 None,
24977 Some(&depth_by_id),
24978 None,
24979 );
24980 assert!(
24981 !result.truncated,
24982 "expected no truncation with small candidate set and default budget"
24983 );
24984 assert!(
24985 result.next_cursor.is_none(),
24986 "expected no next_cursor when not truncated"
24987 );
24988 }
24989
24990 #[test]
24991 fn evidence_pagination_invalid_cursor_returns_first_page() {
24992 let mut nodes = vec![SubstrateGraphNode::new(
24993 "target".to_string(),
24994 "backlog_item",
24995 "target item".to_string(),
24996 )];
24997 let mut depth_by_id = BTreeMap::new();
24998 depth_by_id.insert("target".to_string(), 0);
24999 for idx in 0..5 {
25000 let id = format!("ev-{idx}");
25001 nodes.push(SubstrateGraphNode::new(
25002 id.clone(),
25003 "source_handle",
25004 format!("evidence row {idx}"),
25005 ));
25006 depth_by_id.insert(id, 1);
25007 }
25008 let origin_ids = vec!["target".to_string()];
25009 let result = graph_db_apply_output_budget_with_depths_and_cursor(
25010 &origin_ids,
25011 &BTreeMap::new(),
25012 nodes.clone(),
25013 Vec::new(),
25014 None,
25015 Some(&depth_by_id),
25016 Some("nonexistent-id"),
25017 );
25018 assert!(
25019 result.report.diagnostics.iter().any(|d| d.contains("cursor skipped 0")),
25020 "invalid cursor should skip 0 candidates, got {:?}",
25021 result.report.diagnostics
25022 );
25023 }
25024
25025 #[test]
25026 fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
25027 let dir = setup_traversal_project();
25028 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25029 let graph_db = dir.path().join(".tsift/graph.db");
25030 let _lock = hold_rollback_journal_lock(&graph_db);
25031
25032 let report =
25033 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25034 .unwrap();
25035
25036 assert_eq!(report.status, "current");
25037 assert_eq!(
25038 report.recovery,
25039 Some(index::ReadOnlyRecovery::SnapshotFallback)
25040 );
25041 assert!(
25042 report
25043 .warnings
25044 .iter()
25045 .any(|warning| warning.contains("rollback-journal lock")),
25046 "expected rollback-journal recovery warning, got {:?}",
25047 report.warnings
25048 );
25049 }
25050
25051 #[test]
25052 fn graph_db_status_copies_wal_sidecars_when_locked() {
25053 let dir = setup_traversal_project();
25054 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25055 let graph_db = dir.path().join(".tsift/graph.db");
25056 let _lock = hold_wal_database_lock(&graph_db);
25057
25058 let report =
25059 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25060 .unwrap();
25061
25062 assert_eq!(report.status, "current");
25063 assert_eq!(
25064 report.recovery,
25065 Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
25066 );
25067 assert!(
25068 report
25069 .warnings
25070 .iter()
25071 .any(|warning| warning.contains("WAL-aware snapshot fallback")),
25072 "expected WAL recovery warning, got {:?}",
25073 report.warnings
25074 );
25075 }
25076
25077 #[test]
25078 fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
25079 let dir = setup_traversal_project();
25080 let session = dir.path().join("tasks/software/tsift.md");
25081 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25082 let graph_db = dir.path().join(".tsift/graph.db");
25083 let _lock = hold_rollback_journal_lock(&graph_db);
25084
25085 let result = cmd_graph_db(
25086 &session,
25087 None,
25088 GraphDbBackend::Sqlite,
25089 None,
25090 GraphDbQuery::Evidence {
25091 target: "kgnv".to_string(),
25092 depth: 3,
25093 limit: 8,
25094 cursor: None,
25095 },
25096 OutputFormat {
25097 json_output: false,
25098 compact: true,
25099 pretty: false,
25100 terse: false,
25101 ultra_terse: false,
25102 schema: false,
25103 envelope: false,
25104 },
25105 );
25106
25107 assert!(result.is_ok());
25108 }
25109
25110 fn current_graph_db_freshness() -> GraphDbFreshnessReport {
25111 GraphDbFreshnessReport {
25112 status: "current".to_string(),
25113 fail_closed: false,
25114 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
25115 content_hash: Some("fixture".to_string()),
25116 source_watermark: None,
25117 diagnostics: Vec::new(),
25118 }
25119 }
25120
25121 #[test]
25122 fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
25123 let dir = setup_traversal_project();
25124 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25125 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25126 let stale = GraphDbFreshnessReport {
25127 status: "stale".to_string(),
25128 fail_closed: true,
25129 projection_version: Some("old-v0".to_string()),
25130 content_hash: None,
25131 source_watermark: None,
25132 diagnostics: vec!["projection content hash is missing".to_string()],
25133 };
25134
25135 let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25136 root: dir.path(),
25137 scope: None,
25138 backend: "sqlite",
25139 target: "kgnv",
25140 depth: 3,
25141 limit: 8,
25142 cursor: None,
25143 store: &store,
25144 freshness: stale,
25145 warnings: Vec::new(),
25146 }) {
25147 Ok(_) => panic!("stale graph freshness should fail closed"),
25148 Err(err) => err,
25149 };
25150 let message = err.to_string();
25151 assert!(message.contains("failed closed"), "{message}");
25152 assert!(message.contains("graph-db --path"), "{message}");
25153 assert!(message.contains("refresh --json"), "{message}");
25154 }
25155
25156 fn paged_graph_ids(
25157 store: &impl GraphStore,
25158 cursor: Option<&str>,
25159 ) -> (Vec<String>, GraphDbPageReport) {
25160 let report = graph_db_report_from_store(
25161 Path::new("."),
25162 None,
25163 "fixture",
25164 GraphDbQuery::Kind {
25165 kind: "backlog".to_string(),
25166 cursor: cursor.map(str::to_string),
25167 limit: Some(2),
25168 property_filters: vec!["phase=open".to_string()],
25169 },
25170 store,
25171 current_graph_db_freshness(),
25172 Vec::new(),
25173 )
25174 .unwrap();
25175 (
25176 report.nodes.iter().map(|node| node.id.clone()).collect(),
25177 report.page.unwrap(),
25178 )
25179 }
25180
25181 #[test]
25182 fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
25183 let nodes = (0..5)
25184 .map(|idx| {
25185 let phase = if idx == 1 { "closed" } else { "open" };
25186 SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
25187 .with_property("phase", phase)
25188 })
25189 .collect::<Vec<_>>();
25190 let projection = GraphProjection {
25191 nodes,
25192 edges: Vec::new(),
25193 };
25194 let sqlite = SqliteGraphStore::in_memory().unwrap();
25195 projection.upsert_into(&sqlite).unwrap();
25196 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25197 projection.upsert_into(&convex).unwrap();
25198
25199 let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
25200 let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
25201 assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
25202 assert_eq!(sqlite_first_ids, convex_first_ids);
25203 assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
25204 assert!(sqlite_first_page.truncated);
25205 assert_eq!(
25206 sqlite_first_page.returned_nodes,
25207 convex_first_page.returned_nodes
25208 );
25209 assert_eq!(
25210 sqlite_first_page.property_filters,
25211 convex_first_page.property_filters
25212 );
25213 assert!(
25214 sqlite_first_page
25215 .diagnostics
25216 .iter()
25217 .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
25218 "expected SQLite kind query plan diagnostics, got {:?}",
25219 sqlite_first_page.diagnostics
25220 );
25221
25222 let cursor = sqlite_first_page.next_cursor.as_deref();
25223 let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
25224 let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
25225 assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
25226 assert_eq!(sqlite_next_ids, convex_next_ids);
25227 assert_eq!(sqlite_next_page.next_cursor, None);
25228 assert!(!sqlite_next_page.truncated);
25229 assert_eq!(
25230 sqlite_next_page.returned_nodes,
25231 convex_next_page.returned_nodes
25232 );
25233 assert_eq!(
25234 sqlite_next_page.property_filters,
25235 convex_next_page.property_filters
25236 );
25237 }
25238
25239 #[test]
25240 fn traversal_shortest_path_crosses_artifacts_and_symbols() {
25241 let dir = setup_traversal_project();
25242 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25243 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25244 let main = resolve_traversal_node(&graph, "main").unwrap();
25245
25246 let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
25247 assert_eq!(path.first(), Some(&backlog.handle));
25248 assert_eq!(path.last(), Some(&main.handle));
25249 assert!(
25250 path.len() >= 3,
25251 "expected backlog -> symbol -> main, got {path:?}"
25252 );
25253 }
25254
25255 #[test]
25256 fn traversal_report_recommends_next_bugfix_nodes() {
25257 let dir = setup_traversal_project();
25258 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25259 let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
25260
25261 assert_eq!(report.mode, "neighborhood");
25262 assert!(
25263 report
25264 .recommendations
25265 .iter()
25266 .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
25267 "expected helper recommendation, got {:?}",
25268 report.recommendations
25269 );
25270 assert!(
25271 !report.exploration.source_windows.is_empty(),
25272 "expected exploration source windows"
25273 );
25274 assert!(
25275 report
25276 .exploration
25277 .no_reread_guidance
25278 .contains("avoid whole-file reads")
25279 );
25280 }
25281
25282 #[test]
25283 fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
25284 let dir = setup_traversal_project();
25285 std::thread::sleep(std::time::Duration::from_millis(50));
25286 std::fs::write(
25287 dir.path().join("main.rs"),
25288 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25289 )
25290 .unwrap();
25291
25292 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25293
25294 assert!(
25295 graph
25296 .warnings
25297 .iter()
25298 .any(|warning| warning.contains("index refreshed")
25299 && warning.contains("graph traversal packet")),
25300 "expected refresh diagnostic, got {:?}",
25301 graph.warnings
25302 );
25303 assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
25304
25305 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
25306 let summary = db.compute_changes(dir.path()).unwrap();
25307 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
25308 }
25309
25310 #[test]
25311 fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
25312 let dir = setup_traversal_project();
25313 let db_path = dir.path().join(".tsift/index.db");
25314 let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
25315 std::thread::sleep(std::time::Duration::from_millis(50));
25316 std::fs::write(
25317 dir.path().join("main.rs"),
25318 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25319 )
25320 .unwrap();
25321
25322 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25323 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
25324
25325 assert!(
25326 graph
25327 .warnings
25328 .iter()
25329 .any(|warning| warning.contains("falling back to raw source file nodes")),
25330 "expected raw-source fallback diagnostic, got {:?}",
25331 graph.warnings
25332 );
25333 assert!(
25334 file.detail
25335 .as_deref()
25336 .is_some_and(|detail| detail.contains("raw source fallback")),
25337 "expected raw-source detail, got {:?}",
25338 file.detail
25339 );
25340 assert!(
25341 file.expand.contains("source-read"),
25342 "expected source-read fallback command, got {}",
25343 file.expand
25344 );
25345 assert!(
25346 resolve_traversal_node(&graph, "helper").is_none(),
25347 "stale symbol evidence should be skipped when refresh is blocked"
25348 );
25349 }
25350
25351 #[test]
25352 fn traversal_cmd_supports_json_and_html_outputs() {
25353 let dir = setup_traversal_project();
25354 cmd_traverse(
25355 Some("#kgnv"),
25356 Some("main"),
25357 dir.path(),
25358 None,
25359 1,
25360 50,
25361 TraverseFormat::Json,
25362 false,
25363 false,
25364 false,
25365 None,
25366 )
25367 .unwrap();
25368 cmd_traverse(
25369 None,
25370 None,
25371 dir.path(),
25372 None,
25373 1,
25374 50,
25375 TraverseFormat::Html,
25376 false,
25377 false,
25378 false,
25379 None,
25380 )
25381 .unwrap();
25382 }
25383
25384 #[test]
25385 fn traversal_html_renders_inline_graph_visualization() {
25386 let dir = setup_traversal_project();
25387 seed_traversal_semantic_summaries(dir.path());
25388 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25389 let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
25390 let html = traversal_report_html(&report).unwrap();
25391
25392 assert!(html.contains("id=\"graph-canvas\""));
25393 assert!(html.contains("semantic_concept"));
25394 assert!(html.contains("graph navigation"));
25395 assert!(html.contains("JSON.parse"));
25396 }
25397
25398 #[test]
25399 fn compact_helpers_trim_scores_and_snippets() {
25400 assert_eq!(format_score(0.12345, true), "0.12");
25401 assert_eq!(format_score(0.12345, false), "0.1235");
25402 let snippet = compact_snippet(" first line with useful context\nsecond");
25403 assert_eq!(snippet.as_deref(), Some("first line with useful context"));
25404 }
25405
25406 #[test]
25407 fn compact_members_caps_list() {
25408 let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
25409 .iter()
25410 .map(|n| graph::CommunityMember::new(*n))
25411 .collect();
25412 assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
25413 }
25414
25415 #[test]
25416 fn abbreviate_kind_maps_common_kinds() {
25417 assert_eq!(abbreviate_kind("function"), "fn");
25418 assert_eq!(abbreviate_kind("method"), "meth");
25419 assert_eq!(abbreviate_kind("class"), "cls");
25420 assert_eq!(abbreviate_kind("interface"), "iface");
25421 assert_eq!(abbreviate_kind("type_alias"), "type");
25422 assert_eq!(abbreviate_kind("data_class"), "data_cls");
25423 assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
25424 assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
25425 assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
25426 assert_eq!(abbreviate_kind("object"), "obj");
25427 assert_eq!(abbreviate_kind("heading"), "h");
25428 assert_eq!(abbreviate_kind("code_block"), "code");
25429 assert_eq!(abbreviate_kind("struct"), "struct");
25431 assert_eq!(abbreviate_kind("trait"), "trait");
25432 assert_eq!(abbreviate_kind("enum"), "enum");
25433 assert_eq!(abbreviate_kind("const"), "const");
25434 assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
25435 }
25436
25437 #[test]
25438 fn abbreviate_match_type_maps_search_types() {
25439 assert_eq!(abbreviate_match_type("exact_name"), "exact");
25440 assert_eq!(abbreviate_match_type("partial_tags"), "partial");
25441 assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
25442 assert_eq!(abbreviate_match_type("other_type"), "other_type");
25443 }
25444
25445 #[test]
25446 fn explain_compact_groups_edges_by_file() {
25447 let edges = vec![
25448 index::StoredEdge {
25449 caller_file: "src/main.rs".to_string(),
25450 caller_name: "main".to_string(),
25451 caller_line: 1,
25452 callee_name: "helper".to_string(),
25453 call_site_line: 2,
25454 tagpath_handle: None,
25455 },
25456 index::StoredEdge {
25457 caller_file: "src/main.rs".to_string(),
25458 caller_name: "main".to_string(),
25459 caller_line: 1,
25460 callee_name: "render".to_string(),
25461 call_site_line: 3,
25462 tagpath_handle: None,
25463 },
25464 ];
25465 let lines = format_edge_groups(&edges, false);
25466 assert_eq!(lines, vec![" src/main.rs (2): helper, render"]);
25467 }
25468
25469 #[test]
25470 fn search_hit_groups_preserve_file_counts_and_samples() {
25471 let dir = tempfile::tempdir().unwrap();
25472 let root = dir.path();
25473 let main_rs = root.join("src/main.rs");
25474 fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
25475 fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
25476 let freshness = exact_search_file_timestamp(&main_rs);
25477 let hits = vec![
25478 sift::SearchHit {
25479 artifact_id: "a".to_string(),
25480 artifact_kind: sift::ContextArtifactKind::File,
25481 path: main_rs.display().to_string(),
25482 rank: 1,
25483 score: 10.0,
25484 confidence: sift::ScoreConfidence::High,
25485 location: Some("line 3".to_string()),
25486 snippet: "claudescore-3 anchor".to_string(),
25487 provenance: sift::ArtifactProvenance {
25488 adapter: sift::AcquisitionAdapterKind::FileSystem,
25489 source: "ripgrep -F".to_string(),
25490 synthetic: false,
25491 },
25492 freshness: freshness.clone(),
25493 budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
25494 },
25495 sift::SearchHit {
25496 artifact_id: "b".to_string(),
25497 artifact_kind: sift::ContextArtifactKind::File,
25498 path: main_rs.display().to_string(),
25499 rank: 2,
25500 score: 9.0,
25501 confidence: sift::ScoreConfidence::High,
25502 location: Some("line 7".to_string()),
25503 snippet: "claudescore-3 follow-up".to_string(),
25504 provenance: sift::ArtifactProvenance {
25505 adapter: sift::AcquisitionAdapterKind::FileSystem,
25506 source: "ripgrep -F".to_string(),
25507 synthetic: false,
25508 },
25509 freshness: freshness.clone(),
25510 budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
25511 },
25512 sift::SearchHit {
25513 artifact_id: "c".to_string(),
25514 artifact_kind: sift::ContextArtifactKind::File,
25515 path: main_rs.display().to_string(),
25516 rank: 3,
25517 score: 8.0,
25518 confidence: sift::ScoreConfidence::High,
25519 location: Some("line 9".to_string()),
25520 snippet: "claudescore-3 tail".to_string(),
25521 provenance: sift::ArtifactProvenance {
25522 adapter: sift::AcquisitionAdapterKind::FileSystem,
25523 source: "ripgrep -F".to_string(),
25524 synthetic: false,
25525 },
25526 freshness,
25527 budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
25528 },
25529 ];
25530
25531 let groups = group_search_hits(&hits, root, false);
25532 assert_eq!(groups.len(), 1);
25533 assert_eq!(groups[0].path, "src/main.rs");
25534 assert_eq!(groups[0].hits, 3);
25535 assert_eq!(
25536 groups[0].samples,
25537 vec![
25538 "line 3: claudescore-3 anchor".to_string(),
25539 "line 7: claudescore-3 follow-up".to_string()
25540 ]
25541 );
25542 assert!(should_collapse_search_hits(&hits, root, false));
25543 }
25544
25545 #[test]
25546 fn dense_edge_groups_trigger_collapse() {
25547 let edges = vec![
25548 index::StoredEdge {
25549 caller_file: "src/main.rs".to_string(),
25550 caller_name: "main".to_string(),
25551 caller_line: 1,
25552 callee_name: "helper".to_string(),
25553 call_site_line: 2,
25554 tagpath_handle: None,
25555 },
25556 index::StoredEdge {
25557 caller_file: "src/main.rs".to_string(),
25558 caller_name: "beta".to_string(),
25559 caller_line: 5,
25560 callee_name: "helper".to_string(),
25561 call_site_line: 6,
25562 tagpath_handle: None,
25563 },
25564 index::StoredEdge {
25565 caller_file: "src/main.rs".to_string(),
25566 caller_name: "gamma".to_string(),
25567 caller_line: 9,
25568 callee_name: "helper".to_string(),
25569 call_site_line: 10,
25570 tagpath_handle: None,
25571 },
25572 ];
25573 assert!(should_collapse_edge_groups(&edges));
25574 }
25575
25576 fn setup_workspace() -> tempfile::TempDir {
25579 let dir = tempfile::tempdir().unwrap();
25580 let root = dir.path();
25581 std::fs::write(
25582 root.join(".gitmodules"),
25583 r#"[submodule "src/alpha"]
25584 path = src/alpha
25585 url = https://example.com/alpha
25586[submodule "src/beta"]
25587 path = src/beta
25588 url = https://example.com/beta
25589"#,
25590 )
25591 .unwrap();
25592 let alpha = root.join("src/alpha");
25593 let beta = root.join("src/beta");
25594 std::fs::create_dir_all(&alpha).unwrap();
25595 std::fs::create_dir_all(&beta).unwrap();
25596 std::fs::write(
25597 alpha.join("lib.rs"),
25598 "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
25599 )
25600 .unwrap();
25601 std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
25602 dir
25603 }
25604
25605 fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
25606 let dir = tempfile::tempdir().unwrap();
25607 let root = dir.path();
25608 std::fs::write(
25609 root.join(".gitmodules"),
25610 r#"[submodule "pkg/app/foo"]
25611 path = pkg/app/foo
25612 url = https://example.com/pkg-app-foo
25613[submodule "vendor/foo"]
25614 path = vendor/foo
25615 url = https://example.com/vendor-foo
25616"#,
25617 )
25618 .unwrap();
25619 let pkg_foo = root.join("pkg/app/foo");
25620 let vendor_foo = root.join("vendor/foo");
25621 std::fs::create_dir_all(&pkg_foo).unwrap();
25622 std::fs::create_dir_all(&vendor_foo).unwrap();
25623 std::fs::write(
25624 pkg_foo.join("lib.rs"),
25625 "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
25626 )
25627 .unwrap();
25628 std::fs::write(
25629 vendor_foo.join("lib.rs"),
25630 "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
25631 )
25632 .unwrap();
25633 dir
25634 }
25635
25636 #[test]
25637 fn workspace_index_creates_per_submodule_dbs() {
25638 let dir = setup_workspace();
25639 cmd_index(
25640 dir.path(),
25641 false,
25642 false,
25643 false,
25644 false,
25645 false,
25646 true,
25647 None,
25648 false,
25649 false,
25650 false,
25651 false,
25652 false,
25653 false,
25654 )
25655 .unwrap();
25656 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25657 assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
25658 }
25659
25660 #[test]
25661 fn workspace_index_single_submodule() {
25662 let dir = setup_workspace();
25663 cmd_index(
25664 dir.path(),
25665 false,
25666 false,
25667 false,
25668 false,
25669 false,
25670 false,
25671 Some("alpha"),
25672 false,
25673 false,
25674 false,
25675 false,
25676 false,
25677 false,
25678 )
25679 .unwrap();
25680 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25681 assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
25682 }
25683
25684 #[test]
25685 fn workspace_index_single_submodule_errors_on_unknown_scope() {
25686 let dir = setup_workspace();
25687
25688 let err = cmd_index(
25689 dir.path(),
25690 false,
25691 false,
25692 false,
25693 false,
25694 false,
25695 false,
25696 Some("missing"),
25697 false,
25698 false,
25699 false,
25700 false,
25701 false,
25702 false,
25703 )
25704 .unwrap_err();
25705
25706 let msg = err.to_string();
25707 assert!(msg.contains("unknown scope `missing`"));
25708 assert!(msg.contains("Available scopes: alpha, beta"));
25709 assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
25710 }
25711
25712 #[test]
25713 fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
25714 let dir = setup_workspace_with_duplicate_leaf_names();
25715 cmd_index(
25716 dir.path(),
25717 false,
25718 false,
25719 false,
25720 false,
25721 false,
25722 true,
25723 None,
25724 false,
25725 false,
25726 false,
25727 false,
25728 false,
25729 false,
25730 )
25731 .unwrap();
25732
25733 assert!(
25734 dir.path()
25735 .join(".tsift/indexes/pkg/app/foo/index.db")
25736 .exists()
25737 );
25738 assert!(
25739 dir.path()
25740 .join(".tsift/indexes/vendor/foo/index.db")
25741 .exists()
25742 );
25743 }
25744
25745 #[test]
25746 fn federated_search_across_submodules() {
25747 let dir = setup_workspace();
25748 cmd_index(
25749 dir.path(),
25750 false,
25751 false,
25752 false,
25753 false,
25754 false,
25755 true,
25756 None,
25757 false,
25758 false,
25759 false,
25760 false,
25761 false,
25762 false,
25763 )
25764 .unwrap();
25765 let (hits, _diag) = federated_symbol_search(
25766 dir.path(),
25767 "alpha_helper",
25768 10,
25769 &TagpathSearchOpts {
25770 no_tagpath: true,
25771 strict: false,
25772 },
25773 )
25774 .unwrap();
25775 assert!(
25776 !hits.is_empty(),
25777 "should find alpha_helper via federated search"
25778 );
25779 }
25780
25781 #[test]
25782 fn federated_search_respects_isolation() {
25783 let dir = setup_workspace();
25784 let tsift_dir = dir.path().join(".tsift");
25785 std::fs::create_dir_all(&tsift_dir).unwrap();
25786 std::fs::write(
25787 tsift_dir.join("config.toml"),
25788 r#"
25789[overrides.alpha]
25790tier = "isolated"
25791"#,
25792 )
25793 .unwrap();
25794 cmd_index(
25795 dir.path(),
25796 false,
25797 false,
25798 false,
25799 false,
25800 false,
25801 true,
25802 None,
25803 false,
25804 false,
25805 false,
25806 false,
25807 false,
25808 false,
25809 )
25810 .unwrap();
25811 let (hits, _diag) = federated_symbol_search(
25812 dir.path(),
25813 "alpha_helper",
25814 10,
25815 &TagpathSearchOpts {
25816 no_tagpath: true,
25817 strict: false,
25818 },
25819 )
25820 .unwrap();
25821 assert!(
25822 hits.is_empty(),
25823 "isolated submodule should not appear in federated search"
25824 );
25825 }
25826
25827 #[test]
25828 fn federated_lexical_search_respects_isolation() {
25829 let dir = setup_workspace();
25830 let tsift_dir = dir.path().join(".tsift");
25831 std::fs::create_dir_all(&tsift_dir).unwrap();
25832 std::fs::write(
25833 tsift_dir.join("config.toml"),
25834 r#"
25835[overrides.alpha]
25836tier = "isolated"
25837"#,
25838 )
25839 .unwrap();
25840 cmd_index(
25841 dir.path(),
25842 false,
25843 false,
25844 false,
25845 false,
25846 false,
25847 true,
25848 None,
25849 false,
25850 false,
25851 false,
25852 false,
25853 false,
25854 false,
25855 )
25856 .unwrap();
25857
25858 let response = federated_sift_search(
25859 dir.path(),
25860 &dir.path().join(".tsift/search-cache"),
25861 "fn",
25862 10,
25863 0,
25864 "lexical",
25865 )
25866 .unwrap();
25867
25868 assert!(
25869 !response.hits.is_empty(),
25870 "shared scopes should still contribute lexical hits"
25871 );
25872 assert!(
25873 response
25874 .hits
25875 .iter()
25876 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25877 "isolated scope should not leak lexical hits: {:?}",
25878 response.hits
25879 );
25880 }
25881
25882 #[test]
25883 fn federated_lexical_search_respects_private_tier() {
25884 let dir = setup_workspace();
25885 let tsift_dir = dir.path().join(".tsift");
25886 std::fs::create_dir_all(&tsift_dir).unwrap();
25887 std::fs::write(
25888 tsift_dir.join("config.toml"),
25889 r#"
25890[overrides.alpha]
25891tier = "private"
25892"#,
25893 )
25894 .unwrap();
25895 cmd_index(
25896 dir.path(),
25897 false,
25898 false,
25899 false,
25900 false,
25901 false,
25902 true,
25903 None,
25904 false,
25905 false,
25906 false,
25907 false,
25908 false,
25909 false,
25910 )
25911 .unwrap();
25912
25913 let response = federated_sift_search(
25914 dir.path(),
25915 &dir.path().join(".tsift/search-cache"),
25916 "fn",
25917 10,
25918 0,
25919 "lexical",
25920 )
25921 .unwrap();
25922
25923 assert!(
25924 !response.hits.is_empty(),
25925 "shared scopes should still contribute lexical hits"
25926 );
25927 assert!(
25928 response
25929 .hits
25930 .iter()
25931 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25932 "private scope should not leak lexical hits: {:?}",
25933 response.hits
25934 );
25935 }
25936
25937 #[test]
25938 fn scoped_search_finds_submodule_symbols() {
25939 let dir = setup_workspace();
25940 cmd_index(
25941 dir.path(),
25942 false,
25943 false,
25944 false,
25945 false,
25946 false,
25947 true,
25948 None,
25949 false,
25950 false,
25951 false,
25952 false,
25953 false,
25954 false,
25955 )
25956 .unwrap();
25957 let cfg = config::Config::load(dir.path()).unwrap();
25958 let db_path = cfg.db_path_for(dir.path(), "alpha");
25959 let db = index::IndexDb::open(&db_path).unwrap();
25960 let hits = db.symbol_search("alpha_main", 10).unwrap();
25961 assert!(!hits.is_empty());
25962 assert_eq!(hits[0].name, "alpha_main");
25963 }
25964
25965 #[test]
25966 fn scoped_search_cmd_errors_on_unknown_scope() {
25967 let dir = setup_workspace();
25968
25969 let err = cmd_search(
25970 "alpha_main".to_string(),
25971 Some(dir.path().to_path_buf()),
25972 5,
25973 Some("lexical".to_string()),
25974 Some("missing".to_string()),
25975 false,
25976 false,
25977 false,
25978 0,
25979 false,
25980 false,
25981 false,
25982 false,
25983 false,
25984 false,
25985 false,
25986 )
25987 .unwrap_err();
25988
25989 let msg = err.to_string();
25990 assert!(msg.contains("unknown scope `missing`"));
25991 assert!(msg.contains("Available scopes: alpha, beta"));
25992 }
25993
25994 #[test]
25995 fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
25996 let dir = setup_workspace_with_duplicate_leaf_names();
25997 cmd_index(
25998 dir.path(),
25999 false,
26000 false,
26001 false,
26002 false,
26003 false,
26004 true,
26005 None,
26006 false,
26007 false,
26008 false,
26009 false,
26010 false,
26011 false,
26012 )
26013 .unwrap();
26014
26015 let err = cmd_search(
26016 "vendor_only".to_string(),
26017 Some(dir.path().to_path_buf()),
26018 5,
26019 Some("lexical".to_string()),
26020 Some("foo".to_string()),
26021 false,
26022 false,
26023 false,
26024 0,
26025 false,
26026 false,
26027 false,
26028 false,
26029 false,
26030 false,
26031 false,
26032 )
26033 .unwrap_err();
26034
26035 let msg = err.to_string();
26036 assert!(msg.contains("ambiguous scope `foo`"));
26037 assert!(msg.contains("pkg/app/foo"));
26038 assert!(msg.contains("vendor/foo"));
26039 }
26040
26041 #[test]
26042 fn scoped_graph_query() {
26043 let dir = setup_workspace();
26044 cmd_index(
26045 dir.path(),
26046 false,
26047 false,
26048 false,
26049 false,
26050 false,
26051 true,
26052 None,
26053 false,
26054 false,
26055 false,
26056 false,
26057 false,
26058 false,
26059 )
26060 .unwrap();
26061 let cfg = config::Config::load(dir.path()).unwrap();
26062 let db_path = cfg.db_path_for(dir.path(), "alpha");
26063 let db = index::IndexDb::open(&db_path).unwrap();
26064 let callees = db.callees_of("alpha_main").unwrap();
26065 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
26066 assert!(names.contains(&"alpha_helper"));
26067 }
26068
26069 fn assert_workspace_query_requires_scope(err: anyhow::Error) {
26070 let msg = err.to_string();
26071 assert!(msg.contains("require `--scope <scope>`"), "{msg}");
26072 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26073 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26074 assert!(
26075 !msg.contains("no index found at"),
26076 "workspace query should fail with scope guidance, got: {msg}"
26077 );
26078 }
26079
26080 fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
26081 let msg = err.to_string();
26082 assert!(
26083 msg.contains("requires `--scope <scope>` or `--federated`"),
26084 "{msg}"
26085 );
26086 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26087 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26088 assert!(
26089 !msg.contains("autoindexing index"),
26090 "workspace search should fail before creating a shared root index: {msg}"
26091 );
26092 }
26093
26094 #[test]
26095 fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
26096 let dir = setup_workspace();
26097 cmd_index(
26098 dir.path(),
26099 false,
26100 false,
26101 false,
26102 false,
26103 false,
26104 true,
26105 None,
26106 false,
26107 false,
26108 false,
26109 false,
26110 false,
26111 false,
26112 )
26113 .unwrap();
26114
26115 let err = cmd_graph(
26116 "alpha_main",
26117 dir.path(),
26118 false,
26119 false,
26120 None,
26121 20,
26122 false,
26123 false,
26124 false,
26125 false,
26126 false,
26127 false,
26128 false,
26129 TagpathSearchOpts::default(),
26130 )
26131 .unwrap_err();
26132
26133 assert_workspace_query_requires_scope(err);
26134 }
26135
26136 #[test]
26137 fn graph_cmd_infers_scope_from_nested_workspace_path() {
26138 let dir = setup_workspace();
26139 cmd_index(
26140 dir.path(),
26141 false,
26142 false,
26143 false,
26144 false,
26145 false,
26146 true,
26147 None,
26148 false,
26149 false,
26150 false,
26151 false,
26152 false,
26153 false,
26154 )
26155 .unwrap();
26156 let nested = dir.path().join("src/alpha/nested");
26157 std::fs::create_dir_all(&nested).unwrap();
26158
26159 let result = cmd_graph(
26160 "alpha_main",
26161 &nested,
26162 false,
26163 false,
26164 None,
26165 20,
26166 false,
26167 false,
26168 false,
26169 false,
26170 false,
26171 false,
26172 false,
26173 TagpathSearchOpts::default(),
26174 );
26175
26176 assert!(result.is_ok());
26177 }
26178
26179 #[test]
26180 fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
26181 let dir = setup_workspace();
26182 cmd_index(
26183 dir.path(),
26184 false,
26185 false,
26186 false,
26187 false,
26188 false,
26189 true,
26190 None,
26191 false,
26192 false,
26193 false,
26194 false,
26195 false,
26196 false,
26197 )
26198 .unwrap();
26199
26200 let err = cmd_communities(
26201 dir.path(),
26202 None,
26203 1,
26204 10,
26205 false,
26206 false,
26207 false,
26208 false,
26209 false,
26210 false,
26211 TagpathSearchOpts::default(),
26212 )
26213 .unwrap_err();
26214
26215 assert_workspace_query_requires_scope(err);
26216 }
26217
26218 #[test]
26219 fn communities_cmd_infers_scope_from_nested_workspace_path() {
26220 let dir = setup_workspace();
26221 cmd_index(
26222 dir.path(),
26223 false,
26224 false,
26225 false,
26226 false,
26227 false,
26228 true,
26229 None,
26230 false,
26231 false,
26232 false,
26233 false,
26234 false,
26235 false,
26236 )
26237 .unwrap();
26238 let nested = dir.path().join("src/alpha/nested");
26239 std::fs::create_dir_all(&nested).unwrap();
26240
26241 let result = cmd_communities(
26242 &nested,
26243 None,
26244 1,
26245 10,
26246 false,
26247 false,
26248 false,
26249 false,
26250 false,
26251 false,
26252 TagpathSearchOpts::default(),
26253 );
26254
26255 assert!(result.is_ok());
26256 }
26257
26258 #[test]
26259 fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
26260 let dir = setup_workspace();
26261 cmd_index(
26262 dir.path(),
26263 false,
26264 false,
26265 false,
26266 false,
26267 false,
26268 true,
26269 None,
26270 false,
26271 false,
26272 false,
26273 false,
26274 false,
26275 false,
26276 )
26277 .unwrap();
26278
26279 let err = cmd_path(
26280 "alpha_main",
26281 "alpha_helper",
26282 dir.path(),
26283 None,
26284 false,
26285 false,
26286 false,
26287 false,
26288 false,
26289 TagpathSearchOpts::default(),
26290 )
26291 .unwrap_err();
26292
26293 assert_workspace_query_requires_scope(err);
26294 }
26295
26296 #[test]
26297 fn path_cmd_infers_scope_from_nested_workspace_path() {
26298 let dir = setup_workspace();
26299 cmd_index(
26300 dir.path(),
26301 false,
26302 false,
26303 false,
26304 false,
26305 false,
26306 true,
26307 None,
26308 false,
26309 false,
26310 false,
26311 false,
26312 false,
26313 false,
26314 )
26315 .unwrap();
26316 let nested = dir.path().join("src/alpha/nested");
26317 std::fs::create_dir_all(&nested).unwrap();
26318
26319 let result = cmd_path(
26320 "alpha_main",
26321 "alpha_helper",
26322 &nested,
26323 None,
26324 false,
26325 false,
26326 false,
26327 false,
26328 false,
26329 TagpathSearchOpts::default(),
26330 );
26331
26332 assert!(result.is_ok());
26333 }
26334
26335 #[test]
26336 fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26337 let dir = setup_graph_index();
26338 let db_path = dir.path().join(".tsift/index.db");
26339 let _lock = hold_rollback_journal_lock(&db_path);
26340
26341 let result = cmd_path(
26342 "main",
26343 "helper",
26344 dir.path(),
26345 None,
26346 false,
26347 false,
26348 false,
26349 false,
26350 false,
26351 TagpathSearchOpts::default(),
26352 );
26353
26354 assert!(result.is_ok());
26355 }
26356
26357 #[test]
26358 fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
26359 let dir = setup_workspace();
26360 cmd_index(
26361 dir.path(),
26362 false,
26363 false,
26364 false,
26365 false,
26366 false,
26367 true,
26368 None,
26369 false,
26370 false,
26371 false,
26372 false,
26373 false,
26374 false,
26375 )
26376 .unwrap();
26377
26378 let err = cmd_explain(
26379 "alpha_main",
26380 dir.path(),
26381 None,
26382 15,
26383 false,
26384 false,
26385 false,
26386 false,
26387 false,
26388 false,
26389 false,
26390 false,
26391 )
26392 .unwrap_err();
26393
26394 assert_workspace_query_requires_scope(err);
26395 }
26396
26397 #[test]
26398 fn explain_cmd_infers_scope_from_nested_workspace_path() {
26399 let dir = setup_workspace();
26400 cmd_index(
26401 dir.path(),
26402 false,
26403 false,
26404 false,
26405 false,
26406 false,
26407 true,
26408 None,
26409 false,
26410 false,
26411 false,
26412 false,
26413 false,
26414 false,
26415 )
26416 .unwrap();
26417 let nested = dir.path().join("src/alpha/nested");
26418 std::fs::create_dir_all(&nested).unwrap();
26419
26420 let result = cmd_explain(
26421 "alpha_main",
26422 &nested,
26423 None,
26424 15,
26425 false,
26426 false,
26427 false,
26428 false,
26429 false,
26430 false,
26431 false,
26432 false,
26433 );
26434
26435 assert!(result.is_ok());
26436 }
26437
26438 #[test]
26439 fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26440 let dir = setup_graph_index();
26441 let db_path = dir.path().join(".tsift/index.db");
26442 let _lock = hold_rollback_journal_lock(&db_path);
26443
26444 let result = cmd_explain(
26445 "main",
26446 dir.path(),
26447 None,
26448 15,
26449 false,
26450 false,
26451 false,
26452 false,
26453 false,
26454 false,
26455 false,
26456 false,
26457 );
26458
26459 assert!(result.is_ok());
26460 }
26461
26462 #[test]
26465 fn community_detection_groups_related() {
26466 let dir = setup_graph_index();
26467 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26468 let edges = db.all_edges().unwrap();
26469 let result = graph::detect_communities(&edges);
26470 assert!(result.node_count > 0);
26471 assert!(!result.communities.is_empty());
26472 }
26473
26474 #[test]
26475 fn community_cmd_autoindexes_missing_index_by_default() {
26476 let dir = tempfile::tempdir().unwrap();
26477 let result = cmd_communities(
26478 dir.path(),
26479 None,
26480 2,
26481 10,
26482 false,
26483 false,
26484 false,
26485 false,
26486 false,
26487 false,
26488 TagpathSearchOpts::default(),
26489 );
26490
26491 assert!(result.is_ok());
26492 assert!(dir.path().join(".tsift/index.db").exists());
26493 }
26494
26495 #[test]
26498 fn path_finds_connected_symbols() {
26499 let dir = setup_graph_index();
26500 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26501 let edges = db.all_edges().unwrap();
26502 let result = graph::shortest_path(&edges, "main", "helper");
26503 assert!(result.is_some());
26504 let path = result.unwrap();
26505 assert_eq!(path.hops, 1);
26506 }
26507
26508 #[test]
26509 fn path_returns_none_for_unknown() {
26510 let dir = setup_graph_index();
26511 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26512 let edges = db.all_edges().unwrap();
26513 assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
26514 }
26515
26516 #[test]
26517 fn path_cmd_autoindexes_missing_index_by_default() {
26518 let dir = tempfile::tempdir().unwrap();
26519 let result = cmd_path(
26520 "a",
26521 "b",
26522 dir.path(),
26523 None,
26524 false,
26525 false,
26526 false,
26527 false,
26528 false,
26529 TagpathSearchOpts::default(),
26530 );
26531
26532 assert!(result.is_ok());
26533 assert!(dir.path().join(".tsift/index.db").exists());
26534 }
26535
26536 #[test]
26539 fn explain_shows_symbol_info() {
26540 let dir = setup_graph_index();
26541 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26542 let symbols = db.symbol_info("main").unwrap();
26543 assert!(!symbols.is_empty());
26544 assert_eq!(symbols[0].name, "main");
26545 assert_eq!(symbols[0].kind, "function");
26546 }
26547
26548 #[test]
26549 fn explain_cmd_autoindexes_missing_index_by_default() {
26550 let dir = tempfile::tempdir().unwrap();
26551 let result = cmd_explain(
26552 "main",
26553 dir.path(),
26554 None,
26555 15,
26556 false,
26557 false,
26558 false,
26559 false,
26560 false,
26561 false,
26562 false,
26563 false,
26564 );
26565
26566 assert!(result.is_ok());
26567 assert!(dir.path().join(".tsift/index.db").exists());
26568 }
26569
26570 fn hold_write_lock(db_path: &std::path::Path) -> Connection {
26571 let conn = Connection::open(db_path).unwrap();
26572 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
26573 conn
26574 }
26575
26576 fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
26577 use fs4::fs_std::FileExt;
26578 use std::io::Write;
26579
26580 let mut file = std::fs::OpenOptions::new()
26581 .read(true)
26582 .write(true)
26583 .create(true)
26584 .truncate(false)
26585 .open(lock_path)
26586 .unwrap();
26587 assert!(file.try_lock_exclusive().unwrap());
26588 writeln!(file, "{}", std::process::id()).unwrap();
26589 file
26590 }
26591
26592 fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
26593 let conn = Connection::open(db_path).unwrap();
26594 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26595 .unwrap();
26596 std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
26597 conn
26598 }
26599
26600 fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
26601 let conn = Connection::open(db_path).unwrap();
26602 conn.execute_batch(
26603 "PRAGMA journal_mode=WAL;
26604 PRAGMA wal_autocheckpoint=0;
26605 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
26606 INSERT INTO wal_lock_probe DEFAULT VALUES;
26607 PRAGMA locking_mode=EXCLUSIVE;
26608 BEGIN EXCLUSIVE;",
26609 )
26610 .unwrap();
26611 assert!(substrate::wal_sidecar_path(db_path).exists());
26612 conn
26613 }
26614
26615 #[test]
26616 fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
26617 let dir = setup_graph_index();
26618 let db_path = dir.path().join(".tsift/index.db");
26619 let _lock = hold_wal_database_lock(&db_path);
26620
26621 let err = cmd_index(
26622 dir.path(),
26623 false,
26624 false,
26625 false,
26626 false,
26627 false,
26628 false,
26629 None,
26630 false,
26631 false,
26632 false,
26633 false,
26634 false,
26635 false,
26636 )
26637 .unwrap_err();
26638
26639 let msg = err.to_string();
26640 assert!(msg.contains("indexing"));
26641 assert!(msg.contains("lock diagnostics:"));
26642 assert!(msg.contains("lock: absent"));
26643 assert!(msg.contains("wal: present") || msg.contains("shm: present"));
26644 assert!(msg.contains("wedged writer holding live WAL sidecars"));
26645 assert!(msg.contains("snapshot fallback"));
26646 }
26647
26648 #[test]
26649 fn search_cmd_succeeds_while_writer_lock_is_held() {
26650 let dir = setup_graph_index();
26651 let db_path = dir.path().join(".tsift/index.db");
26652 let _lock = hold_write_lock(&db_path);
26653
26654 let result = cmd_search(
26655 "main".to_string(),
26656 Some(dir.path().to_path_buf()),
26657 5,
26658 Some("lexical".to_string()),
26659 None,
26660 false,
26661 false,
26662 false,
26663 0,
26664 true,
26665 false,
26666 false,
26667 false,
26668 false,
26669 false,
26670 false,
26671 );
26672
26673 assert!(result.is_ok());
26674 }
26675
26676 #[test]
26677 fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
26678 let dir = setup_graph_index();
26679 let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
26680
26681 let result = cmd_search(
26682 "main".to_string(),
26683 Some(dir.path().to_path_buf()),
26684 5,
26685 Some("lexical".to_string()),
26686 None,
26687 false,
26688 false,
26689 false,
26690 0,
26691 true,
26692 false,
26693 false,
26694 false,
26695 false,
26696 false,
26697 false,
26698 );
26699
26700 assert!(result.is_ok());
26701 }
26702
26703 #[test]
26704 fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
26705 let dir = setup_graph_index();
26706 let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
26707
26708 let result = cmd_search(
26709 "main".to_string(),
26710 Some(dir.path().to_path_buf()),
26711 5,
26712 Some("lexical".to_string()),
26713 None,
26714 false,
26715 false,
26716 false,
26717 0,
26718 true,
26719 false,
26720 false,
26721 false,
26722 false,
26723 false,
26724 false,
26725 );
26726
26727 assert!(result.is_ok());
26728 }
26729
26730 #[test]
26731 fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
26732 let dir = setup_graph_index();
26733 std::thread::sleep(std::time::Duration::from_millis(50));
26734 std::fs::write(
26735 dir.path().join("main.rs"),
26736 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26737 )
26738 .unwrap();
26739
26740 let err = cmd_search(
26741 "helper".to_string(),
26742 Some(dir.path().to_path_buf()),
26743 5,
26744 Some("lexical".to_string()),
26745 None,
26746 false,
26747 false,
26748 false,
26749 0,
26750 false,
26751 false,
26752 false,
26753 false,
26754 false,
26755 false,
26756 false,
26757 )
26758 .unwrap_err();
26759
26760 assert!(err.to_string().contains("search aborted"));
26761 assert!(err.to_string().contains("index is stale"));
26762 assert!(err.to_string().contains("--no-autoindex"));
26763 }
26764
26765 #[test]
26766 fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
26767 let dir = setup_graph_index();
26768 std::thread::sleep(std::time::Duration::from_millis(50));
26769 std::fs::write(
26770 dir.path().join("main.rs"),
26771 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26772 )
26773 .unwrap();
26774 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26775
26776 let err = cmd_search(
26777 "helper".to_string(),
26778 Some(dir.path().to_path_buf()),
26779 5,
26780 Some("lexical".to_string()),
26781 None,
26782 false,
26783 false,
26784 false,
26785 0,
26786 false,
26787 false,
26788 false,
26789 false,
26790 false,
26791 false,
26792 false,
26793 )
26794 .unwrap_err();
26795
26796 assert!(err.to_string().contains("search aborted"));
26797 assert!(err.to_string().contains("index is stale"));
26798 assert!(!err.to_string().contains("database is locked"));
26799 }
26800
26801 #[test]
26802 fn search_cmd_autoindexes_stale_index_by_default() {
26803 let dir = setup_graph_index();
26804 std::thread::sleep(std::time::Duration::from_millis(50));
26805 std::fs::write(
26806 dir.path().join("main.rs"),
26807 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26808 )
26809 .unwrap();
26810
26811 let result = cmd_search(
26812 "helper".to_string(),
26813 Some(dir.path().to_path_buf()),
26814 5,
26815 Some("lexical".to_string()),
26816 None,
26817 false,
26818 false,
26819 true,
26820 0,
26821 false,
26822 false,
26823 false,
26824 false,
26825 false,
26826 false,
26827 false,
26828 );
26829
26830 assert!(result.is_ok());
26831
26832 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26833 let summary = db.compute_changes(dir.path()).unwrap();
26834 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26835 }
26836
26837 #[test]
26838 fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
26839 let dir = setup_graph_index();
26840 std::thread::sleep(std::time::Duration::from_millis(50));
26841 std::fs::write(
26842 dir.path().join("main.rs"),
26843 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26844 )
26845 .unwrap();
26846 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
26847
26848 let result = cmd_search(
26849 "helper".to_string(),
26850 Some(dir.path().to_path_buf()),
26851 5,
26852 Some("lexical".to_string()),
26853 None,
26854 false,
26855 false,
26856 true,
26857 0,
26858 false,
26859 false,
26860 false,
26861 false,
26862 false,
26863 false,
26864 false,
26865 );
26866
26867 assert!(result.is_ok());
26868
26869 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26870 let summary = db.compute_changes(dir.path()).unwrap();
26871 assert_eq!(summary.modified, 1);
26872 }
26873
26874 #[test]
26875 fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
26876 let dir = setup_graph_index();
26877 std::thread::sleep(std::time::Duration::from_millis(50));
26878 std::fs::write(
26879 dir.path().join("main.rs"),
26880 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26881 )
26882 .unwrap();
26883 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26884
26885 let err = cmd_search(
26886 "helper".to_string(),
26887 Some(dir.path().to_path_buf()),
26888 5,
26889 Some("lexical".to_string()),
26890 None,
26891 false,
26892 false,
26893 true,
26894 0,
26895 false,
26896 false,
26897 false,
26898 false,
26899 false,
26900 false,
26901 false,
26902 )
26903 .unwrap_err();
26904
26905 let msg = err.to_string();
26906 assert!(msg.contains("autoindexing index"));
26907 assert!(msg.contains("lock diagnostics:"));
26908 assert!(msg.contains("journal: present"));
26909 assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
26910 }
26911
26912 #[test]
26913 fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
26914 let dir = setup_graph_index();
26915 let nested = dir.path().join("src/nested");
26916 std::fs::create_dir_all(&nested).unwrap();
26917
26918 let result = cmd_search(
26919 "helper".to_string(),
26920 Some(nested.clone()),
26921 5,
26922 Some("lexical".to_string()),
26923 None,
26924 false,
26925 false,
26926 true,
26927 0,
26928 false,
26929 false,
26930 false,
26931 false,
26932 false,
26933 false,
26934 false,
26935 );
26936
26937 assert!(result.is_ok());
26938 assert!(!nested.join(".tsift/index.db").exists());
26939 }
26940
26941 #[test]
26942 fn exact_search_returns_literal_matches() {
26943 let dir = tempfile::tempdir().unwrap();
26944 std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
26945
26946 let response = run_exact_search_with_timeout(dir.path(), "claudescore-3", 5, 0).unwrap();
26947
26948 assert_eq!(response.strategy, "exact");
26949 assert_eq!(response.hits.len(), 1);
26950 assert!(response.hits[0].path.ends_with("notes.txt"));
26951 assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
26952 assert!(response.hits[0].snippet.contains("claudescore-3"));
26953 }
26954
26955 #[test]
26956 fn exact_search_skips_stale_index_precheck() {
26957 let dir = setup_graph_index();
26958 std::thread::sleep(std::time::Duration::from_millis(50));
26959 std::fs::write(
26960 dir.path().join("main.rs"),
26961 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
26962 )
26963 .unwrap();
26964
26965 let result = cmd_search(
26966 "println!(\"updated\")".to_string(),
26967 Some(dir.path().to_path_buf()),
26968 5,
26969 Some("exact".to_string()),
26970 None,
26971 false,
26972 false,
26973 false,
26974 0,
26975 false,
26976 false,
26977 false,
26978 false,
26979 false,
26980 false,
26981 false,
26982 );
26983
26984 assert!(result.is_ok());
26985 }
26986
26987 #[test]
26988 fn workspace_exact_search_does_not_require_shared_root_index() {
26989 let dir = setup_workspace();
26990 cmd_index(
26991 dir.path(),
26992 false,
26993 false,
26994 false,
26995 false,
26996 false,
26997 true,
26998 None,
26999 false,
27000 false,
27001 false,
27002 false,
27003 false,
27004 false,
27005 )
27006 .unwrap();
27007
27008 let result = cmd_search(
27009 "alpha_helper".to_string(),
27010 Some(dir.path().to_path_buf()),
27011 5,
27012 Some("exact".to_string()),
27013 None,
27014 false,
27015 false,
27016 false,
27017 0,
27018 false,
27019 false,
27020 false,
27021 false,
27022 false,
27023 false,
27024 false,
27025 );
27026
27027 assert!(result.is_ok());
27028 assert!(!dir.path().join(".tsift/index.db").exists());
27029 }
27030
27031 #[test]
27032 fn identifier_like_query_prefers_exact_search() {
27033 assert!(query_prefers_exact_search("claudescore-3"));
27034 assert!(query_prefers_exact_search("alpha_helper"));
27035 assert!(query_prefers_exact_search("src/main.rs"));
27036 assert!(query_prefers_exact_search("crate::module"));
27037 assert!(!query_prefers_exact_search("authenticate"));
27038 assert!(!query_prefers_exact_search("fn main"));
27039 assert!(!query_prefers_exact_search("."));
27040 }
27041
27042 #[test]
27043 fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
27044 assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
27045 assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
27046 assert_eq!(
27047 resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
27048 "hybrid"
27049 );
27050 }
27051
27052 #[test]
27053 fn workspace_identifier_like_search_auto_uses_exact_backend() {
27054 let dir = setup_workspace();
27055 cmd_index(
27056 dir.path(),
27057 false,
27058 false,
27059 false,
27060 false,
27061 false,
27062 true,
27063 None,
27064 false,
27065 false,
27066 false,
27067 false,
27068 false,
27069 false,
27070 )
27071 .unwrap();
27072
27073 let result = cmd_search(
27074 "alpha_helper".to_string(),
27075 Some(dir.path().to_path_buf()),
27076 5,
27077 None,
27078 None,
27079 false,
27080 false,
27081 false,
27082 0,
27083 false,
27084 false,
27085 false,
27086 false,
27087 false,
27088 false,
27089 false,
27090 );
27091
27092 assert!(result.is_ok());
27093 assert!(!dir.path().join(".tsift/index.db").exists());
27094 }
27095
27096 #[test]
27097 fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
27098 let dir = setup_graph_index();
27099 let nested = dir.path().join("src/nested");
27100 std::fs::create_dir_all(&nested).unwrap();
27101 std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
27102
27103 let result = cmd_index(
27104 &nested, false, false, false, false, false, false, None, false, false, false, false,
27105 false, false,
27106 );
27107
27108 assert!(result.is_ok());
27109 assert!(dir.path().join(".tsift/index.db").exists());
27110 assert!(!nested.join(".tsift/index.db").exists());
27111 }
27112
27113 #[test]
27114 fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
27115 let dir = setup_workspace();
27116 let nested = dir.path().join("docs/nested");
27117 std::fs::create_dir_all(&nested).unwrap();
27118
27119 let result = cmd_index(
27120 &nested, false, false, false, false, false, true, None, false, false, false, false,
27121 false, false,
27122 );
27123
27124 let cfg = config::Config::load(dir.path()).unwrap();
27125
27126 assert!(result.is_ok());
27127 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
27128 assert!(cfg.db_path_for(dir.path(), "beta").exists());
27129 }
27130
27131 #[test]
27132 fn status_cmd_autoindexes_missing_workspace_scopes() {
27133 let dir = setup_workspace();
27134 let cfg = config::Config::load(dir.path()).unwrap();
27135 let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
27136 let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
27137 let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
27138 alpha_db.apply_changes(&alpha.source_root).unwrap();
27139
27140 let beta_db_path = cfg.db_path_for(dir.path(), "beta");
27141 assert!(!beta_db_path.exists());
27142
27143 cmd_status(
27144 dir.path(),
27145 StatusCommandOptions {
27146 fix: false,
27147 no_fix: false,
27148 json_output: true,
27149 compact: false,
27150 pretty: false,
27151 terse: false,
27152 schema: false,
27153 },
27154 )
27155 .unwrap();
27156
27157 assert!(beta_db_path.exists());
27158 let report = status::check_status(dir.path()).unwrap();
27159 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27160 }
27161
27162 #[test]
27163 fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
27164 let dir = setup_workspace();
27165 let cfg = config::Config::load(dir.path()).unwrap();
27166
27167 cmd_status(
27168 dir.path(),
27169 StatusCommandOptions {
27170 fix: false,
27171 no_fix: false,
27172 json_output: true,
27173 compact: false,
27174 pretty: false,
27175 terse: false,
27176 schema: false,
27177 },
27178 )
27179 .unwrap();
27180
27181 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
27182 assert!(cfg.db_path_for(dir.path(), "beta").exists());
27183 let report = status::check_status(dir.path()).unwrap();
27184 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27185 }
27186
27187 #[test]
27188 fn status_cmd_fix_refreshes_stale_index() {
27189 let dir = setup_graph_index();
27190 std::thread::sleep(std::time::Duration::from_millis(50));
27191 std::fs::write(
27192 dir.path().join("main.rs"),
27193 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27194 )
27195 .unwrap();
27196
27197 let report = status::check_status(dir.path()).unwrap();
27198 assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
27199
27200 cmd_status(
27201 dir.path(),
27202 StatusCommandOptions {
27203 fix: false,
27204 no_fix: false,
27205 json_output: true,
27206 compact: false,
27207 pretty: false,
27208 terse: false,
27209 schema: false,
27210 },
27211 )
27212 .unwrap();
27213
27214 let report = status::check_status(dir.path()).unwrap();
27215 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27216 }
27217
27218 #[test]
27219 fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
27220 let dir = setup_graph_index();
27221 let db_path = dir.path().join(".tsift/index.db");
27222 let _lock = hold_wal_database_lock(&db_path);
27223
27224 cmd_status(
27225 dir.path(),
27226 StatusCommandOptions {
27227 fix: false,
27228 no_fix: false,
27229 json_output: true,
27230 compact: false,
27231 pretty: false,
27232 terse: false,
27233 schema: false,
27234 },
27235 )
27236 .unwrap();
27237
27238 let report = status::check_status(dir.path()).unwrap();
27239 assert!(matches!(
27240 report.index,
27241 status::IndexStatus::Fresh {
27242 recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
27243 ..
27244 }
27245 ));
27246 let locks = status::check_locks(dir.path(), None, None).unwrap();
27247 assert!(matches!(
27248 locks.writer_lock,
27249 status::WriterLockStatus::Absent { .. }
27250 ));
27251 assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
27252 assert!(
27253 locks
27254 .recommended_action
27255 .contains("wedged writer holding live WAL sidecars")
27256 );
27257 }
27258
27259 #[test]
27260 fn locks_report_uses_ancestor_project_root_for_nested_paths() {
27261 let dir = setup_graph_index();
27262 let nested = dir.path().join("src/nested");
27263 std::fs::create_dir_all(&nested).unwrap();
27264
27265 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27266 let report = status::check_locks(&root, Some(&nested), None).unwrap();
27267
27268 assert_eq!(report.source_root, dir.path());
27269 assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
27270 }
27271
27272 #[test]
27273 fn workspace_locks_report_infers_scope_from_nested_path() {
27274 let dir = setup_workspace();
27275 cmd_index(
27276 dir.path(),
27277 false,
27278 false,
27279 false,
27280 false,
27281 false,
27282 true,
27283 None,
27284 false,
27285 false,
27286 false,
27287 false,
27288 false,
27289 false,
27290 )
27291 .unwrap();
27292 let nested = dir.path().join("src/alpha/nested");
27293 std::fs::create_dir_all(&nested).unwrap();
27294
27295 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27296 let report = status::check_locks(&root, Some(&nested), None).unwrap();
27297 let cfg = config::Config::load(dir.path()).unwrap();
27298
27299 assert_eq!(report.label, "submodule `alpha` index");
27300 assert_eq!(report.source_root, dir.path().join("src/alpha"));
27301 assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
27302 assert_eq!(
27303 report.reindex_command,
27304 format!("tsift index --submodule alpha {}", dir.path().display())
27305 );
27306 }
27307
27308 #[test]
27309 fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
27310 let dir = setup_workspace();
27311 cmd_index(
27312 dir.path(),
27313 false,
27314 false,
27315 false,
27316 false,
27317 false,
27318 true,
27319 None,
27320 false,
27321 false,
27322 false,
27323 false,
27324 false,
27325 false,
27326 )
27327 .unwrap();
27328
27329 let alpha = dir.path().join("src/alpha/lib.rs");
27330 std::thread::sleep(std::time::Duration::from_millis(50));
27331 std::fs::write(
27332 &alpha,
27333 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27334 )
27335 .unwrap();
27336
27337 let result = cmd_search(
27338 "alpha_helper".to_string(),
27339 Some(dir.path().to_path_buf()),
27340 5,
27341 Some("lexical".to_string()),
27342 Some("alpha".to_string()),
27343 false,
27344 false,
27345 true,
27346 0,
27347 false,
27348 false,
27349 false,
27350 false,
27351 false,
27352 false,
27353 false,
27354 );
27355
27356 assert!(result.is_ok());
27357
27358 let cfg = config::Config::load(dir.path()).unwrap();
27359 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27360 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27361 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27362 }
27363
27364 #[test]
27365 fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27366 let dir = setup_workspace();
27367 cmd_index(
27368 dir.path(),
27369 false,
27370 false,
27371 false,
27372 false,
27373 false,
27374 true,
27375 None,
27376 false,
27377 false,
27378 false,
27379 false,
27380 false,
27381 false,
27382 )
27383 .unwrap();
27384
27385 let alpha = dir.path().join("src/alpha/lib.rs");
27386 std::thread::sleep(std::time::Duration::from_millis(50));
27387 std::fs::write(
27388 &alpha,
27389 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27390 )
27391 .unwrap();
27392
27393 let cfg = config::Config::load(dir.path()).unwrap();
27394 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27395
27396 let err = cmd_search(
27397 "alpha_helper".to_string(),
27398 Some(dir.path().to_path_buf()),
27399 5,
27400 Some("lexical".to_string()),
27401 Some("alpha".to_string()),
27402 false,
27403 false,
27404 false,
27405 0,
27406 false,
27407 false,
27408 false,
27409 false,
27410 false,
27411 false,
27412 false,
27413 )
27414 .unwrap_err();
27415
27416 assert!(err.to_string().contains("search aborted"));
27417 assert!(err.to_string().contains("submodule `alpha` index"));
27418 assert!(!err.to_string().contains("database is locked"));
27419 }
27420
27421 #[test]
27422 fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
27423 let dir = setup_workspace();
27424 cmd_index(
27425 dir.path(),
27426 false,
27427 false,
27428 false,
27429 false,
27430 false,
27431 true,
27432 None,
27433 false,
27434 false,
27435 false,
27436 false,
27437 false,
27438 false,
27439 )
27440 .unwrap();
27441
27442 let alpha = dir.path().join("src/alpha/lib.rs");
27443 std::thread::sleep(std::time::Duration::from_millis(50));
27444 std::fs::write(
27445 &alpha,
27446 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27447 )
27448 .unwrap();
27449
27450 let result = cmd_search(
27451 "alpha_helper".to_string(),
27452 Some(dir.path().to_path_buf()),
27453 5,
27454 Some("lexical".to_string()),
27455 None,
27456 true,
27457 false,
27458 true,
27459 0,
27460 false,
27461 false,
27462 false,
27463 false,
27464 false,
27465 false,
27466 false,
27467 );
27468
27469 assert!(result.is_ok());
27470
27471 let cfg = config::Config::load(dir.path()).unwrap();
27472 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27473 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27474 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27475 }
27476
27477 #[test]
27478 fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27479 let dir = setup_workspace();
27480 cmd_index(
27481 dir.path(),
27482 false,
27483 false,
27484 false,
27485 false,
27486 false,
27487 true,
27488 None,
27489 false,
27490 false,
27491 false,
27492 false,
27493 false,
27494 false,
27495 )
27496 .unwrap();
27497
27498 let alpha = dir.path().join("src/alpha/lib.rs");
27499 std::thread::sleep(std::time::Duration::from_millis(50));
27500 std::fs::write(
27501 &alpha,
27502 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27503 )
27504 .unwrap();
27505
27506 let cfg = config::Config::load(dir.path()).unwrap();
27507 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27508
27509 let err = cmd_search(
27510 "alpha_helper".to_string(),
27511 Some(dir.path().to_path_buf()),
27512 5,
27513 Some("lexical".to_string()),
27514 None,
27515 true,
27516 false,
27517 false,
27518 30,
27519 false,
27520 false,
27521 false,
27522 false,
27523 false,
27524 false,
27525 false,
27526 )
27527 .unwrap_err();
27528
27529 assert!(err.to_string().contains("stale"));
27530 assert!(err.to_string().contains("submodule `alpha` index"));
27531 assert!(!err.to_string().contains("database is locked"));
27532 }
27533
27534 #[test]
27535 fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
27536 let dir = setup_workspace();
27537 cmd_index(
27538 dir.path(),
27539 false,
27540 false,
27541 false,
27542 false,
27543 false,
27544 true,
27545 None,
27546 false,
27547 false,
27548 false,
27549 false,
27550 false,
27551 false,
27552 )
27553 .unwrap();
27554
27555 let err = cmd_search(
27556 "alpha_helper".to_string(),
27557 Some(dir.path().to_path_buf()),
27558 5,
27559 Some("lexical".to_string()),
27560 None,
27561 false,
27562 false,
27563 true,
27564 0,
27565 false,
27566 false,
27567 false,
27568 false,
27569 false,
27570 false,
27571 false,
27572 )
27573 .unwrap_err();
27574
27575 assert_workspace_search_requires_explicit_target(err);
27576 assert!(!dir.path().join(".tsift/index.db").exists());
27577 }
27578
27579 #[test]
27580 fn workspace_search_cmd_infers_scope_from_nested_path() {
27581 let dir = setup_workspace();
27582 cmd_index(
27583 dir.path(),
27584 false,
27585 false,
27586 false,
27587 false,
27588 false,
27589 true,
27590 None,
27591 false,
27592 false,
27593 false,
27594 false,
27595 false,
27596 false,
27597 )
27598 .unwrap();
27599 let nested = dir.path().join("src/alpha/nested");
27600 std::fs::create_dir_all(&nested).unwrap();
27601
27602 let result = cmd_search(
27603 "alpha_helper".to_string(),
27604 Some(nested),
27605 5,
27606 Some("lexical".to_string()),
27607 None,
27608 false,
27609 false,
27610 false,
27611 0,
27612 false,
27613 false,
27614 false,
27615 false,
27616 false,
27617 false,
27618 false,
27619 );
27620
27621 assert!(result.is_ok());
27622 }
27623
27624 #[test]
27625 fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
27626 let dir = setup_workspace_with_duplicate_leaf_names();
27627 cmd_index(
27628 dir.path(),
27629 false,
27630 false,
27631 false,
27632 false,
27633 false,
27634 true,
27635 None,
27636 false,
27637 false,
27638 false,
27639 false,
27640 false,
27641 false,
27642 )
27643 .unwrap();
27644 let nested = dir.path().join("vendor/foo/nested");
27645 std::fs::create_dir_all(&nested).unwrap();
27646
27647 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27648 let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
27649 let cfg = config::Config::load(dir.path()).unwrap();
27650
27651 assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
27652 }
27653
27654 #[test]
27655 fn graph_cmd_succeeds_while_writer_lock_is_held() {
27656 let dir = setup_graph_index();
27657 let db_path = dir.path().join(".tsift/index.db");
27658 let _lock = hold_write_lock(&db_path);
27659
27660 let result = cmd_graph(
27661 "main",
27662 dir.path(),
27663 false,
27664 false,
27665 None,
27666 20,
27667 false,
27668 true,
27669 false,
27670 false,
27671 false,
27672 false,
27673 false,
27674 TagpathSearchOpts::default(),
27675 );
27676
27677 assert!(result.is_ok());
27678 }
27679
27680 #[test]
27681 fn graph_cmd_autoindexes_stale_index_by_default() {
27682 let dir = setup_graph_index();
27683 std::thread::sleep(std::time::Duration::from_millis(50));
27684 std::fs::write(
27685 dir.path().join("main.rs"),
27686 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27687 )
27688 .unwrap();
27689
27690 let result = cmd_graph(
27691 "helper",
27692 dir.path(),
27693 true,
27694 false,
27695 None,
27696 20,
27697 false,
27698 true,
27699 false,
27700 false,
27701 false,
27702 false,
27703 false,
27704 TagpathSearchOpts::default(),
27705 );
27706
27707 assert!(result.is_ok());
27708 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27709 let summary = db.compute_changes(dir.path()).unwrap();
27710 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27711 }
27712
27713 #[test]
27714 fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27715 let dir = setup_graph_index();
27716 let db_path = dir.path().join(".tsift/index.db");
27717 let _lock = hold_rollback_journal_lock(&db_path);
27718
27719 let result = cmd_graph(
27720 "main",
27721 dir.path(),
27722 false,
27723 false,
27724 None,
27725 20,
27726 false,
27727 true,
27728 false,
27729 false,
27730 false,
27731 false,
27732 false,
27733 TagpathSearchOpts::default(),
27734 );
27735
27736 assert!(result.is_ok());
27737 }
27738
27739 #[test]
27740 fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
27741 let dir = setup_graph_index();
27742 let nested = dir.path().join("src/nested");
27743 std::fs::create_dir_all(&nested).unwrap();
27744
27745 let result = cmd_graph(
27746 "helper",
27747 &nested,
27748 true,
27749 false,
27750 None,
27751 20,
27752 false,
27753 false,
27754 false,
27755 false,
27756 false,
27757 false,
27758 false,
27759 TagpathSearchOpts::default(),
27760 );
27761
27762 assert!(result.is_ok());
27763 }
27764
27765 #[test]
27766 fn communities_cmd_succeeds_while_writer_lock_is_held() {
27767 let dir = setup_graph_index();
27768 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27769
27770 let result = cmd_communities(
27771 dir.path(),
27772 None,
27773 1,
27774 10,
27775 false,
27776 false,
27777 false,
27778 false,
27779 false,
27780 false,
27781 TagpathSearchOpts::default(),
27782 );
27783
27784 assert!(result.is_ok());
27785 }
27786
27787 #[test]
27788 fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27789 let dir = setup_graph_index();
27790 let db_path = dir.path().join(".tsift/index.db");
27791 let _lock = hold_rollback_journal_lock(&db_path);
27792
27793 let result = cmd_communities(
27794 dir.path(),
27795 None,
27796 1,
27797 10,
27798 false,
27799 false,
27800 false,
27801 false,
27802 false,
27803 false,
27804 TagpathSearchOpts::default(),
27805 );
27806
27807 assert!(result.is_ok());
27808 }
27809
27810 #[test]
27811 fn lint_finds_entities_from_project_root_index_db() {
27812 let dir = tempfile::tempdir().unwrap();
27813 std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
27814 std::fs::write(
27815 dir.path().join("README.md"),
27816 "alpha_helper should be backticked.\n",
27817 )
27818 .unwrap();
27819 cmd_index(
27820 dir.path(),
27821 false,
27822 false,
27823 false,
27824 false,
27825 false,
27826 false,
27827 None,
27828 false,
27829 false,
27830 false,
27831 false,
27832 false,
27833 false,
27834 )
27835 .unwrap();
27836
27837 let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
27838 .unwrap()
27839 .unwrap();
27840 let entities = lint::collect_entities_from_index_path(&root).unwrap();
27841 let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
27842
27843 assert!(
27844 result
27845 .annotations
27846 .iter()
27847 .any(|ann| ann.text == "alpha_helper")
27848 );
27849 }
27850
27851 #[test]
27854 fn search_direct_runs_ok() {
27855 let dir = tempfile::tempdir().unwrap();
27856 let search_dir = dir.path().to_path_buf();
27857 let cache_dir = search_dir.join(".tsift/search-cache");
27858 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27859 let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical");
27860 assert!(result.is_ok(), "direct search should succeed");
27861 assert!(
27862 cache_dir.exists(),
27863 "search should create the configured cache dir"
27864 );
27865 }
27866
27867 #[test]
27868 fn search_timeout_zero_disables_timeout() {
27869 let dir = tempfile::tempdir().unwrap();
27870 let search_dir = dir.path().to_path_buf();
27871 let cache_dir = search_dir.join(".tsift/search-cache");
27872 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27873 let result = run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[]);
27874 assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
27875 assert!(
27876 cache_dir.exists(),
27877 "timeout=0 should keep using the stable search cache dir"
27878 );
27879 }
27880
27881 #[test]
27882 fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
27883 let dir = tempfile::tempdir().unwrap();
27884 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
27885 cmd_index(
27886 dir.path(),
27887 false,
27888 false,
27889 false,
27890 false,
27891 false,
27892 false,
27893 None,
27894 false,
27895 false,
27896 false,
27897 false,
27898 false,
27899 false,
27900 )
27901 .unwrap();
27902 let db_path = dir.path().join(".tsift/index.db");
27903 std::fs::remove_file(&db_path).unwrap();
27904 let search_target = SearchIndexTarget {
27905 label: "index".to_string(),
27906 db_path,
27907 source_root: dir.path().to_path_buf(),
27908 scope_name: None,
27909 reindex_cmd: format!("tsift index {}", dir.path().display()),
27910 };
27911
27912 let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
27913
27914 assert!(message.contains("timed out after 1s"));
27915 assert!(message.contains("index is missing"));
27916 assert!(message.contains("Run `tsift index"));
27917 assert!(!message.contains("search root looks fresh"));
27918 }
27919
27920 #[test]
27921 fn search_worker_output_path_uses_json_suffix() {
27922 let path = next_search_worker_output_path();
27923 assert!(path.extension().is_some_and(|ext| ext == "json"));
27924 }
27925
27926 #[test]
27929 fn index_quiet_suppresses_file_list() {
27930 let dir = setup_graph_index();
27931 let result = cmd_index(
27932 dir.path(),
27933 false,
27934 true,
27935 false,
27936 false,
27937 true,
27938 false,
27939 None,
27940 false,
27941 false,
27942 false,
27943 false,
27944 false,
27945 false,
27946 );
27947 assert!(result.is_ok());
27948 }
27949
27950 #[test]
27951 fn index_exit_code_implies_quiet() {
27952 let dir = setup_graph_index();
27953 let result = cmd_index(
27954 dir.path(),
27955 false,
27956 true,
27957 false,
27958 false,
27959 false,
27960 false,
27961 None,
27962 false,
27963 false,
27964 false,
27965 false,
27966 false,
27967 false,
27968 );
27969 assert!(result.is_ok());
27970 }
27971
27972 #[test]
27973 fn index_quiet_json_omits_changes() {
27974 let dir = setup_graph_index();
27975 let result = cmd_index(
27976 dir.path(),
27977 false,
27978 true,
27979 false,
27980 false,
27981 true,
27982 false,
27983 None,
27984 true,
27985 false,
27986 false,
27987 false,
27988 false,
27989 false,
27990 );
27991 assert!(result.is_ok());
27992 }
27993
27994 #[test]
27995 fn cli_workflow_defaults_to_search_topic() {
27996 let cli = parse_cli(["tsift", "workflow"]);
27997 match cli.command {
27998 Some(Commands::Workflow { topic, json }) => {
27999 assert_eq!(topic, "search");
28000 assert!(!json);
28001 }
28002 _ => panic!("expected Workflow command"),
28003 }
28004 }
28005
28006 #[test]
28007 fn search_workflow_recipe_preserves_handles_across_expansions() {
28008 let recipe = workflow::search_workflow_recipe();
28009 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
28010 assert_eq!(
28011 step_names,
28012 vec![
28013 "exact-anchor",
28014 "semantic-search",
28015 "explain-symbol",
28016 "summarize-selection",
28017 "digest-expansion"
28018 ]
28019 );
28020 assert!(
28021 recipe
28022 .handle_contract
28023 .iter()
28024 .any(|item| item.contains("originating command"))
28025 );
28026 assert!(
28027 recipe.steps[1]
28028 .preserves
28029 .iter()
28030 .any(|item| item.contains("sfam-*"))
28031 );
28032 assert!(
28033 recipe.steps[2]
28034 .preserves
28035 .iter()
28036 .any(|item| item.contains("ecall-*"))
28037 );
28038 assert!(
28039 recipe.steps[4]
28040 .preserves
28041 .iter()
28042 .any(|item| item.contains("artifact handles"))
28043 );
28044 }
28045
28046 #[test]
28049 fn to_json_compact_default() {
28050 let val = serde_json::json!({"a": 1, "b": [2, 3]});
28051 let compact = to_json(&val, false, false).unwrap();
28052 assert!(!compact.contains('\n'));
28053 assert!(
28054 compact.contains("\"a\":1")
28055 || compact.contains("\"a\": 1")
28056 || compact.contains("\"a\":")
28057 );
28058 }
28059
28060 #[test]
28061 fn to_json_pretty_indents() {
28062 let val = serde_json::json!({"a": 1, "b": [2, 3]});
28063 let pretty = to_json(&val, true, false).unwrap();
28064 assert!(pretty.contains('\n'));
28065 assert!(pretty.contains(" "));
28066 }
28067
28068 #[test]
28069 fn to_json_compact_is_shorter() {
28070 let val =
28071 serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
28072 let compact = to_json(&val, false, false).unwrap();
28073 let pretty = to_json(&val, true, false).unwrap();
28074 assert!(compact.len() < pretty.len());
28075 }
28076
28077 #[test]
28078 fn terse_renames_keys() {
28079 let val =
28080 serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
28081 let result = to_json(&val, false, true).unwrap();
28082 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28083 assert!(parsed["_s"].is_object());
28084 let d = &parsed["d"];
28085 assert_eq!(d["cf"], "a.rs");
28086 assert_eq!(d["cn"], "main");
28087 assert_eq!(d["csl"], 10);
28088 }
28089
28090 #[test]
28091 fn terse_schema_only_includes_used_keys() {
28092 let val = serde_json::json!({"name": "test", "score": 0.5});
28093 let result = to_json(&val, false, true).unwrap();
28094 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28095 let schema = parsed["_s"].as_object().unwrap();
28096 assert_eq!(schema["n"], "name");
28097 assert_eq!(schema["sc"], "score");
28098 assert!(!schema.contains_key("cf"));
28099 }
28100
28101 #[test]
28102 fn terse_nested_arrays() {
28103 let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
28104 let result = to_json(&val, false, true).unwrap();
28105 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28106 let d = &parsed["d"];
28107 assert_eq!(d["crs"][0]["cn"], "a");
28108 assert_eq!(d["crs"][0]["cf"], "b.rs");
28109 }
28110
28111 #[test]
28112 fn terse_preserves_unknown_keys() {
28113 let val = serde_json::json!({"custom_field": "value", "name": "test"});
28114 let result = to_json(&val, false, true).unwrap();
28115 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28116 let d = &parsed["d"];
28117 assert_eq!(d["custom_field"], "value");
28118 assert_eq!(d["n"], "test");
28119 }
28120
28121 #[test]
28124 fn ultra_terse_strips_properties_from_graph_nodes() {
28125 let val = serde_json::json!({
28126 "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
28127 });
28128 let result = to_json_schema(&val, false, true, true, false).unwrap();
28129 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28130 let node = &parsed["d"]["nodes"][0];
28131 assert_eq!(node["id"], "fn:main");
28132 assert_eq!(node["k"], "fn");
28133 assert_eq!(node["n"], "main");
28134 assert!(node.get("properties").is_none());
28135 }
28136
28137 #[test]
28138 fn ultra_terse_strips_properties_from_graph_edges() {
28139 let val = serde_json::json!({
28140 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
28141 });
28142 let result = to_json_schema(&val, false, true, true, false).unwrap();
28143 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28144 let edge = &parsed["d"]["edges"][0];
28145 assert_eq!(edge["from_id"], "a");
28146 assert_eq!(edge["to_id"], "b");
28147 assert_eq!(edge["k"], "c");
28148 assert!(edge.get("properties").is_none());
28149 }
28150
28151 #[test]
28152 fn ultra_terse_abbreviates_edge_kinds() {
28153 let val = serde_json::json!({
28154 "edges": [
28155 {"from_id": "a", "to_id": "b", "kind": "defines"},
28156 {"from_id": "a", "to_id": "c", "kind": "contains"},
28157 {"from_id": "a", "to_id": "d", "kind": "imports"},
28158 {"from_id": "a", "to_id": "e", "kind": "mentions"},
28159 {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
28160 {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
28161 {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
28162 {"from_id": "a", "to_id": "i", "kind": "uses"},
28163 {"from_id": "a", "to_id": "j", "kind": "parent"},
28164 {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
28165 ]
28166 });
28167 let result = to_json_schema(&val, false, true, true, false).unwrap();
28168 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28169 let edges = &parsed["d"]["edges"].as_array().unwrap();
28170 assert_eq!(edges[0]["k"], "d");
28171 assert_eq!(edges[1]["k"], "ct");
28172 assert_eq!(edges[2]["k"], "i");
28173 assert_eq!(edges[3]["k"], "m");
28174 assert_eq!(edges[4]["k"], "sr");
28175 assert_eq!(edges[5]["k"], "bt");
28176 assert_eq!(edges[6]["k"], "sctx");
28177 assert_eq!(edges[7]["k"], "u");
28178 assert_eq!(edges[8]["k"], "p");
28179 assert_eq!(edges[9]["k"], "unknown_edge");
28180 }
28181
28182 #[test]
28183 fn ultra_terse_strips_provenance_freshness_from_edges() {
28184 let val = serde_json::json!({
28185 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
28186 });
28187 let result = to_json_schema(&val, false, true, true, false).unwrap();
28188 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28189 let edge = &parsed["d"]["edges"][0];
28190 assert!(edge.get("provenance").is_none());
28191 assert!(edge.get("freshness").is_none());
28192 assert_eq!(edge["k"], "c");
28193 }
28194
28195 #[test]
28196 fn ultra_terse_truncates_snippets() {
28197 let long_snippet = "x".repeat(120);
28198 let val = serde_json::json!({"snippet": long_snippet});
28199 let result = to_json_schema(&val, false, true, true, false).unwrap();
28200 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28201 let snipped = parsed["d"]["sn"].as_str().unwrap();
28202 assert_eq!(snipped.len(), 80);
28203 assert!(snipped.ends_with("..."));
28204 }
28205
28206 #[test]
28207 fn ultra_terse_truncates_abbreviated_snippet_key() {
28208 let long_snippet = "y".repeat(100);
28209 let val = serde_json::json!({"snippet": long_snippet});
28210 let result = to_json_schema(&val, false, true, true, false).unwrap();
28211 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28212 let snipped = parsed["d"]["sn"].as_str().unwrap();
28213 assert_eq!(snipped.len(), 80);
28214 assert!(snipped.ends_with("..."));
28215 }
28216
28217 #[test]
28218 fn ultra_terse_compacts_coverage_snapshot() {
28219 let val = serde_json::json!({
28220 "mode": "incremental",
28221 "total_sector_count": 10,
28222 "dirty_sector_count": 2,
28223 "active_rebuild": Some("rebuild-1"),
28224 "completed_dirty_sector_count": 1,
28225 "mounted_sector_count": 8,
28226 "rebuilding_sector_count": 1,
28227 "resumed_sector_count": 3,
28228 "reused_sector_count": 5
28229 });
28230 let result = to_json_schema(&val, false, true, true, false).unwrap();
28231 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28232 let d = &parsed["d"];
28233 assert_eq!(d["mode"], "incremental");
28234 assert_eq!(d["total_sector_count"], 10);
28235 assert_eq!(d["dirty_sector_count"], 2);
28236 assert!(d.get("active_rebuild").is_none());
28237 assert!(d.get("completed_dirty_sector_count").is_none());
28238 assert!(d.get("mounted_sector_count").is_none());
28239 assert!(d.get("rebuilding_sector_count").is_none());
28240 assert!(d.get("resumed_sector_count").is_none());
28241 assert!(d.get("reused_sector_count").is_none());
28242 }
28243
28244 #[test]
28245 fn ultra_terse_short_snippet_unchanged() {
28246 let val = serde_json::json!({"snippet": "short text"});
28247 let result = to_json_schema(&val, false, true, true, false).unwrap();
28248 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28249 assert_eq!(parsed["d"]["sn"], "short text");
28250 }
28251
28252 #[test]
28253 fn ultra_terse_non_graph_object_properties_preserved() {
28254 let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
28255 let result = to_json_schema(&val, false, true, true, false).unwrap();
28256 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28257 assert!(parsed["d"]["config"]["properties"].is_object());
28258 }
28259
28260 #[test]
28263 fn schema_converts_homogeneous_arrays() {
28264 let val = serde_json::json!({"symbols": [
28265 {"name": "foo", "kind": "fn", "line": 10},
28266 {"name": "bar", "kind": "fn", "line": 20}
28267 ]});
28268 let result = to_json_schema(&val, false, false, false, true).unwrap();
28269 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28270 let syms = &parsed["symbols"];
28271 let columns = syms["_c"]
28272 .as_array()
28273 .unwrap()
28274 .iter()
28275 .map(|value| value.as_str().unwrap())
28276 .collect::<Vec<_>>();
28277 let row0 = syms["_r"][0].as_array().unwrap();
28278 let row1 = syms["_r"][1].as_array().unwrap();
28279 let name_index = columns.iter().position(|column| *column == "name").unwrap();
28280 let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
28281 let line_index = columns.iter().position(|column| *column == "line").unwrap();
28282 assert_eq!(row0[name_index], "foo");
28283 assert_eq!(row0[kind_index], "fn");
28284 assert_eq!(row0[line_index], 10);
28285 assert_eq!(row1[name_index], "bar");
28286 assert_eq!(row1[kind_index], "fn");
28287 assert_eq!(row1[line_index], 20);
28288 }
28289
28290 #[test]
28291 fn schema_skips_short_arrays() {
28292 let val = serde_json::json!({"items": [{"name": "only"}]});
28293 let result = to_json_schema(&val, false, false, false, true).unwrap();
28294 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28295 assert!(parsed["items"].is_array());
28296 assert_eq!(parsed["items"][0]["name"], "only");
28297 }
28298
28299 #[test]
28300 fn schema_skips_heterogeneous_arrays() {
28301 let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
28302 let result = to_json_schema(&val, false, false, false, true).unwrap();
28303 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28304 assert!(parsed["items"].is_array());
28305 assert_eq!(parsed["items"][0]["a"], 1);
28306 }
28307
28308 #[test]
28309 fn schema_with_terse_combines() {
28310 let val = serde_json::json!({"callers": [
28311 {"caller_name": "a", "caller_file": "x.rs"},
28312 {"caller_name": "b", "caller_file": "y.rs"}
28313 ]});
28314 let result = to_json_schema(&val, false, true, false, true).unwrap();
28315 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28316 assert!(parsed["_s"].is_object());
28317 let d = &parsed["d"];
28318 let crs = &d["crs"];
28319 assert!(crs["_c"].is_array());
28320 assert!(crs["_r"].is_array());
28321 let columns = crs["_c"]
28322 .as_array()
28323 .unwrap()
28324 .iter()
28325 .map(|value| value.as_str().unwrap())
28326 .collect::<Vec<_>>();
28327 let row = crs["_r"][0].as_array().unwrap();
28328 let name_index = columns.iter().position(|column| *column == "cn").unwrap();
28329 let file_index = columns.iter().position(|column| *column == "cf").unwrap();
28330 assert_eq!(row[name_index], "a");
28331 assert_eq!(row[file_index], "x.rs");
28332 }
28333
28334 #[test]
28335 fn schema_preserves_non_object_arrays() {
28336 let val = serde_json::json!({"tags": ["a", "b", "c"]});
28337 let result = to_json_schema(&val, false, false, false, true).unwrap();
28338 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28339 assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
28340 }
28341
28342 #[test]
28343 fn cli_accepts_global_schema_flag() {
28344 let cli = parse_cli(["tsift", "--schema", "search", "test"]);
28345 assert!(cli.schema);
28346 assert!(matches!(cli.command, Some(Commands::Search { .. })));
28347 }
28348
28349 #[test]
28350 fn cli_accepts_global_envelope_flag() {
28351 let cli = parse_cli([
28352 "tsift",
28353 "--envelope",
28354 "context-pack",
28355 "tasks/software/tsift.md",
28356 ]);
28357 assert!(cli.envelope);
28358 assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
28359 }
28360
28361 #[test]
28362 fn cli_accepts_locks_command() {
28363 let cli = parse_cli(["tsift", "locks"]);
28364 assert!(matches!(cli.command, Some(Commands::Locks { .. })));
28365 }
28366
28367 #[test]
28368 fn cli_parses_memory_budget_guard_command() {
28369 let cli = parse_cli([
28370 "tsift",
28371 "memory",
28372 "budget-guard",
28373 "--file",
28374 "tool.log",
28375 "--budget-tokens",
28376 "1000",
28377 "--json",
28378 ]);
28379 match cli.command {
28380 Some(Commands::Memory {
28381 command:
28382 crate::cli::MemoryCommand::BudgetGuard {
28383 file,
28384 budget_tokens,
28385 json,
28386 ..
28387 },
28388 }) => {
28389 assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
28390 assert_eq!(budget_tokens, 1000);
28391 assert!(json);
28392 }
28393 _ => panic!("expected memory budget-guard command"),
28394 }
28395 }
28396
28397 #[test]
28398 fn cli_parses_memory_capture_agent_doc_closeout_command() {
28399 let cli = parse_cli([
28400 "tsift",
28401 "memory",
28402 "capture-agent-doc-closeout",
28403 ".",
28404 "--session-path",
28405 "tasks/software/tsift.md",
28406 "--prompt-target",
28407 "do [#tsiftmemhooks]",
28408 "--response-summary",
28409 "wired closeout capture",
28410 "--commit-hash",
28411 "abc123",
28412 "--session-check-status",
28413 "clean",
28414 "--json",
28415 ]);
28416 match cli.command {
28417 Some(Commands::Memory {
28418 command:
28419 crate::cli::MemoryCommand::CaptureAgentDocCloseout {
28420 path,
28421 session_path,
28422 prompt_target,
28423 response_summary,
28424 commit_hash,
28425 session_check_status,
28426 json,
28427 },
28428 }) => {
28429 assert_eq!(path, std::path::PathBuf::from("."));
28430 assert_eq!(
28431 session_path,
28432 std::path::PathBuf::from("tasks/software/tsift.md")
28433 );
28434 assert_eq!(prompt_target, "do [#tsiftmemhooks]");
28435 assert_eq!(response_summary, "wired closeout capture");
28436 assert_eq!(commit_hash.as_deref(), Some("abc123"));
28437 assert_eq!(session_check_status, "clean");
28438 assert!(json);
28439 }
28440 _ => panic!("expected memory capture-agent-doc-closeout command"),
28441 }
28442 }
28443
28444 #[test]
28445 fn cli_locks_accepts_scope_flag() {
28446 let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
28447 match cli.command {
28448 Some(Commands::Locks { scope, .. }) => {
28449 assert_eq!(scope.as_deref(), Some("alpha"));
28450 }
28451 _ => panic!("expected Locks command"),
28452 }
28453 }
28454
28455 #[test]
28456 fn cli_search_accepts_autoindex_flag() {
28457 let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
28458 match cli.command {
28459 Some(Commands::Search {
28460 autoindex,
28461 no_autoindex,
28462 ..
28463 }) => {
28464 assert!(autoindex);
28465 assert!(!no_autoindex);
28466 }
28467 _ => panic!("expected Search command"),
28468 }
28469 }
28470
28471 #[test]
28472 fn cli_search_accepts_exact_flag() {
28473 let cli = parse_cli(["tsift", "search", "test", "--exact"]);
28474 match cli.command {
28475 Some(Commands::Search {
28476 exact, strategy, ..
28477 }) => {
28478 assert!(exact);
28479 assert!(strategy.is_none());
28480 }
28481 _ => panic!("expected Search command"),
28482 }
28483 }
28484
28485 #[test]
28486 fn cli_parses_diff_digest_command() {
28487 let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
28488 match cli.command {
28489 Some(Commands::DiffDigest {
28490 json,
28491 path,
28492 cached,
28493 revision,
28494 max_parsed_files,
28495 }) => {
28496 assert!(json);
28497 assert_eq!(path, PathBuf::from("."));
28498 assert!(!cached);
28499 assert!(revision.is_none());
28500 assert_eq!(max_parsed_files, 25);
28501 }
28502 _ => panic!("expected DiffDigest command"),
28503 }
28504 }
28505
28506 #[test]
28507 fn cli_rejects_conflicting_diff_digest_modes() {
28508 match try_parse_cli([
28509 "tsift",
28510 "diff-digest",
28511 "--cached",
28512 "--revision",
28513 "HEAD",
28514 ".",
28515 ]) {
28516 Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
28517 Err(err) => {
28518 assert!(err.to_string().contains("--cached"));
28519 assert!(err.to_string().contains("--revision"));
28520 }
28521 }
28522 }
28523
28524 #[test]
28525 fn cli_parses_test_digest_command() {
28526 let cli = parse_cli([
28527 "tsift",
28528 "test-digest",
28529 "--path",
28530 ".",
28531 "--input",
28532 "target/test.log",
28533 "--runner",
28534 "cargo",
28535 "--json",
28536 ]);
28537 match cli.command {
28538 Some(Commands::TestDigest {
28539 json,
28540 path,
28541 input,
28542 runner,
28543 }) => {
28544 assert!(json);
28545 assert_eq!(path, PathBuf::from("."));
28546 assert_eq!(input, Some(PathBuf::from("target/test.log")));
28547 assert_eq!(runner.as_deref(), Some("cargo"));
28548 }
28549 _ => panic!("expected TestDigest command"),
28550 }
28551 }
28552
28553 #[test]
28554 fn cli_parses_log_digest_command() {
28555 let cli = parse_cli([
28556 "tsift",
28557 "log-digest",
28558 "--path",
28559 ".",
28560 "--input",
28561 "target/build.log",
28562 "--json",
28563 ]);
28564 match cli.command {
28565 Some(Commands::LogDigest { json, path, input }) => {
28566 assert!(json);
28567 assert_eq!(path, PathBuf::from("."));
28568 assert_eq!(input, Some(PathBuf::from("target/build.log")));
28569 }
28570 _ => panic!("expected LogDigest command"),
28571 }
28572 }
28573
28574 #[test]
28575 fn cli_parses_metric_digest_command() {
28576 let cli = parse_cli([
28577 "tsift",
28578 "metric-digest",
28579 "--input",
28580 "target/runs.json",
28581 "--baseline",
28582 "target/prior.json",
28583 "--metric",
28584 "session_mae",
28585 "--lower-is-better",
28586 "session_mae",
28587 "--history",
28588 "4",
28589 "--top",
28590 "2",
28591 "--json",
28592 ]);
28593 match cli.command {
28594 Some(Commands::MetricDigest {
28595 input,
28596 baseline,
28597 metrics,
28598 lower_is_better,
28599 history,
28600 top,
28601 json,
28602 ..
28603 }) => {
28604 assert!(json);
28605 assert_eq!(input, Some(PathBuf::from("target/runs.json")));
28606 assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
28607 assert_eq!(metrics, vec!["session_mae"]);
28608 assert_eq!(lower_is_better, vec!["session_mae"]);
28609 assert_eq!(history, 4);
28610 assert_eq!(top, 2);
28611 }
28612 _ => panic!("expected MetricDigest command"),
28613 }
28614 }
28615
28616 #[test]
28617 fn cli_parses_dci_benchmark_command() {
28618 let cli = parse_cli([
28619 "tsift",
28620 "dci-benchmark",
28621 "--fixture",
28622 "fixtures/dci-search-benchmark.json",
28623 "--json",
28624 ]);
28625 match cli.command {
28626 Some(Commands::DciBenchmark { fixture, json }) => {
28627 assert!(json);
28628 assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
28629 }
28630 _ => panic!("expected DciBenchmark command"),
28631 }
28632 }
28633
28634 #[test]
28635 fn cli_parses_session_digest_command() {
28636 let cli = parse_cli([
28637 "tsift",
28638 "session-digest",
28639 "--path",
28640 ".",
28641 "--input",
28642 "target/session.md",
28643 "--source",
28644 "markdown",
28645 "--json",
28646 ]);
28647 match cli.command {
28648 Some(Commands::SessionDigest {
28649 json,
28650 path,
28651 input,
28652 source,
28653 }) => {
28654 assert!(json);
28655 assert_eq!(path, PathBuf::from("."));
28656 assert_eq!(input, Some(PathBuf::from("target/session.md")));
28657 assert_eq!(source.as_deref(), Some("markdown"));
28658 }
28659 _ => panic!("expected SessionDigest command"),
28660 }
28661 }
28662
28663 #[test]
28664 fn cli_parses_session_cost_command() {
28665 let cli = parse_cli([
28666 "tsift",
28667 "session-cost",
28668 "--input",
28669 "target/session.jsonl",
28670 "--source",
28671 "codex-jsonl",
28672 "--json",
28673 ]);
28674 match cli.command {
28675 Some(Commands::SessionCost {
28676 json,
28677 input,
28678 source,
28679 }) => {
28680 assert!(json);
28681 assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
28682 assert_eq!(source.as_deref(), Some("codex-jsonl"));
28683 }
28684 _ => panic!("expected SessionCost command"),
28685 }
28686 }
28687
28688 #[test]
28689 fn cli_parses_session_review_command() {
28690 let cli = parse_cli([
28691 "tsift",
28692 "session-review",
28693 "tasks/software/tsift.md",
28694 "--next-context",
28695 "--json",
28696 ]);
28697 match cli.command {
28698 Some(Commands::SessionReview {
28699 json,
28700 next_context,
28701 path,
28702 ..
28703 }) => {
28704 assert!(json);
28705 assert!(next_context);
28706 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28707 }
28708 _ => panic!("expected SessionReview command"),
28709 }
28710 }
28711
28712 #[test]
28713 fn cli_search_accepts_budget_flags() {
28714 let cli = parse_cli([
28715 "tsift",
28716 "search",
28717 "alpha_helper",
28718 "--max-items",
28719 "3",
28720 "--max-bytes",
28721 "96",
28722 ]);
28723 match cli.command {
28724 Some(Commands::Search {
28725 max_items,
28726 max_bytes,
28727 ..
28728 }) => {
28729 assert_eq!(max_items, Some(3));
28730 assert_eq!(max_bytes, Some(96));
28731 }
28732 _ => panic!("expected Search command"),
28733 }
28734 }
28735
28736 #[test]
28737 fn cli_search_accepts_budget_preset() {
28738 let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
28739 match cli.command {
28740 Some(Commands::Search { budget, .. }) => {
28741 assert_eq!(budget, Some(ResponseBudgetPreset::Small));
28742 }
28743 _ => panic!("expected Search command"),
28744 }
28745 }
28746
28747 #[test]
28748 fn cli_search_accepts_ast_facet_filters() {
28749 let cli = parse_cli([
28750 "tsift",
28751 "search",
28752 "setup",
28753 "--lang",
28754 "markdown",
28755 "--kind",
28756 "list_item",
28757 "--node-kind",
28758 "list_item",
28759 "--section",
28760 "Install",
28761 "--parent",
28762 "Run setup.",
28763 "--child",
28764 "Confirm setup.",
28765 "--fence-language",
28766 "rust",
28767 "--list-depth",
28768 "1",
28769 "--heading-level",
28770 "2",
28771 ]);
28772 match cli.command {
28773 Some(Commands::Search {
28774 lang,
28775 kind,
28776 node_kind,
28777 section,
28778 parent,
28779 child,
28780 fence_language,
28781 list_depth,
28782 heading_level,
28783 ..
28784 }) => {
28785 assert_eq!(lang, vec!["markdown"]);
28786 assert_eq!(kind, vec!["list_item"]);
28787 assert_eq!(node_kind, vec!["list_item"]);
28788 assert_eq!(section, vec!["Install"]);
28789 assert_eq!(parent, vec!["Run setup."]);
28790 assert_eq!(child, vec!["Confirm setup."]);
28791 assert_eq!(fence_language, vec!["rust"]);
28792 assert_eq!(list_depth, vec![1]);
28793 assert_eq!(heading_level, vec![2]);
28794 }
28795 _ => panic!("expected Search command"),
28796 }
28797 }
28798
28799 #[test]
28800 fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
28801 let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
28802 assert_eq!(small.preview_items(), 3);
28803 assert_eq!(small.preview_bytes(), 120);
28804 assert_eq!(small.follow_up_items(), 4);
28805
28806 let overridden =
28807 ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
28808 assert_eq!(overridden.preview_items(), 7);
28809 assert_eq!(overridden.preview_bytes(), 120);
28810 assert_eq!(overridden.follow_up_items(), 7);
28811
28812 let envelope_default = ResponseBudget::from_cli(None, None, None, true);
28813 assert!(envelope_default.is_active());
28814 }
28815
28816 #[test]
28817 fn cli_explain_accepts_budget_flags() {
28818 let cli = parse_cli([
28819 "tsift",
28820 "explain",
28821 "alpha_helper",
28822 "--max-items",
28823 "2",
28824 "--max-bytes",
28825 "80",
28826 ]);
28827 match cli.command {
28828 Some(Commands::Explain {
28829 max_items,
28830 max_bytes,
28831 ..
28832 }) => {
28833 assert_eq!(max_items, Some(2));
28834 assert_eq!(max_bytes, Some(80));
28835 }
28836 _ => panic!("expected Explain command"),
28837 }
28838 }
28839
28840 #[test]
28841 fn cli_session_review_accepts_budget_flags() {
28842 let cli = parse_cli([
28843 "tsift",
28844 "session-review",
28845 "tasks/software/tsift.md",
28846 "--max-items",
28847 "4",
28848 "--max-bytes",
28849 "120",
28850 ]);
28851 match cli.command {
28852 Some(Commands::SessionReview {
28853 max_items,
28854 max_bytes,
28855 ..
28856 }) => {
28857 assert_eq!(max_items, Some(4));
28858 assert_eq!(max_bytes, Some(120));
28859 }
28860 _ => panic!("expected SessionReview command"),
28861 }
28862 }
28863
28864 #[test]
28865 fn cli_parses_context_pack_command() {
28866 let cli = parse_cli([
28867 "tsift",
28868 "context-pack",
28869 "tasks/software/tsift.md",
28870 "--test-input",
28871 "target/test.log",
28872 "--runner",
28873 "cargo",
28874 "--log-input",
28875 "target/build.log",
28876 "--max-items",
28877 "3",
28878 "--max-bytes",
28879 "96",
28880 "--json",
28881 ]);
28882 match cli.command {
28883 Some(Commands::ContextPack {
28884 path,
28885 test_input,
28886 runner,
28887 log_input,
28888 json,
28889 max_items,
28890 max_bytes,
28891 budget,
28892 convex_snapshot,
28893 }) => {
28894 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28895 assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
28896 assert_eq!(runner.as_deref(), Some("cargo"));
28897 assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
28898 assert!(json);
28899 assert_eq!(max_items, Some(3));
28900 assert_eq!(max_bytes, Some(96));
28901 assert!(budget.is_none());
28902 assert!(convex_snapshot.is_none());
28903 }
28904 _ => panic!("expected ContextPack command"),
28905 }
28906 }
28907
28908 #[test]
28909 fn cli_parses_token_savings_command() {
28910 let cli = parse_cli([
28911 "tsift",
28912 "token-savings",
28913 "--fixture",
28914 "fixtures/tsift-token-savings.json",
28915 "--fail-under",
28916 "--json",
28917 ]);
28918 match cli.command {
28919 Some(Commands::TokenSavings {
28920 fixture,
28921 fail_under,
28922 json,
28923 }) => {
28924 assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
28925 assert!(fail_under);
28926 assert!(json);
28927 }
28928 _ => panic!("expected TokenSavings command"),
28929 }
28930 }
28931
28932 #[test]
28933 fn token_savings_report_records_fixture_thresholds() {
28934 let raw_symbols = [
28935 "validate_user",
28936 "validateUser",
28937 "ValidateUser",
28938 "validate-user",
28939 "VALIDATE_USER",
28940 "Validate_User",
28941 "raw_symbol",
28942 "rawSymbol",
28943 "RawSymbol",
28944 "raw-symbol",
28945 "RAW_SYMBOL",
28946 "Raw_Symbol",
28947 ]
28948 .iter()
28949 .enumerate()
28950 .map(|(idx, identifier)| TokenSavingsRawSymbol {
28951 identifier: (*identifier).to_string(),
28952 file: format!("src/example_{idx}.rs"),
28953 line: (idx + 1) as u64,
28954 context: "function".to_string(),
28955 })
28956 .collect();
28957 let fixture = TokenSavingsFixture {
28958 schema_version: 1,
28959 description: "fixture".to_string(),
28960 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28961 cases: vec![TokenSavingsFixtureCase {
28962 name: "search-preview".to_string(),
28963 surface: "search".to_string(),
28964 minimum_savings_percent: 40.0,
28965 raw_symbols,
28966 tagpath_families: vec![
28967 TokenSavingsFamily {
28968 canonical: "validate_user".to_string(),
28969 count: 6,
28970 aliases: BTreeMap::new(),
28971 },
28972 TokenSavingsFamily {
28973 canonical: "raw_symbol".to_string(),
28974 count: 6,
28975 aliases: BTreeMap::new(),
28976 },
28977 ],
28978 context_pack_inputs: None,
28979 session_review_inputs: None,
28980 source_read_inputs: None,
28981 markdown_projection_inputs: None,
28982 }],
28983 };
28984
28985 let report = build_token_savings_report(&fixture).unwrap();
28986
28987 assert!(report.pass);
28988 assert_eq!(report.cases[0].raw_symbol_count, 12);
28989 assert_eq!(report.cases[0].family_count, 2);
28990 assert_eq!(report.cases[0].status, "pass");
28991 assert!(report.cases[0].byte_delta > 0);
28992 assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
28993 assert!(report.cases[0].savings_percent >= 40.0);
28994 }
28995
28996 #[test]
28997 fn token_savings_source_read_inputs_preserve_required_anchors() {
28998 let fixture = TokenSavingsFixture {
28999 schema_version: 1,
29000 description: "fixture".to_string(),
29001 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29002 cases: vec![TokenSavingsFixtureCase {
29003 name: "source-read".to_string(),
29004 surface: "source-read".to_string(),
29005 minimum_savings_percent: 40.0,
29006 raw_symbols: Vec::new(),
29007 tagpath_families: Vec::new(),
29008 context_pack_inputs: None,
29009 session_review_inputs: None,
29010 source_read_inputs: Some(TokenSavingsSourceReadInputs {
29011 reads: vec![TokenSavingsSourceReadInput {
29012 command: "sed -n '40,160p' src/main.rs".to_string(),
29013 file: "src/main.rs".to_string(),
29014 raw_start: 40,
29015 raw_lines: 121,
29016 raw_excerpt: "line 40\n".repeat(121),
29017 envelope_start: 40,
29018 envelope_lines: 121,
29019 required_line_anchors: vec![40, 120, 160],
29020 }],
29021 }),
29022 markdown_projection_inputs: None,
29023 }],
29024 };
29025
29026 let report = build_token_savings_report(&fixture).unwrap();
29027
29028 assert!(report.pass);
29029 assert_eq!(report.cases[0].surface, "source-read");
29030 assert!(report.cases[0].savings_percent >= 40.0);
29031 }
29032
29033 #[test]
29034 fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
29035 let fixture = TokenSavingsFixture {
29036 schema_version: 1,
29037 description: "fixture".to_string(),
29038 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29039 cases: vec![TokenSavingsFixtureCase {
29040 name: "source-read".to_string(),
29041 surface: "source-read".to_string(),
29042 minimum_savings_percent: 40.0,
29043 raw_symbols: Vec::new(),
29044 tagpath_families: Vec::new(),
29045 context_pack_inputs: None,
29046 session_review_inputs: None,
29047 source_read_inputs: Some(TokenSavingsSourceReadInputs {
29048 reads: vec![TokenSavingsSourceReadInput {
29049 command: "cat src/main.rs".to_string(),
29050 file: "src/main.rs".to_string(),
29051 raw_start: 1,
29052 raw_lines: 200,
29053 raw_excerpt: "line\n".repeat(200),
29054 envelope_start: 1,
29055 envelope_lines: 80,
29056 required_line_anchors: vec![120],
29057 }],
29058 }),
29059 markdown_projection_inputs: None,
29060 }],
29061 };
29062
29063 let err = match build_token_savings_report(&fixture) {
29064 Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
29065 Err(err) => err,
29066 };
29067
29068 assert!(err.to_string().contains("hides required line anchor 120"));
29069 }
29070
29071 #[test]
29072 fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
29073 let fixture = TokenSavingsFixture {
29074 schema_version: 1,
29075 description: "fixture".to_string(),
29076 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29077 cases: vec![TokenSavingsFixtureCase {
29078 name: "markdown-projection".to_string(),
29079 surface: "context-pack".to_string(),
29080 minimum_savings_percent: 40.0,
29081 raw_symbols: Vec::new(),
29082 tagpath_families: Vec::new(),
29083 context_pack_inputs: None,
29084 session_review_inputs: None,
29085 source_read_inputs: None,
29086 markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
29087 documents: vec![TokenSavingsMarkdownProjectionInput {
29088 command: "context-pack markdown body".to_string(),
29089 file: "tasks/software/tsift.md".to_string(),
29090 raw_markdown: "# Heading\n\n".repeat(120),
29091 outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
29092 selected_nodes: vec!["mdast-selected".to_string()],
29093 expand:
29094 "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
29095 .to_string(),
29096 }],
29097 }),
29098 }],
29099 };
29100
29101 let report = build_token_savings_report(&fixture).unwrap();
29102
29103 assert!(report.pass);
29104 assert_eq!(report.cases[0].surface, "context-pack");
29105 assert!(report.cases[0].savings_percent >= 40.0);
29106 }
29107
29108 #[test]
29109 fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
29110 let mut content = String::from("# Cache Root\n\n");
29111 for idx in 0..96 {
29112 content.push_str(&format!(
29113 "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
29114 ));
29115 }
29116
29117 let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
29118 assert!(!first.cache_hit);
29119 assert!(first.nodes.len() > 200);
29120
29121 let sections = markdown_section_spans(&content).unwrap();
29122 let list_items = markdown_block_spans(&content, "list_item").unwrap();
29123 let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
29124 let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
29125
29126 assert!(second.cache_hit);
29127 assert_eq!(second.nodes.len(), first.nodes.len());
29128 assert_eq!(sections.len(), 97);
29129 assert_eq!(list_items.len(), 96);
29130 assert_eq!(code_blocks.len(), 96);
29131 let first_code = first
29132 .nodes
29133 .iter()
29134 .find(|node| node.kind == "code_block")
29135 .expect("expected a Markdown code block");
29136 let first_code_node = markdown_ast_node(
29137 Path::new("/repo"),
29138 "semantic-edit",
29139 first_code,
29140 content.as_bytes(),
29141 &first.nodes,
29142 8,
29143 );
29144 assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
29145 assert_eq!(
29146 first_code_node.metadata.embedded_symbols[0].name,
29147 "sample_0"
29148 );
29149 assert_eq!(
29150 first_code_node.metadata.embedded_symbols[0].language,
29151 "rust"
29152 );
29153 }
29154
29155 #[test]
29156 fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
29157 let response = empty_search_response(Path::new("/repo"), "lexical");
29158 let symbol_hits = vec![index::SymbolHit {
29159 name: "alpha_helper_with_a_long_name".to_string(),
29160 kind: "function".to_string(),
29161 language: "rust".to_string(),
29162 file: "/repo/src/lib.rs".to_string(),
29163 line: 12,
29164 end_line: None,
29165 node_kind: None,
29166 start_byte: None,
29167 end_byte: None,
29168 body_start_byte: None,
29169 body_end_byte: None,
29170 tags: None,
29171 score: 0.98,
29172 match_type: "exact_name".to_string(),
29173 tagpath_handle: None,
29174 }];
29175
29176 let report = build_relative_search_budget_report(
29177 "alpha_helper_with_a_long_name",
29178 "lexical",
29179 Path::new("/repo"),
29180 &response,
29181 &symbol_hits,
29182 ResponseBudget::new(Some(1), Some(12)),
29183 &SearchFacetFilters::default(),
29184 );
29185
29186 assert_eq!(report.symbols.len(), 1);
29187 assert!(report.symbols[0].handle.starts_with("sfam-"));
29188 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
29189 assert_eq!(report.symbols[0].name, "alpha_hel...");
29190 assert_eq!(report.symbols[0].file, "src/lib.rs");
29191 assert!(report.symbols[0].expand.contains("tsift search"));
29192 }
29193
29194 #[test]
29195 fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
29196 let dir = tempfile::tempdir().unwrap();
29197 let src_dir = dir.path().join("src");
29198 fs::create_dir_all(&src_dir).unwrap();
29199 let source = "fn alpha_helper() {\n beta();\n}\n";
29200 let file = src_dir.join("lib.rs");
29201 fs::write(&file, source).unwrap();
29202 let body_start = source.find("{\n").unwrap() + 1;
29203 let body_end = source.rfind("\n}").unwrap() + 1;
29204
29205 let response = empty_search_response(dir.path(), "lexical");
29206 let symbol_hits = vec![index::SymbolHit {
29207 name: "alpha_helper".to_string(),
29208 kind: "function".to_string(),
29209 language: "rust".to_string(),
29210 file: file.to_string_lossy().to_string(),
29211 line: 0,
29212 end_line: Some(2),
29213 node_kind: Some("function_item".to_string()),
29214 start_byte: Some(0),
29215 end_byte: Some(i64::try_from(source.len()).unwrap()),
29216 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29217 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29218 tags: Some("alpha,helper".to_string()),
29219 score: 0.98,
29220 match_type: "exact_name".to_string(),
29221 tagpath_handle: None,
29222 }];
29223
29224 let report = build_relative_search_budget_report(
29225 "alpha helper",
29226 "lexical",
29227 dir.path(),
29228 &response,
29229 &symbol_hits,
29230 ResponseBudget::new(Some(5), Some(96)),
29231 &SearchFacetFilters::default(),
29232 );
29233
29234 let symbol = &report.symbols[0];
29235 assert_eq!(symbol.language, "rust");
29236 assert_eq!(symbol.end_line, Some(2));
29237 let ast = symbol
29238 .ast
29239 .as_ref()
29240 .expect("search symbol preview should expose an AST span artifact");
29241 assert_eq!(ast.artifact_kind, "ast_span");
29242 assert!(ast.span.handle.starts_with("span-"));
29243 assert_eq!(ast.span.node_kind, "function_item");
29244 assert_eq!(ast.span.start_byte, 0);
29245 assert_eq!(ast.span.end_byte, source.len());
29246 assert_eq!(ast.span.body_start_byte, Some(body_start));
29247 assert_eq!(ast.span.body_end_byte, Some(body_end));
29248 assert!(ast.expand.source_window.contains("source-read"));
29249 assert!(
29250 ast.expand
29251 .source_body
29252 .as_ref()
29253 .unwrap()
29254 .contains("source-read")
29255 );
29256 assert!(ast.expand.symbol_read.contains("symbol-read"));
29257 assert!(ast.expand.markdown_ast.is_none());
29258 }
29259
29260 #[test]
29261 fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
29262 let dir = tempfile::tempdir().unwrap();
29263 let source = "# Guide\n\n## Install\n\n- Run setup.\n";
29264 let file = dir.path().join("README.md");
29265 fs::write(&file, source).unwrap();
29266 let heading_start = source.find("## Install").unwrap();
29267 let heading_end = source.len();
29268
29269 let response = empty_search_response(dir.path(), "lexical");
29270 let symbol_hits = vec![index::SymbolHit {
29271 name: "Install".to_string(),
29272 kind: "heading".to_string(),
29273 language: "markdown".to_string(),
29274 file: file.to_string_lossy().to_string(),
29275 line: 2,
29276 end_line: Some(4),
29277 node_kind: Some("atx_heading".to_string()),
29278 start_byte: Some(i64::try_from(heading_start).unwrap()),
29279 end_byte: Some(i64::try_from(heading_end).unwrap()),
29280 body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
29281 body_end_byte: Some(i64::try_from(heading_end).unwrap()),
29282 tags: Some("install".to_string()),
29283 score: 1.0,
29284 match_type: "exact_name".to_string(),
29285 tagpath_handle: None,
29286 }];
29287
29288 let report = build_relative_search_budget_report(
29289 "Install",
29290 "lexical",
29291 dir.path(),
29292 &response,
29293 &symbol_hits,
29294 ResponseBudget::new(Some(5), Some(96)),
29295 &SearchFacetFilters::default(),
29296 );
29297
29298 let ast = report.symbols[0]
29299 .ast
29300 .as_ref()
29301 .expect("Markdown search symbol should expose an AST span artifact");
29302 assert_eq!(ast.span.node_kind, "atx_heading");
29303 assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
29304 let markdown_ast = ast
29305 .expand
29306 .markdown_ast
29307 .as_ref()
29308 .expect("Markdown symbols should include markdown-ast expansion");
29309 assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
29310 assert!(markdown_ast.contains("--node"), "{markdown_ast}");
29311 assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
29312 assert!(ast.expand.source_window.contains("source-read"));
29313 assert!(ast.expand.symbol_read.contains("symbol-read"));
29314 }
29315
29316 #[test]
29317 fn search_budget_report_exposes_markdown_embedded_code_symbols() {
29318 let dir = tempfile::tempdir().unwrap();
29319 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29320 let file = dir.path().join("README.md");
29321 fs::write(&file, source).unwrap();
29322 let fence_start = source.find("```rust").unwrap();
29323 let body_start = source.find("fn sample").unwrap();
29324 let body_end = body_start + "fn sample() {}\n".len();
29325
29326 let response = empty_search_response(dir.path(), "lexical");
29327 let symbol_hits = vec![index::SymbolHit {
29328 name: "rust".to_string(),
29329 kind: "code_block".to_string(),
29330 language: "markdown".to_string(),
29331 file: file.to_string_lossy().to_string(),
29332 line: 2,
29333 end_line: Some(4),
29334 node_kind: Some("fenced_code_block".to_string()),
29335 start_byte: Some(i64::try_from(fence_start).unwrap()),
29336 end_byte: Some(i64::try_from(source.len()).unwrap()),
29337 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29338 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29339 tags: Some("rust".to_string()),
29340 score: 1.0,
29341 match_type: "exact_name".to_string(),
29342 tagpath_handle: None,
29343 }];
29344
29345 let report = build_relative_search_budget_report(
29346 "rust",
29347 "lexical",
29348 dir.path(),
29349 &response,
29350 &symbol_hits,
29351 ResponseBudget::new(Some(5), Some(96)),
29352 &SearchFacetFilters::default(),
29353 );
29354
29355 let embedded = &report.symbols[0]
29356 .ast
29357 .as_ref()
29358 .unwrap()
29359 .span
29360 .markdown
29361 .as_ref()
29362 .unwrap()
29363 .embedded_symbols;
29364 assert_eq!(embedded.len(), 1);
29365 assert_eq!(embedded[0].name, "sample");
29366 assert_eq!(embedded[0].kind, "function");
29367 assert_eq!(embedded[0].language, "rust");
29368 assert_eq!(embedded[0].node_kind, "function_item");
29369 assert!(embedded[0].handle.starts_with("span-"));
29370 assert_eq!(embedded[0].start_byte, body_start);
29371 assert_eq!(embedded[0].start_line, 4);
29372 }
29373
29374 fn test_lexical_search_hit(
29375 path: &Path,
29376 rank: usize,
29377 score: f64,
29378 snippet: &str,
29379 ) -> sift::SearchHit {
29380 sift::SearchHit {
29381 artifact_id: format!("hit-{rank}"),
29382 artifact_kind: sift::ContextArtifactKind::File,
29383 budget: sift::ArtifactBudget::from_text(snippet, 1),
29384 confidence: sift::ScoreConfidence::High,
29385 freshness: sift::ArtifactFreshness {
29386 modified_unix_secs: None,
29387 observed_unix_secs: 0,
29388 },
29389 location: Some("line 1".to_string()),
29390 path: path.to_string_lossy().to_string(),
29391 provenance: sift::ArtifactProvenance {
29392 adapter: sift::AcquisitionAdapterKind::FileSystem,
29393 source: "test lexical hit".to_string(),
29394 synthetic: false,
29395 },
29396 rank,
29397 score,
29398 snippet: snippet.to_string(),
29399 }
29400 }
29401
29402 fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
29403 summarize::Summary {
29404 id: 0,
29405 symbol_name: symbol_name.to_string(),
29406 file_path: file_path.to_string(),
29407 content_hash: "hash".to_string(),
29408 summary: summary.to_string(),
29409 entities: None,
29410 relationships: None,
29411 concept_labels: None,
29412 extracted_at: "2026-06-02T00:00:00Z".to_string(),
29413 model: "test".to_string(),
29414 tokens_input: None,
29415 tokens_output: None,
29416 }
29417 }
29418
29419 #[test]
29420 fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
29421 let dir = tempfile::tempdir().unwrap();
29422 let src_dir = dir.path().join("src");
29423 fs::create_dir_all(&src_dir).unwrap();
29424 let source = "fn alpha_helper() {}\n";
29425 let file = src_dir.join("lib.rs");
29426 let broad_file = dir.path().join("README.md");
29427 fs::write(&file, source).unwrap();
29428 fs::write(
29429 &broad_file,
29430 "alpha helper alpha helper alpha helper in prose\n",
29431 )
29432 .unwrap();
29433
29434 let mut response = empty_search_response(dir.path(), "lexical");
29435 response.hits.push(test_lexical_search_hit(
29436 &broad_file,
29437 1,
29438 240.0,
29439 "alpha helper alpha helper alpha helper in prose",
29440 ));
29441 let symbol_hits = vec![index::SymbolHit {
29442 name: "alpha_helper".to_string(),
29443 kind: "function".to_string(),
29444 language: "rust".to_string(),
29445 file: file.to_string_lossy().to_string(),
29446 line: 0,
29447 end_line: Some(0),
29448 node_kind: Some("function_item".to_string()),
29449 start_byte: Some(0),
29450 end_byte: Some(i64::try_from(source.len()).unwrap()),
29451 body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29452 body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29453 tags: Some("alpha,helper".to_string()),
29454 score: 0.8,
29455 match_type: "all_tags".to_string(),
29456 tagpath_handle: None,
29457 }];
29458
29459 let report = build_relative_search_budget_report(
29460 "alpha helper",
29461 "lexical",
29462 dir.path(),
29463 &response,
29464 &symbol_hits,
29465 ResponseBudget::new(Some(5), Some(128)),
29466 &SearchFacetFilters::default(),
29467 );
29468
29469 assert_eq!(report.ranked[0].source, "symbol_span");
29470 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
29471 assert!(report.ranked[0].score > report.ranked[1].score);
29472 assert_eq!(report.ranked[1].source, "lexical_file");
29473 }
29474
29475 #[test]
29476 fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
29477 let dir = tempfile::tempdir().unwrap();
29478 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29479 let file = dir.path().join("README.md");
29480 fs::write(&file, source).unwrap();
29481 let summary_db =
29482 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
29483 summary_db
29484 .insert(&test_summary(
29485 "rust",
29486 "README.md",
29487 "Rust fence contains a sample function.",
29488 ))
29489 .unwrap();
29490
29491 let fence_start = source.find("```rust").unwrap();
29492 let body_start = source.find("fn sample").unwrap();
29493 let body_end = body_start + "fn sample() {}\n".len();
29494 let response = empty_search_response(dir.path(), "lexical");
29495 let symbol_hits = vec![index::SymbolHit {
29496 name: "rust".to_string(),
29497 kind: "code_block".to_string(),
29498 language: "markdown".to_string(),
29499 file: file.to_string_lossy().to_string(),
29500 line: 2,
29501 end_line: Some(4),
29502 node_kind: Some("fenced_code_block".to_string()),
29503 start_byte: Some(i64::try_from(fence_start).unwrap()),
29504 end_byte: Some(i64::try_from(source.len()).unwrap()),
29505 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29506 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29507 tags: Some("rust".to_string()),
29508 score: 1.0,
29509 match_type: "exact_name".to_string(),
29510 tagpath_handle: None,
29511 }];
29512
29513 let report = build_relative_search_budget_report(
29514 "rust",
29515 "lexical",
29516 dir.path(),
29517 &response,
29518 &symbol_hits,
29519 ResponseBudget::new(Some(5), Some(128)),
29520 &SearchFacetFilters::default(),
29521 );
29522
29523 let symbol = &report.symbols[0];
29524 assert_eq!(symbol.summary_refs, 1);
29525 assert_eq!(symbol.graph_neighbors, 1);
29526 assert!(
29527 report.ranked[0]
29528 .reasons
29529 .iter()
29530 .any(|reason| reason == "summary_refs:1")
29531 );
29532 assert!(
29533 report.ranked[0]
29534 .reasons
29535 .iter()
29536 .any(|reason| reason == "graph_neighbors:1")
29537 );
29538 }
29539
29540 fn markdown_search_facet_fixture() -> tempfile::TempDir {
29541 let dir = tempfile::tempdir().unwrap();
29542 let source = r#"# Guide
29543
29544## Install
29545
29546- Run setup.
29547 - Confirm setup.
29548
29549```rust
29550fn sample() {}
29551```
29552"#;
29553 fs::write(dir.path().join("README.md"), source).unwrap();
29554 let index_dir = dir.path().join(".tsift");
29555 fs::create_dir_all(&index_dir).unwrap();
29556 run_index_update(
29557 &index_dir.join("index.db"),
29558 dir.path(),
29559 "indexing markdown search facet fixture".to_string(),
29560 dir.path(),
29561 None,
29562 false,
29563 false,
29564 )
29565 .unwrap();
29566 dir
29567 }
29568
29569 fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
29570 let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
29571 db.symbol_search(query, 20).unwrap()
29572 }
29573
29574 #[test]
29575 fn search_facet_filters_match_scalar_symbol_fields() {
29576 let dir = tempfile::tempdir().unwrap();
29577 let hits = vec![
29578 index::SymbolHit {
29579 name: "alpha_helper".to_string(),
29580 kind: "function".to_string(),
29581 language: "rust".to_string(),
29582 file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
29583 line: 0,
29584 end_line: None,
29585 node_kind: Some("function_item".to_string()),
29586 start_byte: None,
29587 end_byte: None,
29588 body_start_byte: None,
29589 body_end_byte: None,
29590 tags: None,
29591 score: 1.0,
29592 match_type: "exact_name".to_string(),
29593 tagpath_handle: None,
29594 },
29595 index::SymbolHit {
29596 name: "Install".to_string(),
29597 kind: "heading".to_string(),
29598 language: "markdown".to_string(),
29599 file: dir.path().join("README.md").to_string_lossy().to_string(),
29600 line: 0,
29601 end_line: None,
29602 node_kind: Some("atx_heading".to_string()),
29603 start_byte: None,
29604 end_byte: None,
29605 body_start_byte: None,
29606 body_end_byte: None,
29607 tags: None,
29608 score: 0.9,
29609 match_type: "exact_name".to_string(),
29610 tagpath_handle: None,
29611 },
29612 ];
29613
29614 let filtered = apply_search_facet_filters(
29615 dir.path(),
29616 hits,
29617 &SearchFacetFilters {
29618 languages: vec!["rust".to_string()],
29619 kinds: vec!["function".to_string()],
29620 node_kinds: vec!["function_item".to_string()],
29621 ..SearchFacetFilters::default()
29622 },
29623 );
29624
29625 assert_eq!(filtered.len(), 1);
29626 assert_eq!(filtered[0].name, "alpha_helper");
29627 }
29628
29629 #[test]
29630 fn search_facet_filters_match_markdown_sections_and_block_metadata() {
29631 let dir = markdown_search_facet_fixture();
29632
29633 let nested_list = apply_search_facet_filters(
29634 dir.path(),
29635 markdown_search_facet_hits(dir.path(), "setup"),
29636 &SearchFacetFilters {
29637 sections: vec!["Install".to_string()],
29638 parents: vec!["Run setup.".to_string()],
29639 list_depths: vec![1],
29640 ..SearchFacetFilters::default()
29641 },
29642 );
29643 assert_eq!(nested_list.len(), 1);
29644 assert_eq!(nested_list[0].name, "Confirm setup.");
29645
29646 let parent_list = apply_search_facet_filters(
29647 dir.path(),
29648 markdown_search_facet_hits(dir.path(), "setup"),
29649 &SearchFacetFilters {
29650 children: vec!["Confirm setup.".to_string()],
29651 ..SearchFacetFilters::default()
29652 },
29653 );
29654 assert_eq!(parent_list.len(), 1);
29655 assert_eq!(parent_list[0].name, "Run setup.");
29656
29657 let heading = apply_search_facet_filters(
29658 dir.path(),
29659 markdown_search_facet_hits(dir.path(), "Install"),
29660 &SearchFacetFilters {
29661 heading_levels: vec![2],
29662 node_kinds: vec!["atx_heading".to_string()],
29663 ..SearchFacetFilters::default()
29664 },
29665 );
29666 assert_eq!(heading.len(), 1);
29667 assert_eq!(heading[0].name, "Install");
29668
29669 let fence = apply_search_facet_filters(
29670 dir.path(),
29671 markdown_search_facet_hits(dir.path(), "rust"),
29672 &SearchFacetFilters {
29673 fence_languages: vec!["rust".to_string()],
29674 kinds: vec!["code_block".to_string()],
29675 ..SearchFacetFilters::default()
29676 },
29677 );
29678 assert_eq!(fence.len(), 1);
29679 assert_eq!(fence[0].kind, "code_block");
29680
29681 let embedded_child = apply_search_facet_filters(
29682 dir.path(),
29683 markdown_search_facet_hits(dir.path(), "rust"),
29684 &SearchFacetFilters {
29685 children: vec!["sample".to_string()],
29686 kinds: vec!["code_block".to_string()],
29687 ..SearchFacetFilters::default()
29688 },
29689 );
29690 assert_eq!(embedded_child.len(), 1);
29691 assert_eq!(embedded_child[0].name, "rust");
29692 }
29693
29694 #[test]
29695 fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
29696 let response = empty_search_response(Path::new("/repo"), "lexical");
29697 let symbol_hits = vec![
29698 index::SymbolHit {
29699 name: "alpha_helper".to_string(),
29700 kind: "function".to_string(),
29701 language: "rust".to_string(),
29702 file: "/repo/src/lib.rs".to_string(),
29703 line: 12,
29704 end_line: None,
29705 node_kind: None,
29706 start_byte: None,
29707 end_byte: None,
29708 body_start_byte: None,
29709 body_end_byte: None,
29710 tags: Some("alpha,helper".to_string()),
29711 score: 0.98,
29712 match_type: "exact_name".to_string(),
29713 tagpath_handle: None,
29714 },
29715 index::SymbolHit {
29716 name: "alphaHelper".to_string(),
29717 kind: "method".to_string(),
29718 language: "rust".to_string(),
29719 file: "/repo/src/main.rs".to_string(),
29720 line: 34,
29721 end_line: None,
29722 node_kind: None,
29723 start_byte: None,
29724 end_byte: None,
29725 body_start_byte: None,
29726 body_end_byte: None,
29727 tags: Some("alpha,helper".to_string()),
29728 score: 0.93,
29729 match_type: "tag_overlap".to_string(),
29730 tagpath_handle: None,
29731 },
29732 index::SymbolHit {
29733 name: "alpha_helper".to_string(),
29734 kind: "function".to_string(),
29735 language: "rust".to_string(),
29736 file: "/repo/src/worker.rs".to_string(),
29737 line: 56,
29738 end_line: None,
29739 node_kind: None,
29740 start_byte: None,
29741 end_byte: None,
29742 body_start_byte: None,
29743 body_end_byte: None,
29744 tags: Some("alpha,helper".to_string()),
29745 score: 0.91,
29746 match_type: "tag_overlap".to_string(),
29747 tagpath_handle: None,
29748 },
29749 ];
29750
29751 let report = build_relative_search_budget_report(
29752 "alpha helper",
29753 "lexical",
29754 Path::new("/repo"),
29755 &response,
29756 &symbol_hits,
29757 ResponseBudget::new(Some(5), Some(48)),
29758 &SearchFacetFilters::default(),
29759 );
29760
29761 assert_eq!(report.symbol_total, 1);
29762 assert_eq!(report.raw_symbol_total, 3);
29763 assert_eq!(report.symbols.len(), 1);
29764 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
29765 assert_eq!(report.symbols[0].match_count, 3);
29766 assert_eq!(report.symbols[0].surface_count, 2);
29767 assert_eq!(report.symbols[0].file_count, 3);
29768 assert_eq!(
29769 report.symbols[0].surface_examples,
29770 vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
29771 );
29772 assert!(report.symbols[0].name.contains("(+1 variant)"));
29773 assert!(report.symbols[0].file.contains("(+2 files)"));
29774 assert!(report.symbols[0].expand.contains("tsift search"));
29775 assert!(report.symbols[0].expand.contains("alpha helper"));
29776 }
29777
29778 #[test]
29779 fn search_budget_report_carries_active_filters() {
29780 let response = empty_search_response(Path::new("/repo"), "lexical");
29781 let symbol_hits = vec![index::SymbolHit {
29782 name: "alpha_helper".to_string(),
29783 kind: "function".to_string(),
29784 language: "rust".to_string(),
29785 file: "/repo/src/lib.rs".to_string(),
29786 line: 12,
29787 end_line: None,
29788 node_kind: Some("function_item".to_string()),
29789 start_byte: None,
29790 end_byte: None,
29791 body_start_byte: None,
29792 body_end_byte: None,
29793 tags: Some("alpha,helper".to_string()),
29794 score: 0.98,
29795 match_type: "exact_name".to_string(),
29796 tagpath_handle: None,
29797 }];
29798 let filters = SearchFacetFilters {
29799 languages: vec!["rust".to_string()],
29800 kinds: vec!["function".to_string()],
29801 node_kinds: vec!["function_item".to_string()],
29802 ..SearchFacetFilters::default()
29803 };
29804
29805 let report = build_relative_search_budget_report(
29806 "alpha helper",
29807 "lexical",
29808 Path::new("/repo"),
29809 &response,
29810 &symbol_hits,
29811 ResponseBudget::new(Some(5), Some(48)),
29812 &filters,
29813 );
29814
29815 assert_eq!(report.filters, filters);
29816 assert_eq!(
29817 search_facet_filters_summary(&report.filters),
29818 "lang=rust kind=function node-kind=function_item"
29819 );
29820 }
29821
29822 #[test]
29823 fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
29824 let mut response = empty_search_response(Path::new("/repo"), "lexical");
29825 response.indexed_artifacts = 450;
29826 let symbol_hits = vec![
29827 index::SymbolHit {
29828 name: "alpha_helper".to_string(),
29829 kind: "function".to_string(),
29830 language: "rust".to_string(),
29831 file: "/repo/src/lib.rs".to_string(),
29832 line: 12,
29833 end_line: None,
29834 node_kind: None,
29835 start_byte: None,
29836 end_byte: None,
29837 body_start_byte: None,
29838 body_end_byte: None,
29839 tags: Some("alpha,helper".to_string()),
29840 score: 0.98,
29841 match_type: "exact_name".to_string(),
29842 tagpath_handle: None,
29843 },
29844 index::SymbolHit {
29845 name: "beta_helper".to_string(),
29846 kind: "function".to_string(),
29847 language: "rust".to_string(),
29848 file: "/repo/src/beta.rs".to_string(),
29849 line: 21,
29850 end_line: None,
29851 node_kind: None,
29852 start_byte: None,
29853 end_byte: None,
29854 body_start_byte: None,
29855 body_end_byte: None,
29856 tags: Some("beta,helper".to_string()),
29857 score: 0.92,
29858 match_type: "tag_overlap".to_string(),
29859 tagpath_handle: None,
29860 },
29861 ];
29862
29863 let report = build_relative_search_budget_report(
29864 "helper",
29865 "lexical",
29866 Path::new("/repo"),
29867 &response,
29868 &symbol_hits,
29869 ResponseBudget::new(Some(1), Some(64)),
29870 &SearchFacetFilters::default(),
29871 );
29872
29873 let guard = report
29874 .scale_guard
29875 .as_ref()
29876 .expect("broad previews should emit a scale guard");
29877 assert_eq!(guard.level, "high-hit");
29878 assert_eq!(guard.signals.indexed_artifacts, 450);
29879 assert_eq!(guard.signals.raw_symbol_matches, 2);
29880 assert!(
29881 guard
29882 .narrow_commands
29883 .iter()
29884 .any(|command| command.contains("--exact"))
29885 );
29886 assert!(
29887 guard
29888 .narrow_commands
29889 .iter()
29890 .any(|command| command.contains("alpha helper"))
29891 );
29892 assert!(
29893 guard
29894 .narrow_commands
29895 .last()
29896 .unwrap()
29897 .contains("workflow search")
29898 );
29899 }
29900
29901 #[test]
29902 fn explain_budget_report_limits_edges_and_members() {
29903 let symbols = vec![index::StoredSymbol {
29904 name: "alpha_helper".to_string(),
29905 kind: "function".to_string(),
29906 language: "rust".to_string(),
29907 signature: None,
29908 file: "src/lib.rs".to_string(),
29909 line: 10,
29910 end_line: None,
29911 node_kind: None,
29912 start_byte: None,
29913 end_byte: None,
29914 body_start_byte: None,
29915 body_end_byte: None,
29916 parent_module: None,
29917 visibility: None,
29918 tags: None,
29919 tagpath_handle: None,
29920 }];
29921 let callers = vec![
29922 index::StoredEdge {
29923 caller_file: "src/main.rs".to_string(),
29924 caller_name: "main".to_string(),
29925 caller_line: 1,
29926 callee_name: "alpha_helper".to_string(),
29927 call_site_line: 3,
29928 tagpath_handle: None,
29929 },
29930 index::StoredEdge {
29931 caller_file: "src/worker.rs".to_string(),
29932 caller_name: "worker".to_string(),
29933 caller_line: 5,
29934 callee_name: "alpha_helper".to_string(),
29935 call_site_line: 8,
29936 tagpath_handle: None,
29937 },
29938 ];
29939 let community = graph::Community {
29940 id: 1,
29941 members: vec![
29942 graph::CommunityMember::new("alpha_helper"),
29943 graph::CommunityMember::new("main"),
29944 graph::CommunityMember::new("worker"),
29945 ],
29946 modularity_contribution: 0.5,
29947 };
29948
29949 let report = build_explain_budget_report(
29950 "alpha_helper",
29951 Path::new("/repo"),
29952 &symbols,
29953 &callers,
29954 2,
29955 false,
29956 &[],
29957 0,
29958 false,
29959 Some(&community),
29960 ResponseBudget::new(Some(1), Some(24)),
29961 );
29962
29963 assert_eq!(report.definitions.len(), 1);
29964 assert_eq!(report.callers.len(), 1);
29965 assert!(report.truncated);
29966 assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
29967 assert_eq!(
29968 report.definitions[0].tag_alias.as_deref(),
29969 Some("alpha/helper")
29970 );
29971 assert!(report.callers[0].handle.starts_with("ecall-"));
29972 assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
29973 }
29974
29975 #[test]
29976 fn session_review_next_context_budget_limits_lists() {
29977 let report = session_review::SessionReviewReport {
29978 root: "/repo".to_string(),
29979 target: "tasks/software/tsift.md".to_string(),
29980 target_kind: "file".to_string(),
29981 sessions_considered: 1,
29982 sessions_matched: 1,
29983 claude_sessions: 1,
29984 codex_sessions: 0,
29985 agent_doc_logs: 0,
29986 prompt_target_count: 2,
29987 command_groups: 0,
29988 file_groups: 2,
29989 symbol_groups: 1,
29990 failure_groups: 1,
29991 runtime_event_groups: 0,
29992 restart_churn_groups: 0,
29993 closeout_groups: 0,
29994 usage_samples: 1,
29995 prompt_tokens: 120,
29996 cached_input_tokens: 80,
29997 cache_creation_input_tokens: 0,
29998 output_tokens: 40,
29999 reasoning_output_tokens: 0,
30000 total_tokens: 240,
30001 cached_input_ratio: Some(40.0),
30002 largest_turn_total_tokens: 240,
30003 aggregate_cost: session_review::SessionReviewCostSummary {
30004 scope: "bounded_matched_sessions".to_string(),
30005 sessions: 1,
30006 usage_samples: 1,
30007 prompt_tokens: 120,
30008 cached_input_tokens: 80,
30009 cache_creation_input_tokens: 0,
30010 output_tokens: 40,
30011 reasoning_output_tokens: 0,
30012 total_tokens: 240,
30013 cached_input_ratio: Some(40.0),
30014 largest_turn_total_tokens: 240,
30015 },
30016 latest_session_cost: Some(session_review::SessionReviewCostSummary {
30017 scope: "latest_matched_session".to_string(),
30018 sessions: 1,
30019 usage_samples: 1,
30020 prompt_tokens: 120,
30021 cached_input_tokens: 80,
30022 cache_creation_input_tokens: 0,
30023 output_tokens: 40,
30024 reasoning_output_tokens: 0,
30025 total_tokens: 240,
30026 cached_input_ratio: Some(66.67),
30027 largest_turn_total_tokens: 240,
30028 }),
30029 guardrails: vec![
30030 session_cost::SessionCostGuardrail {
30031 kind: "cache_resend".to_string(),
30032 severity: "warn".to_string(),
30033 message: "cached input ratio was high".to_string(),
30034 guidance: "compact or restart the session".to_string(),
30035 },
30036 session_cost::SessionCostGuardrail {
30037 kind: "prompt_budget".to_string(),
30038 severity: "warn".to_string(),
30039 message: "largest prompt turn reached 999999 tokens".to_string(),
30040 guidance: "compact the session before another large turn".to_string(),
30041 },
30042 session_cost::SessionCostGuardrail {
30043 kind: "restart_loop".to_string(),
30044 severity: "warn".to_string(),
30045 message: "restart churn detected".to_string(),
30046 guidance: "restart cleanly".to_string(),
30047 },
30048 session_cost::SessionCostGuardrail {
30049 kind: "noop_closeout".to_string(),
30050 severity: "warn".to_string(),
30051 message: "commit_already_current appeared 8 times".to_string(),
30052 guidance: "avoid reopening without new edits".to_string(),
30053 },
30054 ],
30055 loop_clusters: vec![],
30056 file_read_diagnostics: vec![],
30057 prompt_targets: vec![
30058 session_review::SessionReviewPromptTarget {
30059 text: "do one".to_string(),
30060 occurrences: 1,
30061 },
30062 session_review::SessionReviewPromptTarget {
30063 text: "do two".to_string(),
30064 occurrences: 1,
30065 },
30066 ],
30067 commands: vec![],
30068 touched_files: vec![],
30069 touched_symbols: vec![],
30070 failures: vec![],
30071 runtime_events: vec![],
30072 restart_churn: vec![],
30073 closeout: vec![],
30074 largest_turns: vec![],
30075 sessions: vec![session_review::SessionReviewSession {
30076 source: "claude_jsonl".to_string(),
30077 path: "/tmp/session.jsonl".to_string(),
30078 matched_by: vec!["path".to_string()],
30079 modified_unix_secs: None,
30080 prompt_target_count: 2,
30081 command_groups: 0,
30082 file_groups: 2,
30083 symbol_groups: 1,
30084 failure_groups: 1,
30085 runtime_event_groups: 0,
30086 restart_churn_groups: 0,
30087 closeout_groups: 0,
30088 usage_samples: 1,
30089 prompt_tokens: 120,
30090 cached_input_tokens: 80,
30091 cache_creation_input_tokens: 0,
30092 output_tokens: 40,
30093 reasoning_output_tokens: 0,
30094 total_tokens: 240,
30095 largest_turn_total_tokens: 240,
30096 }],
30097 next_context: session_review::SessionReviewNextContext {
30098 target: "tasks/software/tsift.md".to_string(),
30099 active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
30100 last_verification: session_review::SessionReviewVerificationState {
30101 status: "green".to_string(),
30102 detail: "cargo test".to_string(),
30103 },
30104 touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
30105 touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
30106 unresolved_failures: vec![session_review::SessionReviewFailure {
30107 kind: "timeout".to_string(),
30108 message: "search timed out".to_string(),
30109 occurrences: 1,
30110 command: None,
30111 session_path: None,
30112 }],
30113 next_digest_commands: vec![
30114 "tsift session-review --next-context tasks/software/tsift.md".to_string(),
30115 "tsift diff-digest .".to_string(),
30116 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
30117 "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
30118 ],
30119 },
30120 warnings: vec![],
30121 };
30122
30123 let budget_report = build_session_review_next_context_budget_report(
30124 &report,
30125 ResponseBudget::new(Some(1), Some(12)),
30126 None,
30127 );
30128
30129 assert!(budget_report.truncated);
30130 assert_eq!(budget_report.prompt_targets, vec!["do one"]);
30131 assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
30132 assert!(
30133 budget_report.touched_symbol_refs[0]
30134 .handle
30135 .starts_with("ncsym-")
30136 );
30137 assert_eq!(
30138 budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
30139 Some("alpha/helper")
30140 );
30141 assert!(
30142 budget_report.unresolved_failures[0]
30143 .handle
30144 .starts_with("snf-")
30145 );
30146 assert_eq!(budget_report.next_digest_commands.len(), 4);
30147 assert_eq!(
30148 budget_report.next_digest_commands[2],
30149 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
30150 );
30151 assert_eq!(budget_report.next_token_actions.len(), 1);
30152 assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
30153
30154 let full_action_report = build_session_review_next_context_budget_report(
30155 &report,
30156 ResponseBudget::new(Some(4), Some(120)),
30157 None,
30158 );
30159 assert_eq!(
30160 full_action_report
30161 .next_token_actions
30162 .iter()
30163 .map(|action| action.kind.as_str())
30164 .collect::<Vec<_>>(),
30165 vec![
30166 "prompt_budget",
30167 "cache_resend",
30168 "restart_loop",
30169 "noop_closeout"
30170 ]
30171 );
30172 assert_eq!(
30173 full_action_report.next_token_actions[0]
30174 .compact_command
30175 .as_deref(),
30176 Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
30177 );
30178 assert_eq!(
30179 full_action_report.next_token_actions[0]
30180 .restart_command
30181 .as_deref(),
30182 Some("agent-doc start \"tasks/software/tsift.md\"")
30183 );
30184 assert!(
30185 full_action_report.next_token_actions[0]
30186 .digest_commands
30187 .iter()
30188 .any(|command| command
30189 == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
30190 );
30191 }
30192
30193 #[test]
30194 fn context_pack_diff_preview_limits_files_and_symbols() {
30195 let report = diff_digest::DiffDigestReport {
30196 root: "/repo".to_string(),
30197 mode: diff_digest::DiffDigestMode::WorkingTree,
30198 revision: None,
30199 files_changed: 2,
30200 files_with_current_summaries: 1,
30201 symbols_touched: 3,
30202 call_edges_added: 1,
30203 call_edges_removed: 0,
30204 files: vec![
30205 diff_digest::DiffDigestFile {
30206 path: "src/lib.rs".to_string(),
30207 status: diff_digest::DiffDigestFileStatus::Modified,
30208 touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
30209 summary_state: diff_digest::DiffDigestSummaryState::Current,
30210 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30211 symbol: "alpha_helper".to_string(),
30212 summary: "alpha helper handles the main alpha workflow".to_string(),
30213 }],
30214 added_call_edges: vec!["alpha->beta".to_string()],
30215 removed_call_edges: vec![],
30216 warnings: vec!["stale parse".to_string()],
30217 },
30218 diff_digest::DiffDigestFile {
30219 path: "src/main.rs".to_string(),
30220 status: diff_digest::DiffDigestFileStatus::Added,
30221 touched_symbols: vec!["main".to_string()],
30222 summary_state: diff_digest::DiffDigestSummaryState::Missing,
30223 current_summaries: vec![],
30224 added_call_edges: vec![],
30225 removed_call_edges: vec![],
30226 warnings: vec![],
30227 },
30228 ],
30229 };
30230
30231 let preview =
30232 build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
30233
30234 assert!(preview.truncated);
30235 assert_eq!(preview.files.len(), 1);
30236 assert_eq!(preview.files[0].path, "src/lib.rs");
30237 assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
30238 assert!(
30239 preview.files[0].touched_symbol_refs[0]
30240 .handle
30241 .starts_with("cdsym-")
30242 );
30243 assert_eq!(
30244 preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
30245 Some("alpha/he...")
30246 );
30247 assert!(
30248 preview.files[0].summary_refs[0]
30249 .handle
30250 .starts_with("cdsum-")
30251 );
30252 assert_eq!(
30253 preview.files[0].summary_refs[0].tag_alias.as_deref(),
30254 Some("alpha/he...")
30255 );
30256 assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
30257 assert_eq!(
30258 preview.files[0].summary_refs[0].expand,
30259 "tsift summarize --file \"src/lib.rs\""
30260 );
30261 assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
30262 }
30263
30264 #[test]
30265 fn context_pack_status_reminders_include_stale_index_state() {
30266 let dir = setup_graph_index();
30267 std::thread::sleep(std::time::Duration::from_millis(50));
30268 std::fs::write(
30269 dir.path().join("main.rs"),
30270 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
30271 )
30272 .unwrap();
30273
30274 let reminders = context_pack_status_reminders(dir.path());
30275
30276 assert_eq!(reminders.len(), 1);
30277 assert!(reminders[0].contains("index stale"));
30278 assert!(reminders[0].contains("tsift index ."));
30279 }
30280
30281 #[test]
30288 fn build_context_pack_reuses_inspect_within_scope() {
30289 let dir = setup_graph_index();
30290 init_git_repo(dir.path());
30291 let _guard = index::InspectScopeGuard::new();
30292 let _ = build_context_pack_report(
30293 dir.path(),
30294 None,
30295 None,
30296 None,
30297 ResponseBudget::new(Some(2), Some(96)),
30298 )
30299 .unwrap();
30300 let (hits, misses) = index::inspect_scope_stats();
30301 assert!(
30302 hits >= 1,
30303 "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
30304 );
30305 assert!(
30306 misses >= 1,
30307 "expected at least one initial inspect miss (hits={hits}, misses={misses})"
30308 );
30309 }
30310
30311 #[test]
30316 fn inspect_read_only_outside_scope_does_not_cache() {
30317 let dir = setup_graph_index();
30318 let db_path = dir.path().join(".tsift/index.db");
30319 let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30320 let (hits, misses) = index::inspect_scope_stats();
30321 assert_eq!(
30322 (hits, misses),
30323 (0, 0),
30324 "no scope guard => no hits/misses recorded"
30325 );
30326 let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30327 let (hits, _) = index::inspect_scope_stats();
30328 assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
30329 }
30330
30331 #[test]
30332 fn context_pack_refreshes_stale_index_before_handoff() {
30333 let dir = setup_graph_index();
30334 init_git_repo(dir.path());
30335 std::thread::sleep(std::time::Duration::from_millis(50));
30336 std::fs::write(
30337 dir.path().join("main.rs"),
30338 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
30339 )
30340 .unwrap();
30341
30342 let report = build_context_pack_report(
30343 dir.path(),
30344 None,
30345 None,
30346 None,
30347 ResponseBudget::new(Some(2), Some(96)),
30348 )
30349 .unwrap();
30350
30351 assert!(
30352 report
30353 .status_reminders
30354 .iter()
30355 .any(|reminder| reminder.contains("index refreshed")
30356 && reminder.contains("context-pack handoff")),
30357 "expected context-pack refresh diagnostic, got {:?}",
30358 report.status_reminders
30359 );
30360 assert!(
30361 !report
30362 .status_reminders
30363 .iter()
30364 .any(|reminder| reminder.contains("index stale")),
30365 "stale reminder should be gone after refresh: {:?}",
30366 report.status_reminders
30367 );
30368
30369 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
30370 let summary = db.compute_changes(dir.path()).unwrap();
30371 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
30372 }
30373
30374 #[test]
30375 fn context_pack_materializes_source_handles_into_graph_store() {
30376 let dir = tempfile::tempdir().unwrap();
30377 let packet = ExplorationPacket {
30378 budget: exploration_budget_for_counts(2, 1),
30379 relationship_map: vec![ExplorationRelation {
30380 from: "file:main.rs".to_string(),
30381 relation: "touches_symbol".to_string(),
30382 to: "symbol:helper".to_string(),
30383 label: Some("modified diff".to_string()),
30384 }],
30385 source_windows: vec![ExplorationSourceWindow {
30386 handle: "xwin-test".to_string(),
30387 file: "main.rs".to_string(),
30388 start: 1,
30389 end: 32,
30390 reason: "changed file".to_string(),
30391 expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
30392 }],
30393 worker_context: vec![ExplorationWorkerContext {
30394 handle: "xwrk-test".to_string(),
30395 target: "tasks/software/tsift.md".to_string(),
30396 summary: "do #kgnv".to_string(),
30397 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
30398 .to_string(),
30399 }],
30400 no_reread_guidance: "use windows".to_string(),
30401 };
30402
30403 let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
30404 assert_eq!(packet.source_windows[0].handle, "xwin-test");
30405
30406 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
30407 let source_handles = store.nodes_by_kind("source_handle").unwrap();
30408 assert_eq!(source_handles.len(), 1);
30409 assert_eq!(
30410 source_handles[0].properties.get("file"),
30411 Some(&"main.rs".to_string())
30412 );
30413 assert_eq!(
30414 store
30415 .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
30416 .unwrap()
30417 .len(),
30418 1
30419 );
30420 let worker_context = store.nodes_by_kind("worker_context").unwrap();
30421 assert_eq!(worker_context.len(), 1);
30422 assert_eq!(
30423 store
30424 .outgoing_edges("xwrk-test", Some("scopes_source"))
30425 .unwrap()
30426 .len(),
30427 1
30428 );
30429 }
30430
30431 #[test]
30432 fn context_pack_records_graph_orchestration_observability() {
30433 let dir = setup_traversal_project();
30434 init_git_repo(dir.path());
30435 let session = dir.path().join("tasks/software/tsift.md");
30436 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
30437
30438 let report = build_context_pack_report(
30439 &session,
30440 None,
30441 None,
30442 None,
30443 ResponseBudget::new(Some(4), Some(160)),
30444 )
30445 .unwrap();
30446
30447 assert_eq!(
30448 report.graph_orchestration.contract_version,
30449 CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
30450 );
30451 assert_eq!(
30452 report
30453 .graph_orchestration
30454 .projection_freshness
30455 .status
30456 .as_str(),
30457 "current"
30458 );
30459 assert!(!report.graph_orchestration.projection_hashes.is_empty());
30460 assert_eq!(report.graph_orchestration.readiness.status, "blocked");
30461 assert_eq!(
30462 report.graph_orchestration.readiness.reason,
30463 "summary_cache_empty"
30464 );
30465 assert!(report.graph_orchestration.readiness.fail_closed);
30466 assert!(
30467 report
30468 .graph_orchestration
30469 .readiness
30470 .next_commands
30471 .iter()
30472 .any(|command| command == "tsift summarize --extract ."),
30473 "{:?}",
30474 report.graph_orchestration.readiness.next_commands
30475 );
30476 assert!(
30477 report
30478 .graph_orchestration
30479 .evidence_packet_ids
30480 .iter()
30481 .all(|id| !id.starts_with("gevd-")),
30482 "evidence packet ids should be empty when readiness is blocked: {:?}",
30483 report.graph_orchestration.evidence_packet_ids
30484 );
30485 assert!(
30486 report
30487 .graph_orchestration
30488 .conflict_matrix_decisions
30489 .iter()
30490 .any(|decision| decision.contains("readiness blocked")),
30491 "conflict-matrix decisions should reference readiness block: {:?}",
30492 report.graph_orchestration.conflict_matrix_decisions
30493 );
30494 assert!(
30495 !report
30496 .graph_orchestration
30497 .follow_up_commands
30498 .iter()
30499 .any(|command| command.contains("conflict-matrix")),
30500 "conflict-matrix command should not appear when readiness is blocked: {:?}",
30501 report.graph_orchestration.follow_up_commands
30502 );
30503 assert!(
30504 report
30505 .graph_orchestration
30506 .follow_up_commands
30507 .iter()
30508 .any(|command| command == "tsift summarize --extract ."),
30509 "{:?}",
30510 report.graph_orchestration.follow_up_commands
30511 );
30512 assert!(
30513 !report
30514 .graph_orchestration
30515 .worker_ownership_blocks
30516 .is_empty()
30517 );
30518 }
30519
30520 #[test]
30521 fn convex_sync_report_chunks_upserts_and_tombstones() {
30522 let dir = setup_traversal_project();
30523 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
30524 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
30525 let mut snapshot = projection.to_convex_rows();
30526 snapshot.nodes.push(ConvexNodeRow {
30527 external_id: "stale-node".to_string(),
30528 kind: "backlog".to_string(),
30529 label: "stale".to_string(),
30530 properties: BTreeMap::new(),
30531 provenance: Vec::new(),
30532 freshness: None,
30533 });
30534 snapshot.edges.clear();
30535 snapshot.edges.push(ConvexEdgeRow {
30536 edge_key: "stale-edge".to_string(),
30537 from_external_id: "stale-node".to_string(),
30538 to_external_id: "stale-node".to_string(),
30539 kind: "mentions".to_string(),
30540 properties: BTreeMap::new(),
30541 provenance: Vec::new(),
30542 freshness: None,
30543 });
30544 let snapshot_path = dir.path().join("convex-snapshot.json");
30545 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30546
30547 let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
30548
30549 assert_eq!(report.freshness.status, "stale");
30550 assert!(report.freshness.fail_closed);
30551 assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
30552 assert!(
30553 report.edge_upserts.len() > 1,
30554 "snapshot without edges should upsert local edges"
30555 );
30556 assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
30557 assert_eq!(
30558 report.chunks.first().map(|chunk| chunk.operation.as_str()),
30559 Some("delete_edges"),
30560 "edge tombstones should be planned before node tombstones"
30561 );
30562 assert!(
30563 report
30564 .chunks
30565 .iter()
30566 .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
30567 "expected chunked edge upserts, got {:?}",
30568 report.chunks
30569 );
30570 }
30571
30572 #[test]
30573 fn convex_snapshot_validation_fails_closed_when_stale() {
30574 let dir = setup_traversal_project();
30575 build_traversal_graph(dir.path(), dir.path(), None).unwrap();
30576 let snapshot = ConvexProjectionRows::default();
30577 let snapshot_path = dir.path().join("empty-convex-snapshot.json");
30578 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30579
30580 let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
30581 assert!(
30582 err.to_string()
30583 .contains("Convex graph projection is not current"),
30584 "{err}"
30585 );
30586 }
30587
30588 #[test]
30589 fn convex_sync_report_marks_live_apply_mode_without_network() {
30590 let dir = setup_traversal_project();
30591 let report =
30592 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30593
30594 assert!(!report.dry_run);
30595 assert!(
30596 !report
30597 .diagnostics
30598 .iter()
30599 .any(|diagnostic| diagnostic.contains("dry-run only")),
30600 "apply-mode report should not claim dry-run diagnostics"
30601 );
30602 assert!(
30603 report
30604 .chunks
30605 .iter()
30606 .any(|chunk| chunk.operation == "upsert_nodes"),
30607 "live apply mode should still expose chunked idempotent operations"
30608 );
30609 }
30610
30611 #[test]
30612 fn convex_sync_apply_round_trips_with_http_backend() {
30613 use std::net::TcpListener;
30614 use std::sync::{Arc, Mutex};
30615
30616 let dir = setup_traversal_project();
30617 let report =
30618 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30619 let expected_chunks = report.chunks.len();
30620 assert!(expected_chunks > 0);
30621
30622 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
30623 let endpoint = format!("http://{}", listener.local_addr().unwrap());
30624 let operations = Arc::new(Mutex::new(Vec::<String>::new()));
30625 let server_operations = Arc::clone(&operations);
30626 let server = std::thread::spawn(move || {
30627 for _ in 0..expected_chunks {
30628 let (mut stream, _) = listener.accept().unwrap();
30629 let mut reader = BufReader::new(stream.try_clone().unwrap());
30630 let mut request_line = String::new();
30631 reader.read_line(&mut request_line).unwrap();
30632 assert!(request_line.starts_with("POST "));
30633
30634 let mut content_length = 0usize;
30635 loop {
30636 let mut line = String::new();
30637 reader.read_line(&mut line).unwrap();
30638 if line == "\r\n" {
30639 break;
30640 }
30641 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
30642 content_length = value.trim().parse().unwrap();
30643 }
30644 }
30645
30646 let mut body = vec![0u8; content_length];
30647 reader.read_exact(&mut body).unwrap();
30648 let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
30649 server_operations
30650 .lock()
30651 .unwrap()
30652 .push(request["operation"].as_str().unwrap().to_string());
30653
30654 let response = br#"{"status":"ok","message":"accepted"}"#;
30655 write!(
30656 stream,
30657 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
30658 response.len()
30659 )
30660 .unwrap();
30661 stream.write_all(response).unwrap();
30662 }
30663 });
30664
30665 cmd_convex_sync(
30666 ConvexSyncOptions {
30667 path: dir.path(),
30668 scope: None,
30669 snapshot: None,
30670 chunk_size: 100,
30671 remote_snapshot: false,
30672 apply: true,
30673 endpoint: Some(&endpoint),
30674 auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
30675 },
30676 OutputFormat {
30677 json_output: false,
30678 compact: true,
30679 pretty: false,
30680 terse: false,
30681 ultra_terse: false,
30682 schema: false,
30683 envelope: false,
30684 },
30685 )
30686 .unwrap();
30687 server.join().unwrap();
30688
30689 let operations = operations.lock().unwrap().clone();
30690 assert!(operations.contains(&"upsert_nodes".to_string()));
30691 assert!(operations.contains(&"upsert_edges".to_string()));
30692 }
30693
30694 #[test]
30695 fn context_pack_diff_preview_attaches_tag_ontology_refs() {
30696 let root = tempfile::tempdir().unwrap();
30697 fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
30698 fs::write(
30699 root.path().join(".naming/tags/alpha.md"),
30700 "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
30701 )
30702 .unwrap();
30703 let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
30704 let report = diff_digest::DiffDigestReport {
30705 root: root.path().display().to_string(),
30706 mode: diff_digest::DiffDigestMode::WorkingTree,
30707 revision: None,
30708 files_changed: 1,
30709 files_with_current_summaries: 1,
30710 symbols_touched: 1,
30711 call_edges_added: 0,
30712 call_edges_removed: 0,
30713 files: vec![diff_digest::DiffDigestFile {
30714 path: "src/lib.rs".to_string(),
30715 status: diff_digest::DiffDigestFileStatus::Modified,
30716 touched_symbols: vec!["alpha_helper".to_string()],
30717 summary_state: diff_digest::DiffDigestSummaryState::Current,
30718 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30719 symbol: "alpha_helper".to_string(),
30720 summary: "alpha helper summary".to_string(),
30721 }],
30722 added_call_edges: vec![],
30723 removed_call_edges: vec![],
30724 warnings: vec![],
30725 }],
30726 };
30727
30728 let preview = build_context_pack_diff_preview(
30729 &report,
30730 ResponseBudget::new(Some(1), Some(80)),
30731 Some(&ontology),
30732 );
30733
30734 let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
30735 assert!(symbol_ref.handle.starts_with("tont-"));
30736 assert_eq!(symbol_ref.tag, "alpha");
30737 assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
30738 assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
30739 assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
30740 assert_eq!(
30741 preview.files[0].summary_refs[0].ontology_refs[0].path,
30742 ".naming/tags/alpha.md"
30743 );
30744 }
30745
30746 #[test]
30747 fn context_pack_test_preview_limits_failure_groups() {
30748 let report = test_digest::TestDigestReport {
30749 root: "/repo".to_string(),
30750 runner: "cargo".to_string(),
30751 failures: 2,
30752 grouped_failures: 2,
30753 counts: test_digest::TestDigestCounts {
30754 passed: Some(8),
30755 failed: Some(2),
30756 skipped: Some(1),
30757 },
30758 failure_groups: vec![
30759 test_digest::TestDigestFailure {
30760 tests: vec!["suite::alpha_failure".to_string()],
30761 message: "assertion failed".to_string(),
30762 path: Some("src/lib.rs".to_string()),
30763 line: Some(42),
30764 column: None,
30765 occurrences: 1,
30766 summary_state: test_digest::TestDigestSummaryState::Current,
30767 current_summaries: vec![test_digest::TestDigestSummarySnippet {
30768 symbol: "alpha_failure".to_string(),
30769 summary: "failure summary for alpha test".to_string(),
30770 }],
30771 },
30772 test_digest::TestDigestFailure {
30773 tests: vec!["suite::beta_failure".to_string()],
30774 message: "panic".to_string(),
30775 path: Some("src/main.rs".to_string()),
30776 line: Some(7),
30777 column: None,
30778 occurrences: 1,
30779 summary_state: test_digest::TestDigestSummaryState::Missing,
30780 current_summaries: vec![],
30781 },
30782 ],
30783 warnings: vec!["warning text".to_string()],
30784 };
30785
30786 let preview =
30787 build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30788
30789 assert!(preview.truncated);
30790 assert_eq!(preview.failure_groups.len(), 1);
30791 assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
30792 assert_eq!(preview.failure_groups[0].message, "assertion f...");
30793 assert!(
30794 preview.failure_groups[0].summary_refs[0]
30795 .handle
30796 .starts_with("ctsum-")
30797 );
30798 assert_eq!(
30799 preview.failure_groups[0].summary_refs[0].expand,
30800 "tsift summarize --file \"src/lib.rs\""
30801 );
30802 assert_eq!(preview.warnings, vec!["warning text"]);
30803 }
30804
30805 #[test]
30806 fn context_pack_log_preview_limits_signals_and_refs() {
30807 let report = log_digest::LogDigestReport {
30808 root: "/repo".to_string(),
30809 total_lines: 12,
30810 non_empty_lines: 10,
30811 signal_groups: 2,
30812 repeated_line_groups: 2,
30813 repeated_line_occurrences: 3,
30814 file_ref_groups: 2,
30815 symbol_ref_groups: 2,
30816 stack_groups: 1,
30817 signals: vec![
30818 log_digest::LogDigestSignal {
30819 severity: "error".to_string(),
30820 message: "src/lib.rs:42 boom".to_string(),
30821 path: Some("src/lib.rs".to_string()),
30822 line: Some(42),
30823 column: None,
30824 occurrences: 2,
30825 summary_state: log_digest::LogDigestSummaryState::Current,
30826 current_summaries: vec![log_digest::LogDigestSummarySnippet {
30827 symbol: "alpha_helper".to_string(),
30828 summary: "alpha helper cached log summary".to_string(),
30829 }],
30830 },
30831 log_digest::LogDigestSignal {
30832 severity: "warn".to_string(),
30833 message: "slow path".to_string(),
30834 path: None,
30835 line: None,
30836 column: None,
30837 occurrences: 1,
30838 summary_state: log_digest::LogDigestSummaryState::Unavailable,
30839 current_summaries: vec![],
30840 },
30841 ],
30842 repeated_lines: vec![
30843 log_digest::LogDigestRepeatedLine {
30844 line: "retrying work item alpha".to_string(),
30845 occurrences: 3,
30846 },
30847 log_digest::LogDigestRepeatedLine {
30848 line: "retrying work item beta".to_string(),
30849 occurrences: 2,
30850 },
30851 ],
30852 file_refs: vec![
30853 log_digest::LogDigestFileRef {
30854 path: "src/lib.rs".to_string(),
30855 line: Some(42),
30856 column: None,
30857 occurrences: 2,
30858 summary_state: log_digest::LogDigestSummaryState::Current,
30859 current_summaries: vec![log_digest::LogDigestSummarySnippet {
30860 symbol: "alpha_helper".to_string(),
30861 summary: "alpha helper cached file summary".to_string(),
30862 }],
30863 },
30864 log_digest::LogDigestFileRef {
30865 path: "src/main.rs".to_string(),
30866 line: Some(7),
30867 column: None,
30868 occurrences: 1,
30869 summary_state: log_digest::LogDigestSummaryState::Missing,
30870 current_summaries: vec![],
30871 },
30872 ],
30873 symbol_refs: vec![
30874 log_digest::LogDigestSymbolRef {
30875 symbol: "alpha_helper".to_string(),
30876 occurrences: 2,
30877 summary_state: log_digest::LogDigestSummaryState::Current,
30878 current_summaries: vec![log_digest::LogDigestSummarySnippet {
30879 symbol: "alpha_helper".to_string(),
30880 summary: "alpha helper cached symbol summary".to_string(),
30881 }],
30882 },
30883 log_digest::LogDigestSymbolRef {
30884 symbol: "beta_helper".to_string(),
30885 occurrences: 1,
30886 summary_state: log_digest::LogDigestSummaryState::Missing,
30887 current_summaries: vec![],
30888 },
30889 ],
30890 stack_traces: vec![log_digest::LogDigestStackGroup {
30891 frames: vec!["frame one".to_string()],
30892 occurrences: 1,
30893 }],
30894 warnings: vec!["warning text".to_string()],
30895 };
30896
30897 let preview =
30898 build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30899
30900 assert!(preview.truncated);
30901 assert_eq!(preview.signals.len(), 1);
30902 assert_eq!(preview.signals[0].message, "src/lib.rs:...");
30903 assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
30904 assert_eq!(preview.file_refs.len(), 1);
30905 assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
30906 assert!(
30907 preview.signals[0].summary_refs[0]
30908 .handle
30909 .starts_with("clsum-")
30910 );
30911 assert!(
30912 preview.file_refs[0].summary_refs[0]
30913 .handle
30914 .starts_with("clfsum-")
30915 );
30916 assert!(
30917 preview.symbol_refs[0].summary_refs[0]
30918 .handle
30919 .starts_with("clssum-")
30920 );
30921 assert_eq!(
30922 preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
30923 Some("alpha/helper")
30924 );
30925 assert_eq!(
30926 preview.symbol_refs[0].summary_refs[0].expand,
30927 "tsift summarize \"alpha_helper\""
30928 );
30929 assert_eq!(preview.warnings, vec!["warning text"]);
30930 }
30931
30932 #[test]
30933 fn cli_search_rejects_exact_with_strategy_flag() {
30934 let cli = try_parse_cli([
30935 "tsift",
30936 "search",
30937 "test",
30938 "--exact",
30939 "--strategy",
30940 "lexical",
30941 ]);
30942 assert!(cli.is_err());
30943 }
30944
30945 #[test]
30946 fn cli_search_autoindexes_by_default() {
30947 let cli = parse_cli(["tsift", "search", "test"]);
30948 match cli.command {
30949 Some(Commands::Search {
30950 autoindex,
30951 no_autoindex,
30952 ..
30953 }) => {
30954 assert!(!autoindex);
30955 assert!(!no_autoindex);
30956 assert!(autoindex || !no_autoindex);
30957 }
30958 _ => panic!("expected Search command"),
30959 }
30960 }
30961
30962 #[test]
30963 fn cli_search_accepts_no_autoindex_flag() {
30964 let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
30965 match cli.command {
30966 Some(Commands::Search {
30967 autoindex,
30968 no_autoindex,
30969 ..
30970 }) => {
30971 assert!(!autoindex);
30972 assert!(no_autoindex);
30973 }
30974 _ => panic!("expected Search command"),
30975 }
30976 }
30977
30978 #[test]
30979 fn cli_search_rejects_conflicting_autoindex_flags() {
30980 let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
30981 assert!(cli.is_err());
30982 }
30983
30984 #[test]
30987 fn cli_accepts_global_absolute_flag() {
30988 let cli = parse_cli(["tsift", "--absolute", "status"]);
30989 assert!(cli.absolute);
30990 assert!(matches!(cli.command, Some(Commands::Status { .. })));
30991 }
30992
30993 #[test]
30994 fn cli_accepts_global_tabular_flag() {
30995 let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
30996 assert!(cli.tabular);
30997 assert!(matches!(cli.command, Some(Commands::Search { .. })));
30998 }
30999
31000 #[test]
31001 fn cli_tabular_with_graph() {
31002 let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
31003 assert!(cli.tabular);
31004 assert!(matches!(cli.command, Some(Commands::Graph { .. })));
31005 }
31006
31007 #[test]
31008 fn cli_tabular_with_communities() {
31009 let cli = parse_cli(["tsift", "--tabular", "communities"]);
31010 assert!(cli.tabular);
31011 assert!(matches!(cli.command, Some(Commands::Communities { .. })));
31012 }
31013
31014 #[test]
31015 fn cli_tabular_with_explain() {
31016 let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
31017 assert!(cli.tabular);
31018 assert!(matches!(cli.command, Some(Commands::Explain { .. })));
31019 }
31020
31021 #[test]
31022 fn cli_traverse_accepts_path_target_and_html_format() {
31023 let cli = parse_cli([
31024 "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
31025 ]);
31026 match cli.command {
31027 Some(Commands::Traverse {
31028 node,
31029 to,
31030 path,
31031 format,
31032 ..
31033 }) => {
31034 assert_eq!(node.as_deref(), Some("#kgnv"));
31035 assert_eq!(to.as_deref(), Some("main"));
31036 assert_eq!(path, PathBuf::from("."));
31037 assert_eq!(format, TraverseFormat::Html);
31038 }
31039 _ => panic!("expected Traverse command"),
31040 }
31041 }
31042
31043 #[test]
31044 fn cli_parses_semantic_related_command() {
31045 let cli = parse_cli([
31046 "tsift",
31047 "semantic",
31048 "graph navigation",
31049 "--path",
31050 ".",
31051 "--kind",
31052 "all",
31053 "--limit",
31054 "3",
31055 "--json",
31056 ]);
31057 match cli.command {
31058 Some(Commands::Semantic {
31059 query,
31060 path,
31061 kind,
31062 limit,
31063 json,
31064 ..
31065 }) => {
31066 assert_eq!(query, "graph navigation");
31067 assert_eq!(path, PathBuf::from("."));
31068 assert_eq!(kind, SemanticRelatedKind::All);
31069 assert_eq!(limit, 3);
31070 assert!(json);
31071 }
31072 _ => panic!("expected Semantic command"),
31073 }
31074 }
31075
31076 #[test]
31077 fn cli_parses_convex_sync_command() {
31078 let cli = parse_cli([
31079 "tsift",
31080 "convex-sync",
31081 ".",
31082 "--snapshot",
31083 "rows.json",
31084 "--chunk-size",
31085 "25",
31086 "--json",
31087 ]);
31088 match cli.command {
31089 Some(Commands::ConvexSync {
31090 path,
31091 snapshot,
31092 chunk_size,
31093 json,
31094 ..
31095 }) => {
31096 assert_eq!(path, PathBuf::from("."));
31097 assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
31098 assert_eq!(chunk_size, 25);
31099 assert!(json);
31100 }
31101 _ => panic!("expected ConvexSync command"),
31102 }
31103 }
31104
31105 #[test]
31106 fn cli_parses_convex_sync_live_flags() {
31107 let cli = parse_cli([
31108 "tsift",
31109 "convex-sync",
31110 ".",
31111 "--remote-snapshot",
31112 "--apply",
31113 "--endpoint",
31114 "https://example.test/convex-graph",
31115 "--auth-token-env",
31116 "TSIFT_TEST_TOKEN",
31117 ]);
31118 match cli.command {
31119 Some(Commands::ConvexSync {
31120 remote_snapshot,
31121 apply,
31122 endpoint,
31123 auth_token_env,
31124 ..
31125 }) => {
31126 assert!(remote_snapshot);
31127 assert!(apply);
31128 assert_eq!(
31129 endpoint.as_deref(),
31130 Some("https://example.test/convex-graph")
31131 );
31132 assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
31133 }
31134 _ => panic!("expected ConvexSync command"),
31135 }
31136 }
31137
31138 #[test]
31139 fn cli_parses_graph_db_query() {
31140 let cli = parse_cli([
31141 "tsift",
31142 "graph-db",
31143 "--backend",
31144 "convex-snapshot",
31145 "--convex-snapshot",
31146 "rows.json",
31147 "--json",
31148 "neighborhood",
31149 "gbak-kgnv",
31150 "--depth",
31151 "2",
31152 "--edge-kind",
31153 "mentions",
31154 "--property",
31155 "path=tasks/software/tsift.md",
31156 "--cursor",
31157 "gbak-old",
31158 "--limit",
31159 "10",
31160 ]);
31161 match cli.command {
31162 Some(Commands::GraphDb {
31163 backend,
31164 convex_snapshot,
31165 json,
31166 query,
31167 ..
31168 }) => {
31169 assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
31170 assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
31171 assert!(json);
31172 match query {
31173 GraphDbQuery::Neighborhood {
31174 id,
31175 depth,
31176 edge_kind,
31177 cursor,
31178 limit,
31179 property_filters,
31180 } => {
31181 assert_eq!(id, "gbak-kgnv");
31182 assert_eq!(depth, 2);
31183 assert_eq!(edge_kind.as_deref(), Some("mentions"));
31184 assert_eq!(cursor.as_deref(), Some("gbak-old"));
31185 assert_eq!(limit, Some(10));
31186 assert_eq!(
31187 property_filters,
31188 vec!["path=tasks/software/tsift.md".to_string()]
31189 );
31190 }
31191 _ => panic!("expected graph-db neighborhood query"),
31192 }
31193 }
31194 _ => panic!("expected GraphDb command"),
31195 }
31196 }
31197
31198 #[test]
31199 fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
31200 let cli = parse_cli([
31201 "tsift",
31202 "graph-db",
31203 "--json",
31204 "backend-eval",
31205 "--candidate",
31206 "surrealdb",
31207 "--target",
31208 "gval",
31209 "--full-projection",
31210 ]);
31211 match cli.command {
31212 Some(Commands::GraphDb { json, query, .. }) => {
31213 assert!(json);
31214 match query {
31215 GraphDbQuery::BackendEval {
31216 candidates,
31217 targets,
31218 full_projection,
31219 } => {
31220 assert_eq!(candidates, vec!["surrealdb".to_string()]);
31221 assert_eq!(targets, vec!["gval".to_string()]);
31222 assert!(full_projection);
31223 }
31224 _ => panic!("expected graph-db backend-eval query"),
31225 }
31226 }
31227 _ => panic!("expected GraphDb command"),
31228 }
31229 }
31230
31231 #[test]
31232 fn cli_parses_graph_db_tokensave_backend() {
31233 let cli = parse_cli([
31234 "tsift",
31235 "graph-db",
31236 "--backend",
31237 "tokensave",
31238 "--json",
31239 "node",
31240 "fn:main",
31241 ]);
31242 match cli.command {
31243 Some(Commands::GraphDb {
31244 backend,
31245 json,
31246 query,
31247 ..
31248 }) => {
31249 assert_eq!(backend, GraphDbBackend::Tokensave);
31250 assert!(json);
31251 match query {
31252 GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
31253 _ => panic!("expected graph-db node query"),
31254 }
31255 }
31256 _ => panic!("expected GraphDb command"),
31257 }
31258 }
31259
31260 #[test]
31261 fn cli_parses_analyze_command() {
31262 let cli = parse_cli([
31263 "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
31264 "--limit", "7", "--json",
31265 ]);
31266 match cli.command {
31267 Some(Commands::Analyze {
31268 path,
31269 scope,
31270 entry_points,
31271 limit,
31272 json,
31273 }) => {
31274 assert_eq!(path, PathBuf::from("."));
31275 assert_eq!(scope.as_deref(), Some("core"));
31276 assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
31277 assert_eq!(limit, 7);
31278 assert!(json);
31279 }
31280 _ => panic!("expected Analyze command"),
31281 }
31282 }
31283
31284 #[test]
31285 fn cli_parses_graph_db_related_query() {
31286 let cli = parse_cli([
31287 "tsift",
31288 "graph-db",
31289 "--json",
31290 "related",
31291 "voice avatar memory retrieval",
31292 "--kind",
31293 "all",
31294 "--depth",
31295 "3",
31296 "--seed-limit",
31297 "4",
31298 "--limit",
31299 "12",
31300 ]);
31301 match cli.command {
31302 Some(Commands::GraphDb { json, query, .. }) => {
31303 assert!(json);
31304 match query {
31305 GraphDbQuery::Related {
31306 query,
31307 kind,
31308 depth,
31309 seed_limit,
31310 limit,
31311 } => {
31312 assert_eq!(query, "voice avatar memory retrieval");
31313 assert_eq!(kind, SemanticRelatedKind::All);
31314 assert_eq!(depth, 3);
31315 assert_eq!(seed_limit, 4);
31316 assert_eq!(limit, 12);
31317 }
31318 _ => panic!("expected graph-db related query"),
31319 }
31320 }
31321 _ => panic!("expected GraphDb command"),
31322 }
31323 }
31324
31325 #[test]
31326 fn cli_parses_graph_db_compact_query() {
31327 let cli = parse_cli([
31328 "tsift",
31329 "graph-db",
31330 "--path",
31331 ".",
31332 "compact",
31333 "--apply",
31334 "--prune-tombstones",
31335 "--confirmed-convex-reconciled",
31336 ]);
31337 match cli.command {
31338 Some(Commands::GraphDb { query, .. }) => match query {
31339 GraphDbQuery::Compact {
31340 apply,
31341 prune_tombstones,
31342 confirmed_convex_reconciled,
31343 } => {
31344 assert!(apply);
31345 assert!(prune_tombstones);
31346 assert!(confirmed_convex_reconciled);
31347 }
31348 _ => panic!("expected graph-db compact query"),
31349 },
31350 _ => panic!("expected GraphDb command"),
31351 }
31352 }
31353
31354 #[test]
31355 fn cli_parses_impact_command() {
31356 let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
31357 match cli.command {
31358 Some(Commands::Impact {
31359 path,
31360 cached,
31361 limit,
31362 ..
31363 }) => {
31364 assert_eq!(path, PathBuf::from("."));
31365 assert!(cached);
31366 assert_eq!(limit, 5);
31367 }
31368 _ => panic!("expected Impact command"),
31369 }
31370 }
31371
31372 #[test]
31373 fn cli_parses_conflict_matrix_command() {
31374 let cli = parse_cli([
31375 "tsift",
31376 "conflict-matrix",
31377 "--path",
31378 "tasks/software/tsift.md",
31379 "--depth",
31380 "4",
31381 "--limit",
31382 "12",
31383 "--impact-limit",
31384 "6",
31385 "--json",
31386 "pwcm",
31387 "#g6kf",
31388 ]);
31389 match cli.command {
31390 Some(Commands::ConflictMatrix {
31391 targets,
31392 path,
31393 depth,
31394 limit,
31395 impact_limit,
31396 json,
31397 ..
31398 }) => {
31399 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31400 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31401 assert_eq!(depth, 4);
31402 assert_eq!(limit, 12);
31403 assert_eq!(impact_limit, 6);
31404 assert!(json);
31405 }
31406 _ => panic!("expected ConflictMatrix command"),
31407 }
31408 }
31409
31410 #[test]
31411 fn cli_parses_dispatch_trace_command() {
31412 let cli = parse_cli([
31413 "tsift",
31414 "dispatch-trace",
31415 "--path",
31416 "tasks/software/tsift.md",
31417 "--format",
31418 "html",
31419 "--depth",
31420 "4",
31421 "pwcm",
31422 "#g6kf",
31423 ]);
31424 match cli.command {
31425 Some(Commands::DispatchTrace {
31426 targets,
31427 path,
31428 format,
31429 depth,
31430 ..
31431 }) => {
31432 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31433 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31434 assert_eq!(format, DispatchTraceFormat::Html);
31435 assert_eq!(depth, 4);
31436 }
31437 _ => panic!("expected DispatchTrace command"),
31438 }
31439 }
31440
31441 #[test]
31442 fn cli_parses_dependency_dag_command() {
31443 let cli = parse_cli([
31444 "tsift",
31445 "dependency-dag",
31446 "--path",
31447 "tasks/software/tsift.md",
31448 "--depth",
31449 "5",
31450 "--limit",
31451 "20",
31452 "--json",
31453 "alpha",
31454 "#beta",
31455 ]);
31456 match cli.command {
31457 Some(Commands::DependencyDag {
31458 targets,
31459 path,
31460 depth,
31461 limit,
31462 json,
31463 ..
31464 }) => {
31465 assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
31466 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31467 assert_eq!(depth, 5);
31468 assert_eq!(limit, 20);
31469 assert!(json);
31470 }
31471 _ => panic!("expected DependencyDag command"),
31472 }
31473 }
31474
31475 #[test]
31476 fn relativize_strips_root_prefix() {
31477 let root = std::path::Path::new("/home/user/project");
31478 assert_eq!(
31479 relativize("/home/user/project/src/main.rs", root),
31480 "src/main.rs"
31481 );
31482 }
31483
31484 #[test]
31485 fn relativize_leaves_non_matching_path() {
31486 let root = std::path::Path::new("/home/user/project");
31487 assert_eq!(
31488 relativize("/other/path/file.rs", root),
31489 "/other/path/file.rs"
31490 );
31491 }
31492
31493 #[test]
31494 fn relativize_leaves_already_relative() {
31495 let root = std::path::Path::new("/home/user/project");
31496 assert_eq!(relativize("src/main.rs", root), "src/main.rs");
31497 }
31498
31499 #[test]
31500 fn relativize_pathbuf_strips_prefix() {
31501 let root = std::path::Path::new("/home/user/project");
31502 let path = std::path::Path::new("/home/user/project/src/lib.rs");
31503 assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
31504 }
31505
31506 #[test]
31507 fn relativize_edges_strips_caller_file() {
31508 let root = std::path::Path::new("/tmp/proj");
31509 let mut edges = vec![index::StoredEdge {
31510 caller_file: "/tmp/proj/src/main.rs".to_string(),
31511 caller_name: "main".to_string(),
31512 caller_line: 1,
31513 callee_name: "helper".to_string(),
31514 call_site_line: 5,
31515 tagpath_handle: None,
31516 }];
31517 relativize_edges(&mut edges, root);
31518 assert_eq!(edges[0].caller_file, "src/main.rs");
31519 }
31520
31521 #[test]
31522 fn relativize_json_paths_strips_known_keys() {
31523 let root = std::path::Path::new("/tmp/proj");
31524 let mut val = serde_json::json!({
31525 "file": "/tmp/proj/src/main.rs",
31526 "path": "/tmp/proj/test.rs",
31527 "name": "/tmp/proj/not-a-path",
31528 "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
31529 });
31530 relativize_json_paths(&mut val, root);
31531 assert_eq!(val["file"], "src/main.rs");
31532 assert_eq!(val["path"], "test.rs");
31533 assert_eq!(val["name"], "/tmp/proj/not-a-path");
31534 assert_eq!(val["hits"][0]["path"], "nested.rs");
31535 }
31536
31537 #[test]
31540 fn cli_graph_accepts_limit_flag() {
31541 let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
31542 match cli.command {
31543 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
31544 _ => panic!("expected Graph command"),
31545 }
31546 }
31547
31548 #[test]
31549 fn cli_graph_default_limit_is_20() {
31550 let cli = parse_cli(["tsift", "graph", "main"]);
31551 match cli.command {
31552 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
31553 _ => panic!("expected Graph command"),
31554 }
31555 }
31556
31557 #[test]
31558 fn cli_communities_accepts_limit_flag() {
31559 let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
31560 match cli.command {
31561 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
31562 _ => panic!("expected Communities command"),
31563 }
31564 }
31565
31566 #[test]
31567 fn cli_communities_default_limit_is_10() {
31568 let cli = parse_cli(["tsift", "communities"]);
31569 match cli.command {
31570 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
31571 _ => panic!("expected Communities command"),
31572 }
31573 }
31574
31575 #[test]
31576 fn cli_explain_accepts_limit_flag() {
31577 let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
31578 match cli.command {
31579 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
31580 _ => panic!("expected Explain command"),
31581 }
31582 }
31583
31584 #[test]
31585 fn cli_explain_default_limit_is_15() {
31586 let cli = parse_cli(["tsift", "explain", "main"]);
31587 match cli.command {
31588 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
31589 _ => panic!("expected Explain command"),
31590 }
31591 }
31592
31593 #[test]
31594 fn cli_limit_zero_means_unlimited() {
31595 let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
31596 match cli.command {
31597 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
31598 _ => panic!("expected Graph command"),
31599 }
31600 }
31601
31602 #[test]
31603 fn graph_cmd_limit_runs_ok() {
31604 let dir = setup_graph_index();
31605 let result = cmd_graph(
31606 "main",
31607 dir.path(),
31608 false,
31609 false,
31610 None,
31611 1,
31612 false,
31613 false,
31614 false,
31615 false,
31616 false,
31617 false,
31618 false,
31619 TagpathSearchOpts::default(),
31620 );
31621 assert!(result.is_ok());
31622 }
31623
31624 #[test]
31625 fn graph_cmd_unlimited_runs_ok() {
31626 let dir = setup_graph_index();
31627 let result = cmd_graph(
31628 "main",
31629 dir.path(),
31630 false,
31631 false,
31632 None,
31633 0,
31634 false,
31635 false,
31636 false,
31637 false,
31638 false,
31639 false,
31640 false,
31641 TagpathSearchOpts::default(),
31642 );
31643 assert!(result.is_ok());
31644 }
31645
31646 #[test]
31647 fn graph_cmd_tabular_runs_ok() {
31648 let dir = setup_graph_index();
31649 let result = cmd_graph(
31650 "main",
31651 dir.path(),
31652 false,
31653 false,
31654 None,
31655 20,
31656 false,
31657 false,
31658 false,
31659 false,
31660 false,
31661 true,
31662 false,
31663 TagpathSearchOpts::default(),
31664 );
31665 assert!(result.is_ok());
31666 }
31667
31668 #[test]
31669 fn communities_cmd_tabular_runs_ok() {
31670 let dir = setup_graph_index();
31671 let result = cmd_communities(
31672 dir.path(),
31673 None,
31674 1,
31675 10,
31676 false,
31677 false,
31678 false,
31679 false,
31680 true,
31681 false,
31682 TagpathSearchOpts::default(),
31683 );
31684 assert!(result.is_ok());
31685 }
31686
31687 #[test]
31688 fn explain_cmd_tabular_runs_ok() {
31689 let dir = setup_graph_index();
31690 let result = cmd_explain(
31691 "main",
31692 dir.path(),
31693 None,
31694 15,
31695 false,
31696 false,
31697 false,
31698 false,
31699 false,
31700 true,
31701 false,
31702 false,
31703 );
31704 assert!(result.is_ok());
31705 }
31706
31707 #[test]
31708 fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
31709 let cases = [
31714 ".agent-doc",
31715 ".agent-doc/snapshots/abc.md",
31716 ".agent-doc/baselines/abc.md",
31717 ".agent-doc/archives/2026.md",
31718 ".agent-doc/runtime/run.jsonl",
31719 "src/foo/.agent-doc",
31720 "src/foo/.agent-doc/snapshots/x.md",
31721 "./.agent-doc/snapshots/x.md",
31722 ];
31723 for path in cases {
31724 assert!(
31725 traversal_relative_path_is_generated_artifact(path),
31726 "expected `{path}` to be excluded from source watermark"
31727 );
31728 }
31729 for path in [
31731 "src/main.rs",
31732 "tests/perf_gate.rs",
31733 "fixtures/x.json",
31734 "agent-doc/src/lib.rs", "src/.agent-doc-helper.rs",
31736 ] {
31737 assert!(
31738 !traversal_relative_path_is_generated_artifact(path),
31739 "expected `{path}` to be included in source watermark"
31740 );
31741 }
31742 }
31743
31744 #[test]
31745 fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
31746 let cases = [
31754 ".tsift",
31755 ".tsift/index.db",
31756 ".tsift/indexes/foo/index.db",
31757 ".tsift/conflict-matrix-cache/inputs/abc.json",
31758 ".tsift/summaries.db",
31759 "src/foo/.tsift",
31760 "src/foo/.tsift/graph.db",
31761 "./.tsift/index.db",
31762 "target",
31763 "target/debug/build/x",
31764 "target/release/tsift",
31765 "src/foo/target/debug/x",
31766 "./target/release/x",
31767 ];
31768 for path in cases {
31769 assert!(
31770 traversal_relative_path_is_generated_artifact(path),
31771 "expected `{path}` to be excluded from source watermark"
31772 );
31773 }
31774 for path in [
31776 "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
31777 "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
31778 "src/tsift-extras/lib.rs",
31779 "tsift/README.md",
31780 "src/targeting.rs",
31781 "src/.tsiftrc",
31782 "src/agent-doc-helper.rs",
31783 ] {
31784 assert!(
31785 !traversal_relative_path_is_generated_artifact(path),
31786 "expected `{path}` to be included in source watermark"
31787 );
31788 }
31789 }
31790
31791 #[test]
31792 fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
31793 let dir = tempfile::tempdir().unwrap();
31802 let root = dir.path();
31803 std::fs::create_dir_all(root.join("src")).unwrap();
31804 std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
31805 let hint = root.join("README.md");
31806 std::fs::write(&hint, "# stable\n").unwrap();
31807 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31809 std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
31810 std::fs::create_dir_all(root.join("target/debug")).unwrap();
31811 std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
31812
31813 let first = traversal_source_watermark(root, &hint, None, true)
31814 .expect("first watermark call must succeed")
31815 .expect("first watermark must produce a hash for hinted markdown");
31816 let second = traversal_source_watermark(root, &hint, None, true)
31817 .expect("second watermark call must succeed")
31818 .expect("second watermark must produce a hash for hinted markdown");
31819 assert_eq!(
31820 first, second,
31821 "watermark must be identical across back-to-back invocations on a quiescent root"
31822 );
31823
31824 std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
31826 std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
31827 let third = traversal_source_watermark(root, &hint, None, true)
31828 .expect("third watermark call must succeed")
31829 .expect("third watermark must produce a hash for hinted markdown");
31830 assert_eq!(
31831 first, third,
31832 "watermark must ignore mutations under .tsift/ and target/"
31833 );
31834
31835 std::thread::sleep(std::time::Duration::from_millis(20));
31840 std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
31841 let fourth = traversal_source_watermark(root, &hint, None, true)
31842 .expect("fourth watermark call must succeed")
31843 .expect("fourth watermark must produce a hash for hinted markdown");
31844 assert_ne!(
31845 first, fourth,
31846 "watermark must invalidate when the hinted markdown file changes"
31847 );
31848 }
31849
31850 #[test]
31851 fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
31852 let dir = tempfile::tempdir().unwrap();
31856 let root = dir.path();
31857 std::fs::write(root.join("README.md"), "# stable\n").unwrap();
31858 let summaries_db_path = root.join(".tsift/summaries.db");
31859 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31860 let mut summary = summarize::Summary {
31861 id: 0,
31862 symbol_name: "main".to_string(),
31863 file_path: "src/main.rs".to_string(),
31864 content_hash: "hash-main".to_string(),
31865 summary: "main wires the CLI".to_string(),
31866 entities: Some(vec![summarize::Entity {
31867 name: "Cli".to_string(),
31868 kind: "type".to_string(),
31869 description: "Command-line interface".to_string(),
31870 }]),
31871 relationships: None,
31872 concept_labels: Some(vec!["cli".to_string()]),
31873 extracted_at: "1700000000".to_string(),
31874 model: "test-model".to_string(),
31875 tokens_input: Some(10),
31876 tokens_output: Some(5),
31877 };
31878 summary_db.insert(&summary).unwrap();
31879 drop(summary_db);
31880
31881 let hint = root.join("README.md");
31882 let first = traversal_source_watermark(root, &hint, None, true)
31883 .expect("first watermark call must succeed")
31884 .expect("first watermark must produce a hash");
31885
31886 std::thread::sleep(std::time::Duration::from_millis(20));
31887 let conn = Connection::open(&summaries_db_path).unwrap();
31888 conn.pragma_update(None, "user_version", 1).unwrap();
31889 conn.pragma_update(None, "user_version", 0).unwrap();
31890 drop(conn);
31891
31892 let second = traversal_source_watermark(root, &hint, None, true)
31893 .expect("second watermark call must succeed")
31894 .expect("second watermark must produce a hash");
31895 assert_eq!(
31896 first, second,
31897 "metadata-only summaries.db churn must not invalidate the source watermark"
31898 );
31899
31900 summary.entities = Some(vec![summarize::Entity {
31901 name: "GraphCache".to_string(),
31902 kind: "type".to_string(),
31903 description: "Stable full-projection cache input".to_string(),
31904 }]);
31905 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31906 summary_db.delete_by_file("src/main.rs").unwrap();
31907 summary_db.insert(&summary).unwrap();
31908 drop(summary_db);
31909
31910 let third = traversal_source_watermark(root, &hint, None, true)
31911 .expect("third watermark call must succeed")
31912 .expect("third watermark must produce a hash");
31913 assert_ne!(
31914 first, third,
31915 "semantic summary row changes must invalidate the source watermark"
31916 );
31917 }
31918
31919 #[test]
31920 fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
31921 let dir = tempfile::tempdir().unwrap();
31925 let root = dir.path();
31926 std::fs::create_dir_all(root.join("src")).unwrap();
31927 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31928 let source = root.join("src/lib.rs");
31929 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
31930 std::fs::write(&source, source_body).unwrap();
31931 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31932 db.rebuild(root).unwrap();
31933 drop(db);
31934
31935 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31936 .unwrap()
31937 .value;
31938 std::thread::sleep(std::time::Duration::from_millis(20));
31939 std::fs::write(&source, source_body).unwrap();
31940 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31941 db.apply_changes(root).unwrap();
31942 drop(db);
31943
31944 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31945 .unwrap()
31946 .value;
31947 assert_eq!(
31948 first, second,
31949 "mtime-only source index churn must not invalidate the full-projection cache"
31950 );
31951 }
31952
31953 #[test]
31954 fn full_projection_source_watermark_ignores_session_markdown_churn() {
31955 let dir = tempfile::tempdir().unwrap();
31960 let root = dir.path();
31961 std::fs::create_dir_all(root.join("src")).unwrap();
31962 std::fs::create_dir_all(root.join("tasks/software")).unwrap();
31963 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31964 std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
31965 let task_doc = root.join("tasks/software/tsift.md");
31966 std::fs::write(
31967 &task_doc,
31968 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
31969 )
31970 .unwrap();
31971 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31972 db.rebuild(root).unwrap();
31973 drop(db);
31974
31975 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31976 .unwrap()
31977 .value;
31978 std::fs::write(
31979 &task_doc,
31980 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
31981 )
31982 .unwrap();
31983 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31984 .unwrap()
31985 .value;
31986 assert_eq!(
31987 first, second,
31988 "session markdown churn must not invalidate the full-projection code/summary cache"
31989 );
31990 }
31991
31992 #[test]
31993 fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
31994 let dir = tempfile::tempdir().unwrap();
31998 let root = dir.path();
31999 std::fs::create_dir_all(root.join("src")).unwrap();
32000 std::fs::create_dir_all(root.join(".tsift")).unwrap();
32001 let source = root.join("src/lib.rs");
32002 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
32003 std::fs::write(&source, source_body).unwrap();
32004 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32005 db.rebuild(root).unwrap();
32006 drop(db);
32007
32008 let (_projection, _warnings, _phases, first_stats) =
32009 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
32010 assert!(
32011 !first_stats.hit,
32012 "the first full-projection run should populate the cache"
32013 );
32014
32015 std::thread::sleep(std::time::Duration::from_millis(20));
32016 std::fs::write(&source, source_body).unwrap();
32017 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32018 db.apply_changes(root).unwrap();
32019 drop(db);
32020
32021 let (_projection, _warnings, phases, second_stats) =
32022 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
32023 assert!(second_stats.hit, "mtime-only churn should still cache-hit");
32024 let source_graph_build = phases
32025 .iter()
32026 .find(|phase| phase.name == "full_projection.source_graph_build")
32027 .expect("cache hit must report source_graph_build");
32028 let projection_rows = phases
32029 .iter()
32030 .find(|phase| phase.name == "full_projection.projection_rows")
32031 .expect("cache hit must report projection_rows");
32032 assert_eq!(source_graph_build.duration_micros, 0);
32033 assert_eq!(projection_rows.duration_micros, 0);
32034 }
32035
32036 #[test]
32037 fn build_token_capped_preview_within_cap() {
32038 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 2", "}"];
32039 let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
32040 assert!(!capped.was_capped);
32041 assert_eq!(capped.preview.len(), 3);
32042 assert_eq!(capped.capped_end, 3);
32043 }
32044
32045 #[test]
32046 fn build_token_capped_preview_truncates_long_body() {
32047 let owned: Vec<String> = (0..200).map(|i| format!(" let line_{i} = {i};")).collect();
32048 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
32049 let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
32050 assert!(capped.was_capped);
32051 assert!(capped.preview.len() < 200);
32052 assert!(capped.capped_end < 200);
32053 assert!(!capped.preview.is_empty());
32054 }
32055
32056 #[test]
32057 fn build_token_capped_preview_respects_start_offset() {
32058 let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
32059 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
32060 let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
32061 assert!(capped.was_capped);
32062 assert!(capped.capped_end >= 50);
32063 assert!(capped.capped_end < 100);
32064 assert_eq!(capped.preview[0].line, 50);
32065 }
32066
32067 #[test]
32068 fn response_budget_body_token_cap_defaults() {
32069 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
32070 assert_eq!(budget.body_token_cap(), 1500);
32071
32072 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
32073 assert_eq!(budget.body_token_cap(), 500);
32074
32075 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
32076 assert_eq!(budget.body_token_cap(), 3000);
32077 }
32078
32079 #[test]
32080 fn build_token_capped_preview_empty_input() {
32081 let lines: Vec<&str> = vec![];
32082 let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
32083 assert!(!capped.was_capped);
32084 assert!(capped.preview.is_empty());
32085 }
32086
32087 #[test]
32088 fn build_token_capped_preview_single_long_line_fits() {
32089 let lines: Vec<&str> = vec!["short"];
32090 let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
32091 assert!(!capped.was_capped);
32092 assert_eq!(capped.preview.len(), 1);
32093 assert_eq!(capped.capped_end, 1);
32094 }
32095
32096 #[test]
32097 fn edge_index_replaces_from_id_to_id_with_positions() {
32098 let input = serde_json::json!({
32099 "nodes": [
32100 {"id": "symbol:src/lib.rs:foo"},
32101 {"id": "symbol:src/lib.rs:bar"},
32102 {"id": "symbol:src/lib.rs:baz"}
32103 ],
32104 "edges": [
32105 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
32106 {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
32107 ]
32108 });
32109 let result = edge_index_transform(input);
32110 let edges = result.get("edges").unwrap().as_array().unwrap();
32111 assert_eq!(edges.len(), 2);
32112 assert_eq!(edges[0]["from"], 0);
32113 assert_eq!(edges[0]["to"], 1);
32114 assert_eq!(edges[1]["from"], 1);
32115 assert_eq!(edges[1]["to"], 2);
32116 assert!(edges[0].get("from_id").is_none());
32117 assert!(edges[0].get("to_id").is_none());
32118 }
32119
32120 #[test]
32121 fn edge_index_preserves_unresolved_ids_as_strings() {
32122 let input = serde_json::json!({
32123 "nodes": [{"id": "symbol:src/lib.rs:foo"}],
32124 "edges": [
32125 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
32126 ]
32127 });
32128 let result = edge_index_transform(input);
32129 let edge = &result["edges"][0];
32130 assert_eq!(edge["from"], 0);
32131 assert_eq!(edge["to_id"], "symbol:other.rs:missing");
32132 }
32133
32134 #[test]
32135 fn edge_index_noop_without_nodes_and_edges() {
32136 let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
32137 let result = edge_index_transform(input);
32138 assert_eq!(result["report"]["entries"][0]["from_id"], "a");
32139 }
32140}
32141
32142#[derive(Serialize)]
32145struct TableInfo {
32146 name: String,
32147 columns: Vec<ColumnInfo>,
32148 row_count: i64,
32149}
32150
32151#[derive(Serialize)]
32152struct ColumnInfo {
32153 name: String,
32154 #[serde(rename = "type")]
32155 col_type: String,
32156 notnull: bool,
32157 pk: bool,
32158 #[serde(skip_serializing_if = "Option::is_none")]
32159 default_value: Option<String>,
32160}
32161
32162pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
32164 let conn = Connection::open_with_flags(
32165 path,
32166 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
32167 )
32168 .with_context(|| format!("opening database: {}", path.display()))?;
32169 Ok(conn)
32170}
32171
32172pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
32174 let mut stmt = conn.prepare(
32175 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
32176 )?;
32177 let table_names: Vec<String> = stmt
32178 .query_map([], |row| row.get(0))?
32179 .collect::<std::result::Result<Vec<_>, _>>()?;
32180
32181 let mut tables = Vec::new();
32182 for tbl in table_names {
32183 let columns = table_columns(conn, &tbl)?;
32184 let row_count: i64 =
32185 conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
32186 row.get(0)
32187 })?;
32188 tables.push(TableInfo {
32189 name: tbl,
32190 columns,
32191 row_count,
32192 });
32193 }
32194 Ok(tables)
32195}
32196
32197pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
32199 let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
32200 let cols = stmt
32201 .query_map([], |row| {
32202 Ok(ColumnInfo {
32203 name: row.get(1)?,
32204 col_type: row.get::<_, String>(2).unwrap_or_default(),
32205 notnull: row.get::<_, bool>(3).unwrap_or(false),
32206 pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
32207 default_value: row.get(4)?,
32208 })
32209 })?
32210 .collect::<std::result::Result<Vec<_>, _>>()?;
32211 Ok(cols)
32212}
32213
32214pub(crate) fn execute_query(
32216 conn: &Connection,
32217 sql: &str,
32218) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
32219 let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
32220 let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
32221 let col_count = col_names.len();
32222
32223 let mut rows = Vec::new();
32224 let mut query_rows = stmt.query([])?;
32225 while let Some(row) = query_rows.next()? {
32226 let mut vals = Vec::with_capacity(col_count);
32227 for i in 0..col_count {
32228 let val = match row.get_ref(i)? {
32229 rusqlite::types::ValueRef::Null => serde_json::Value::Null,
32230 rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
32231 rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
32232 rusqlite::types::ValueRef::Text(s) => {
32233 serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
32234 }
32235 rusqlite::types::ValueRef::Blob(b) => {
32236 serde_json::Value::String(format!("<blob {} bytes>", b.len()))
32237 }
32238 };
32239 vals.push(val);
32240 }
32241 rows.push(vals);
32242 }
32243 Ok((col_names, rows))
32244}
32245
32246
32247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32248enum DigestRunnerKind {
32249 Test,
32250 Log,
32251}
32252
32253impl DigestRunnerKind {
32254 fn parse(raw: &str) -> Result<Self> {
32255 match raw.trim().to_ascii_lowercase().as_str() {
32256 "test" => Ok(Self::Test),
32257 "log" => Ok(Self::Log),
32258 other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
32259 }
32260 }
32261
32262 fn as_str(self) -> &'static str {
32263 match self {
32264 Self::Test => "test",
32265 Self::Log => "log",
32266 }
32267 }
32268}
32269
32270pub(crate) fn shell_split(s: &str) -> Vec<&str> {
32272 let mut parts = Vec::new();
32273 let mut i = 0;
32274 let bytes = s.as_bytes();
32275 while i < bytes.len() {
32276 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
32278 i += 1;
32279 }
32280 if i >= bytes.len() {
32281 break;
32282 }
32283 let start = i;
32284 if bytes[i] == b'"' || bytes[i] == b'\'' {
32285 let quote = bytes[i];
32286 i += 1;
32287 while i < bytes.len() && bytes[i] != quote {
32288 i += 1;
32289 }
32290 if i < bytes.len() {
32291 i += 1; }
32293 } else {
32294 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
32295 i += 1;
32296 }
32297 }
32298 parts.push(&s[start..i]);
32299 }
32300 parts
32301}
32302
32303pub(crate) fn shell_quote(s: &str) -> String {
32305 let unquoted =
32307 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
32308 &s[1..s.len() - 1]
32309 } else {
32310 s
32311 };
32312
32313 if unquoted
32314 .chars()
32315 .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
32316 {
32317 format!("\"{}\"", unquoted)
32318 } else {
32319 format!(
32320 "\"{}\"",
32321 unquoted.replace('\\', "\\\\").replace('"', "\\\"")
32322 )
32323 }
32324}
32325
32326fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
32327 sift::SearchCoverageSnapshot {
32328 mode: sift::SearchCoverageMode::Sealed,
32329 total_sector_count: 0,
32330 mounted_sector_count: 0,
32331 reused_sector_count: 0,
32332 dirty_sector_count: 0,
32333 completed_dirty_sector_count: 0,
32334 rebuilding_sector_count: 0,
32335 resumed_sector_count: 0,
32336 active_rebuild: None,
32337 }
32338}
32339
32340fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
32341 let total_sector_count = responses
32342 .iter()
32343 .map(|response| response.coverage.total_sector_count)
32344 .sum();
32345 let mounted_sector_count = responses
32346 .iter()
32347 .map(|response| response.coverage.mounted_sector_count)
32348 .sum();
32349 let reused_sector_count = responses
32350 .iter()
32351 .map(|response| response.coverage.reused_sector_count)
32352 .sum();
32353 let dirty_sector_count = responses
32354 .iter()
32355 .map(|response| response.coverage.dirty_sector_count)
32356 .sum();
32357 let completed_dirty_sector_count = responses
32358 .iter()
32359 .map(|response| response.coverage.completed_dirty_sector_count)
32360 .sum();
32361 let rebuilding_sector_count = responses
32362 .iter()
32363 .map(|response| response.coverage.rebuilding_sector_count)
32364 .sum();
32365 let resumed_sector_count = responses
32366 .iter()
32367 .map(|response| response.coverage.resumed_sector_count)
32368 .sum();
32369
32370 let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
32371 sift::SearchCoverageMode::Sealed
32372 } else if completed_dirty_sector_count > 0
32373 || rebuilding_sector_count > 0
32374 || resumed_sector_count > 0
32375 {
32376 sift::SearchCoverageMode::Converging
32377 } else {
32378 sift::SearchCoverageMode::Frontier
32379 };
32380
32381 sift::SearchCoverageSnapshot {
32382 mode,
32383 total_sector_count,
32384 mounted_sector_count,
32385 reused_sector_count,
32386 dirty_sector_count,
32387 completed_dirty_sector_count,
32388 rebuilding_sector_count,
32389 resumed_sector_count,
32390 active_rebuild: responses
32391 .iter()
32392 .find_map(|response| response.coverage.active_rebuild.clone()),
32393 }
32394}
32395
32396fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
32397 sift::SearchResponse {
32398 strategy: strategy.to_string(),
32399 root: root.display().to_string(),
32400 indexed_artifacts: 0,
32401 skipped_artifacts: 0,
32402 coverage: empty_search_coverage(),
32403 hits: Vec::new(),
32404 }
32405}
32406
32407fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
32408 for hit in &mut response.hits {
32409 let path = Path::new(&hit.path);
32410 if path.is_relative() {
32411 hit.path = search_root.join(path).display().to_string();
32412 }
32413 }
32414}
32415
32416fn merge_search_responses(
32417 root: &Path,
32418 strategy: &str,
32419 limit: usize,
32420 responses: Vec<sift::SearchResponse>,
32421) -> sift::SearchResponse {
32422 let indexed_artifacts = responses
32423 .iter()
32424 .map(|response| response.indexed_artifacts)
32425 .sum();
32426 let skipped_artifacts = responses
32427 .iter()
32428 .map(|response| response.skipped_artifacts)
32429 .sum();
32430 let coverage = if responses.is_empty() {
32431 empty_search_coverage()
32432 } else {
32433 aggregate_search_coverage(&responses)
32434 };
32435 let mut hits: Vec<sift::SearchHit> = responses
32436 .into_iter()
32437 .flat_map(|response| response.hits)
32438 .collect();
32439 hits.sort_by(|left, right| {
32440 right
32441 .score
32442 .partial_cmp(&left.score)
32443 .unwrap_or(Ordering::Equal)
32444 .then_with(|| left.path.cmp(&right.path))
32445 .then_with(|| left.location.cmp(&right.location))
32446 });
32447 hits.truncate(limit);
32448 for (rank, hit) in hits.iter_mut().enumerate() {
32449 hit.rank = rank + 1;
32450 }
32451
32452 sift::SearchResponse {
32453 strategy: strategy.to_string(),
32454 root: root.display().to_string(),
32455 indexed_artifacts,
32456 skipped_artifacts,
32457 coverage,
32458 hits,
32459 }
32460}
32461
32462pub(crate) fn federated_sift_search(
32463 root: &Path,
32464 cache_dir: &Path,
32465 query: &str,
32466 limit: usize,
32467 timeout_secs: u64,
32468 strategy: &str,
32469) -> Result<sift::SearchResponse> {
32470 let targets = resolve_search_index_targets(root, root, None, true)?;
32471 if targets.is_empty() {
32472 if config::Config::submodule_dirs(root)?.is_empty() {
32473 return run_search_with_timeout(
32474 root,
32475 cache_dir,
32476 query,
32477 limit,
32478 timeout_secs,
32479 strategy,
32480 &[],
32481 );
32482 }
32483 return Ok(empty_search_response(root, strategy));
32484 }
32485
32486 let mut responses = Vec::with_capacity(targets.len());
32487 for target in &targets {
32488 let mut response = run_search_with_timeout(
32489 &target.source_root,
32490 cache_dir,
32491 query,
32492 limit,
32493 timeout_secs,
32494 strategy,
32495 std::slice::from_ref(target),
32496 )?;
32497 absolutize_search_hit_paths(&mut response, &target.source_root);
32498 response.root = root.display().to_string();
32499 responses.push(response);
32500 }
32501
32502 Ok(merge_search_responses(root, strategy, limit, responses))
32503}
32504
32505pub(crate) fn federated_symbol_search(
32513 root: &std::path::Path,
32514 query: &str,
32515 limit: usize,
32516 tagpath_opts: &TagpathSearchOpts,
32517) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
32518 let cfg = config::Config::load(root)?;
32519 let submodules = config::Config::submodule_dirs(root)?;
32520 let mut all_hits: Vec<index::SymbolHit> = Vec::new();
32521 let mut combined = TagpathAnnotationDiagnostic::default();
32522 for scope in &submodules {
32523 if !cfg.federation_for_scope(scope) {
32524 continue;
32525 }
32526 let db_path = cfg.db_path_for(root, &scope.id);
32527 if !db_path.exists() {
32528 continue;
32529 }
32530 let db = index::IndexDb::open_read_only(&db_path)?;
32531 let mut hits = db.symbol_search(query, limit)?;
32532 let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
32533 combined.loaded |= diag.loaded;
32534 if diag.stale && !combined.stale {
32535 combined.stale = true;
32536 combined.reason = diag.reason;
32537 }
32538 all_hits.append(&mut hits);
32539 }
32540 all_hits.sort_by(|a, b| {
32541 b.score
32542 .partial_cmp(&a.score)
32543 .unwrap_or(std::cmp::Ordering::Equal)
32544 });
32545 all_hits.truncate(limit);
32546 Ok((all_hits, combined))
32547}
32548
32549#[derive(Debug, Deserialize)]
32550#[serde(tag = "type", rename_all = "lowercase")]
32551enum RipgrepJsonEvent {
32552 Match {
32553 data: RipgrepMatchData,
32554 },
32555 #[serde(other)]
32556 Other,
32557}
32558
32559#[derive(Debug, Deserialize)]
32560struct RipgrepMatchData {
32561 path: RipgrepTextField,
32562 lines: RipgrepTextField,
32563 line_number: Option<usize>,
32564}
32565
32566#[derive(Debug, Deserialize)]
32567struct RipgrepTextField {
32568 text: Option<String>,
32569}
32570
32571pub(crate) fn federated_exact_search(
32572 root: &Path,
32573 query: &str,
32574 limit: usize,
32575 timeout_secs: u64,
32576) -> Result<sift::SearchResponse> {
32577 let cfg = config::Config::load(root)?;
32578 let mut responses = Vec::new();
32579 for scope in config::Config::submodule_dirs(root)? {
32580 if !cfg.federation_for_scope(&scope) {
32581 continue;
32582 }
32583 let mut response =
32584 run_exact_search_with_timeout(&scope.source_root, query, limit, timeout_secs)?;
32585 absolutize_search_hit_paths(&mut response, &scope.source_root);
32586 response.root = root.display().to_string();
32587 responses.push(response);
32588 }
32589
32590 Ok(merge_search_responses(root, "exact", limit, responses))
32591}
32592
32593pub(crate) fn run_sift_search(
32594 search_path: &Path,
32595 cache_dir: &Path,
32596 query: &str,
32597 limit: usize,
32598 strategy: &str,
32599) -> Result<sift::SearchResponse> {
32600 let engine = Sift::builder().with_cache_dir(cache_dir).build();
32601 let options = SearchOptions::default()
32602 .with_limit(limit)
32603 .with_strategy(strategy.to_string());
32604 let input = SearchInput::new(search_path, query).with_options(options);
32605 engine.search(input).context("sift search failed")
32606}
32607
32608fn exact_search_timeout_message(timeout_secs: u64) -> String {
32609 format!(
32610 "tsift search timed out after {}s (strategy: exact). \
32611 Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
32612 timeout_secs
32613 )
32614}
32615
32616fn exact_search_command(search_path: &Path, query: &str) -> Command {
32617 let mut command = Command::new("rg");
32618 command
32619 .arg("--json")
32620 .arg("--fixed-strings")
32621 .arg("--line-number")
32622 .arg("--hidden")
32623 .arg("--")
32624 .arg(query)
32625 .arg(search_path);
32626 command
32627}
32628
32629fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
32630 let observed_unix_secs = SystemTime::now()
32631 .duration_since(UNIX_EPOCH)
32632 .unwrap_or_default()
32633 .as_secs() as i64;
32634 let modified_unix_secs = fs::metadata(path)
32635 .ok()
32636 .and_then(|metadata| metadata.modified().ok())
32637 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
32638 .map(|duration| duration.as_secs() as i64);
32639 sift::ArtifactFreshness {
32640 observed_unix_secs,
32641 modified_unix_secs,
32642 }
32643}
32644
32645fn parse_exact_search_output(
32646 search_path: &Path,
32647 limit: usize,
32648 raw: &str,
32649) -> Result<sift::SearchResponse> {
32650 if limit == 0 {
32651 return Ok(sift::SearchResponse {
32652 strategy: "exact".to_string(),
32653 root: search_path.display().to_string(),
32654 indexed_artifacts: 0,
32655 skipped_artifacts: 0,
32656 coverage: empty_search_coverage(),
32657 hits: Vec::new(),
32658 });
32659 }
32660
32661 let mut hits = Vec::new();
32662 for line in raw.lines() {
32663 let event: RipgrepJsonEvent =
32664 serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
32665 let RipgrepJsonEvent::Match { data } = event else {
32666 continue;
32667 };
32668 let Some(path_text) = data.path.text else {
32669 continue;
32670 };
32671 let Some(lines_text) = data.lines.text else {
32672 continue;
32673 };
32674 let path = PathBuf::from(path_text);
32675 let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
32676 let rank = hits.len() + 1;
32677 hits.push(sift::SearchHit {
32678 artifact_id: format!(
32679 "exact:{}:{}:{}",
32680 path.display(),
32681 data.line_number.unwrap_or(0),
32682 rank
32683 ),
32684 artifact_kind: sift::ContextArtifactKind::File,
32685 path: path.display().to_string(),
32686 rank,
32687 score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
32688 confidence: sift::ScoreConfidence::High,
32689 location: data.line_number.map(|line| format!("line {}", line)),
32690 snippet: snippet.clone(),
32691 provenance: sift::ArtifactProvenance {
32692 adapter: sift::AcquisitionAdapterKind::FileSystem,
32693 source: "ripgrep -F".to_string(),
32694 synthetic: false,
32695 },
32696 freshness: exact_search_file_timestamp(&path),
32697 budget: sift::ArtifactBudget::from_text(&snippet, 1),
32698 });
32699 if hits.len() >= limit {
32700 break;
32701 }
32702 }
32703
32704 Ok(sift::SearchResponse {
32705 strategy: "exact".to_string(),
32706 root: search_path.display().to_string(),
32707 indexed_artifacts: hits.len(),
32708 skipped_artifacts: 0,
32709 coverage: empty_search_coverage(),
32710 hits,
32711 })
32712}
32713
32714fn exact_search_response_from_process(
32715 search_path: &Path,
32716 limit: usize,
32717 status: std::process::ExitStatus,
32718 stdout: &[u8],
32719 stderr: &[u8],
32720) -> Result<sift::SearchResponse> {
32721 if !status.success() && status.code() != Some(1) {
32722 let message = String::from_utf8_lossy(stderr);
32723 let trimmed = message.trim();
32724 if trimmed.is_empty() {
32725 bail!("ripgrep exact search exited with status {}", status);
32726 }
32727 bail!("{}", trimmed);
32728 }
32729
32730 let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
32731 parse_exact_search_output(search_path, limit, &raw)
32732}
32733
32734fn run_exact_search(search_path: &Path, query: &str, limit: usize) -> Result<sift::SearchResponse> {
32735 let output = exact_search_command(search_path, query)
32736 .output()
32737 .context("running exact search with ripgrep")?;
32738 exact_search_response_from_process(
32739 search_path,
32740 limit,
32741 output.status,
32742 &output.stdout,
32743 &output.stderr,
32744 )
32745}
32746
32747pub(crate) fn run_exact_search_with_timeout(
32748 search_path: &Path,
32749 query: &str,
32750 limit: usize,
32751 timeout_secs: u64,
32752) -> Result<sift::SearchResponse> {
32753 if timeout_secs == 0 {
32754 return run_exact_search(search_path, query, limit);
32755 }
32756
32757 let mut child = exact_search_command(search_path, query)
32758 .stdin(Stdio::null())
32759 .stdout(Stdio::piped())
32760 .stderr(Stdio::piped())
32761 .spawn()
32762 .context("spawning timed exact search worker")?;
32763
32764 let timeout = Duration::from_secs(timeout_secs);
32765 let status = wait_for_child_exit(&mut child, timeout)
32766 .context("waiting for timed exact search worker")?;
32767 if status.is_none() {
32768 let _ = child.kill();
32769 let _ = child.wait();
32770 bail!("{}", exact_search_timeout_message(timeout_secs));
32771 }
32772
32773 let status = status.unwrap();
32774 let stdout = read_child_stdout(&mut child)?;
32775 let stderr = read_child_stderr(&mut child)?;
32776 exact_search_response_from_process(
32777 search_path,
32778 limit,
32779 status,
32780 stdout.as_bytes(),
32781 stderr.as_bytes(),
32782 )
32783}
32784
32785pub(crate) fn run_search_with_timeout(
32786 search_path: &Path,
32787 cache_dir: &Path,
32788 query: &str,
32789 limit: usize,
32790 timeout_secs: u64,
32791 strategy: &str,
32792 search_targets: &[SearchIndexTarget],
32793) -> Result<sift::SearchResponse> {
32794 if timeout_secs == 0 {
32795 return run_sift_search(search_path, cache_dir, query, limit, strategy);
32796 }
32797
32798 let output_path = next_search_worker_output_path();
32799 let mut child = Command::new(
32800 std::env::current_exe().context("resolving tsift executable for timed search")?,
32801 )
32802 .arg("__search-worker")
32803 .arg("--path")
32804 .arg(search_path)
32805 .arg("--cache-dir")
32806 .arg(cache_dir)
32807 .arg("--query")
32808 .arg(query)
32809 .arg("--limit")
32810 .arg(limit.to_string())
32811 .arg("--strategy")
32812 .arg(strategy)
32813 .arg("--output")
32814 .arg(&output_path)
32815 .stdin(Stdio::null())
32816 .stdout(Stdio::null())
32817 .stderr(Stdio::piped())
32818 .spawn()
32819 .context("spawning timed sift search worker")?;
32820
32821 let timeout = Duration::from_secs(timeout_secs);
32822 let status =
32823 wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
32824 if status.is_none() {
32825 let _ = child.kill();
32826 let _ = child.wait();
32827 let _ = fs::remove_file(&output_path);
32828 bail!(
32829 "{}",
32830 search_timeout_message(timeout_secs, strategy, search_targets)?
32831 );
32832 }
32833
32834 let status = status.unwrap();
32835 let stderr = read_child_stderr(&mut child)?;
32836 if !status.success() {
32837 let _ = fs::remove_file(&output_path);
32838 let message = stderr.trim();
32839 if message.is_empty() {
32840 bail!("sift search worker exited with status {}", status);
32841 }
32842 bail!("{}", message);
32843 }
32844
32845 let raw = fs::read_to_string(&output_path)
32846 .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
32847 let _ = fs::remove_file(&output_path);
32848 serde_json::from_str(&raw).context("parsing search worker output")
32849}
32850
32851fn next_search_worker_output_path() -> PathBuf {
32852 let stamp = SystemTime::now()
32853 .duration_since(UNIX_EPOCH)
32854 .unwrap_or_default()
32855 .as_nanos();
32856 std::env::temp_dir().join(format!(
32857 "tsift-search-{}-{}.json",
32858 std::process::id(),
32859 stamp
32860 ))
32861}
32862
32863fn wait_for_child_exit(
32864 child: &mut std::process::Child,
32865 timeout: Duration,
32866) -> Result<Option<std::process::ExitStatus>> {
32867 let started = Instant::now();
32868 loop {
32869 if let Some(status) = child.try_wait()? {
32870 return Ok(Some(status));
32871 }
32872 if started.elapsed() >= timeout {
32873 return Ok(None);
32874 }
32875 let remaining = timeout.saturating_sub(started.elapsed());
32876 std::thread::sleep(remaining.min(Duration::from_millis(10)));
32877 }
32878}
32879
32880fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
32881 let mut stderr = String::new();
32882 if let Some(mut pipe) = child.stderr.take() {
32883 pipe.read_to_string(&mut stderr)
32884 .context("reading search worker stderr")?;
32885 }
32886 Ok(stderr)
32887}
32888
32889fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
32890 let mut stdout = String::new();
32891 if let Some(mut pipe) = child.stdout.take() {
32892 pipe.read_to_string(&mut stdout)
32893 .context("reading search worker stdout")?;
32894 }
32895 Ok(stdout)
32896}
32897
32898pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
32899 if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
32900 fs::write(&path, std::process::id().to_string())
32901 .with_context(|| format!("writing search worker pid file: {path}"))?;
32902 }
32903 if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
32904 let delay_ms = ms
32905 .parse::<u64>()
32906 .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
32907 std::thread::sleep(Duration::from_millis(delay_ms));
32908 }
32909 Ok(())
32910}
32911
32912#[cfg(test)]
32913thread_local! {
32914 static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
32915}
32916
32917#[cfg(test)]
32918enum SearchPostPrecheckLockMode {
32919 RollbackJournal,
32920 Wal,
32921}
32922
32923#[cfg(test)]
32924struct SearchPostPrecheckLockHook {
32925 db_path: PathBuf,
32926 mode: SearchPostPrecheckLockMode,
32927}
32928
32929#[cfg(test)]
32930struct SearchPostPrecheckLockGuard;
32931
32932#[cfg(test)]
32933impl Drop for SearchPostPrecheckLockGuard {
32934 fn drop(&mut self) {
32935 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32936 hook.borrow_mut().take();
32937 });
32938 }
32939}
32940
32941#[cfg(test)]
32942fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32943 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
32944}
32945
32946#[cfg(test)]
32947fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32948 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
32949}
32950
32951#[cfg(test)]
32952fn install_search_post_precheck_lock_hook(
32953 db_path: PathBuf,
32954 mode: SearchPostPrecheckLockMode,
32955) -> SearchPostPrecheckLockGuard {
32956 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32957 assert!(
32958 hook.borrow().is_none(),
32959 "search post-precheck lock hook already installed"
32960 );
32961 *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
32962 });
32963 SearchPostPrecheckLockGuard
32964}
32965
32966#[cfg(test)]
32967pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
32968 let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
32969 return Ok(());
32970 };
32971 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
32972 std::thread::spawn(move || {
32973 let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
32974 match hook.mode {
32975 SearchPostPrecheckLockMode::RollbackJournal => {
32976 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
32977 .expect("acquiring rollback-journal hook lock");
32978 fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
32979 .expect("writing rollback journal marker");
32980 }
32981 SearchPostPrecheckLockMode::Wal => {
32982 conn.execute_batch(
32983 "PRAGMA journal_mode=WAL;
32984 PRAGMA wal_autocheckpoint=0;
32985 CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
32986 INSERT INTO search_wal_lock_probe DEFAULT VALUES;
32987 PRAGMA locking_mode=EXCLUSIVE;
32988 BEGIN EXCLUSIVE;",
32989 )
32990 .expect("acquiring WAL hook lock");
32991 assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
32992 }
32993 }
32994 ready_tx.send(()).expect("signaling search lock hook");
32995 std::thread::sleep(Duration::from_millis(200));
32996 drop(conn);
32997 let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
32998 });
32999 ready_rx
33000 .recv_timeout(Duration::from_secs(1))
33001 .context("waiting for search post-precheck lock hook")?;
33002 Ok(())
33003}
33004
33005#[cfg(not(test))]
33006pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
33007 Ok(())
33008}