1mod cli;
2mod commands;
3mod community_detection;
4mod conflict_matrix;
5mod context_pack;
6mod output;
7mod rewrite;
8mod search_budget;
9mod semantic_edit;
10mod session_review_budget;
11mod token_savings;
12mod workflow;
13
14pub use rewrite::rewrite_command;
15pub(crate) use rewrite::{apply_rewrite_output_format, execute_rewritten_command, no_rewrite_message};
16pub(crate) use community_detection::{
17 CommunityDetectionReport, annotate_community_members_with_context,
18 community_tagpath_cache_part, community_tagpath_cache_part_for_loaded,
19 detect_communities_cached, file_communities_from_callers,
20 graph_effectiveness_blocked, graph_effectiveness_ready,
21 resolve_tagpath_handle_for_callee_edge, update_community_annotation_diagnostics,
22};
23#[allow(unused_imports)]
24pub(crate) use conflict_matrix::{
25 ConflictMatrixCandidate, ConflictMatrixGraphPreparedInputs,
26 ConflictMatrixPreparedInputs, ConflictMatrixReport,
27 ConflictMatrixSemanticRef, ConflictMatrixSharedPreparationSummary,
28 ConflictMatrixWorkerFeedback, ConflictMatrixWorkerPromptPacket,
29 build_conflict_matrix_report, build_conflict_matrix_report_from_prepared_graph,
30 cmd_conflict_matrix, collect_conflict_matrix_evidence_packets,
31 conflict_matrix_candidate_from_evidence, conflict_matrix_graph_index,
32 conflict_matrix_semantic_ref, conflict_matrix_shared_preparation_summary,
33 conflict_matrix_source_handle, conflict_matrix_target_scoped_graph_snapshot,
34 conflict_matrix_worker_feedback,
35 conflict_risk_label, extract_conflict_target_refs, hash_bytes_hex,
36 is_planner_config_path, normalize_conflict_target,
37 prepare_conflict_matrix_graph_orchestration,
38 prepare_conflict_matrix_inputs, resolve_conflict_matrix_targets,
39 sorted_intersection, sorted_set,
40};
41#[allow(unused_imports)]
42pub(crate) use context_pack::{
43 ContextPackReport, ContextPackSummaryRefPreview,
44 build_context_pack_diff_preview, build_context_pack_log_preview,
45 build_context_pack_report, build_context_pack_report_with_profile,
46 build_context_pack_test_preview, context_pack_status_reminders,
47 exploration_ref_id, materialize_context_pack_exploration_packet,
48 print_context_pack_human,
49};
50pub(crate) use search_budget::{
51 SearchBudgetReportInput,
52 apply_search_facet_filters, build_search_budget_follow_up, build_search_budget_report,
53 print_search_budget_human,
54};
55#[allow(unused_imports)]
56pub(crate) use session_review_budget::{
57 SessionReviewBudgetFailurePreview, SessionReviewBudgetReport,
58 SessionReviewNextContextBudgetReport, SessionReviewNextTokenAction,
59 build_session_review_budget_report, build_session_review_next_context_budget_report,
60 print_session_review_budget_human, print_session_review_next_context_budget_human,
61};
62#[cfg(test)]
63use search_budget::{SearchBudgetReport, search_facet_filters_summary};
64pub(crate) use semantic_edit::{
65 AstSpanPreview, EditBatch, EditResult, EditStatus,
66 MarkdownEmbeddedSymbol, MarkdownSpanMetadata, MetricDigestOptions,
67 SemanticEditVerifyOptions, apply_edit_plan_atomically, build_edit_plan, cmd_edit_intents,
68};
69
70#[cfg(test)]
71use rewrite::{apply_output_cap, effective_rewrite_run_command, resolve_digest_context_path, rewrite_output_cap, OutputCap};
72#[cfg(test)]
73use std::io::{BufRead as _, BufReader};
74#[cfg(test)]
75use token_savings::{
76 TokenSavingsFamily, TokenSavingsFixture, TokenSavingsFixtureCase,
77 TokenSavingsMarkdownProjectionInput, TokenSavingsMarkdownProjectionInputs,
78 TokenSavingsRawSymbol, TokenSavingsSourceReadInput, TokenSavingsSourceReadInputs,
79 build_token_savings_report,
80};
81
82use anyhow::{Context, Result, bail};
83use clap::Parser;
84use cli::{Cli, Commands, DispatchTraceFormat, GraphDbQuery, SemanticRelatedKind, SourceReadStyle};
85#[cfg(test)]
86use cli::{GraphDbBackend, TraverseFormat};
87use commands::digests::{
88 cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
89 cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
90};
91#[cfg(test)]
92use commands::graph::cmd_explain;
93use commands::graph::{
94 cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
95};
96#[cfg(test)]
97use commands::index_search::cmd_search;
98use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
99use commands::infra::{
100 StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
101 cmd_rewrite, cmd_route, cmd_sql, cmd_status,
102};
103use commands::memory::cmd_memory;
104use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
105use commands::summarize::cmd_summarize;
106use flate2::{Compression, read::GzDecoder, write::GzEncoder};
107use output::tagpath::{
108 TagpathAnnotationDiagnostic, TagpathSearchOpts,
109 annotate_communities_with_tagpath, annotate_hits_with_tagpath,
110 annotate_path_nodes_with_tagpath, annotate_stored_edges_with_tagpath,
111 annotate_stored_symbols_with_tagpath,
112};
113#[cfg(test)]
114use output::ResponseBudgetPreset;
115use output::{
116 OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric,
117 ToolEnvelopeSummary, TranscriptArtifactRef,
118};
119use rusqlite::{Connection, OptionalExtension};
120use serde::{Deserialize, Serialize};
121use sift::{SearchInput, SearchOptions, Sift};
122#[cfg(test)]
123use std::cell::RefCell;
124use std::cmp::Ordering;
125use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
126use std::env;
127use std::fs;
128use std::io::{Read as _, Write as _};
129use std::path::{Path, PathBuf};
130use std::process::{Command, Stdio};
131use std::sync::{Mutex, OnceLock};
132use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
133use substrate::{
134 ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
135 GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
136 GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
137 SqliteGraphStore, SqliteProjectionRefresh,
138 TerseGraphNode as SubstrateTerseGraphNode, TerseGraphEdge as SubstrateTerseGraphEdge,
139};
140use tsift_core::{NeighborhoodScoring, RankedNeighborhoodOptions};
141use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
142#[cfg(test)]
143use tsift_agent_doc::session_cost;
144#[cfg(test)]
145use tsift_agent_doc::session_review;
146use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
147use tsift_graph as graph;
148use tsift_index::{config, index, init, multiplicity, walk};
149use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
150#[cfg(test)]
151use tsift_memory::MemoryEvent;
152use tsift_cache::cycle_packet_cache;
153use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
154use tsift_resolution as resolution;
155use tsift_search::{impact, sift};
156use tsift_sqlite as substrate;
157use tsift_status::status;
158use tsift_summarize::summarize;
159#[cfg(feature = "backend-surrealdb")]
160use tsift_surrealdb::SurrealdbGraphStore;
161use tsift_tokensave::TokensaveDb;
162
163#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
164pub(crate) enum GraphDbExperimentalBackend {
165 DuckdbDuckpgq,
166 Falkordb,
167 Ladybug,
168 Kuzu,
169 Surrealdb,
170}
171
172#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
173pub(crate) struct SearchFacetFilters {
174 #[serde(skip_serializing_if = "Vec::is_empty", default)]
175 pub(crate) languages: Vec<String>,
176 #[serde(skip_serializing_if = "Vec::is_empty", default)]
177 pub(crate) kinds: Vec<String>,
178 #[serde(skip_serializing_if = "Vec::is_empty", default)]
179 pub(crate) node_kinds: Vec<String>,
180 #[serde(skip_serializing_if = "Vec::is_empty", default)]
181 pub(crate) sections: Vec<String>,
182 #[serde(skip_serializing_if = "Vec::is_empty", default)]
183 pub(crate) parents: Vec<String>,
184 #[serde(skip_serializing_if = "Vec::is_empty", default)]
185 pub(crate) children: Vec<String>,
186 #[serde(skip_serializing_if = "Vec::is_empty", default)]
187 pub(crate) fence_languages: Vec<String>,
188 #[serde(skip_serializing_if = "Vec::is_empty", default)]
189 pub(crate) list_depths: Vec<usize>,
190 #[serde(skip_serializing_if = "Vec::is_empty", default)]
191 pub(crate) heading_levels: Vec<usize>,
192}
193
194impl SearchFacetFilters {
195 pub(crate) fn is_empty(&self) -> bool {
196 self.languages.is_empty()
197 && self.kinds.is_empty()
198 && self.node_kinds.is_empty()
199 && self.sections.is_empty()
200 && self.parents.is_empty()
201 && self.children.is_empty()
202 && self.fence_languages.is_empty()
203 && self.list_depths.is_empty()
204 && self.heading_levels.is_empty()
205 }
206
207 fn needs_ast_context(&self) -> bool {
208 !self.sections.is_empty()
209 || !self.parents.is_empty()
210 || !self.children.is_empty()
211 || !self.fence_languages.is_empty()
212 || !self.list_depths.is_empty()
213 || !self.heading_levels.is_empty()
214 }
215}
216
217#[derive(Serialize)]
218struct GraphDbBackendPromotionGate {
219 status: String,
220 native_adapter_required: bool,
221 required_checks: Vec<String>,
222}
223
224impl GraphDbExperimentalBackend {
225 fn name(self) -> &'static str {
226 match self {
227 Self::DuckdbDuckpgq => "duckdb-duckpgq",
228 Self::Falkordb => "falkordb",
229 Self::Ladybug => "ladybug",
230 Self::Kuzu => "kuzu",
231 Self::Surrealdb => "surrealdb",
232 }
233 }
234
235 fn adapter_label(self) -> &'static str {
236 match self {
237 Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
238 Self::Falkordb => "FalkorDB read-only prototype",
239 Self::Ladybug => "Ladybug read-only prototype",
240 Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
241 Self::Surrealdb => "SurrealDB read-only prototype",
242 }
243 }
244
245 fn projection_load(self) -> &'static str {
246 match self {
247 Self::Falkordb => {
248 "provider-neutral rows loaded into a FalkorDB-shaped read snapshot for parity and timing only; production FalkorDB storage remains behind backend-eval until a real adapter passes the full-projection gate"
249 }
250 Self::Kuzu => {
251 "provider-neutral rows loaded into a Kuzu-compatible in-process read snapshot for parity and performance gates; production Vela-Engineering/kuzu storage remains behind a future optional adapter"
252 }
253 Self::Surrealdb => {
254 "provider-neutral rows loaded into a SurrealDB-compatible read snapshot for parity and timing only; production SurrealDB storage remains behind backend-eval until a real optional adapter passes the full-projection gate"
255 }
256 _ => {
257 "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
258 }
259 }
260 }
261
262 fn lock_behavior(self) -> &'static str {
263 match self {
264 Self::Falkordb => {
265 "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
266 }
267 Self::Kuzu => {
268 "read-only Kuzu prototype snapshot; no SQLite writer lock is taken during benchmarks, and production Vela-Engineering/kuzu promotion must prove concurrent writer semantics before replacing SQLite"
269 }
270 Self::Surrealdb => {
271 "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
272 }
273 _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
274 }
275 }
276
277 fn install_portability(self) -> &'static str {
278 match self {
279 Self::Falkordb => {
280 "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
281 }
282 Self::Kuzu => {
283 "prototype is dependency-free in this binary; production Vela-Engineering/kuzu integration must stay optional so cargo build/install works without a native Kuzu toolchain"
284 }
285 Self::Surrealdb => {
286 "prototype is dependency-free in this binary; production SurrealDB integration must stay optional so cargo build/install works without pulling SurrealDB into the default build"
287 }
288 _ => {
289 "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
290 }
291 }
292 }
293
294 fn prototype_hold_reason(self) -> Option<&'static str> {
295 match self {
296 Self::DuckdbDuckpgq => Some(
297 "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
298 ),
299 Self::Falkordb => Some(
300 "FalkorDB remains behind backend-eval until a production adapter beats SQLite on full_projection conflict-matrix, evidence, dispatch-trace, path tiers, install portability, and lock behavior",
301 ),
302 Self::Ladybug => Some(
303 "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
304 ),
305 Self::Kuzu => Some(
306 "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
307 ),
308 Self::Surrealdb => Some(
309 "SurrealDB remains behind backend-eval until a feature-gated optional adapter proves provider-neutral projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
310 ),
311 }
312 }
313
314 fn promotion_gate(self) -> GraphDbBackendPromotionGate {
315 match self {
316 Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
317 status: "hold_native_adapter_required".to_string(),
318 native_adapter_required: true,
319 required_checks: vec![
320 "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
321 .to_string(),
322 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
323 .to_string(),
324 "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
325 "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
326 .to_string(),
327 ],
328 },
329 Self::Falkordb => GraphDbBackendPromotionGate {
330 status: "hold_native_adapter_required".to_string(),
331 native_adapter_required: true,
332 required_checks: vec![
333 "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
334 .to_string(),
335 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
336 .to_string(),
337 "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
338 .to_string(),
339 "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
340 .to_string(),
341 ],
342 },
343 Self::Ladybug => GraphDbBackendPromotionGate {
344 status: "hold_native_adapter_required".to_string(),
345 native_adapter_required: true,
346 required_checks: vec![
347 "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
348 .to_string(),
349 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
350 .to_string(),
351 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
352 .to_string(),
353 "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
354 .to_string(),
355 ],
356 },
357 Self::Kuzu => GraphDbBackendPromotionGate {
358 status: "hold_native_adapter_required".to_string(),
359 native_adapter_required: true,
360 required_checks: vec![
361 "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
362 .to_string(),
363 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
364 .to_string(),
365 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
366 .to_string(),
367 "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
368 .to_string(),
369 ],
370 },
371 Self::Surrealdb => GraphDbBackendPromotionGate {
372 status: "hold_native_adapter_required".to_string(),
373 native_adapter_required: true,
374 required_checks: vec![
375 "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
376 .to_string(),
377 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
378 .to_string(),
379 "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
380 .to_string(),
381 "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
382 .to_string(),
383 ],
384 },
385 }
386 }
387
388 fn parse(raw: &str) -> Result<Self> {
389 match raw {
390 "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
391 "falkordb" | "falkor" => Ok(Self::Falkordb),
392 "ladybug" => Ok(Self::Ladybug),
393 "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
394 "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
395 _ => {
396 bail!(
397 "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
398 )
399 }
400 }
401 }
402}
403
404
405pub fn run() -> Result<()> {
406 let cli = Cli::parse();
407 let compact = cli.compact;
408 let pretty = cli.pretty;
409 let terse = cli.terse || cli.ultra_terse;
410 let ultra_terse = cli.ultra_terse;
411 let absolute = cli.absolute;
412 let tabular = cli.tabular;
413 let schema = cli.schema;
414 let envelope = cli.envelope;
415 match cli.command {
416 Some(Commands::Search {
417 query,
418 path,
419 limit,
420 strategy,
421 exact,
422 scope,
423 federated,
424 lang,
425 kind,
426 node_kind,
427 section,
428 parent,
429 child,
430 fence_language,
431 list_depth,
432 heading_level,
433 json,
434 autoindex,
435 no_autoindex,
436 timeout,
437 max_items,
438 max_bytes,
439 budget,
440 no_tagpath,
441 tagpath_strict,
442 }) => cmd_search_with_budget(
443 query,
444 path,
445 limit,
446 if exact {
447 Some("exact".to_string())
448 } else {
449 strategy
450 },
451 scope,
452 federated,
453 json || terse || schema || envelope,
454 autoindex || !no_autoindex,
455 timeout,
456 compact,
457 pretty,
458 terse,
459 ultra_terse,
460 absolute,
461 tabular,
462 schema,
463 envelope,
464 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
465 TagpathSearchOpts {
466 no_tagpath,
467 strict: tagpath_strict,
468 },
469 SearchFacetFilters {
470 languages: lang,
471 kinds: kind,
472 node_kinds: node_kind,
473 sections: section,
474 parents: parent,
475 children: child,
476 fence_languages: fence_language,
477 list_depths: list_depth,
478 heading_levels: heading_level,
479 },
480 ),
481 Some(Commands::SearchWorker {
482 path,
483 cache_dir,
484 query,
485 limit,
486 strategy,
487 output,
488 }) => cmd_search_worker(&path, &cache_dir, &query, limit, &strategy, &output),
489 Some(Commands::DigestRunner {
490 kind,
491 path,
492 runner,
493 shell_command,
494 json,
495 }) => cmd_digest_runner(
496 &kind,
497 &path,
498 runner.as_deref(),
499 &shell_command,
500 OutputFormat {
501 json_output: json || terse || schema || envelope,
502 compact,
503 pretty,
504 terse,
505 ultra_terse,
506 schema,
507 envelope,
508 },
509 ),
510 Some(Commands::Edit { dry_run, file }) => {
511 cmd_edit(dry_run, file, compact, pretty, terse, schema)
512 }
513 Some(Commands::EditIntents {
514 path,
515 scope,
516 file,
517 json,
518 apply,
519 verify,
520 verify_command,
521 max_items,
522 max_bytes,
523 budget,
524 }) => cmd_edit_intents(
525 &path,
526 scope.as_deref(),
527 file,
528 apply,
529 SemanticEditVerifyOptions {
530 enabled: verify,
531 command: verify_command.as_deref(),
532 },
533 OutputFormat {
534 json_output: json || terse || schema || envelope,
535 compact,
536 pretty,
537 terse,
538 ultra_terse,
539 schema,
540 envelope,
541 },
542 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
543 ),
544 Some(Commands::Index {
545 path,
546 rebuild,
547 check,
548 exit_code,
549 prune,
550 quiet,
551 workspace,
552 submodule,
553 json,
554 }) => cmd_index(
555 &path,
556 rebuild,
557 check,
558 exit_code,
559 prune,
560 quiet,
561 workspace,
562 submodule.as_deref(),
563 json || terse || schema || envelope,
564 compact,
565 pretty,
566 terse,
567 absolute,
568 schema,
569 ),
570 Some(Commands::Rewrite { command, run }) => cmd_rewrite(
571 &command,
572 run,
573 OutputFormat {
574 json_output: terse || schema || envelope,
575 compact,
576 pretty,
577 terse,
578 ultra_terse,
579 schema,
580 envelope,
581 },
582 ),
583 Some(Commands::Route { task, id }) => cmd_route(&task, id),
584 Some(Commands::Memory { command }) => {
585 let json = command.json_output();
586 cmd_memory(
587 command,
588 OutputFormat {
589 json_output: json || terse || schema || envelope,
590 compact,
591 pretty,
592 terse,
593 ultra_terse,
594 schema,
595 envelope,
596 },
597 )
598 }
599 Some(Commands::Finding { command }) => match command {
600 cli::FindingCommand::Add {
601 path,
602 kind,
603 title,
604 body,
605 about,
606 confidence,
607 status,
608 relates,
609 scope,
610 json,
611 } => commands::finding::cmd_finding_add(
612 &path,
613 &kind,
614 &title,
615 &body,
616 &about,
617 confidence,
618 &status,
619 relates.as_deref(),
620 scope.as_deref(),
621 json || terse || schema || envelope,
622 pretty,
623 ),
624 cli::FindingCommand::List {
625 path,
626 about,
627 kind,
628 status,
629 include_stale,
630 scope,
631 json,
632 } => commands::finding::cmd_finding_list(
633 &path,
634 about.as_deref(),
635 kind.as_deref(),
636 status.as_deref(),
637 include_stale,
638 scope.as_deref(),
639 json || terse || schema || envelope,
640 pretty,
641 ),
642 cli::FindingCommand::Harvest { path, scope, json } => {
643 commands::finding::cmd_finding_harvest(
644 &path,
645 scope.as_deref(),
646 json || terse || schema || envelope,
647 pretty,
648 )
649 }
650 cli::FindingCommand::Promote { id, path, json } => {
651 commands::finding::cmd_finding_promote(
652 &path,
653 &id,
654 json || terse || schema || envelope,
655 pretty,
656 )
657 }
658 },
659 Some(Commands::Graph {
660 symbol,
661 path,
662 callers,
663 callees,
664 scope,
665 limit,
666 json,
667 no_tagpath,
668 tagpath_strict,
669 }) => cmd_graph(
670 &symbol,
671 &path,
672 callers,
673 callees,
674 scope.as_deref(),
675 limit,
676 json || terse || schema || envelope,
677 compact,
678 pretty,
679 terse,
680 absolute,
681 tabular,
682 schema,
683 TagpathSearchOpts {
684 no_tagpath,
685 strict: tagpath_strict,
686 },
687 ),
688 Some(Commands::Sql {
689 db,
690 query,
691 table,
692 json,
693 }) => cmd_sql(
694 &db,
695 query,
696 table,
697 json || terse || schema || envelope,
698 compact,
699 pretty,
700 terse,
701 schema,
702 ),
703 Some(Commands::Communities {
704 path,
705 scope,
706 min_size,
707 limit,
708 json,
709 no_tagpath,
710 tagpath_strict,
711 }) => cmd_communities(
712 &path,
713 scope.as_deref(),
714 min_size,
715 limit,
716 json || terse || schema || envelope,
717 compact,
718 pretty,
719 terse,
720 tabular,
721 schema,
722 TagpathSearchOpts {
723 no_tagpath,
724 strict: tagpath_strict,
725 },
726 ),
727 Some(Commands::Analyze {
728 path,
729 scope,
730 entry_points,
731 limit,
732 json,
733 }) => cmd_analyze(
734 &path,
735 scope.as_deref(),
736 &entry_points,
737 limit,
738 OutputFormat {
739 json_output: json || terse || schema || envelope,
740 compact,
741 pretty,
742 terse,
743 ultra_terse,
744 schema,
745 envelope,
746 },
747 ),
748 Some(Commands::Path {
749 from,
750 to,
751 path,
752 scope,
753 json,
754 no_tagpath,
755 tagpath_strict,
756 }) => cmd_path(
757 &from,
758 &to,
759 &path,
760 scope.as_deref(),
761 json || terse || schema || envelope,
762 compact,
763 pretty,
764 terse,
765 schema,
766 TagpathSearchOpts {
767 no_tagpath,
768 strict: tagpath_strict,
769 },
770 ),
771 Some(Commands::Explain {
772 symbol,
773 path,
774 scope,
775 limit,
776 json,
777 max_items,
778 max_bytes,
779 budget,
780 no_tagpath,
781 tagpath_strict,
782 }) => cmd_explain_with_budget(
783 &symbol,
784 &path,
785 scope.as_deref(),
786 limit,
787 json || terse || schema || envelope,
788 compact,
789 pretty,
790 terse,
791 ultra_terse,
792 absolute,
793 tabular,
794 schema,
795 envelope,
796 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
797 TagpathSearchOpts {
798 no_tagpath,
799 strict: tagpath_strict,
800 },
801 ),
802 Some(Commands::Traverse {
803 node,
804 to,
805 path,
806 scope,
807 depth,
808 limit,
809 format,
810 convex_snapshot,
811 }) => cmd_traverse(
812 node.as_deref(),
813 to.as_deref(),
814 &path,
815 scope.as_deref(),
816 depth,
817 limit,
818 format,
819 pretty,
820 terse,
821 schema,
822 convex_snapshot.as_deref(),
823 ),
824 Some(Commands::ConvexSync {
825 path,
826 scope,
827 snapshot,
828 chunk_size,
829 remote_snapshot,
830 apply,
831 endpoint,
832 auth_token_env,
833 json,
834 }) => cmd_convex_sync(
835 ConvexSyncOptions {
836 path: &path,
837 scope: scope.as_deref(),
838 snapshot: snapshot.as_deref(),
839 chunk_size,
840 remote_snapshot,
841 apply,
842 endpoint: endpoint.as_deref(),
843 auth_token_env: &auth_token_env,
844 },
845 OutputFormat {
846 json_output: json || terse || schema || envelope,
847 compact,
848 pretty,
849 terse,
850 ultra_terse,
851 schema,
852 envelope,
853 },
854 ),
855 Some(Commands::GraphDb {
856 path,
857 scope,
858 backend,
859 convex_snapshot,
860 json,
861 query,
862 }) => cmd_graph_db(
863 &path,
864 scope.as_deref(),
865 backend,
866 convex_snapshot.as_deref(),
867 query,
868 OutputFormat {
869 json_output: json || terse || schema || envelope,
870 compact,
871 pretty,
872 terse,
873 ultra_terse,
874 schema,
875 envelope,
876 },
877 ),
878 Some(Commands::SourceRead {
879 file,
880 path,
881 style,
882 start,
883 lines,
884 end,
885 scope,
886 json,
887 max_items,
888 max_bytes,
889 budget,
890 }) => cmd_source_read(
891 &file,
892 &path,
893 style,
894 start,
895 lines,
896 end,
897 scope.as_deref(),
898 OutputFormat {
899 json_output: json || terse || schema || envelope,
900 compact,
901 pretty,
902 terse,
903 ultra_terse,
904 schema,
905 envelope,
906 },
907 absolute,
908 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
909 ),
910 Some(Commands::MarkdownAst {
911 file,
912 path,
913 node,
914 json,
915 max_items,
916 max_bytes,
917 budget,
918 }) => cmd_markdown_ast(
919 &file,
920 &path,
921 node.as_deref(),
922 OutputFormat {
923 json_output: json || terse || schema || envelope,
924 compact,
925 pretty,
926 terse,
927 ultra_terse,
928 schema,
929 envelope,
930 },
931 absolute,
932 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
933 ),
934 Some(Commands::SymbolRead {
935 symbol,
936 file,
937 path,
938 scope,
939 json,
940 max_items,
941 max_bytes,
942 budget,
943 }) => cmd_symbol_read(
944 &symbol,
945 file.as_deref(),
946 &path,
947 scope.as_deref(),
948 OutputFormat {
949 json_output: json || terse || schema || envelope,
950 compact,
951 pretty,
952 terse,
953 ultra_terse,
954 schema,
955 envelope,
956 },
957 absolute,
958 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
959 ),
960 Some(Commands::Audit {
961 skills_dir,
962 manifest,
963 usage,
964 cleanup,
965 report,
966 json,
967 }) => cmd_audit(
968 &skills_dir,
969 manifest,
970 usage,
971 cleanup,
972 report,
973 json || terse || schema || envelope,
974 compact,
975 pretty,
976 terse,
977 schema,
978 ),
979 Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
980 &path,
981 scope.as_deref(),
982 json || terse || schema || envelope,
983 pretty,
984 terse,
985 schema,
986 ),
987 Some(Commands::Init {
988 path,
989 codex,
990 opencode,
991 workspace,
992 }) => cmd_init(&path, codex, opencode, workspace),
993 Some(Commands::Lint {
994 file,
995 index,
996 entities_from,
997 json,
998 }) => cmd_lint(
999 &file,
1000 index,
1001 entities_from,
1002 json || terse || schema || envelope,
1003 compact,
1004 pretty,
1005 terse,
1006 schema,
1007 ),
1008 Some(Commands::Summarize {
1009 symbol,
1010 file,
1011 extract,
1012 diff,
1013 stats,
1014 path,
1015 json,
1016 }) => cmd_summarize(
1017 symbol,
1018 file,
1019 extract,
1020 diff,
1021 stats,
1022 &path,
1023 json || terse || schema || envelope,
1024 compact,
1025 pretty,
1026 terse,
1027 schema,
1028 ),
1029 Some(Commands::Semantic {
1030 query,
1031 path,
1032 scope,
1033 limit,
1034 kind,
1035 json,
1036 }) => cmd_semantic_related(
1037 &query,
1038 &path,
1039 scope.as_deref(),
1040 limit,
1041 kind,
1042 json || terse || schema || envelope,
1043 compact,
1044 pretty,
1045 terse,
1046 schema,
1047 ),
1048 Some(Commands::DiffDigest {
1049 path,
1050 cached,
1051 revision,
1052 max_parsed_files,
1053 json,
1054 }) => cmd_diff_digest(
1055 &path,
1056 cached,
1057 revision.as_deref(),
1058 max_parsed_files,
1059 OutputFormat {
1060 json_output: json || terse || schema || envelope,
1061 compact,
1062 pretty,
1063 terse,
1064 ultra_terse,
1065 schema,
1066 envelope,
1067 },
1068 ),
1069 Some(Commands::Impact {
1070 path,
1071 cached,
1072 revision,
1073 scope,
1074 limit,
1075 json,
1076 }) => cmd_impact(
1077 &path,
1078 cached,
1079 revision.as_deref(),
1080 scope.as_deref(),
1081 limit,
1082 OutputFormat {
1083 json_output: json || terse || schema || envelope,
1084 compact,
1085 pretty,
1086 terse,
1087 ultra_terse,
1088 schema,
1089 envelope,
1090 },
1091 ),
1092 Some(Commands::TestDigest {
1093 path,
1094 input,
1095 runner,
1096 json,
1097 }) => cmd_test_digest(
1098 &path,
1099 input.as_deref(),
1100 runner.as_deref(),
1101 OutputFormat {
1102 json_output: json || terse || schema || envelope,
1103 compact,
1104 pretty,
1105 terse,
1106 ultra_terse,
1107 schema,
1108 envelope,
1109 },
1110 ),
1111 Some(Commands::LogDigest { path, input, json }) => cmd_log_digest(
1112 &path,
1113 input.as_deref(),
1114 OutputFormat {
1115 json_output: json || terse || schema || envelope,
1116 compact,
1117 pretty,
1118 terse,
1119 ultra_terse,
1120 schema,
1121 envelope,
1122 },
1123 ),
1124 Some(Commands::ContextPack {
1125 path,
1126 test_input,
1127 runner,
1128 log_input,
1129 json,
1130 max_items,
1131 max_bytes,
1132 budget,
1133 convex_snapshot,
1134 }) => cmd_context_pack(
1135 &path,
1136 test_input.as_deref(),
1137 runner.as_deref(),
1138 log_input.as_deref(),
1139 OutputFormat {
1140 json_output: json || terse || schema || envelope,
1141 compact,
1142 pretty,
1143 terse,
1144 ultra_terse,
1145 schema,
1146 envelope,
1147 },
1148 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1149 convex_snapshot.as_deref(),
1150 ),
1151 Some(Commands::ConflictMatrix {
1152 targets,
1153 path,
1154 scope,
1155 depth,
1156 limit,
1157 impact_limit,
1158 json,
1159 }) => cmd_conflict_matrix(
1160 &path,
1161 scope.as_deref(),
1162 &targets,
1163 depth,
1164 limit,
1165 impact_limit,
1166 OutputFormat {
1167 json_output: json || terse || schema || envelope,
1168 compact,
1169 pretty,
1170 terse,
1171 ultra_terse,
1172 schema,
1173 envelope,
1174 },
1175 ),
1176 Some(Commands::DispatchTrace {
1177 targets,
1178 path,
1179 scope,
1180 depth,
1181 limit,
1182 impact_limit,
1183 format,
1184 json,
1185 }) => cmd_dispatch_trace(
1186 DispatchTraceOptions {
1187 path: &path,
1188 scope: scope.as_deref(),
1189 raw_targets: &targets,
1190 depth,
1191 limit,
1192 impact_limit,
1193 trace_format: if json {
1194 DispatchTraceFormat::Json
1195 } else {
1196 format
1197 },
1198 },
1199 OutputFormat {
1200 json_output: json || terse || schema || envelope,
1201 compact,
1202 pretty,
1203 terse,
1204 ultra_terse,
1205 schema,
1206 envelope,
1207 },
1208 ),
1209 Some(Commands::DependencyDag {
1210 targets,
1211 path,
1212 scope,
1213 depth,
1214 limit,
1215 json,
1216 }) => cmd_dependency_dag(
1217 &path,
1218 scope.as_deref(),
1219 &targets,
1220 depth,
1221 limit,
1222 OutputFormat {
1223 json_output: json || terse || schema || envelope,
1224 compact,
1225 pretty,
1226 terse,
1227 ultra_terse,
1228 schema,
1229 envelope,
1230 },
1231 ),
1232 Some(Commands::TokenSavings {
1233 fixture,
1234 fail_under,
1235 json,
1236 }) => token_savings::cmd_token_savings(
1237 &fixture,
1238 fail_under,
1239 OutputFormat {
1240 json_output: json || terse || schema || envelope,
1241 compact,
1242 pretty,
1243 terse,
1244 ultra_terse,
1245 schema,
1246 envelope,
1247 },
1248 ),
1249 Some(Commands::MetricDigest {
1250 input,
1251 baseline,
1252 metrics,
1253 lower_is_better,
1254 higher_is_better,
1255 history,
1256 top,
1257 json,
1258 }) => cmd_metric_digest(
1259 MetricDigestOptions {
1260 input_path: input.as_deref(),
1261 baseline_path: baseline.as_deref(),
1262 metrics: &metrics,
1263 lower_is_better: &lower_is_better,
1264 higher_is_better: &higher_is_better,
1265 history,
1266 top,
1267 },
1268 OutputFormat {
1269 json_output: json || terse || schema || envelope,
1270 compact,
1271 pretty,
1272 terse,
1273 ultra_terse,
1274 schema,
1275 envelope,
1276 },
1277 ),
1278 Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1279 &fixture,
1280 OutputFormat {
1281 json_output: json || terse || schema || envelope,
1282 compact,
1283 pretty,
1284 terse,
1285 ultra_terse,
1286 schema,
1287 envelope,
1288 },
1289 ),
1290 Some(Commands::TokenGate { command }) => {
1291 cmd_token_gate(command, OutputFormat {
1292 json_output: true,
1293 compact,
1294 pretty,
1295 terse,
1296 ultra_terse,
1297 schema,
1298 envelope,
1299 })?;
1300 Ok(())
1301 },
1302 Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1303 &topic,
1304 OutputFormat {
1305 json_output: json || terse || schema || envelope,
1306 compact,
1307 pretty,
1308 terse,
1309 ultra_terse,
1310 schema,
1311 envelope,
1312 },
1313 ),
1314 Some(Commands::SessionDigest {
1315 path,
1316 input,
1317 source,
1318 json,
1319 }) => cmd_session_digest(
1320 &path,
1321 input.as_deref(),
1322 source.as_deref(),
1323 OutputFormat {
1324 json_output: json || terse || schema || envelope,
1325 compact,
1326 pretty,
1327 terse,
1328 ultra_terse,
1329 schema,
1330 envelope,
1331 },
1332 ),
1333 Some(Commands::SessionCost {
1334 input,
1335 source,
1336 json,
1337 }) => cmd_session_cost(
1338 input.as_deref(),
1339 source.as_deref(),
1340 OutputFormat {
1341 json_output: json || terse || schema || envelope,
1342 compact,
1343 pretty,
1344 terse,
1345 ultra_terse,
1346 schema,
1347 envelope,
1348 },
1349 ),
1350 Some(Commands::SessionReview {
1351 path,
1352 next_context,
1353 json,
1354 max_items,
1355 max_bytes,
1356 budget,
1357 }) => cmd_session_review_with_budget(
1358 &path,
1359 next_context,
1360 OutputFormat {
1361 json_output: json || terse || schema || envelope,
1362 compact,
1363 pretty,
1364 terse,
1365 ultra_terse,
1366 schema,
1367 envelope,
1368 },
1369 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1370 ),
1371 Some(Commands::Status {
1372 path,
1373 fix,
1374 no_fix,
1375 json,
1376 }) => cmd_status(
1377 &path,
1378 StatusCommandOptions {
1379 fix,
1380 no_fix,
1381 json_output: json || terse || schema || envelope,
1382 compact,
1383 pretty,
1384 terse,
1385 schema,
1386 },
1387 ),
1388 Some(Commands::Locks { path, scope, json }) => cmd_locks(
1389 &path,
1390 scope.as_deref(),
1391 json || terse || schema || envelope,
1392 compact,
1393 pretty,
1394 terse,
1395 schema,
1396 ),
1397 None => {
1398 println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1399 println!("Run `tsift --help` for usage.");
1400 Ok(())
1401 }
1402 }
1403}
1404
1405pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1408 let lower = task.to_lowercase();
1409 for signal in &[
1411 "architect",
1412 "architecture",
1413 "design",
1414 "plan",
1415 "strateg",
1416 "analy",
1417 "review",
1418 "evaluate",
1419 "assess",
1420 ] {
1421 if lower.contains(signal) {
1422 return ("opus", "claude-opus-4-6");
1423 }
1424 }
1425 for signal in &[
1427 "edit",
1428 "write",
1429 "fix",
1430 "change",
1431 "update",
1432 "create",
1433 "add ",
1434 "remove",
1435 "delete",
1436 "modify",
1437 "refactor",
1438 "implement",
1439 "build",
1440 ] {
1441 if lower.contains(signal) {
1442 return ("sonnet", "claude-sonnet-4-6");
1443 }
1444 }
1445 ("haiku", "claude-haiku-4-5-20251001")
1447}
1448
1449#[cfg(test)]
1450fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
1451 to_json_schema(val, pretty, terse, false, false)
1452}
1453
1454pub(crate) fn inject_tagpath_stale_into_json(
1461 value: &mut serde_json::Value,
1462 stale: bool,
1463 reason: Option<&str>,
1464) {
1465 if !stale {
1466 return;
1467 }
1468 if let Some(obj) = value.as_object_mut() {
1469 obj.insert(
1470 "tagpath_index_stale".to_string(),
1471 serde_json::Value::Bool(true),
1472 );
1473 if let Some(reason) = reason {
1474 obj.insert(
1475 "tagpath_stale_reason".to_string(),
1476 serde_json::Value::String(reason.to_string()),
1477 );
1478 }
1479 }
1480}
1481
1482pub(crate) fn to_json_schema<T: serde::Serialize>(
1483 val: &T,
1484 pretty: bool,
1485 terse: bool,
1486 ultra_terse: bool,
1487 schema: bool,
1488) -> anyhow::Result<String> {
1489 if terse || schema {
1490 let value = serde_json::to_value(val)?;
1491 let mut transformed = if terse { terse_transform(value) } else { value };
1492 if ultra_terse {
1493 transformed = ultra_terse_transform(transformed);
1494 transformed = edge_index_transform(transformed);
1495 }
1496 if schema {
1497 transformed = schema_transform(transformed);
1498 }
1499 if terse {
1500 let terse_schema = terse_schema_for(&transformed);
1501 let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
1502 if pretty {
1503 Ok(serde_json::to_string_pretty(&wrapped)?)
1504 } else {
1505 Ok(serde_json::to_string(&wrapped)?)
1506 }
1507 } else if pretty {
1508 Ok(serde_json::to_string_pretty(&transformed)?)
1509 } else {
1510 Ok(serde_json::to_string(&transformed)?)
1511 }
1512 } else if pretty {
1513 Ok(serde_json::to_string_pretty(val)?)
1514 } else {
1515 Ok(serde_json::to_string(val)?)
1516 }
1517}
1518
1519pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
1520 ToolEnvelopeMetric {
1521 label: label.to_string(),
1522 value: value.to_string(),
1523 }
1524}
1525
1526pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
1527 let mut seen = HashSet::new();
1528 let mut deduped = Vec::new();
1529 for value in values {
1530 if seen.insert(value.clone()) {
1531 deduped.push(value);
1532 }
1533 }
1534 deduped
1535}
1536
1537pub(crate) fn print_json_or_envelope<T: Serialize>(
1538 report: &T,
1539 format: &OutputFormat,
1540 tool: &str,
1541 view: &str,
1542 summary: ToolEnvelopeSummary,
1543 truncated: bool,
1544 follow_up: Vec<String>,
1545) -> Result<()> {
1546 if format.envelope {
1547 let schema = format.schema || tool == "source-read";
1548 let envelope = ToolEnvelope {
1549 tool,
1550 view,
1551 summary,
1552 truncated,
1553 follow_up: dedupe_preserve_order(follow_up),
1554 report,
1555 };
1556 println!(
1557 "{}",
1558 to_json_schema(
1559 &envelope,
1560 format.pretty,
1561 format.terse,
1562 format.ultra_terse,
1563 schema
1564 )?
1565 );
1566 } else {
1567 println!(
1568 "{}",
1569 to_json_schema(
1570 report,
1571 format.pretty,
1572 format.terse,
1573 format.ultra_terse,
1574 format.schema
1575 )?
1576 );
1577 }
1578 Ok(())
1579}
1580
1581pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
1582 bytes.div_ceil(4)
1583}
1584
1585fn cmd_token_gate(
1586 command: cli::TokenGateCommand,
1587 format: OutputFormat,
1588) -> Result<()> {
1589 match command {
1590 cli::TokenGateCommand::Sample {
1591 surface,
1592 path,
1593 scope,
1594 target,
1595 depth,
1596 sample_index,
1597 json: _,
1598 } => cmd_token_gate_sample(&surface, &path, scope.as_deref(), target.as_deref(), depth, sample_index),
1599 cli::TokenGateCommand::Evaluate {
1600 history,
1601 allowed_regression_percent,
1602 json: _,
1603 } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
1604 }
1605}
1606
1607fn cmd_token_gate_sample(
1608 surface: &str,
1609 path: &Path,
1610 scope: Option<&str>,
1611 target: Option<&str>,
1612 depth: usize,
1613 sample_index: usize,
1614) -> Result<()> {
1615 if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
1616 bail!(
1617 "unknown surface `{}`; expected one of: {}",
1618 surface,
1619 token_gate::TOKEN_GATE_SURFACES.join(", ")
1620 );
1621 }
1622
1623 let path_str = path.to_string_lossy().to_string();
1624 let tsift_bin = std::env::current_exe()?;
1625
1626 let args: Vec<String> = match surface {
1627 "context_pack" => vec![
1628 "context-pack".to_string(),
1629 "--json".to_string(),
1630 path_str,
1631 ],
1632 "session_review_next_context" => vec![
1633 "session-review".to_string(),
1634 "--json".to_string(),
1635 "--next-context".to_string(),
1636 path_str,
1637 ],
1638 "graph_db_evidence" => {
1639 let tgt = target.unwrap_or("default").to_string();
1640 vec![
1641 "graph-db".to_string(),
1642 "--json".to_string(),
1643 "--path".to_string(),
1644 path_str,
1645 "evidence".to_string(),
1646 tgt,
1647 "--depth".to_string(),
1648 depth.to_string(),
1649 ]
1650 }
1651 "conflict_matrix" => {
1652 let tgt = target.unwrap_or("default").to_string();
1653 let mut a = vec![
1654 "conflict-matrix".to_string(),
1655 "--json".to_string(),
1656 "--path".to_string(),
1657 path_str,
1658 "--depth".to_string(),
1659 depth.to_string(),
1660 ];
1661 if let Some(s) = scope {
1662 a.push("--scope".to_string());
1663 a.push(s.to_string());
1664 }
1665 a.push(tgt);
1666 a
1667 }
1668 "dispatch_trace" => {
1669 let tgt = target.unwrap_or("default").to_string();
1670 vec![
1671 "dispatch-trace".to_string(),
1672 "--json".to_string(),
1673 "--path".to_string(),
1674 path_str,
1675 tgt,
1676 ]
1677 }
1678 _ => bail!("unhandled surface: {}", surface),
1679 };
1680
1681 let start = Instant::now();
1682 let child = Command::new(&tsift_bin)
1683 .args(&args)
1684 .stdout(Stdio::piped())
1685 .stderr(Stdio::piped())
1686 .env("TSIFT_QUIET", "1")
1687 .spawn();
1688 let output = match child {
1689 Ok(c) => c.wait_with_output()?,
1690 Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
1691 };
1692 let runtime_micros = start.elapsed().as_micros() as f64;
1693
1694 let stdout = String::from_utf8_lossy(&output.stdout);
1695 let envelope_bytes = stdout.trim().len() as f64;
1696 let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
1697
1698 let cache_hit_rate_percent = 0.0;
1699 let raw_read_avoidance = 0.0;
1700 let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
1701
1702 let timestamp = iso_timestamp_now();
1703 let id = format!(
1704 "{surface}-baseline-{}-sample-{sample_index}",
1705 ×tamp[..10]
1706 );
1707 let label = format!(
1708 "token-gate baseline {surface} sample {sample_index} for {}",
1709 path.display()
1710 );
1711
1712 let mut metrics = BTreeMap::new();
1713 metrics.insert("prompt_tokens".to_string(), prompt_tokens);
1714 metrics.insert("envelope_bytes".to_string(), envelope_bytes);
1715 metrics.insert("runtime_micros".to_string(), runtime_micros);
1716 metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
1717 metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
1718 metrics.insert("useful_hit_density".to_string(), useful_hit_density);
1719
1720 let sample = token_gate::TokenGateSample {
1721 label,
1722 id,
1723 timestamp: Some(timestamp),
1724 surface: surface.to_string(),
1725 metrics,
1726 };
1727
1728 println!("{}", serde_json::to_string_pretty(&sample)?);
1729 Ok(())
1730}
1731
1732fn cmd_token_gate_evaluate(
1733 history_path: Option<&Path>,
1734 allowed_regression_percent: f64,
1735 format: &OutputFormat,
1736) -> Result<()> {
1737 let history_path = history_path
1738 .map(PathBuf::from)
1739 .unwrap_or_else(|| {
1740 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1741 p.push("../../fixtures/token-gate-history.json");
1742 p
1743 });
1744
1745 let raw = std::fs::read_to_string(&history_path)
1746 .with_context(|| format!("failed to read token gate history: {}", history_path.display()))?;
1747 let samples = token_gate::parse_token_history(&raw)?;
1748 let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
1749
1750 if format.json_output {
1751 println!("{}", to_json_schema(&report, format.pretty, format.terse, false, format.schema)?);
1752 } else {
1753 println!("Token Gate Report");
1754 println!(" min_samples: {}", report.min_samples);
1755 println!(" allowed_regression: {:.1}%", report.allowed_regression_percent);
1756 println!(" decision: {:?}", report.decision);
1757 for eval in &report.surface_evaluations {
1758 println!(
1759 " {} ({} samples): {:?}",
1760 eval.display_name, eval.sample_count, eval.verdict
1761 );
1762 for me in &eval.metric_evaluations {
1763 println!(
1764 " {} ({:?}): {}",
1765 me.metric, me.direction, me.diagnostic
1766 );
1767 }
1768 }
1769 for d in &report.diagnostics {
1770 println!(" ! {}", d);
1771 }
1772 }
1773 Ok(())
1774}
1775
1776fn iso_timestamp_now() -> String {
1777 let dur = SystemTime::now()
1778 .duration_since(UNIX_EPOCH)
1779 .unwrap_or_default();
1780 let total_secs = dur.as_secs();
1781 let days_since_epoch = total_secs / 86400;
1782 let (year, month, day) = days_to_ymd(days_since_epoch);
1783 let time_of_day = total_secs % 86400;
1784 let hour = (time_of_day / 3600) as u8;
1785 let minute = ((time_of_day % 3600) / 60) as u8;
1786 let second = (time_of_day % 60) as u8;
1787 format!(
1788 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1789 year, month, day, hour, minute, second
1790 )
1791}
1792
1793fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
1794 let mut year = 1970u64;
1795 loop {
1796 let days_in_year = if is_leap(year) { 366 } else { 365 };
1797 if days < days_in_year {
1798 break;
1799 }
1800 days -= days_in_year;
1801 year += 1;
1802 }
1803 let leap = is_leap(year);
1804 let month_days: [u8; 12] = if leap {
1805 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1806 } else {
1807 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1808 };
1809 let mut month: u8 = 1;
1810 for &md in &month_days {
1811 if days < md as u64 {
1812 break;
1813 }
1814 days -= md as u64;
1815 month += 1;
1816 }
1817 let day = days as u8 + 1;
1818 (year, month, day)
1819}
1820
1821fn is_leap(year: u64) -> bool {
1822 year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
1823}
1824
1825fn persist_transcript_artifact(
1826 root: &Path,
1827 prefix: &str,
1828 suffix: &str,
1829 key: &str,
1830 body: &str,
1831 expand: String,
1832) -> Result<TranscriptArtifactRef> {
1833 let handle = stable_handle(prefix, key);
1834 let artifacts_dir = root.join(".tsift/artifacts");
1835 fs::create_dir_all(&artifacts_dir).with_context(|| {
1836 format!(
1837 "creating transcript artifacts dir: {}",
1838 artifacts_dir.display()
1839 )
1840 })?;
1841 let file_name = format!("{handle}.{suffix}");
1842 let artifact_path = artifacts_dir.join(file_name);
1843 fs::write(&artifact_path, body)
1844 .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
1845 let rel_path = relativize_pathbuf(&artifact_path, root);
1846 Ok(TranscriptArtifactRef {
1847 handle,
1848 path: rel_path.display().to_string(),
1849 bytes: body.len(),
1850 lines: body.lines().count(),
1851 expand,
1852 })
1853}
1854
1855fn terse_key(key: &str) -> &str {
1856 match key {
1857 "name" => "n",
1858 "kind" => "k",
1859 "file" => "f",
1860 "line" => "l",
1861 "path" => "p",
1862 "from" => "fr",
1863 "type" => "ty",
1864 "text" => "tx",
1865 "new" => "nw",
1866 "run" => "r",
1867 "use" => "u",
1868 "score" => "sc",
1869 "language" => "la",
1870 "status" => "st",
1871 "state" => "stt",
1872 "error" => "err",
1873 "errors" => "ers",
1874 "hops" => "hp",
1875 "tags" => "tg",
1876 "model" => "ml",
1877 "skill" => "sk",
1878 "count" => "ct",
1879 "total" => "tot",
1880 "column" => "col",
1881 "description" => "dsc",
1882 "end_line" => "el",
1883 "signature" => "sig",
1884 "parent_module" => "pm",
1885 "visibility" => "vis",
1886 "match_type" => "mt",
1887 "caller_file" => "cf",
1888 "caller_name" => "cn",
1889 "caller_line" => "cl",
1890 "callee_name" => "en",
1891 "call_site_line" => "csl",
1892 "members" => "m",
1893 "refs" => "refs",
1894 "role" => "rl",
1895 "peer" => "pr",
1896 "modularity" => "q",
1897 "modularity_contribution" => "mc",
1898 "iterations" => "it",
1899 "node_count" => "nc",
1900 "edge_count" => "ec",
1901 "community_count" => "cc",
1902 "communities" => "cms",
1903 "community" => "cm",
1904 "community_diagnostics" => "cd",
1905 "cache_hit" => "cah",
1906 "tagpath_state" => "tps",
1907 "tagpath_stale_reason" => "tsr",
1908 "annotated_community_count" => "acc",
1909 "annotated_member_count" => "amc",
1910 "ambiguous_member_count" => "ambc",
1911 "ambiguous_members" => "amb",
1912 "candidate_count" => "cand",
1913 "tagpath_candidate_count" => "tcand",
1914 "evidence" => "ev",
1915 "chosen_file" => "chf",
1916 "symbol" => "s",
1917 "symbols" => "sy",
1918 "definitions" => "df",
1919 "callers" => "crs",
1920 "callees" => "ces",
1921 "total_tracked" => "tt",
1922 "modified" => "md",
1923 "deleted" => "dl",
1924 "unchanged" => "uc",
1925 "changes" => "ch",
1926 "prune_stats" => "ps",
1927 "hits" => "h",
1928 "rank" => "rk",
1929 "snippet" => "sn",
1930 "confidence" => "co",
1931 "index" => "ix",
1932 "summaries" => "sms",
1933 "recommendations" => "rec",
1934 "total_files" => "tf",
1935 "stale_files" => "sf",
1936 "last_indexed_secs_ago" => "age",
1937 "cached_files" => "caf",
1938 "total_indexed_files" => "tif",
1939 "coverage_pct" => "cov",
1940 "symbol_name" => "syn",
1941 "file_path" => "fp",
1942 "content_hash" => "hsh",
1943 "summary" => "sum",
1944 "tool" => "tl",
1945 "view" => "vw",
1946 "truncated" => "tr",
1947 "follow_up" => "fu",
1948 "report" => "rp",
1949 "metrics" => "ms",
1950 "label" => "lb",
1951 "value" => "v",
1952 "command" => "cmd",
1953 "exit_code" => "xc",
1954 "success" => "ok",
1955 "artifact" => "art",
1956 "digest" => "dg",
1957 "bytes" => "bt",
1958 "lines" => "lns",
1959 "expand" => "xp",
1960 "entities" => "ent",
1961 "relationships" => "rel",
1962 "concept_labels" => "cls",
1963 "extracted_at" => "at",
1964 "tokens_input" => "ti",
1965 "tokens_output" => "tout",
1966 "total_summaries" => "ts",
1967 "stale_count" => "stc",
1968 "total_tokens_input" => "tti",
1969 "total_tokens_output" => "tto",
1970 "estimated_tokens_saved" => "ets",
1971 "files_processed" => "fps",
1972 "symbols_extracted" => "se",
1973 "skills_dir" => "sd",
1974 "healthy" => "ok",
1975 "broken" => "brk",
1976 "skills" => "sks",
1977 "manifest_diffs" => "mdf",
1978 "similar_pairs" => "sim",
1979 "usage" => "usg",
1980 "cleanup" => "cln",
1981 "has_skill_md" => "hsm",
1982 "is_symlink" => "isl",
1983 "issues" => "iss",
1984 "invocation_count" => "inv",
1985 "reasons" => "rsn",
1986 "token_estimate" => "te",
1987 "skill_a" => "sa",
1988 "skill_b" => "sb",
1989 "desc_a" => "da",
1990 "desc_b" => "db",
1991 "annotations" => "ann",
1992 "entity" => "ety",
1993 "suggestion" => "sug",
1994 "columns" => "cols",
1995 "row_count" => "rc",
1996 "notnull" => "nn",
1997 "default_value" => "dv",
1998 "replace_all" => "ra",
1999 other => other,
2000 }
2001}
2002
2003fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2004 match val {
2005 serde_json::Value::Object(map) => {
2006 let mut new_map = serde_json::Map::new();
2007 for (k, v) in map {
2008 new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2009 }
2010 serde_json::Value::Object(new_map)
2011 }
2012 serde_json::Value::Array(arr) => {
2013 serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2014 }
2015 other => other,
2016 }
2017}
2018
2019fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2020 match val {
2021 serde_json::Value::Object(mut map) => {
2022 let is_graph_node =
2023 map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2024 let is_graph_edge =
2025 map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2026 if is_graph_node || is_graph_edge {
2027 map.remove("properties");
2028 map.remove("provenance");
2029 map.remove("freshness");
2030 }
2031 if is_graph_edge
2032 && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2033 *s = abbreviate_edge_kind(s).to_string();
2034 }
2035 let is_coverage = map.contains_key("mode")
2036 && (map.contains_key("total_sector_count")
2037 || map.contains_key("dirty_sector_count"));
2038 if is_coverage {
2039 map.remove("active_rebuild");
2040 map.remove("completed_dirty_sector_count");
2041 map.remove("mounted_sector_count");
2042 map.remove("rebuilding_sector_count");
2043 map.remove("resumed_sector_count");
2044 map.remove("reused_sector_count");
2045 }
2046 if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2047 *s = truncate_for_ultra_terse(s, 80);
2048 }
2049 if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2050 *s = truncate_for_ultra_terse(s, 80);
2051 }
2052 let new_map: serde_json::Map<String, serde_json::Value> = map
2053 .into_iter()
2054 .map(|(k, v)| (k, ultra_terse_transform(v)))
2055 .collect();
2056 serde_json::Value::Object(new_map)
2057 }
2058 serde_json::Value::Array(arr) => {
2059 serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2060 }
2061 other => other,
2062 }
2063}
2064
2065fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2066 match val {
2067 serde_json::Value::Object(mut map) => {
2068 let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2069 nodes.as_array().map(|arr| {
2070 arr.iter()
2071 .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2072 .collect()
2073 })
2074 });
2075 if let Some(ref ids) = node_ids {
2076 let id_map: std::collections::HashMap<&str, usize> = ids
2077 .iter()
2078 .enumerate()
2079 .map(|(i, id)| (id.as_str(), i))
2080 .collect();
2081 if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2082 for edge in edges.iter_mut() {
2083 if let serde_json::Value::Object(edge_map) = edge {
2084 if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id") {
2085 if let Some(&idx) = id_map.get(fid.as_str()) {
2086 edge_map.insert("from".to_string(), serde_json::Value::Number(idx.into()));
2087 } else {
2088 edge_map.insert("from_id".to_string(), serde_json::Value::String(fid));
2089 }
2090 }
2091 if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2092 if let Some(&idx) = id_map.get(tid.as_str()) {
2093 edge_map.insert("to".to_string(), serde_json::Value::Number(idx.into()));
2094 } else {
2095 edge_map.insert("to_id".to_string(), serde_json::Value::String(tid));
2096 }
2097 }
2098 }
2099 }
2100 }
2101 }
2102 let new_map: serde_json::Map<String, serde_json::Value> = map
2103 .into_iter()
2104 .map(|(k, v)| (k, edge_index_transform(v)))
2105 .collect();
2106 serde_json::Value::Object(new_map)
2107 }
2108 serde_json::Value::Array(arr) => {
2109 serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2110 }
2111 other => other,
2112 }
2113}
2114
2115fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2116 if s.len() <= max_len {
2117 s.to_string()
2118 } else {
2119 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2120 format!("{truncated}...")
2121 }
2122}
2123
2124fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2125 let mut keys = HashSet::new();
2126 collect_terse_keys(val, &mut keys);
2127 let mut schema = serde_json::Map::new();
2128 for (long, short) in TERSE_PAIRS {
2129 if keys.contains(*short) {
2130 schema.insert(
2131 short.to_string(),
2132 serde_json::Value::String(long.to_string()),
2133 );
2134 }
2135 }
2136 serde_json::Value::Object(schema)
2137}
2138
2139fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2140 match val {
2141 serde_json::Value::Object(map) => {
2142 for (k, v) in map {
2143 keys.insert(k.clone());
2144 collect_terse_keys(v, keys);
2145 }
2146 }
2147 serde_json::Value::Array(arr) => {
2148 for v in arr {
2149 collect_terse_keys(v, keys);
2150 }
2151 }
2152 _ => {}
2153 }
2154}
2155
2156fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2157 match val {
2158 serde_json::Value::Array(arr) if arr.len() >= 2 => {
2159 if let Some(cols) = homogeneous_keys(&arr) {
2160 let rows: Vec<serde_json::Value> = arr
2161 .into_iter()
2162 .map(|item| {
2163 if let serde_json::Value::Object(map) = item {
2164 let vals: Vec<serde_json::Value> = cols
2165 .iter()
2166 .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2167 .collect();
2168 serde_json::Value::Array(vals)
2169 } else {
2170 item
2171 }
2172 })
2173 .collect();
2174 let col_vals: Vec<serde_json::Value> =
2175 cols.into_iter().map(serde_json::Value::String).collect();
2176 serde_json::json!({"_c": col_vals, "_r": rows})
2177 } else {
2178 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2179 }
2180 }
2181 serde_json::Value::Array(arr) => {
2182 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2183 }
2184 serde_json::Value::Object(map) => {
2185 let new_map: serde_json::Map<String, serde_json::Value> = map
2186 .into_iter()
2187 .map(|(k, v)| (k, schema_transform(v)))
2188 .collect();
2189 serde_json::Value::Object(new_map)
2190 }
2191 other => other,
2192 }
2193}
2194
2195fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2196 let first = arr.first()?.as_object()?;
2197 let keys: Vec<String> = first.keys().cloned().collect();
2198 for item in &arr[1..] {
2199 let obj = item.as_object()?;
2200 if obj.len() != keys.len() {
2201 return None;
2202 }
2203 for k in &keys {
2204 if !obj.contains_key(k) {
2205 return None;
2206 }
2207 }
2208 }
2209 Some(keys)
2210}
2211
2212const TERSE_PAIRS: &[(&str, &str)] = &[
2213 ("name", "n"),
2214 ("kind", "k"),
2215 ("file", "f"),
2216 ("line", "l"),
2217 ("path", "p"),
2218 ("from", "fr"),
2219 ("type", "ty"),
2220 ("text", "tx"),
2221 ("new", "nw"),
2222 ("run", "r"),
2223 ("use", "u"),
2224 ("score", "sc"),
2225 ("language", "la"),
2226 ("status", "st"),
2227 ("state", "stt"),
2228 ("error", "err"),
2229 ("errors", "ers"),
2230 ("hops", "hp"),
2231 ("tags", "tg"),
2232 ("model", "ml"),
2233 ("skill", "sk"),
2234 ("count", "ct"),
2235 ("total", "tot"),
2236 ("column", "col"),
2237 ("description", "dsc"),
2238 ("end_line", "el"),
2239 ("signature", "sig"),
2240 ("parent_module", "pm"),
2241 ("visibility", "vis"),
2242 ("match_type", "mt"),
2243 ("caller_file", "cf"),
2244 ("caller_name", "cn"),
2245 ("caller_line", "cl"),
2246 ("callee_name", "en"),
2247 ("call_site_line", "csl"),
2248 ("members", "m"),
2249 ("refs", "refs"),
2250 ("role", "rl"),
2251 ("peer", "pr"),
2252 ("modularity", "q"),
2253 ("modularity_contribution", "mc"),
2254 ("iterations", "it"),
2255 ("node_count", "nc"),
2256 ("edge_count", "ec"),
2257 ("community_count", "cc"),
2258 ("communities", "cms"),
2259 ("community", "cm"),
2260 ("community_diagnostics", "cd"),
2261 ("cache_hit", "cah"),
2262 ("tagpath_state", "tps"),
2263 ("tagpath_stale_reason", "tsr"),
2264 ("annotated_community_count", "acc"),
2265 ("annotated_member_count", "amc"),
2266 ("ambiguous_member_count", "ambc"),
2267 ("ambiguous_members", "amb"),
2268 ("candidate_count", "cand"),
2269 ("tagpath_candidate_count", "tcand"),
2270 ("evidence", "ev"),
2271 ("chosen_file", "chf"),
2272 ("symbol", "s"),
2273 ("symbols", "sy"),
2274 ("definitions", "df"),
2275 ("callers", "crs"),
2276 ("callees", "ces"),
2277 ("total_tracked", "tt"),
2278 ("modified", "md"),
2279 ("deleted", "dl"),
2280 ("unchanged", "uc"),
2281 ("changes", "ch"),
2282 ("prune_stats", "ps"),
2283 ("hits", "h"),
2284 ("rank", "rk"),
2285 ("snippet", "sn"),
2286 ("confidence", "co"),
2287 ("index", "ix"),
2288 ("summaries", "sms"),
2289 ("recommendations", "rec"),
2290 ("total_files", "tf"),
2291 ("stale_files", "sf"),
2292 ("last_indexed_secs_ago", "age"),
2293 ("cached_files", "caf"),
2294 ("total_indexed_files", "tif"),
2295 ("coverage_pct", "cov"),
2296 ("symbol_name", "syn"),
2297 ("file_path", "fp"),
2298 ("content_hash", "hsh"),
2299 ("summary", "sum"),
2300 ("tool", "tl"),
2301 ("view", "vw"),
2302 ("truncated", "tr"),
2303 ("follow_up", "fu"),
2304 ("report", "rp"),
2305 ("metrics", "ms"),
2306 ("label", "lb"),
2307 ("value", "v"),
2308 ("command", "cmd"),
2309 ("exit_code", "xc"),
2310 ("success", "ok"),
2311 ("artifact", "art"),
2312 ("digest", "dg"),
2313 ("bytes", "bt"),
2314 ("lines", "lns"),
2315 ("expand", "xp"),
2316 ("entities", "ent"),
2317 ("relationships", "rel"),
2318 ("concept_labels", "cls"),
2319 ("extracted_at", "at"),
2320 ("tokens_input", "ti"),
2321 ("tokens_output", "tout"),
2322 ("total_summaries", "ts"),
2323 ("stale_count", "stc"),
2324 ("total_tokens_input", "tti"),
2325 ("total_tokens_output", "tto"),
2326 ("estimated_tokens_saved", "ets"),
2327 ("files_processed", "fps"),
2328 ("symbols_extracted", "se"),
2329 ("skills_dir", "sd"),
2330 ("healthy", "ok"),
2331 ("broken", "brk"),
2332 ("skills", "sks"),
2333 ("manifest_diffs", "mdf"),
2334 ("similar_pairs", "sim"),
2335 ("usage", "usg"),
2336 ("cleanup", "cln"),
2337 ("has_skill_md", "hsm"),
2338 ("is_symlink", "isl"),
2339 ("issues", "iss"),
2340 ("invocation_count", "inv"),
2341 ("reasons", "rsn"),
2342 ("token_estimate", "te"),
2343 ("skill_a", "sa"),
2344 ("skill_b", "sb"),
2345 ("desc_a", "da"),
2346 ("desc_b", "db"),
2347 ("annotations", "ann"),
2348 ("entity", "ety"),
2349 ("suggestion", "sug"),
2350 ("columns", "cols"),
2351 ("row_count", "rc"),
2352 ("notnull", "nn"),
2353 ("default_value", "dv"),
2354 ("replace_all", "ra"),
2355];
2356
2357pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2358 let root_str = root.to_string_lossy();
2359 let prefix = format!("{}/", root_str.trim_end_matches('/'));
2360 path.strip_prefix(&prefix).unwrap_or(path).to_string()
2361}
2362
2363fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2364 let canonical = path
2365 .canonicalize()
2366 .with_context(|| format!("canonicalizing {}", path.display()))?;
2367 let start = if canonical.is_dir() {
2368 canonical.clone()
2369 } else {
2370 canonical
2371 .parent()
2372 .map(Path::to_path_buf)
2373 .unwrap_or_else(|| canonical.clone())
2374 };
2375
2376 for ancestor in start.ancestors() {
2377 if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2378 return Ok(ancestor.to_path_buf());
2379 }
2380 }
2381
2382 Ok(start)
2383}
2384
2385pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2386 path.strip_prefix(root)
2387 .map(|p| p.to_path_buf())
2388 .unwrap_or_else(|_| path.to_path_buf())
2389}
2390
2391pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
2392 for edge in edges {
2393 edge.caller_file = relativize(&edge.caller_file, root);
2394 }
2395}
2396
2397pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
2398 for sym in symbols {
2399 sym.file = relativize(&sym.file, root);
2400 }
2401}
2402
2403pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
2404 for hit in hits {
2405 hit.file = relativize(&hit.file, root);
2406 }
2407}
2408
2409
2410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2413pub enum EdgeSide {
2414 Caller,
2415 Callee,
2416}
2417
2418const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
2419
2420pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
2421 let root_str = root.to_string_lossy();
2422 let prefix = format!("{}/", root_str.trim_end_matches('/'));
2423 relativize_json_inner(val, &prefix);
2424}
2425
2426fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
2427 match val {
2428 serde_json::Value::Array(arr) => {
2429 for v in arr {
2430 relativize_json_inner(v, prefix);
2431 }
2432 }
2433 serde_json::Value::Object(map) => {
2434 for (k, v) in map.iter_mut() {
2435 if JSON_PATH_KEYS.contains(&k.as_str())
2436 && let serde_json::Value::String(s) = v
2437 && let Some(rest) = s.strip_prefix(prefix)
2438 {
2439 *s = rest.to_string();
2440 }
2441 relativize_json_inner(v, prefix);
2442 }
2443 }
2444 _ => {}
2445 }
2446}
2447
2448pub(crate) fn format_score(score: f64, compact: bool) -> String {
2449 if compact {
2450 format!("{score:.2}")
2451 } else {
2452 format!("{score:.4}")
2453 }
2454}
2455
2456pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
2457 let trimmed = input.trim();
2458 let count = trimmed.chars().count();
2459 if count <= max_chars {
2460 return trimmed.to_string();
2461 }
2462 let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
2463 format!("{prefix}...")
2464}
2465
2466pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
2467 snippet
2468 .lines()
2469 .find(|line| !line.trim().is_empty())
2470 .map(|line| truncate_for_compact(line, 100))
2471}
2472
2473pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
2474 let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
2475 if names.len() <= limit {
2476 return names.join(", ");
2477 }
2478 format!(
2479 "{} (+{} more)",
2480 names[..limit].join(", "),
2481 names.len() - limit
2482 )
2483}
2484
2485pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
2486 let mut hasher = blake3::Hasher::new();
2487 hasher.update(prefix.as_bytes());
2488 hasher.update(&[0]);
2489 hasher.update(key.as_bytes());
2490 let hex = hasher.finalize().to_hex();
2491 format!("{prefix}-{}", &hex[..10])
2492}
2493
2494#[derive(Clone, Debug, PartialEq, Eq)]
2495struct CanonicalTagFamily {
2496 canonical: String,
2497 tag_alias: String,
2498}
2499
2500fn canonical_family_from_tagpath_family(
2501 family: tagpath_family::TagFamily,
2502) -> Option<CanonicalTagFamily> {
2503 let tag_alias = if family.dimensions.is_empty() {
2504 family.tags.join("/")
2505 } else {
2506 family
2507 .dimensions
2508 .iter()
2509 .filter(|dimension| !dimension.tags.is_empty())
2510 .map(|dimension| dimension.tags.join("."))
2511 .collect::<Vec<_>>()
2512 .join("/")
2513 };
2514
2515 if tag_alias.is_empty() {
2516 None
2517 } else {
2518 Some(CanonicalTagFamily {
2519 canonical: family.canonical,
2520 tag_alias,
2521 })
2522 }
2523}
2524
2525fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
2526 let trimmed = name.trim();
2527 if trimmed.is_empty() {
2528 return None;
2529 }
2530
2531 canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
2532}
2533
2534fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
2535 let canonical = tags
2536 .split(',')
2537 .map(str::trim)
2538 .filter(|tag| !tag.is_empty())
2539 .collect::<Vec<_>>()
2540 .join("_");
2541 if canonical.is_empty() {
2542 None
2543 } else {
2544 canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
2545 }
2546}
2547
2548pub(crate) fn canonical_tag_family_from_symbol(name: &str, tags: Option<&str>) -> Option<CanonicalTagFamily> {
2549 tags.and_then(canonical_tag_family_from_tags)
2550 .or_else(|| canonical_tag_family_from_name(name))
2551}
2552
2553fn tag_alias_from_name(name: &str) -> Option<String> {
2554 canonical_tag_family_from_name(name).map(|family| family.tag_alias)
2555}
2556
2557fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
2558 canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
2559}
2560
2561pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
2562 let query = tag_alias
2563 .split(['/', '.'])
2564 .map(str::trim)
2565 .filter(|part| !part.is_empty())
2566 .collect::<Vec<_>>()
2567 .join(" ");
2568 if query.is_empty() { None } else { Some(query) }
2569}
2570
2571#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2572struct CompactOntologyRefPreview {
2573 handle: String,
2574 tag: String,
2575 path: String,
2576 #[serde(skip_serializing_if = "Option::is_none")]
2577 title: Option<String>,
2578 #[serde(skip_serializing_if = "Option::is_none")]
2579 domain: Option<String>,
2580}
2581
2582#[derive(Clone, Debug)]
2583struct TagOntologyPreviewContext {
2584 project_root: PathBuf,
2585 tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
2586}
2587
2588#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2589struct CompactSymbolRefPreview {
2590 handle: String,
2591 name: String,
2592 #[serde(skip_serializing_if = "Option::is_none")]
2593 tag_alias: Option<String>,
2594 #[serde(skip_serializing_if = "Vec::is_empty", default)]
2595 ontology_refs: Vec<CompactOntologyRefPreview>,
2596}
2597
2598fn build_compact_symbol_ref(
2599 prefix: &str,
2600 key: &str,
2601 name: &str,
2602 tags: Option<&str>,
2603 max_bytes: usize,
2604) -> CompactSymbolRefPreview {
2605 build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
2606}
2607
2608fn build_compact_symbol_ref_with_ontology(
2609 prefix: &str,
2610 key: &str,
2611 name: &str,
2612 tags: Option<&str>,
2613 max_bytes: usize,
2614 ontology: Option<&TagOntologyPreviewContext>,
2615) -> CompactSymbolRefPreview {
2616 let tag_alias = tag_alias_from_tags(name, tags);
2617 let ontology_refs = tag_alias
2618 .as_deref()
2619 .map(|alias| ontology_refs_for_alias(ontology, alias))
2620 .unwrap_or_default();
2621 CompactSymbolRefPreview {
2622 handle: stable_handle(prefix, key),
2623 name: truncate_for_budget(name, max_bytes),
2624 tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
2625 ontology_refs,
2626 }
2627}
2628
2629fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
2630 let report = tagpath_ontology::load_project(root).ok()?;
2631 if report.tags.is_empty() {
2632 return None;
2633 }
2634 Some(TagOntologyPreviewContext {
2635 project_root: report.project_path,
2636 tags: report
2637 .tags
2638 .into_iter()
2639 .map(|tag| (tag.tag.clone(), tag))
2640 .collect(),
2641 })
2642}
2643
2644fn ontology_refs_for_alias(
2645 ontology: Option<&TagOntologyPreviewContext>,
2646 alias: &str,
2647) -> Vec<CompactOntologyRefPreview> {
2648 let Some(ontology) = ontology else {
2649 return Vec::new();
2650 };
2651 let mut seen = BTreeSet::new();
2652 alias
2653 .split('/')
2654 .flat_map(|part| part.split('.'))
2655 .map(str::trim)
2656 .filter(|tag| !tag.is_empty())
2657 .filter_map(|tag| {
2658 let key = tag.to_ascii_lowercase();
2659 if !seen.insert(key.clone()) {
2660 return None;
2661 }
2662 let ontology_tag = ontology.tags.get(&key)?;
2663 let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
2664 Some(CompactOntologyRefPreview {
2665 handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
2666 tag: ontology_tag.tag.clone(),
2667 path,
2668 title: ontology_tag.title.clone(),
2669 domain: ontology_tag.domain.clone(),
2670 })
2671 })
2672 .collect()
2673}
2674
2675fn relativize_ontology_path(path: &Path, root: &Path) -> String {
2676 path.strip_prefix(root)
2677 .unwrap_or(path)
2678 .to_string_lossy()
2679 .replace('\\', "/")
2680}
2681
2682fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
2683 match tag_alias {
2684 Some(alias) => format!("{handle} {name} tag:{alias}"),
2685 None => format!("{handle} {name}"),
2686 }
2687}
2688
2689fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
2690 match summary.tag_alias.as_deref() {
2691 Some(alias) => format!(
2692 "{} {} tag:{} expand:{}",
2693 summary.handle, summary.symbol, alias, summary.expand
2694 ),
2695 None => format!(
2696 "{} {} expand:{}",
2697 summary.handle, summary.symbol, summary.expand
2698 ),
2699 }
2700}
2701
2702fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
2703 match symbol.tag_alias.as_deref() {
2704 Some(alias) => format!("{}@{}", symbol.handle, alias),
2705 None => format!("{}@{}", symbol.handle, symbol.name),
2706 }
2707}
2708
2709pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
2710 let trimmed = input.trim();
2711 if trimmed.len() <= max_bytes {
2712 return trimmed.to_string();
2713 }
2714 if max_bytes <= 3 {
2715 return ".".repeat(max_bytes);
2716 }
2717
2718 let mut end = 0usize;
2719 for (idx, ch) in trimmed.char_indices() {
2720 let next = idx + ch.len_utf8();
2721 if next > max_bytes.saturating_sub(3) {
2722 break;
2723 }
2724 end = next;
2725 }
2726
2727 if end == 0 {
2728 "...".to_string()
2729 } else {
2730 format!("{}...", &trimmed[..end])
2731 }
2732}
2733
2734struct TokenCappedPreview {
2735 preview: Vec<SourceLinePreview>,
2736 capped_end: usize,
2737 was_capped: bool,
2738}
2739
2740fn build_token_capped_preview(
2741 all_lines: &[&str],
2742 start: usize,
2743 end: usize,
2744 max_bytes: usize,
2745 token_cap: usize,
2746) -> TokenCappedPreview {
2747 let mut preview = Vec::new();
2748 let mut accumulated_tokens = 0usize;
2749 let mut capped_end = end;
2750 let mut was_capped = false;
2751
2752 for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
2753 let truncated = truncate_for_budget(line, max_bytes);
2754 let line_tokens = estimated_tokens_from_bytes(truncated.len());
2755 if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
2756 capped_end = start + idx - 1;
2757 was_capped = true;
2758 break;
2759 }
2760 accumulated_tokens += line_tokens;
2761 preview.push(SourceLinePreview {
2762 line: start + idx,
2763 text: truncated,
2764 });
2765 }
2766
2767 TokenCappedPreview {
2768 preview,
2769 capped_end,
2770 was_capped,
2771 }
2772}
2773
2774pub(crate) fn abbreviate_kind(kind: &str) -> &str {
2775 match kind {
2776 "function" => "fn",
2777 "method" => "meth",
2778 "module" | "mod" => "mod",
2779 "struct" => "struct",
2780 "trait" => "trait",
2781 "impl" => "impl",
2782 "class" => "cls",
2783 "interface" => "iface",
2784 "type_alias" => "type",
2785 "data_class" => "data_cls",
2786 "sealed_class" => "sealed_cls",
2787 "enum_class" => "enum_cls",
2788 "companion_object" => "comp_obj",
2789 "object" => "obj",
2790 "heading" => "h",
2791 "code_block" => "code",
2792 "alias" => "alias",
2793 other => other,
2794 }
2795}
2796
2797pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
2798 match kind {
2799 "calls" => "c",
2800 "defines" => "d",
2801 "contains" => "ct",
2802 "imports" => "i",
2803 "mentions" => "m",
2804 "mentions_concept" => "mc",
2805 "mentions_entity" => "me",
2806 "semantic_relation" => "sr",
2807 "belongs_to" => "bt",
2808 "scopes_context" => "sctx",
2809 "scopes_source" => "ssrc",
2810 "requests_context" => "rctx",
2811 "explains_result" => "er",
2812 "tagged_concept" => "tc",
2813 "tagged_entity" => "te",
2814 "related_concept" => "relc",
2815 "handled_by" => "hb",
2816 "defines_route" => "dr",
2817 "handles_route" => "hr",
2818 "targets" => "tgt",
2819 "has_vector_handle" => "hv",
2820 "parent" => "p",
2821 "child" => "ch",
2822 "uses" => "u",
2823 "projects_source" => "psrc",
2824 "records_memory_source" => "rms",
2825 "records_memory_event" => "rme",
2826 "has_ast_span" => "ha",
2827 "represents_symbol" => "rs",
2828 "contains_embedded_symbol" => "ces",
2829 "embedded_in_fence" => "ef",
2830 "contains_markdown_block" => "cmb",
2831 "contains_embedded_code" => "cec",
2832 "enclosing_module" => "em",
2833 "enclosing_section" => "es",
2834 "previous_sibling" => "psib",
2835 "next_sibling" => "nsib",
2836 "explicit_depends_on" => "edo",
2837 "worker_result_follow_up" => "wrf",
2838 "shared_resource" => "shr",
2839 "community_member" => "cm",
2840 other => other,
2841 }
2842}
2843
2844pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
2845 match mt {
2846 "exact_name" => "exact",
2847 "all_tags" => "all_tags",
2848 "partial_tags" => "partial",
2849 other => other,
2850 }
2851}
2852
2853pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
2854 path.iter()
2855 .map(|n| n.name.as_str())
2856 .collect::<Vec<_>>()
2857 .join(" -> ")
2858}
2859
2860const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
2861
2862struct SearchHitGroup {
2863 path: String,
2864 first_rank: usize,
2865 top_score: f64,
2866 confidence: String,
2867 hits: usize,
2868 samples: Vec<String>,
2869}
2870
2871fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
2872 let snippet = compact_snippet(&hit.snippet)?;
2873 Some(match hit.location.as_deref() {
2874 Some(location) => format!("{location}: {snippet}"),
2875 None => snippet,
2876 })
2877}
2878
2879pub(crate) fn group_search_hits(
2880 hits: &[sift::SearchHit],
2881 root: &Path,
2882 absolute: bool,
2883) -> Vec<SearchHitGroup> {
2884 let mut positions = BTreeMap::new();
2885 let mut groups = Vec::new();
2886 for hit in hits {
2887 let path = if absolute {
2888 hit.path.clone()
2889 } else {
2890 relativize(&hit.path, root)
2891 };
2892 let entry = positions.entry(path.clone()).or_insert_with(|| {
2893 groups.push(SearchHitGroup {
2894 path: path.clone(),
2895 first_rank: hit.rank,
2896 top_score: hit.score,
2897 confidence: format!("{:?}", hit.confidence),
2898 hits: 0,
2899 samples: Vec::new(),
2900 });
2901 groups.len() - 1
2902 });
2903 let group = &mut groups[*entry];
2904 group.hits += 1;
2905 if hit.rank < group.first_rank {
2906 group.first_rank = hit.rank;
2907 }
2908 if hit.score > group.top_score {
2909 group.top_score = hit.score;
2910 }
2911 if let Some(sample) = format_search_sample(hit)
2912 && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
2913 && !group.samples.contains(&sample)
2914 {
2915 group.samples.push(sample);
2916 }
2917 }
2918 groups.sort_by_key(|group| group.first_rank);
2919 groups
2920}
2921
2922pub(crate) fn should_collapse_search_hits(
2923 hits: &[sift::SearchHit],
2924 root: &Path,
2925 absolute: bool,
2926) -> bool {
2927 let groups = group_search_hits(hits, root, absolute);
2928 let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
2929 max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
2930}
2931
2932pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
2933 let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
2934 for edge in edges {
2935 let key = edge.caller_file.as_str();
2936 let name = if use_callers {
2937 edge.caller_name.as_str()
2938 } else {
2939 edge.callee_name.as_str()
2940 };
2941 let names = grouped.entry(key).or_default();
2942 if !names.contains(&name) {
2943 names.push(name);
2944 }
2945 }
2946
2947 grouped
2948 .into_iter()
2949 .map(|(file, names)| format!(" {} ({}): {}", file, names.len(), names.join(", ")))
2950 .collect()
2951}
2952
2953pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
2954 let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
2955 for edge in edges {
2956 *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
2957 }
2958 let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
2959 max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
2960}
2961
2962
2963fn resolve_query_index_target(
2964 root: &Path,
2965 path_hint: &Path,
2966 scope: Option<&str>,
2967) -> Result<SearchIndexTarget> {
2968 let cfg = config::Config::load(root)?;
2969 if let Some(scope_name) = scope {
2970 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
2971 return Ok(SearchIndexTarget {
2972 label: format!("submodule `{}` index", scope.id),
2973 db_path: cfg.db_path_for(root, &scope.id),
2974 source_root: scope.source_root.clone(),
2975 scope_name: Some(scope.id.clone()),
2976 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
2977 });
2978 }
2979 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
2980 return Ok(cargo_package_index_target(root, package));
2981 }
2982 config::Config::resolve_submodule(root, scope_name)?;
2983 }
2984
2985 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
2986 return Ok(SearchIndexTarget {
2987 label: format!("submodule `{}` index", scope.id),
2988 db_path: cfg.db_path_for(root, &scope.id),
2989 source_root: scope.source_root.clone(),
2990 scope_name: Some(scope.id.clone()),
2991 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
2992 });
2993 }
2994
2995 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
2996 return Ok(cargo_package_index_target(root, package));
2997 }
2998
2999 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3000 return Ok(SearchIndexTarget {
3001 label: format!("submodule `{}` index", scope.id),
3002 db_path: cfg.db_path_for(root, &scope.id),
3003 source_root: scope.source_root.clone(),
3004 scope_name: Some(scope.id.clone()),
3005 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3006 });
3007 }
3008
3009 let db_path = root.join(".tsift/index.db");
3010 if db_path.exists() {
3011 return Ok(SearchIndexTarget {
3012 label: "index".to_string(),
3013 db_path,
3014 source_root: root.to_path_buf(),
3015 scope_name: None,
3016 reindex_cmd: format!("tsift index {}", root.display()),
3017 });
3018 }
3019
3020 let scopes = config::Config::submodule_dirs(root)?;
3021 if scopes.is_empty() {
3022 return Ok(SearchIndexTarget {
3023 label: "index".to_string(),
3024 db_path,
3025 source_root: root.to_path_buf(),
3026 scope_name: None,
3027 reindex_cmd: format!("tsift index {}", root.display()),
3028 });
3029 }
3030
3031 let available_scopes = scopes
3032 .iter()
3033 .map(|scope| scope.id.as_str())
3034 .collect::<Vec<_>>()
3035 .join(", ");
3036 let indexed_scopes = scopes
3037 .iter()
3038 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3039 .map(|scope| scope.id.as_str())
3040 .collect::<Vec<_>>();
3041 let indexed_label = if indexed_scopes.is_empty() {
3042 "none".to_string()
3043 } else {
3044 indexed_scopes.join(", ")
3045 };
3046
3047 bail!(
3048 "workspace root {} has no shared root index at {}. Read-only graph queries require `--scope <scope>` when the workspace is indexed into `.tsift/indexes/*/index.db`. Available scopes: {}. Indexed scopes: {}.",
3049 root.display(),
3050 db_path.display(),
3051 available_scopes,
3052 indexed_label
3053 );
3054}
3055
3056pub(crate) fn resolve_query_db_path(root: &Path, path_hint: &Path, scope: Option<&str>) -> Result<PathBuf> {
3057 Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3058}
3059
3060fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3061 let state = inspect_search_index(target)?;
3062 let Some(reason) = index_reason_for_state(state) else {
3063 return Ok(());
3064 };
3065
3066 match apply_search_index_update(root, target) {
3067 Ok(_) => {
3068 index::inspect_scope_invalidate_all();
3069 Ok(())
3070 }
3071 Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3072 eprintln!(
3073 "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3074 Continuing with the current read-only index snapshot; graph results may lag. \
3075 Retry `{}` after the active writer finishes for fresh graph results.",
3076 index_reason_detail(target, reason),
3077 target.reindex_cmd
3078 );
3079 Ok(())
3080 }
3081 Err(err) => Err(err),
3082 }
3083}
3084
3085pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3086 let root = lint::resolve_project_root_or_canonical_path(path)?;
3087 let target = resolve_query_index_target(&root, path, scope)?;
3088 ensure_query_index_current(&root, &target)?;
3089 let db_path = target.db_path;
3090 if !db_path.exists() {
3091 bail!(
3092 "no index found at {}. Run `tsift index` first.",
3093 db_path.display()
3094 );
3095 }
3096 index::IndexDb::open_read_only_resilient(&db_path)
3097}
3098
3099pub(crate) fn query_tagpath_root(
3100 root: &std::path::Path,
3101 path_hint: &std::path::Path,
3102 scope: Option<&str>,
3103) -> Result<PathBuf> {
3104 if let Some(scope_name) = scope {
3105 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3106 return Ok(scope.source_root);
3107 }
3108 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3109 return Ok(package.package_root);
3110 }
3111 config::Config::resolve_submodule(root, scope_name)?;
3112 }
3113 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3114 return Ok(scope.source_root);
3115 }
3116 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3117 return Ok(package.package_root);
3118 }
3119 Ok(root.to_path_buf())
3120}
3121
3122#[derive(Clone, Debug, Serialize, PartialEq)]
3123struct TraversalNode {
3124 handle: String,
3125 kind: String,
3126 label: String,
3127 #[serde(skip_serializing_if = "Option::is_none")]
3128 ref_id: Option<String>,
3129 #[serde(skip_serializing_if = "Option::is_none")]
3130 path: Option<String>,
3131 #[serde(skip_serializing_if = "Option::is_none")]
3132 line: Option<i64>,
3133 #[serde(skip_serializing_if = "Option::is_none")]
3134 detail: Option<String>,
3135 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3136 properties: BTreeMap<String, String>,
3137 expand: String,
3138}
3139
3140#[derive(Clone, Debug, Serialize, PartialEq)]
3141struct TraversalEdge {
3142 from: String,
3143 to: String,
3144 relation: String,
3145 #[serde(skip_serializing_if = "Option::is_none")]
3146 label: Option<String>,
3147 weight: usize,
3148}
3149
3150#[derive(Clone, Debug, Default)]
3151struct TraversalGraphBuild {
3152 nodes: BTreeMap<String, TraversalNode>,
3153 edges: Vec<TraversalEdge>,
3154 edge_keys: BTreeSet<(String, String, String)>,
3155 warnings: Vec<String>,
3156}
3157
3158pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3159const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3160const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3161const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3162const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3163 "context-pack-graph-orchestration-v1";
3164const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3165const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3166const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3167const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3168const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3169const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3170const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3171
3172#[derive(Debug, Serialize, PartialEq)]
3173struct TraversalTotals {
3174 nodes: usize,
3175 edges: usize,
3176}
3177
3178#[derive(Debug, Serialize, PartialEq)]
3179struct TraversalPathReport {
3180 from: TraversalNode,
3181 to: TraversalNode,
3182 hops: usize,
3183 nodes: Vec<TraversalNode>,
3184 edges: Vec<TraversalEdge>,
3185}
3186
3187#[derive(Debug, Serialize, PartialEq)]
3188struct TraversalRecommendation {
3189 handle: String,
3190 kind: String,
3191 label: String,
3192 reason: String,
3193 score: usize,
3194 expand: String,
3195}
3196
3197#[derive(Debug, Serialize, PartialEq)]
3198struct TraversalReport {
3199 root: String,
3200 #[serde(skip_serializing_if = "Option::is_none")]
3201 scope: Option<String>,
3202 mode: String,
3203 totals: TraversalTotals,
3204 #[serde(skip_serializing_if = "Option::is_none")]
3205 query: Option<String>,
3206 #[serde(skip_serializing_if = "Option::is_none")]
3207 target: Option<String>,
3208 nodes: Vec<TraversalNode>,
3209 edges: Vec<TraversalEdge>,
3210 #[serde(skip_serializing_if = "Option::is_none")]
3211 shortest_path: Option<TraversalPathReport>,
3212 recommendations: Vec<TraversalRecommendation>,
3213 exploration: ExplorationPacket,
3214 truncated: bool,
3215 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3216 warnings: Vec<String>,
3217}
3218
3219#[derive(Debug, Serialize, PartialEq)]
3220struct SemanticRelatedReport {
3221 root: String,
3222 #[serde(skip_serializing_if = "Option::is_none")]
3223 scope: Option<String>,
3224 query: String,
3225 embedding_model: String,
3226 count: usize,
3227 items: Vec<SemanticRelatedItem>,
3228 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3229 warnings: Vec<String>,
3230}
3231
3232#[derive(Clone, Debug, Serialize, PartialEq)]
3233struct SemanticRelatedItem {
3234 handle: String,
3235 kind: String,
3236 label: String,
3237 score: f64,
3238 #[serde(skip_serializing_if = "Option::is_none")]
3239 file_path: Option<String>,
3240 #[serde(skip_serializing_if = "Option::is_none")]
3241 source_symbol: Option<String>,
3242 #[serde(skip_serializing_if = "Option::is_none")]
3243 detail: Option<String>,
3244 expand: String,
3245}
3246
3247#[derive(Clone)]
3248struct TraversalSymbolIndexEntry {
3249 handle: String,
3250 node: TraversalNode,
3251 tokens: BTreeSet<String>,
3252}
3253
3254#[derive(Clone)]
3255struct TraversalFileIndexEntry {
3256 handle: String,
3257 node: TraversalNode,
3258 tokens: BTreeSet<String>,
3259}
3260
3261#[derive(Clone)]
3262struct TraversalRouteIndexEntry {
3263 handle: String,
3264 node: TraversalNode,
3265 tokens: BTreeSet<String>,
3266}
3267
3268#[derive(Clone)]
3269struct TraversalAstSpanIndexEntry {
3270 handle: String,
3271 symbol_handle: String,
3272 file_handle: Option<String>,
3273 file: String,
3274 name: String,
3275 kind: String,
3276 language: String,
3277 node_kind: String,
3278 start_byte: usize,
3279 end_byte: usize,
3280 parent_module: Option<String>,
3281 markdown: Option<MarkdownSpanMetadata>,
3282}
3283
3284#[derive(Clone)]
3285struct TraversalMultiplicityIndexEntry {
3286 handle: String,
3287 node: TraversalNode,
3288 tokens: BTreeSet<String>,
3289}
3290
3291struct TraversalCodeLookup<'a> {
3292 symbols: &'a [TraversalSymbolIndexEntry],
3293 files: &'a [TraversalFileIndexEntry],
3294 routes: &'a [TraversalRouteIndexEntry],
3295 multiplicities: &'a [TraversalMultiplicityIndexEntry],
3296 symbol_index: HashMap<String, Vec<usize>>,
3297 file_index: HashMap<String, Vec<usize>>,
3298 route_index: HashMap<String, Vec<usize>>,
3299 multiplicity_index: HashMap<String, Vec<usize>>,
3300 file_path_index: HashMap<String, String>,
3301}
3302
3303#[derive(Clone, Debug, Serialize, PartialEq)]
3304struct ExplorationBudget {
3305 project_size: String,
3306 max_source_windows: usize,
3307 lines_per_window: usize,
3308 relationship_limit: usize,
3309}
3310
3311#[derive(Clone, Debug, Serialize, PartialEq)]
3312struct ExplorationRelation {
3313 from: String,
3314 relation: String,
3315 to: String,
3316 #[serde(skip_serializing_if = "Option::is_none")]
3317 label: Option<String>,
3318}
3319
3320#[derive(Clone, Debug, Serialize, PartialEq)]
3321struct ExplorationSourceWindow {
3322 handle: String,
3323 file: String,
3324 start: usize,
3325 end: usize,
3326 reason: String,
3327 expand: String,
3328}
3329
3330#[derive(Clone, Debug, Serialize, PartialEq)]
3331struct ExplorationWorkerContext {
3332 handle: String,
3333 target: String,
3334 summary: String,
3335 expand: String,
3336}
3337
3338#[derive(Clone, Debug, Serialize, PartialEq)]
3339struct ExplorationPacket {
3340 budget: ExplorationBudget,
3341 relationship_map: Vec<ExplorationRelation>,
3342 source_windows: Vec<ExplorationSourceWindow>,
3343 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3344 worker_context: Vec<ExplorationWorkerContext>,
3345 no_reread_guidance: String,
3346}
3347
3348impl TraversalGraphBuild {
3349 fn add_node(&mut self, node: TraversalNode) {
3350 self.nodes.entry(node.handle.clone()).or_insert(node);
3351 }
3352
3353 fn add_edge(
3354 &mut self,
3355 from: &str,
3356 to: &str,
3357 relation: &str,
3358 label: Option<String>,
3359 weight: usize,
3360 ) {
3361 if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3362 return;
3363 }
3364 let key = (from.to_string(), to.to_string(), relation.to_string());
3365 if self.edge_keys.insert(key) {
3366 self.edges.push(TraversalEdge {
3367 from: from.to_string(),
3368 to: to.to_string(),
3369 relation: relation.to_string(),
3370 label,
3371 weight,
3372 });
3373 }
3374 }
3375}
3376
3377pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3378 match scope {
3379 Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3380 None => root.join(".tsift/graph.db"),
3381 }
3382}
3383
3384fn graph_projection_meta_id(scope: Option<&str>) -> String {
3385 format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3386}
3387
3388pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
3389 let bytes = serde_json::to_vec(value)?;
3390 Ok(blake3::hash(&bytes).to_hex().to_string())
3391}
3392
3393fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
3394 let mut hashable = node.clone();
3395 hashable.freshness = None;
3396 node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3397 Ok(node)
3398}
3399
3400fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
3401 let mut hashable = edge.clone();
3402 hashable.freshness = None;
3403 edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3404 Ok(edge)
3405}
3406
3407const SEMANTIC_EMBEDDING_DIM: usize = 32;
3408const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
3409
3410fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
3411 match kind {
3412 SemanticRelatedKind::Concept => "concept",
3413 SemanticRelatedKind::Entity => "entity",
3414 SemanticRelatedKind::All => "all",
3415 }
3416}
3417
3418fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
3419 format!(
3420 "tsift semantic {} --path {} --kind {} --limit 10",
3421 shell_quote(query),
3422 shell_quote(root.to_string_lossy().as_ref()),
3423 semantic_related_kind_name(kind)
3424 )
3425}
3426
3427fn semantic_embedding(input: &str) -> Vec<f64> {
3428 let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
3429 let mut tokens = traversal_tokens(input);
3430 if tokens.is_empty() {
3431 let trimmed = input.trim().to_ascii_lowercase();
3432 if !trimmed.is_empty() {
3433 tokens.insert(trimmed);
3434 }
3435 }
3436
3437 for token in tokens {
3438 let hash = blake3::hash(token.as_bytes());
3439 let bytes = hash.as_bytes();
3440 let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
3441 let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
3442 vector[idx] += sign;
3443 }
3444
3445 let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
3446 if norm > 0.0 {
3447 for value in &mut vector {
3448 *value /= norm;
3449 }
3450 }
3451 vector
3452}
3453
3454fn semantic_embedding_property(input: &str) -> String {
3455 semantic_embedding(input)
3456 .iter()
3457 .map(|value| format!("{value:.6}"))
3458 .collect::<Vec<_>>()
3459 .join(",")
3460}
3461
3462fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
3463 let parsed = value
3464 .split(',')
3465 .map(str::trim)
3466 .map(str::parse::<f64>)
3467 .collect::<std::result::Result<Vec<_>, _>>()
3468 .ok()?;
3469 (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
3470}
3471
3472fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
3473 if left.len() != right.len() {
3474 return 0.0;
3475 }
3476 left.iter()
3477 .zip(right.iter())
3478 .map(|(left, right)| left * right)
3479 .sum::<f64>()
3480}
3481
3482fn semantic_entity_handle(name: &str, kind: &str) -> String {
3483 stable_handle(
3484 "gent",
3485 &format!(
3486 "entity:{}:{}",
3487 kind.trim().to_ascii_lowercase(),
3488 name.trim().to_ascii_lowercase()
3489 ),
3490 )
3491}
3492
3493fn semantic_concept_handle(label: &str) -> String {
3494 stable_handle(
3495 "gcon",
3496 &format!("concept:{}", label.trim().to_ascii_lowercase()),
3497 )
3498}
3499
3500fn summary_source_handles(
3501 summary: &summarize::Summary,
3502 file_node_by_path: &BTreeMap<String, String>,
3503 symbol_node_by_file_label: &BTreeMap<(String, String), String>,
3504) -> Vec<String> {
3505 let mut handles = Vec::new();
3506 if let Some(handle) = file_node_by_path.get(&summary.file_path) {
3507 handles.push(handle.clone());
3508 }
3509 if let Some(handle) =
3510 symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
3511 && !handles.iter().any(|existing| existing == handle)
3512 {
3513 handles.push(handle.clone());
3514 }
3515 handles
3516}
3517
3518fn semantic_entity_node(
3519 root: &Path,
3520 summary: &summarize::Summary,
3521 name: &str,
3522 kind: &str,
3523 description: &str,
3524 provenance: &GraphProvenance,
3525) -> SubstrateGraphNode {
3526 let handle = semantic_entity_handle(name, kind);
3527 let detail = if description.trim().is_empty() {
3528 format!("{kind} entity from cached summaries")
3529 } else {
3530 format!("{kind}: {description}")
3531 };
3532 SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
3533 .with_property("handle", handle)
3534 .with_property("ref_id", name.to_string())
3535 .with_property("detail", detail)
3536 .with_property("entity_kind", kind.to_string())
3537 .with_property("description", description.to_string())
3538 .with_property("source_file", summary.file_path.clone())
3539 .with_property("source_symbol", summary.symbol_name.clone())
3540 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3541 .with_property(
3542 "embedding",
3543 semantic_embedding_property(&format!("{name} {kind} {description}")),
3544 )
3545 .with_property(
3546 "expand",
3547 semantic_related_command(root, name, SemanticRelatedKind::Entity),
3548 )
3549 .with_provenance(provenance.clone())
3550}
3551
3552fn semantic_concept_node(
3553 root: &Path,
3554 summary: &summarize::Summary,
3555 label: &str,
3556 provenance: &GraphProvenance,
3557) -> SubstrateGraphNode {
3558 let handle = semantic_concept_handle(label);
3559 SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
3560 .with_property("handle", handle)
3561 .with_property("ref_id", label.to_string())
3562 .with_property("detail", "concept label from cached summaries".to_string())
3563 .with_property("source_file", summary.file_path.clone())
3564 .with_property("source_symbol", summary.symbol_name.clone())
3565 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3566 .with_property("embedding", semantic_embedding_property(label))
3567 .with_property(
3568 "expand",
3569 semantic_related_command(root, label, SemanticRelatedKind::Concept),
3570 )
3571 .with_provenance(provenance.clone())
3572}
3573
3574fn insert_semantic_edge(
3575 edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
3576 edge: SubstrateGraphEdge,
3577) {
3578 edge_map
3579 .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
3580 .or_insert(edge);
3581}
3582
3583fn append_summary_semantic_projection_rows(
3584 root: &Path,
3585 graph: &TraversalGraphBuild,
3586 provenance: &GraphProvenance,
3587 nodes: &mut Vec<SubstrateGraphNode>,
3588 edges: &mut Vec<SubstrateGraphEdge>,
3589) -> Result<()> {
3590 let summaries_db = root.join(".tsift/summaries.db");
3591 if !summaries_db.exists() {
3592 return Ok(());
3593 }
3594
3595 let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
3596 let summaries = summary_db.all()?;
3597 if summaries.is_empty() {
3598 return Ok(());
3599 }
3600
3601 let file_node_by_path = graph
3602 .nodes
3603 .values()
3604 .filter(|node| node.kind == "file")
3605 .filter_map(|node| {
3606 node.path
3607 .as_ref()
3608 .map(|path| (path.clone(), node.handle.clone()))
3609 })
3610 .collect::<BTreeMap<_, _>>();
3611 let symbol_node_by_file_label = graph
3612 .nodes
3613 .values()
3614 .filter(|node| node.kind == "symbol")
3615 .filter_map(|node| {
3616 Some((
3617 (node.path.clone()?, node.label.clone()),
3618 node.handle.clone(),
3619 ))
3620 })
3621 .collect::<BTreeMap<_, _>>();
3622
3623 let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
3624 let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
3625
3626 for summary in &summaries {
3627 let source_handles =
3628 summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
3629 let mut entity_ids_by_name = BTreeMap::<String, String>::new();
3630
3631 if let Some(entities) = &summary.entities {
3632 for entity in entities {
3633 let node = semantic_entity_node(
3634 root,
3635 summary,
3636 &entity.name,
3637 &entity.kind,
3638 &entity.description,
3639 provenance,
3640 );
3641 let entity_id = node.id.clone();
3642 entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
3643 semantic_nodes.entry(entity_id.clone()).or_insert(node);
3644
3645 for source_handle in &source_handles {
3646 insert_semantic_edge(
3647 &mut semantic_edges,
3648 SubstrateGraphEdge::new(
3649 source_handle.clone(),
3650 entity_id.clone(),
3651 "mentions_entity",
3652 )
3653 .with_property("label", format!("summary entity: {}", entity.name))
3654 .with_property("source_file", summary.file_path.clone())
3655 .with_provenance(provenance.clone()),
3656 );
3657 }
3658 }
3659 }
3660
3661 let mut concept_ids = Vec::new();
3662 if let Some(labels) = &summary.concept_labels {
3663 for label in labels
3664 .iter()
3665 .map(|label| label.trim())
3666 .filter(|label| !label.is_empty())
3667 {
3668 let node = semantic_concept_node(root, summary, label, provenance);
3669 let concept_id = node.id.clone();
3670 semantic_nodes.entry(concept_id.clone()).or_insert(node);
3671 concept_ids.push(concept_id.clone());
3672
3673 for source_handle in &source_handles {
3674 insert_semantic_edge(
3675 &mut semantic_edges,
3676 SubstrateGraphEdge::new(
3677 source_handle.clone(),
3678 concept_id.clone(),
3679 "mentions_concept",
3680 )
3681 .with_property("label", format!("summary concept: {label}"))
3682 .with_property("source_file", summary.file_path.clone())
3683 .with_provenance(provenance.clone()),
3684 );
3685 }
3686 }
3687 }
3688
3689 for entity_id in entity_ids_by_name.values() {
3690 for concept_id in &concept_ids {
3691 insert_semantic_edge(
3692 &mut semantic_edges,
3693 SubstrateGraphEdge::new(
3694 entity_id.clone(),
3695 concept_id.clone(),
3696 "tagged_concept",
3697 )
3698 .with_property("label", "entity concept label".to_string())
3699 .with_property("source_file", summary.file_path.clone())
3700 .with_provenance(provenance.clone()),
3701 );
3702 }
3703 }
3704
3705 for idx in 0..concept_ids.len() {
3706 for next_idx in (idx + 1)..concept_ids.len() {
3707 insert_semantic_edge(
3708 &mut semantic_edges,
3709 SubstrateGraphEdge::new(
3710 concept_ids[idx].clone(),
3711 concept_ids[next_idx].clone(),
3712 "related_concept",
3713 )
3714 .with_property("label", format!("co-occurs in {}", summary.symbol_name))
3715 .with_property("source_file", summary.file_path.clone())
3716 .with_provenance(provenance.clone()),
3717 );
3718 }
3719 }
3720
3721 if let Some(relationships) = &summary.relationships {
3722 for relationship in relationships {
3723 let from_id = entity_ids_by_name
3724 .get(&relationship.from.to_ascii_lowercase())
3725 .cloned()
3726 .unwrap_or_else(|| {
3727 let node = semantic_entity_node(
3728 root,
3729 summary,
3730 &relationship.from,
3731 "unknown",
3732 "",
3733 provenance,
3734 );
3735 let id = node.id.clone();
3736 semantic_nodes.entry(id.clone()).or_insert(node);
3737 id
3738 });
3739 let to_id = entity_ids_by_name
3740 .get(&relationship.to.to_ascii_lowercase())
3741 .cloned()
3742 .unwrap_or_else(|| {
3743 let node = semantic_entity_node(
3744 root,
3745 summary,
3746 &relationship.to,
3747 "unknown",
3748 "",
3749 provenance,
3750 );
3751 let id = node.id.clone();
3752 semantic_nodes.entry(id.clone()).or_insert(node);
3753 id
3754 });
3755 insert_semantic_edge(
3756 &mut semantic_edges,
3757 SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
3758 .with_property("relationship_kind", relationship.kind.clone())
3759 .with_property("label", relationship.kind.clone())
3760 .with_property("source_file", summary.file_path.clone())
3761 .with_property("source_symbol", summary.symbol_name.clone())
3762 .with_provenance(provenance.clone()),
3763 );
3764 }
3765 }
3766 }
3767
3768 for node in semantic_nodes.into_values() {
3769 nodes.push(node_with_content_freshness(node)?);
3770 }
3771 for edge in semantic_edges.into_values() {
3772 edges.push(edge_with_content_freshness(edge)?);
3773 }
3774
3775 Ok(())
3776}
3777
3778fn projection_content_hash(
3779 nodes: &[SubstrateGraphNode],
3780 edges: &[SubstrateGraphEdge],
3781) -> Result<String> {
3782 #[derive(Serialize)]
3783 struct Payload<'a> {
3784 version: &'static str,
3785 nodes: &'a [SubstrateGraphNode],
3786 edges: &'a [SubstrateGraphEdge],
3787 }
3788
3789 content_hash(&Payload {
3790 version: GRAPH_PROJECTION_VERSION,
3791 nodes,
3792 edges,
3793 })
3794}
3795
3796pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
3797 projection
3798 .nodes
3799 .iter()
3800 .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
3801 .and_then(|node| node.properties.get("content_hash").cloned())
3802}
3803
3804fn traversal_projection_from_graph(
3805 root: &Path,
3806 scope: Option<&str>,
3807 graph: &TraversalGraphBuild,
3808) -> Result<GraphProjection> {
3809 let provenance = GraphProvenance::new(
3810 "tsift.traverse",
3811 format!("{}:{}", root.display(), scope.unwrap_or("root")),
3812 );
3813 let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
3814 for node in graph.nodes.values() {
3815 let mut projected =
3816 SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
3817 .with_property("handle", node.handle.clone())
3818 .with_property("expand", node.expand.clone())
3819 .with_provenance(provenance.clone());
3820 if let Some(ref_id) = &node.ref_id {
3821 projected = projected.with_property("ref_id", ref_id.clone());
3822 }
3823 if let Some(path) = &node.path {
3824 projected = projected.with_property("path", path.clone());
3825 }
3826 if let Some(line) = node.line {
3827 projected = projected.with_property("line", line.to_string());
3828 }
3829 if let Some(detail) = &node.detail {
3830 projected = projected.with_property("detail", detail.clone());
3831 }
3832 for (key, value) in &node.properties {
3833 projected = projected.with_property(key.clone(), value.clone());
3834 }
3835 nodes.push(node_with_content_freshness(projected)?);
3836 }
3837
3838 let mut edges = Vec::with_capacity(graph.edges.len());
3839 for edge in &graph.edges {
3840 let mut projected =
3841 SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
3842 .with_property("weight", edge.weight.to_string())
3843 .with_provenance(provenance.clone());
3844 if let Some(label) = &edge.label {
3845 projected = projected.with_property("label", label.clone());
3846 }
3847 edges.push(edge_with_content_freshness(projected)?);
3848 }
3849
3850 append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3851 append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3852 append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
3853
3854 let projection_hash = projection_content_hash(&nodes, &edges)?;
3855 let meta = SubstrateGraphNode::new(
3856 graph_projection_meta_id(scope),
3857 GRAPH_PROJECTION_META_KIND,
3858 "tsift traversal projection",
3859 )
3860 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
3861 .with_property("content_hash", projection_hash.clone())
3862 .with_property("root", root.to_string_lossy().to_string())
3863 .with_property("scope", scope.unwrap_or("root"))
3864 .with_property("node_count", graph.nodes.len().to_string())
3865 .with_property("edge_count", graph.edges.len().to_string())
3866 .with_provenance(provenance)
3867 .with_freshness(GraphFreshness::content_hash(projection_hash));
3868 nodes.push(meta);
3869
3870 Ok(GraphProjection { nodes, edges })
3871}
3872
3873#[allow(clippy::too_many_arguments)]
3874fn ensure_traversal_source_handle(
3875 root: &Path,
3876 provenance: &GraphProvenance,
3877 file_node_by_path: &BTreeMap<String, String>,
3878 node: &TraversalNode,
3879 budget: &ExplorationBudget,
3880 source_handle_by_node: &mut BTreeMap<String, String>,
3881 seen_windows: &mut BTreeMap<(String, usize, usize), String>,
3882 nodes: &mut Vec<SubstrateGraphNode>,
3883 edges: &mut Vec<SubstrateGraphEdge>,
3884) -> Result<Option<String>> {
3885 if let Some(handle) = source_handle_by_node.get(&node.handle) {
3886 return Ok(Some(handle.clone()));
3887 }
3888 let Some(window) = exploration_source_window_for_node(root, node, budget) else {
3889 return Ok(None);
3890 };
3891 let window_key = (window.file.clone(), window.start, window.end);
3892 let handle = if let Some(handle) = seen_windows.get(&window_key) {
3893 handle.clone()
3894 } else {
3895 let label = format!("{}:{}-{}", window.file, window.start, window.end);
3896 let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
3897 .with_property("handle", window.handle.clone())
3898 .with_property("file", window.file.clone())
3899 .with_property("start", window.start.to_string())
3900 .with_property("end", window.end.to_string())
3901 .with_property("reason", window.reason.clone())
3902 .with_property("expand", window.expand.clone())
3903 .with_provenance(provenance.clone());
3904 nodes.push(node_with_content_freshness(projected)?);
3905
3906 if let Some(file_handle) = file_node_by_path.get(&window.file) {
3907 let edge = SubstrateGraphEdge::new(
3908 window.handle.clone(),
3909 file_handle.clone(),
3910 "expands_source",
3911 )
3912 .with_property("label", window.reason.clone())
3913 .with_provenance(provenance.clone());
3914 edges.push(edge_with_content_freshness(edge)?);
3915 }
3916 if node.kind != "file" {
3917 let edge = SubstrateGraphEdge::new(
3918 window.handle.clone(),
3919 node.handle.clone(),
3920 "anchors_source",
3921 )
3922 .with_property("label", window.reason.clone())
3923 .with_provenance(provenance.clone());
3924 edges.push(edge_with_content_freshness(edge)?);
3925 }
3926 seen_windows.insert(window_key, window.handle.clone());
3927 window.handle
3928 };
3929 source_handle_by_node.insert(node.handle.clone(), handle.clone());
3930 Ok(Some(handle))
3931}
3932
3933fn push_traversal_backlog_target_handles<'a>(
3934 backlog: &TraversalNode,
3935 edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
3936 node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
3937 max_handles: usize,
3938 seen_target_nodes: &mut BTreeSet<String>,
3939 target_node_handles: &mut Vec<String>,
3940) {
3941 for edge in edges_by_from
3942 .get(backlog.handle.as_str())
3943 .into_iter()
3944 .flatten()
3945 .filter(|edge| edge.relation == "mentions")
3946 {
3947 let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
3948 continue;
3949 };
3950 if !matches!(
3951 target_node.kind.as_str(),
3952 "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
3953 ) {
3954 continue;
3955 }
3956 if target_node
3957 .path
3958 .as_deref()
3959 .zip(backlog.path.as_deref())
3960 .is_some_and(|(target_path, backlog_path)| {
3961 target_path == backlog_path && target_path.ends_with(".md")
3962 })
3963 {
3964 continue;
3965 }
3966 if seen_target_nodes.insert(target_node.handle.clone()) {
3967 target_node_handles.push(target_node.handle.clone());
3968 }
3969 if target_node_handles.len() >= max_handles {
3970 break;
3971 }
3972 }
3973}
3974
3975fn append_traversal_context_projection_rows(
3976 root: &Path,
3977 graph: &TraversalGraphBuild,
3978 provenance: &GraphProvenance,
3979 nodes: &mut Vec<SubstrateGraphNode>,
3980 edges: &mut Vec<SubstrateGraphEdge>,
3981) -> Result<()> {
3982 let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
3983 let file_node_by_path = graph
3984 .nodes
3985 .values()
3986 .filter(|node| node.kind == "file")
3987 .filter_map(|node| {
3988 node.path
3989 .as_ref()
3990 .map(|path| (path.clone(), node.handle.clone()))
3991 })
3992 .collect::<BTreeMap<_, _>>();
3993
3994 let node_by_handle = graph
3995 .nodes
3996 .values()
3997 .map(|node| (node.handle.as_str(), node))
3998 .collect::<BTreeMap<_, _>>();
3999 let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4000 for edge in &graph.edges {
4001 edges_by_from
4002 .entry(edge.from.as_str())
4003 .or_default()
4004 .push(edge);
4005 }
4006 for rows in edges_by_from.values_mut() {
4007 rows.sort_by(|left, right| {
4008 right
4009 .weight
4010 .cmp(&left.weight)
4011 .then(left.relation.cmp(&right.relation))
4012 .then(left.to.cmp(&right.to))
4013 });
4014 }
4015
4016 let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4017 let mut source_handle_by_node = BTreeMap::<String, String>::new();
4018
4019 let mut code_context_count = 0usize;
4020 let code_context_limit = budget.relationship_limit.min(8);
4021 for node in graph.nodes.values() {
4022 if !matches!(
4023 node.kind.as_str(),
4024 "backlog" | "job_packet" | "worker_result"
4025 ) {
4026 continue;
4027 }
4028 let mut target_node_handles = Vec::new();
4029 let mut fallback_target_handles = Vec::new();
4030 let mut seen_target_nodes = BTreeSet::new();
4031 if node.kind == "backlog" || node.kind == "worker_result" {
4032 push_traversal_backlog_target_handles(
4033 node,
4034 &edges_by_from,
4035 &node_by_handle,
4036 budget.max_source_windows,
4037 &mut seen_target_nodes,
4038 &mut target_node_handles,
4039 );
4040 fallback_target_handles.push(node.handle.clone());
4041 } else {
4042 for edge in edges_by_from
4043 .get(node.handle.as_str())
4044 .into_iter()
4045 .flatten()
4046 .filter(|edge| edge.relation == "targets")
4047 {
4048 let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4049 continue;
4050 };
4051 fallback_target_handles.push(backlog.handle.clone());
4052 push_traversal_backlog_target_handles(
4053 backlog,
4054 &edges_by_from,
4055 &node_by_handle,
4056 budget.max_source_windows,
4057 &mut seen_target_nodes,
4058 &mut target_node_handles,
4059 );
4060 if target_node_handles.len() >= budget.max_source_windows {
4061 break;
4062 }
4063 }
4064 if fallback_target_handles.is_empty() {
4065 continue;
4066 }
4067 }
4068 let code_context = !target_node_handles.is_empty();
4069 if target_node_handles.is_empty() {
4070 target_node_handles = dedupe_preserve_order(fallback_target_handles);
4071 } else if code_context_count >= code_context_limit {
4072 continue;
4073 }
4074
4075 let mut worker_source_handles = Vec::new();
4076 let mut seen_worker_handles = BTreeSet::new();
4077 for target_handle in target_node_handles {
4078 if worker_source_handles.len() >= budget.max_source_windows {
4079 break;
4080 }
4081 let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4082 continue;
4083 };
4084 let Some(handle) = ensure_traversal_source_handle(
4085 root,
4086 provenance,
4087 &file_node_by_path,
4088 target_node,
4089 &budget,
4090 &mut source_handle_by_node,
4091 &mut seen_windows,
4092 nodes,
4093 edges,
4094 )?
4095 else {
4096 continue;
4097 };
4098 if seen_worker_handles.insert(handle.clone()) {
4099 worker_source_handles.push(handle);
4100 }
4101 }
4102 if worker_source_handles.is_empty() {
4103 continue;
4104 }
4105 let target = node
4106 .path
4107 .clone()
4108 .unwrap_or_else(|| root.to_string_lossy().to_string());
4109 let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4110 let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4111 let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4112 .with_property("handle", handle.clone())
4113 .with_property("target", target.clone())
4114 .with_property("summary", summary)
4115 .with_property(
4116 "source_handle_count",
4117 worker_source_handles.len().to_string(),
4118 )
4119 .with_property(
4120 "expand",
4121 format!(
4122 "tsift --envelope context-pack {} --budget normal",
4123 shell_quote(&target)
4124 ),
4125 )
4126 .with_provenance(provenance.clone());
4127 nodes.push(node_with_content_freshness(projected)?);
4128
4129 let request_edge =
4130 SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4131 .with_property("label", "bounded worker context".to_string())
4132 .with_provenance(provenance.clone());
4133 edges.push(edge_with_content_freshness(request_edge)?);
4134
4135 for source_handle in &worker_source_handles {
4136 let scope_edge =
4137 SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4138 .with_property("label", "bounded worker source window".to_string())
4139 .with_provenance(provenance.clone());
4140 edges.push(edge_with_content_freshness(scope_edge)?);
4141 }
4142 if code_context {
4143 code_context_count += 1;
4144 }
4145 }
4146
4147 Ok(())
4148}
4149
4150fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4151 let handle = node
4152 .properties
4153 .get("handle")
4154 .cloned()
4155 .unwrap_or_else(|| node.id.clone());
4156 TraversalNode {
4157 expand: node
4158 .properties
4159 .get("expand")
4160 .cloned()
4161 .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4162 handle,
4163 kind: node.kind,
4164 label: node.label,
4165 ref_id: node.properties.get("ref_id").cloned(),
4166 path: node.properties.get("path").cloned(),
4167 line: node
4168 .properties
4169 .get("line")
4170 .and_then(|value| value.parse::<i64>().ok()),
4171 detail: node.properties.get("detail").cloned(),
4172 properties: node.properties,
4173 }
4174}
4175
4176fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4177 let mut graph = TraversalGraphBuild::default();
4178 for node in store.all_nodes()? {
4179 if node.kind == GRAPH_PROJECTION_META_KIND {
4180 continue;
4181 }
4182 graph.add_node(traversal_node_from_graph_node(root, node));
4183 }
4184 for edge in store.all_edges()? {
4185 graph.add_edge(
4186 &edge.from_id,
4187 &edge.to_id,
4188 &edge.kind,
4189 edge.properties.get("label").cloned(),
4190 edge.properties
4191 .get("weight")
4192 .and_then(|value| value.parse::<usize>().ok())
4193 .unwrap_or(1),
4194 );
4195 }
4196 Ok(graph)
4197}
4198
4199pub(crate) fn convex_rows_from_graph_store(
4200 store: &impl GraphStore,
4201) -> Result<ConvexProjectionRows> {
4202 Ok(GraphProjection {
4203 nodes: store.all_nodes()?,
4204 edges: store.all_edges()?,
4205 }
4206 .to_convex_rows())
4207}
4208
4209#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4210struct ConvexRequiredIndex {
4211 table: String,
4212 name: String,
4213 fields: Vec<String>,
4214}
4215
4216#[derive(Clone, Debug, Serialize, PartialEq)]
4217struct ConvexSyncChunk {
4218 operation: String,
4219 chunk: usize,
4220 count: usize,
4221 keys: Vec<String>,
4222 max_attempts: usize,
4223 retry_policy: String,
4224}
4225
4226#[derive(Clone, Debug, Serialize, PartialEq)]
4227struct ConvexTransportSummary {
4228 endpoint_env: String,
4229 endpoint_configured: bool,
4230 auth_token_env: String,
4231 auth_configured: bool,
4232 remote_snapshot: bool,
4233 applied_chunks: usize,
4234}
4235
4236#[derive(Clone, Debug, Serialize, PartialEq)]
4237struct ConvexTransportReceipt {
4238 operation: String,
4239 chunk: usize,
4240 attempt: usize,
4241 status: String,
4242 message: Option<String>,
4243}
4244
4245#[derive(Serialize)]
4246#[serde(rename_all = "camelCase")]
4247struct ConvexTransportRequest<'a> {
4248 operation: &'a str,
4249 chunk: usize,
4250 projection_version: &'a str,
4251 projection_hash: Option<&'a str>,
4252 #[serde(skip_serializing_if = "Option::is_none")]
4253 projection_meta_id: Option<&'a str>,
4254 node_rows: Vec<ConvexNodeRow>,
4255 edge_rows: Vec<ConvexEdgeRow>,
4256 keys: Vec<String>,
4257 #[serde(skip_serializing_if = "Option::is_none")]
4258 cursor: Option<String>,
4259 #[serde(skip_serializing_if = "Option::is_none")]
4260 limit: Option<usize>,
4261}
4262
4263#[derive(Deserialize)]
4264#[serde(rename_all = "camelCase")]
4265struct ConvexTransportResponse {
4266 status: Option<String>,
4267 message: Option<String>,
4268 rows: Option<ConvexProjectionRows>,
4269 #[serde(default)]
4270 meta: Option<ConvexSnapshotMeta>,
4271 #[serde(default)]
4272 page: Option<ConvexSnapshotPage>,
4273}
4274
4275#[derive(Deserialize, Debug, Clone)]
4276#[serde(rename_all = "camelCase")]
4277struct ConvexSnapshotMeta {
4278 #[serde(default)]
4282 #[allow(dead_code)]
4283 indexes: Vec<ConvexRequiredIndex>,
4284 #[serde(default)]
4285 #[allow(dead_code)]
4286 node_count: Option<usize>,
4287 #[serde(default)]
4288 #[allow(dead_code)]
4289 edge_count: Option<usize>,
4290 #[serde(default)]
4291 projection_hash: Option<String>,
4292 #[serde(default)]
4293 #[allow(dead_code)]
4294 page_size: Option<usize>,
4295}
4296
4297#[derive(Deserialize, Debug, Clone)]
4302#[serde(rename_all = "camelCase")]
4303struct ConvexSnapshotPage {
4304 rows: Vec<serde_json::Value>,
4305 #[serde(default)]
4306 next_cursor: Option<String>,
4307}
4308
4309#[derive(Clone, Debug, Serialize, PartialEq)]
4310struct ConvexProjectionFreshness {
4311 status: String,
4312 fail_closed: bool,
4313 local_hash: Option<String>,
4314 snapshot_hash: Option<String>,
4315 missing_nodes: Vec<String>,
4316 stale_nodes: Vec<String>,
4317 missing_edges: Vec<String>,
4318 stale_edges: Vec<String>,
4319 diagnostics: Vec<String>,
4320}
4321
4322const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4323
4324impl ConvexProjectionFreshness {
4325 fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4326 Self {
4327 status: "current".to_string(),
4328 fail_closed: false,
4329 local_hash,
4330 snapshot_hash,
4331 missing_nodes: Vec::new(),
4332 stale_nodes: Vec::new(),
4333 missing_edges: Vec::new(),
4334 stale_edges: Vec::new(),
4335 diagnostics: Vec::new(),
4336 }
4337 }
4338}
4339
4340#[derive(Clone, Debug, Serialize, PartialEq)]
4341struct ConvexSyncReport {
4342 root: String,
4343 #[serde(skip_serializing_if = "Option::is_none")]
4344 scope: Option<String>,
4345 graph_db: String,
4346 dry_run: bool,
4347 projection_version: String,
4348 projection_hash: Option<String>,
4349 required_indexes: Vec<ConvexRequiredIndex>,
4350 node_upserts: Vec<ConvexNodeRow>,
4351 edge_upserts: Vec<ConvexEdgeRow>,
4352 node_tombstones: Vec<String>,
4353 edge_tombstones: Vec<String>,
4354 chunks: Vec<ConvexSyncChunk>,
4355 freshness: ConvexProjectionFreshness,
4356 transport: Option<ConvexTransportSummary>,
4357 receipts: Vec<ConvexTransportReceipt>,
4358 diagnostics: Vec<String>,
4359 warnings: Vec<String>,
4360}
4361
4362fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4363 vec![
4364 ConvexRequiredIndex {
4365 table: "nodes".to_string(),
4366 name: "by_external_id".to_string(),
4367 fields: vec!["externalId".to_string()],
4368 },
4369 ConvexRequiredIndex {
4370 table: "nodes".to_string(),
4371 name: "by_kind".to_string(),
4372 fields: vec!["kind".to_string()],
4373 },
4374 ConvexRequiredIndex {
4375 table: "edges".to_string(),
4376 name: "by_edge_key".to_string(),
4377 fields: vec!["edgeKey".to_string()],
4378 },
4379 ConvexRequiredIndex {
4380 table: "edges".to_string(),
4381 name: "by_from_kind".to_string(),
4382 fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4383 },
4384 ConvexRequiredIndex {
4385 table: "edges".to_string(),
4386 name: "by_to_kind".to_string(),
4387 fields: vec!["toExternalId".to_string(), "kind".to_string()],
4388 },
4389 ]
4390}
4391
4392pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
4393 let content = fs::read_to_string(path)
4394 .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
4395 serde_json::from_str(&content)
4396 .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
4397}
4398
4399fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
4400 let mut diagnostics = Vec::new();
4401 let mut node_counts = BTreeMap::<&str, usize>::new();
4402 for row in &rows.nodes {
4403 *node_counts.entry(row.external_id.as_str()).or_default() += 1;
4404 }
4405 for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
4406 diagnostics.push(format!(
4407 "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
4408 ));
4409 }
4410
4411 let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
4412 let mut edge_counts = BTreeMap::<&str, usize>::new();
4413 for edge in &rows.edges {
4414 *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
4415 if !node_ids.contains(edge.from_external_id.as_str()) {
4416 diagnostics.push(format!(
4417 "Convex snapshot edge {} references missing from node {}",
4418 edge.edge_key, edge.from_external_id
4419 ));
4420 }
4421 if !node_ids.contains(edge.to_external_id.as_str()) {
4422 diagnostics.push(format!(
4423 "Convex snapshot edge {} references missing to node {}",
4424 edge.edge_key, edge.to_external_id
4425 ));
4426 }
4427 let expected_key =
4428 ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
4429 if edge.edge_key != expected_key {
4430 diagnostics.push(format!(
4431 "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
4432 edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
4433 ));
4434 }
4435 }
4436 for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
4437 diagnostics.push(format!(
4438 "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
4439 ));
4440 }
4441 diagnostics
4442}
4443
4444pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
4445 let diagnostics = convex_projection_row_diagnostics(rows);
4446 if diagnostics.is_empty() {
4447 Ok(())
4448 } else {
4449 bail!("{}", diagnostics.join("; "))
4450 }
4451}
4452
4453pub(crate) struct ConvexHttpTransport {
4454 endpoint: String,
4455 auth_token_env: String,
4456 auth_token: Option<String>,
4457}
4458
4459impl ConvexHttpTransport {
4460 fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
4461 let endpoint = endpoint
4462 .map(str::to_string)
4463 .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
4464 .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
4465 let auth_token = env::var(auth_token_env)
4466 .ok()
4467 .filter(|value| !value.trim().is_empty());
4468 Ok(Self {
4469 endpoint,
4470 auth_token_env: auth_token_env.to_string(),
4471 auth_token,
4472 })
4473 }
4474
4475 fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
4476 ConvexTransportSummary {
4477 endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
4478 endpoint_configured: true,
4479 auth_token_env: self.auth_token_env.clone(),
4480 auth_configured: self.auth_token.is_some(),
4481 remote_snapshot,
4482 applied_chunks,
4483 }
4484 }
4485
4486 fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
4487 let mut builder = ureq::post(&self.endpoint);
4488 if let Some(token) = &self.auth_token {
4489 builder = builder.header("Authorization", &format!("Bearer {token}"));
4490 }
4491 builder
4492 .send_json(request)
4493 .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
4494 .body_mut()
4495 .read_json::<ConvexTransportResponse>()
4496 .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
4497 }
4498
4499 fn fetch_snapshot(
4510 &self,
4511 projection_version: &str,
4512 scope: Option<&str>,
4513 local_hash: Option<&str>,
4514 local_rows: Option<&ConvexProjectionRows>,
4515 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4516 match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
4517 Ok(rows) => Ok(rows),
4518 Err(err) => {
4519 let msg = format!("{err:#}");
4524 let is_unknown_op = msg.contains("unknown operation")
4525 || msg.contains("snapshot_meta")
4526 || msg.contains("404");
4527 if !is_unknown_op {
4528 return Err(err);
4529 }
4530 self.fetch_snapshot_legacy(projection_version)
4531 .map(|rows| (rows, Vec::new()))
4532 }
4533 }
4534 }
4535
4536 fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
4537 let response = self.post(&ConvexTransportRequest {
4538 operation: "snapshot",
4539 chunk: 0,
4540 projection_version,
4541 projection_hash: None,
4542 projection_meta_id: None,
4543 node_rows: Vec::new(),
4544 edge_rows: Vec::new(),
4545 keys: Vec::new(),
4546 cursor: None,
4547 limit: None,
4548 })?;
4549 response
4550 .rows
4551 .context("Convex snapshot response did not include rows")
4552 }
4553
4554 fn fetch_snapshot_paginated(
4555 &self,
4556 projection_version: &str,
4557 scope: Option<&str>,
4558 local_hash: Option<&str>,
4559 local_rows: Option<&ConvexProjectionRows>,
4560 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4561 let projection_meta_id = graph_projection_meta_id(scope);
4562 let meta_response = self.post(&ConvexTransportRequest {
4563 operation: "snapshot_meta",
4564 chunk: 0,
4565 projection_version,
4566 projection_hash: None,
4567 projection_meta_id: Some(&projection_meta_id),
4568 node_rows: Vec::new(),
4569 edge_rows: Vec::new(),
4570 keys: Vec::new(),
4571 cursor: None,
4572 limit: None,
4573 })?;
4574 if matches!(meta_response.status.as_deref(), Some("error")) {
4575 anyhow::bail!(
4576 "Convex snapshot_meta returned error: {}",
4577 meta_response.message.unwrap_or_default()
4578 );
4579 }
4580 let meta = meta_response
4581 .meta
4582 .context("Convex snapshot_meta response did not include meta")?;
4583 if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
4584 (meta.projection_hash.as_deref(), local_hash, local_rows)
4585 && remote_hash == local_hash
4586 {
4587 return Ok((
4588 local_rows.clone(),
4589 vec![
4590 "remote projection hash matched local graph; skipped full row-page snapshot diff"
4591 .to_string(),
4592 ],
4593 ));
4594 }
4595
4596 let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
4597 let mut node_cursor: Option<String> = None;
4598 loop {
4599 let response = self.post(&ConvexTransportRequest {
4600 operation: "snapshot_nodes_page",
4601 chunk: 0,
4602 projection_version,
4603 projection_hash: None,
4604 projection_meta_id: None,
4605 node_rows: Vec::new(),
4606 edge_rows: Vec::new(),
4607 keys: Vec::new(),
4608 cursor: node_cursor.clone(),
4609 limit: None,
4610 })?;
4611 let page = response
4612 .page
4613 .context("Convex snapshot_nodes_page response did not include page")?;
4614 for raw in page.rows {
4615 let row: ConvexNodeRow =
4616 serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
4617 nodes.push(row);
4618 }
4619 match page.next_cursor {
4620 Some(next) => node_cursor = Some(next),
4621 None => break,
4622 }
4623 }
4624
4625 let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
4626 let mut edge_cursor: Option<String> = None;
4627 loop {
4628 let response = self.post(&ConvexTransportRequest {
4629 operation: "snapshot_edges_page",
4630 chunk: 0,
4631 projection_version,
4632 projection_hash: None,
4633 projection_meta_id: None,
4634 node_rows: Vec::new(),
4635 edge_rows: Vec::new(),
4636 keys: Vec::new(),
4637 cursor: edge_cursor.clone(),
4638 limit: None,
4639 })?;
4640 let page = response
4641 .page
4642 .context("Convex snapshot_edges_page response did not include page")?;
4643 for raw in page.rows {
4644 let row: ConvexEdgeRow =
4645 serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
4646 edges.push(row);
4647 }
4648 match page.next_cursor {
4649 Some(next) => edge_cursor = Some(next),
4650 None => break,
4651 }
4652 }
4653
4654 Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
4655 }
4656
4657 fn apply_chunk(
4658 &self,
4659 report: &ConvexSyncReport,
4660 chunk: &ConvexSyncChunk,
4661 ) -> Result<ConvexTransportReceipt> {
4662 let node_rows = if chunk.operation == "upsert_nodes" {
4663 report
4664 .node_upserts
4665 .iter()
4666 .filter(|row| chunk.keys.contains(&row.external_id))
4667 .cloned()
4668 .collect()
4669 } else {
4670 Vec::new()
4671 };
4672 let edge_rows = if chunk.operation == "upsert_edges" {
4673 report
4674 .edge_upserts
4675 .iter()
4676 .filter(|row| chunk.keys.contains(&row.edge_key))
4677 .cloned()
4678 .collect()
4679 } else {
4680 Vec::new()
4681 };
4682 let request = ConvexTransportRequest {
4683 operation: &chunk.operation,
4684 chunk: chunk.chunk,
4685 projection_version: &report.projection_version,
4686 projection_hash: report.projection_hash.as_deref(),
4687 projection_meta_id: None,
4688 node_rows,
4689 edge_rows,
4690 keys: chunk.keys.clone(),
4691 cursor: None,
4692 limit: None,
4693 };
4694 let mut last_error = None;
4695 for attempt in 1..=chunk.max_attempts {
4696 match self.post(&request) {
4697 Ok(response) => {
4698 return Ok(ConvexTransportReceipt {
4699 operation: chunk.operation.clone(),
4700 chunk: chunk.chunk,
4701 attempt,
4702 status: response.status.unwrap_or_else(|| "ok".to_string()),
4703 message: response.message,
4704 });
4705 }
4706 Err(err) => {
4707 last_error = Some(err);
4708 if attempt < chunk.max_attempts {
4709 std::thread::sleep(Duration::from_millis(100 * attempt as u64));
4710 }
4711 }
4712 }
4713 }
4714 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
4715 .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
4716 }
4717}
4718
4719fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
4720 let meta_id = graph_projection_meta_id(scope);
4721 rows.nodes
4722 .iter()
4723 .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
4724 .and_then(|row| row.properties.get("content_hash").cloned())
4725}
4726
4727fn convex_projection_freshness(
4728 local: &ConvexProjectionRows,
4729 snapshot: Option<&ConvexProjectionRows>,
4730 scope: Option<&str>,
4731) -> ConvexProjectionFreshness {
4732 let local_hash = convex_projection_hash(local, scope);
4733 let Some(snapshot) = snapshot else {
4734 return ConvexProjectionFreshness {
4735 status: "unchecked".to_string(),
4736 fail_closed: false,
4737 local_hash,
4738 snapshot_hash: None,
4739 missing_nodes: Vec::new(),
4740 stale_nodes: Vec::new(),
4741 missing_edges: Vec::new(),
4742 stale_edges: Vec::new(),
4743 diagnostics: vec![
4744 "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
4745 ],
4746 };
4747 };
4748
4749 let snapshot_hash = convex_projection_hash(snapshot, scope);
4750 let snapshot_nodes = snapshot
4751 .nodes
4752 .iter()
4753 .map(|row| (row.external_id.as_str(), row))
4754 .collect::<BTreeMap<_, _>>();
4755 let snapshot_edges = snapshot
4756 .edges
4757 .iter()
4758 .map(|row| (row.edge_key.as_str(), row))
4759 .collect::<BTreeMap<_, _>>();
4760
4761 let mut missing_nodes = Vec::new();
4762 let mut stale_nodes = Vec::new();
4763 for row in &local.nodes {
4764 match snapshot_nodes.get(row.external_id.as_str()) {
4765 Some(snapshot_row) if *snapshot_row == row => {}
4766 Some(_) => stale_nodes.push(row.external_id.clone()),
4767 None => missing_nodes.push(row.external_id.clone()),
4768 }
4769 }
4770
4771 let mut missing_edges = Vec::new();
4772 let mut stale_edges = Vec::new();
4773 for row in &local.edges {
4774 match snapshot_edges.get(row.edge_key.as_str()) {
4775 Some(snapshot_row) if *snapshot_row == row => {}
4776 Some(_) => stale_edges.push(row.edge_key.clone()),
4777 None => missing_edges.push(row.edge_key.clone()),
4778 }
4779 }
4780
4781 let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
4782 let rows_current = missing_nodes.is_empty()
4783 && stale_nodes.is_empty()
4784 && missing_edges.is_empty()
4785 && stale_edges.is_empty();
4786 if hash_current && rows_current {
4787 return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
4788 }
4789
4790 let mut diagnostics = Vec::new();
4791 if local_hash != snapshot_hash {
4792 diagnostics.push(format!(
4793 "projection hash mismatch: local={} snapshot={}",
4794 local_hash.as_deref().unwrap_or("missing"),
4795 snapshot_hash.as_deref().unwrap_or("missing")
4796 ));
4797 }
4798 if !missing_nodes.is_empty() || !missing_edges.is_empty() {
4799 diagnostics.push(format!(
4800 "Convex snapshot is missing {} node(s) and {} edge(s)",
4801 missing_nodes.len(),
4802 missing_edges.len()
4803 ));
4804 }
4805 if !stale_nodes.is_empty() || !stale_edges.is_empty() {
4806 diagnostics.push(format!(
4807 "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
4808 stale_nodes.len(),
4809 stale_edges.len()
4810 ));
4811 }
4812
4813 ConvexProjectionFreshness {
4814 status: "stale".to_string(),
4815 fail_closed: true,
4816 local_hash,
4817 snapshot_hash,
4818 missing_nodes,
4819 stale_nodes,
4820 missing_edges,
4821 stale_edges,
4822 diagnostics,
4823 }
4824}
4825
4826pub(crate) fn verify_convex_projection_snapshot(
4827 root: &Path,
4828 scope: Option<&str>,
4829 snapshot_path: &Path,
4830) -> Result<()> {
4831 let graph_db = graph_substrate_db_path(root, scope);
4832 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4833 let local = convex_rows_from_graph_store(&store)?;
4834 let snapshot = load_convex_projection_rows(snapshot_path)?;
4835 validate_convex_projection_rows(&snapshot)?;
4836 let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
4837 if freshness.fail_closed {
4838 bail!(
4839 "Convex graph projection is not current for {}: {}",
4840 root.display(),
4841 freshness.diagnostics.join("; ")
4842 );
4843 }
4844 Ok(())
4845}
4846
4847fn convex_rows_diff(
4848 local: &ConvexProjectionRows,
4849 snapshot: Option<&ConvexProjectionRows>,
4850) -> (
4851 Vec<ConvexNodeRow>,
4852 Vec<ConvexEdgeRow>,
4853 Vec<String>,
4854 Vec<String>,
4855) {
4856 let Some(snapshot) = snapshot else {
4857 return (
4858 local.nodes.clone(),
4859 local.edges.clone(),
4860 Vec::new(),
4861 Vec::new(),
4862 );
4863 };
4864 let local_nodes = local
4865 .nodes
4866 .iter()
4867 .map(|row| (row.external_id.as_str(), row))
4868 .collect::<BTreeMap<_, _>>();
4869 let local_edges = local
4870 .edges
4871 .iter()
4872 .map(|row| (row.edge_key.as_str(), row))
4873 .collect::<BTreeMap<_, _>>();
4874 let snapshot_nodes = snapshot
4875 .nodes
4876 .iter()
4877 .map(|row| (row.external_id.as_str(), row))
4878 .collect::<BTreeMap<_, _>>();
4879 let snapshot_edges = snapshot
4880 .edges
4881 .iter()
4882 .map(|row| (row.edge_key.as_str(), row))
4883 .collect::<BTreeMap<_, _>>();
4884
4885 let node_upserts = local
4886 .nodes
4887 .iter()
4888 .filter(|row| {
4889 snapshot_nodes
4890 .get(row.external_id.as_str())
4891 .is_none_or(|snapshot_row| *snapshot_row != *row)
4892 })
4893 .cloned()
4894 .collect::<Vec<_>>();
4895 let edge_upserts = local
4896 .edges
4897 .iter()
4898 .filter(|row| {
4899 snapshot_edges
4900 .get(row.edge_key.as_str())
4901 .is_none_or(|snapshot_row| *snapshot_row != *row)
4902 })
4903 .cloned()
4904 .collect::<Vec<_>>();
4905 let node_tombstones = snapshot
4906 .nodes
4907 .iter()
4908 .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
4909 .map(|row| row.external_id.clone())
4910 .collect::<Vec<_>>();
4911 let edge_tombstones = snapshot
4912 .edges
4913 .iter()
4914 .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
4915 .map(|row| row.edge_key.clone())
4916 .collect::<Vec<_>>();
4917
4918 (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
4919}
4920
4921fn push_sync_chunks(
4922 chunks: &mut Vec<ConvexSyncChunk>,
4923 operation: &str,
4924 keys: Vec<String>,
4925 size: usize,
4926) {
4927 if keys.is_empty() {
4928 return;
4929 }
4930 for (idx, chunk) in keys.chunks(size).enumerate() {
4931 chunks.push(ConvexSyncChunk {
4932 operation: operation.to_string(),
4933 chunk: idx + 1,
4934 count: chunk.len(),
4935 keys: chunk.to_vec(),
4936 max_attempts: 3,
4937 retry_policy:
4938 "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
4939 .to_string(),
4940 });
4941 }
4942}
4943
4944pub(crate) fn build_convex_sync_report_with_snapshot(
4945 path: &Path,
4946 scope: Option<&str>,
4947 snapshot: Option<ConvexProjectionRows>,
4948 chunk_size: usize,
4949 dry_run: bool,
4950) -> Result<ConvexSyncReport> {
4951 if chunk_size == 0 {
4952 bail!("--chunk-size must be greater than zero");
4953 }
4954 let root = lint::resolve_project_root_or_canonical_path(path)?;
4955 let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
4956 let graph_db = graph_substrate_db_path(&root, scope);
4957 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4958 let local = convex_rows_from_graph_store(&store)?;
4959 let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
4960 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
4961 convex_rows_diff(&local, snapshot.as_ref());
4962
4963 let mut chunks = Vec::new();
4964 push_sync_chunks(
4965 &mut chunks,
4966 "delete_edges",
4967 edge_tombstones.clone(),
4968 chunk_size,
4969 );
4970 push_sync_chunks(
4971 &mut chunks,
4972 "upsert_nodes",
4973 node_upserts
4974 .iter()
4975 .map(|row| row.external_id.clone())
4976 .collect(),
4977 chunk_size,
4978 );
4979 push_sync_chunks(
4980 &mut chunks,
4981 "upsert_edges",
4982 edge_upserts
4983 .iter()
4984 .map(|row| row.edge_key.clone())
4985 .collect(),
4986 chunk_size,
4987 );
4988 push_sync_chunks(
4989 &mut chunks,
4990 "delete_nodes",
4991 node_tombstones.clone(),
4992 chunk_size,
4993 );
4994
4995 let mut diagnostics = vec![
4996 "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
4997 .to_string(),
4998 ];
4999 if dry_run {
5000 diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5001 }
5002 if freshness.fail_closed {
5003 diagnostics.push(
5004 "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5005 .to_string(),
5006 );
5007 }
5008
5009 Ok(ConvexSyncReport {
5010 root: root.to_string_lossy().to_string(),
5011 scope: scope.map(str::to_string),
5012 graph_db: graph_db.to_string_lossy().to_string(),
5013 dry_run,
5014 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5015 projection_hash: convex_projection_hash(&local, scope),
5016 required_indexes: convex_required_indexes(),
5017 node_upserts,
5018 edge_upserts,
5019 node_tombstones,
5020 edge_tombstones,
5021 chunks,
5022 freshness,
5023 transport: None,
5024 receipts: Vec::new(),
5025 diagnostics,
5026 warnings: graph.warnings,
5027 })
5028}
5029
5030#[cfg(test)]
5031fn build_convex_sync_report(
5032 path: &Path,
5033 scope: Option<&str>,
5034 snapshot_path: Option<&Path>,
5035 chunk_size: usize,
5036) -> Result<ConvexSyncReport> {
5037 let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5038 build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5039}
5040
5041pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5042 if compact {
5043 println!(
5044 "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5045 report.node_upserts.len(),
5046 report.node_tombstones.len(),
5047 report.edge_upserts.len(),
5048 report.edge_tombstones.len(),
5049 report.chunks.len(),
5050 report.freshness.status
5051 );
5052 return;
5053 }
5054
5055 println!(
5056 "Convex graph sync {}",
5057 if report.dry_run { "dry-run" } else { "apply" }
5058 );
5059 println!("root: {}", report.root);
5060 println!("graph_db: {}", report.graph_db);
5061 println!(
5062 "upserts: {} node(s), {} edge(s)",
5063 report.node_upserts.len(),
5064 report.edge_upserts.len()
5065 );
5066 println!(
5067 "tombstones: {} node(s), {} edge(s)",
5068 report.node_tombstones.len(),
5069 report.edge_tombstones.len()
5070 );
5071 println!("chunks: {}", report.chunks.len());
5072 println!("freshness: {}", report.freshness.status);
5073 if let Some(transport) = &report.transport {
5074 println!(
5075 "transport: endpoint_env={} auth_env={} applied_chunks={}",
5076 transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5077 );
5078 }
5079 for receipt in &report.receipts {
5080 println!(
5081 "receipt: {} chunk {} attempt {} {}",
5082 receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5083 );
5084 }
5085 for diagnostic in report
5086 .diagnostics
5087 .iter()
5088 .chain(report.freshness.diagnostics.iter())
5089 {
5090 println!("- {}", diagnostic);
5091 }
5092}
5093
5094pub(crate) struct ConvexSyncOptions<'a> {
5095 path: &'a Path,
5096 scope: Option<&'a str>,
5097 snapshot: Option<&'a Path>,
5098 chunk_size: usize,
5099 remote_snapshot: bool,
5100 apply: bool,
5101 endpoint: Option<&'a str>,
5102 auth_token_env: &'a str,
5103}
5104
5105#[derive(Serialize)]
5106struct GraphDbSchemaField {
5107 name: &'static str,
5108 value_type: &'static str,
5109 description: &'static str,
5110}
5111
5112#[derive(Serialize)]
5113struct GraphDbSchemaOperation {
5114 command: &'static str,
5115 description: &'static str,
5116}
5117
5118#[derive(Serialize)]
5119struct GraphDbSchemaContract {
5120 name: &'static str,
5121 version: &'static str,
5122 description: &'static str,
5123}
5124
5125#[derive(Serialize)]
5126struct GraphDbSchema {
5127 contract_versions: Vec<GraphDbSchemaContract>,
5128 node_fields: Vec<GraphDbSchemaField>,
5129 edge_fields: Vec<GraphDbSchemaField>,
5130 operations: Vec<GraphDbSchemaOperation>,
5131}
5132
5133#[derive(Clone, Serialize, Deserialize)]
5134struct GraphDbFreshnessReport {
5135 status: String,
5136 fail_closed: bool,
5137 projection_version: Option<String>,
5138 content_hash: Option<String>,
5139 source_watermark: Option<String>,
5140 diagnostics: Vec<String>,
5141}
5142
5143#[derive(Clone, Debug, Serialize)]
5144pub(crate) struct GraphEffectivenessReadiness {
5145 pub(crate) status: String,
5146 pub(crate) fail_closed: bool,
5147 pub(crate) reason: String,
5148 pub(crate) diagnostics: Vec<String>,
5149 pub(crate) next_commands: Vec<String>,
5150}
5151
5152#[derive(Clone, Debug, Serialize, PartialEq)]
5153struct GraphDbPropertyFilter {
5154 key: String,
5155 value: String,
5156}
5157
5158#[derive(Clone, Debug, Default)]
5159struct GraphDbQueryOptions {
5160 cursor: Option<String>,
5161 limit: Option<usize>,
5162 property_filters: Vec<GraphDbPropertyFilter>,
5163}
5164
5165#[derive(Clone, Debug, Serialize, PartialEq)]
5166struct GraphDbPageReport {
5167 #[serde(skip_serializing_if = "Option::is_none")]
5168 cursor: Option<String>,
5169 #[serde(skip_serializing_if = "Option::is_none")]
5170 limit: Option<usize>,
5171 #[serde(skip_serializing_if = "Option::is_none")]
5172 next_cursor: Option<String>,
5173 returned_nodes: usize,
5174 returned_edges: usize,
5175 truncated: bool,
5176 property_filters: Vec<GraphDbPropertyFilter>,
5177 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5178 diagnostics: Vec<String>,
5179}
5180
5181type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5182
5183#[derive(Clone, Debug, Serialize)]
5184struct CommunityTruncationSummary {
5185 total_communities: usize,
5186 fully_kept: usize,
5187 partially_pruned: usize,
5188 fully_pruned: usize,
5189 pruned_community_kinds: Vec<String>,
5190 pruned_community_top_labels: Vec<String>,
5191}
5192
5193#[derive(Clone, Debug, Serialize)]
5194struct GraphDbRankedNeighborhoodComparison {
5195 traversal_nodes: usize,
5196 traversal_edges: usize,
5197 pruned_count: usize,
5198 total_discovered: usize,
5199 latency_micros: u128,
5200 overlap_with_unranked_pct: f64,
5201 useful_hit_density_ranked: f64,
5202 useful_hit_density_unranked: f64,
5203 duplicate_name_count_ranked: usize,
5204 duplicate_name_count_unranked: usize,
5205 handle_coverage_ranked_pct: f64,
5206 handle_coverage_unranked_pct: f64,
5207 #[serde(skip_serializing_if = "Option::is_none")]
5208 community_truncation_summary: Option<CommunityTruncationSummary>,
5209 diagnostics: Vec<String>,
5210}
5211
5212#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5213struct GraphDbDroppedByBudget {
5214 item: String,
5215 kind: String,
5216 dropped: usize,
5217 reason: String,
5218}
5219
5220#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5221struct GraphDbOutputBudgetReport {
5222 max_tokens: usize,
5223 estimated_tokens: usize,
5224 selected_nodes: usize,
5225 selected_edges: usize,
5226 candidate_nodes: usize,
5227 candidate_edges: usize,
5228 dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5229 diagnostics: Vec<String>,
5230}
5231
5232#[derive(Clone, Debug, Serialize, PartialEq)]
5233struct GraphDbKnowledgeRetrieval {
5234 mode: String,
5235 query: String,
5236 seed_kind: String,
5237 seed_limit: usize,
5238 seed_count: usize,
5239 depth: usize,
5240 limit: usize,
5241 node_count: usize,
5242 edge_count: usize,
5243 truncated: bool,
5244 traversal: String,
5245 freshness_boundary: String,
5246 privacy_boundary: String,
5247 diagnostics: Vec<String>,
5248}
5249
5250struct GraphDbSemanticSeededSubgraph {
5251 nodes: Vec<SubstrateGraphNode>,
5252 edges: Vec<SubstrateGraphEdge>,
5253 truncated: bool,
5254 diagnostics: Vec<String>,
5255}
5256
5257type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5258
5259#[derive(Serialize)]
5260struct GraphDbReport {
5261 root: String,
5262 #[serde(skip_serializing_if = "Option::is_none")]
5263 scope: Option<String>,
5264 backend: String,
5265 query: String,
5266 freshness: GraphDbFreshnessReport,
5267 #[serde(skip_serializing_if = "Option::is_none")]
5268 readiness: Option<GraphEffectivenessReadiness>,
5269 #[serde(skip_serializing_if = "Option::is_none")]
5270 schema: Option<GraphDbSchema>,
5271 #[serde(skip_serializing_if = "Option::is_none")]
5272 node: Option<SubstrateTerseGraphNode>,
5273 #[serde(skip_serializing_if = "Option::is_none")]
5274 edge: Option<SubstrateTerseGraphEdge>,
5275 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5276 nodes: Vec<SubstrateTerseGraphNode>,
5277 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5278 edges: Vec<SubstrateTerseGraphEdge>,
5279 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5280 ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5281 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5282 semantic_related: Vec<SemanticRelatedItem>,
5283 #[serde(skip_serializing_if = "Option::is_none")]
5284 neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5285 #[serde(skip_serializing_if = "Option::is_none")]
5286 ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5287 #[serde(skip_serializing_if = "Option::is_none")]
5288 knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5289 #[serde(skip_serializing_if = "Option::is_none")]
5290 output_budget: Option<GraphDbOutputBudgetReport>,
5291 #[serde(skip_serializing_if = "Option::is_none")]
5292 path: Option<substrate::GraphPath>,
5293 #[serde(skip_serializing_if = "Option::is_none")]
5294 page: Option<GraphDbPageReport>,
5295 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5296 warnings: Vec<String>,
5297}
5298
5299struct ExperimentalReadOnlyGraphStore {
5300 backend: GraphDbExperimentalBackend,
5301 nodes: BTreeMap<String, SubstrateGraphNode>,
5302 edges: BTreeMap<String, SubstrateGraphEdge>,
5303 node_ids_by_kind: BTreeMap<String, Vec<String>>,
5304 outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5305}
5306
5307impl ExperimentalReadOnlyGraphStore {
5308 fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5309 validate_convex_projection_rows(rows)?;
5310 let nodes = rows
5311 .nodes
5312 .iter()
5313 .map(|row| {
5314 let node = SubstrateGraphNode {
5315 id: row.external_id.clone(),
5316 kind: row.kind.clone(),
5317 label: row.label.clone(),
5318 properties: row.properties.clone(),
5319 provenance: row.provenance.clone(),
5320 freshness: row.freshness.clone(),
5321 };
5322 (node.id.clone(), node)
5323 })
5324 .collect::<BTreeMap<_, _>>();
5325 let edges = rows
5326 .edges
5327 .iter()
5328 .map(|row| {
5329 let edge = SubstrateGraphEdge {
5330 id: row.edge_key.clone(),
5331 from_id: row.from_external_id.clone(),
5332 to_id: row.to_external_id.clone(),
5333 kind: row.kind.clone(),
5334 properties: row.properties.clone(),
5335 provenance: row.provenance.clone(),
5336 freshness: row.freshness.clone(),
5337 };
5338 (graph_db_edge_key(&edge), edge)
5339 })
5340 .collect::<BTreeMap<_, _>>();
5341 let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5342 for node in nodes.values() {
5343 node_ids_by_kind
5344 .entry(node.kind.clone())
5345 .or_default()
5346 .push(node.id.clone());
5347 }
5348 for ids in node_ids_by_kind.values_mut() {
5349 ids.sort();
5350 }
5351 let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5352 for edge in edges.values() {
5353 outgoing_edge_keys_by_from
5354 .entry(edge.from_id.clone())
5355 .or_default()
5356 .push(graph_db_edge_key(edge));
5357 }
5358 for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5359 edge_keys.sort_by(|left_key, right_key| {
5360 let left = &edges[left_key];
5361 let right = &edges[right_key];
5362 left.to_id
5363 .cmp(&right.to_id)
5364 .then(left.kind.cmp(&right.kind))
5365 .then(left_key.cmp(right_key))
5366 });
5367 }
5368 Ok(Self {
5369 backend,
5370 nodes,
5371 edges,
5372 node_ids_by_kind,
5373 outgoing_edge_keys_by_from,
5374 })
5375 }
5376}
5377
5378impl GraphStore for ExperimentalReadOnlyGraphStore {
5379 fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5380 bail!("{} backend-eval adapter is read-only", self.backend.name())
5381 }
5382
5383 fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5384 bail!("{} backend-eval adapter is read-only", self.backend.name())
5385 }
5386
5387 fn delete_node(&self, _id: &str) -> Result<usize> {
5388 bail!("{} backend-eval adapter is read-only", self.backend.name())
5389 }
5390
5391 fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
5392 bail!("{} backend-eval adapter is read-only", self.backend.name())
5393 }
5394
5395 fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
5396 Ok(self.nodes.get(id).cloned())
5397 }
5398
5399 fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
5400 Ok(self.nodes.values().cloned().collect())
5401 }
5402
5403 fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
5404 let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
5405 edges.sort_by(|left, right| {
5406 left.from_id
5407 .cmp(&right.from_id)
5408 .then(left.kind.cmp(&right.kind))
5409 .then(left.to_id.cmp(&right.to_id))
5410 });
5411 Ok(edges)
5412 }
5413
5414 fn graph_counts(&self) -> Result<(usize, usize)> {
5415 Ok((self.nodes.len(), self.edges.len()))
5416 }
5417
5418 fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
5419 let mut edges = self
5420 .edges
5421 .values()
5422 .filter(|edge| edge.from_id != edge.to_id)
5423 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5424 .cloned()
5425 .collect::<Vec<_>>();
5426 edges.sort_by(|left, right| {
5427 left.from_id
5428 .cmp(&right.from_id)
5429 .then(left.kind.cmp(&right.kind))
5430 .then(left.to_id.cmp(&right.to_id))
5431 });
5432 Ok(edges.into_iter().next())
5433 }
5434
5435 fn sample_edge_with_property(
5436 &self,
5437 ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
5438 Ok(self
5439 .edges
5440 .values()
5441 .filter(|edge| edge.from_id != edge.to_id)
5442 .filter_map(|edge| {
5443 edge.properties.iter().next().map(|(key, value)| {
5444 (
5445 edge,
5446 GraphPropertyFilter {
5447 key: key.clone(),
5448 value: value.clone(),
5449 },
5450 )
5451 })
5452 })
5453 .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
5454 left_filter
5455 .key
5456 .cmp(&right_filter.key)
5457 .then(left_filter.value.cmp(&right_filter.value))
5458 .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
5459 })
5460 .map(|(edge, filter)| (edge.clone(), filter)))
5461 }
5462
5463 fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
5464 Ok(self
5465 .node_ids_by_kind
5466 .get(kind)
5467 .into_iter()
5468 .flatten()
5469 .filter_map(|id| self.nodes.get(id).cloned())
5470 .collect())
5471 }
5472
5473 fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
5474 Ok(self
5475 .outgoing_edge_keys_by_from
5476 .get(from_id)
5477 .into_iter()
5478 .flatten()
5479 .filter_map(|key| self.edges.get(key))
5480 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5481 .cloned()
5482 .collect())
5483 }
5484
5485 fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
5486 Ok(self
5487 .edges
5488 .values()
5489 .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
5490 .cloned()
5491 .collect())
5492 }
5493
5494 fn shortest_path(
5495 &self,
5496 from_id: &str,
5497 to_id: &str,
5498 kind: Option<&str>,
5499 ) -> Result<Option<substrate::GraphPath>> {
5500 if from_id == to_id {
5501 return Ok(Some(substrate::GraphPath {
5502 nodes: vec![from_id.to_string()],
5503 hops: 0,
5504 }));
5505 }
5506
5507 let mut queue = VecDeque::new();
5508 let mut parent = BTreeMap::<String, String>::new();
5509 parent.insert(from_id.to_string(), String::new());
5510 queue.push_back(from_id.to_string());
5511
5512 while let Some(current) = queue.pop_front() {
5513 for edge in self.outgoing_edges(¤t, kind)? {
5514 if parent.contains_key(&edge.to_id) {
5515 continue;
5516 }
5517 parent.insert(edge.to_id.clone(), current.clone());
5518 if edge.to_id == to_id {
5519 let mut nodes = vec![to_id.to_string()];
5520 let mut cursor = to_id;
5521 while let Some(previous) = parent.get(cursor) {
5522 if previous.is_empty() {
5523 break;
5524 }
5525 nodes.push(previous.clone());
5526 cursor = previous;
5527 }
5528 nodes.reverse();
5529 return Ok(Some(substrate::GraphPath {
5530 hops: nodes.len().saturating_sub(1),
5531 nodes,
5532 }));
5533 }
5534 queue.push_back(edge.to_id);
5535 }
5536 }
5537
5538 Ok(None)
5539 }
5540
5541 fn reachable_nodes_by_kinds(
5542 &self,
5543 from_id: &str,
5544 kinds: &[&str],
5545 depth: usize,
5546 limit: usize,
5547 ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
5548 let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
5549 let mut rows = requested
5550 .iter()
5551 .map(|kind| {
5552 (
5553 (*kind).to_string(),
5554 BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
5555 )
5556 })
5557 .collect::<BTreeMap<_, _>>();
5558 if requested.is_empty() {
5559 return Ok(BTreeMap::new());
5560 }
5561
5562 let mut seen = BTreeSet::from([from_id.to_string()]);
5563 let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
5564 while let Some((current, path)) = queue.pop_front() {
5565 let current_depth = path.len().saturating_sub(1);
5566 if current_depth >= depth {
5567 continue;
5568 }
5569 for edge in self.outgoing_edges(¤t, None)? {
5570 if !seen.insert(edge.to_id.clone()) {
5571 continue;
5572 }
5573 let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
5574 continue;
5575 };
5576 let mut next_path = path.clone();
5577 next_path.push(edge.to_id.clone());
5578 let graph_path = substrate::GraphPath {
5579 hops: next_path.len().saturating_sub(1),
5580 nodes: next_path.clone(),
5581 };
5582 if requested.contains(node.kind.as_str()) {
5583 rows.entry(node.kind.clone())
5584 .or_default()
5585 .entry(node.id.clone())
5586 .or_insert((node.clone(), graph_path));
5587 }
5588 queue.push_back((edge.to_id, next_path));
5589 }
5590 }
5591
5592 Ok(rows
5593 .into_iter()
5594 .map(|(kind, values)| {
5595 let mut values = values.into_values().collect::<Vec<_>>();
5596 values.sort_by(|(left_node, left_path), (right_node, right_path)| {
5597 left_path
5598 .hops
5599 .cmp(&right_path.hops)
5600 .then(left_node.label.cmp(&right_node.label))
5601 .then(left_node.id.cmp(&right_node.id))
5602 });
5603 if limit > 0 && values.len() > limit {
5604 values.truncate(limit);
5605 }
5606 (kind, values)
5607 })
5608 .collect())
5609 }
5610}
5611
5612pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
5613pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
5614pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
5615const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
5616pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
5617const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
5618const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
5619const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
5620const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
5621
5622#[derive(Clone, Serialize, Deserialize)]
5623pub(crate) struct GraphDbBackendEvalPhaseTiming {
5624 name: String,
5625 duration_micros: u128,
5626 detail: String,
5627}
5628
5629#[derive(Serialize, Deserialize)]
5630struct GraphDbBackendEvalFullProjectionCache {
5631 version: String,
5632 key: String,
5633 source_watermark: String,
5634 projection: GraphProjection,
5635 warnings: Vec<String>,
5636}
5637
5638#[derive(Clone, Default)]
5639struct GraphDbBackendEvalFullProjectionCacheStats {
5640 hit: bool,
5641 disk_bytes: u64,
5642 json_bytes: u64,
5643 pruned_files: usize,
5644 pruned_bytes: u64,
5645}
5646
5647#[derive(Serialize)]
5648struct GraphDbBackendEvalRawSourceWatermarkRow {
5649 path: String,
5650 bytes: u64,
5651 content_hash: String,
5652}
5653
5654#[derive(Clone)]
5655struct GraphDbBackendEvalFullProjectionSourceWatermark {
5656 value: String,
5657 detail: String,
5658}
5659
5660#[derive(Serialize)]
5661pub(crate) struct GraphDbBackendEvalConfig {
5662 high_degree_nodes: usize,
5663 high_degree_fanout: usize,
5664 deep_chain_nodes: usize,
5665 deep_chain_fanout: usize,
5666 depth: usize,
5667 limit: usize,
5668 impact_limit: usize,
5669 path_max_hops: usize,
5670 path_direct_hop_budget: usize,
5671 path_deep_chain_hop_budget: usize,
5672 path_extended_hop_budgets: Vec<usize>,
5673 path_hop_policy: String,
5674 path_probe_strategy: String,
5675 path_query_plan_checks: Vec<String>,
5676 full_projection_enabled: bool,
5677 full_projection_profile: String,
5678 normalization_row_unit: usize,
5679}
5680
5681#[derive(Clone)]
5682struct GraphDbBackendEvalSignature {
5683 operation: String,
5684 value: serde_json::Value,
5685}
5686
5687#[derive(Serialize)]
5688struct GraphDbBackendEvalOperation {
5689 name: String,
5690 supported: bool,
5691 status: String,
5692 duration_micros: u128,
5693 #[serde(skip_serializing_if = "Option::is_none")]
5694 rows: Option<usize>,
5695 #[serde(skip_serializing_if = "Option::is_none")]
5696 error: Option<String>,
5697}
5698
5699#[derive(Serialize)]
5700struct GraphDbBackendEvalParity {
5701 matches_sqlite: bool,
5702 diagnostics: Vec<String>,
5703}
5704
5705#[derive(Serialize)]
5706struct GraphDbBackendEvalBackendReport {
5707 backend: String,
5708 adapter: String,
5709 read_only: bool,
5710 projection_load: String,
5711 operations: Vec<GraphDbBackendEvalOperation>,
5712 total_micros: u128,
5713 parity: GraphDbBackendEvalParity,
5714 lock_behavior: String,
5715 install_portability: String,
5716}
5717
5718#[derive(Serialize)]
5719struct GraphDbBackendEvalDataset {
5720 name: String,
5721 target_count: usize,
5722 nodes: usize,
5723 edges: usize,
5724 backends: Vec<GraphDbBackendEvalBackendReport>,
5725}
5726
5727#[derive(Serialize)]
5728struct GraphDbBackendPromotionDecision {
5729 backend: String,
5730 decision: String,
5731 reasons: Vec<String>,
5732 gate: GraphDbBackendPromotionGate,
5733}
5734
5735#[derive(Serialize)]
5736struct GraphDbBackendEvalPerformanceGate {
5737 baseline_fixture: String,
5738 ci_profile: String,
5739 opt_in_real_profile: String,
5740 full_projection_cache_hit_gate: String,
5741 allowed_regression_percent: f64,
5742 minimum_sample_runs: usize,
5743 normalized_metric_unit: String,
5744 required_metrics: Vec<String>,
5745 digest_command: String,
5746 repeated_sample_command: String,
5747 hop_cap_promotion: GraphDbHopCapPromotionGate,
5748 backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
5749}
5750
5751#[derive(Serialize)]
5752struct GraphDbHopCapPromotionGate {
5753 status: String,
5754 current_default_hops: usize,
5755 candidate_hop_tiers: Vec<usize>,
5756 required_backend: String,
5757 required_workloads: Vec<String>,
5758 required_metrics: Vec<String>,
5759 allowed_regression_percent: f64,
5760 minimum_sample_runs: usize,
5761 decision_rule: String,
5762}
5763
5764#[derive(Serialize)]
5765struct GraphDbBackendAdapterSpikeGate {
5766 status: String,
5767 candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
5768 required_workloads: Vec<String>,
5769 required_checks: Vec<String>,
5770 decision_rule: String,
5771 evidence_plan: String,
5772}
5773
5774#[derive(Serialize)]
5775struct GraphDbBackendAdapterSpikeCandidate {
5776 backend: String,
5777 adapter_label: String,
5778 projection_load: String,
5779 lock_behavior: String,
5780 install_portability: String,
5781}
5782
5783#[derive(Serialize)]
5784pub(crate) struct GraphDbBackendEvalReport {
5785 root: String,
5786 #[serde(skip_serializing_if = "Option::is_none")]
5787 scope: Option<String>,
5788 label: String,
5789 baseline_backend: String,
5790 candidates: Vec<String>,
5791 targets: Vec<String>,
5792 config: GraphDbBackendEvalConfig,
5793 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5794 datasets: Vec<GraphDbBackendEvalDataset>,
5795 promotion: Vec<GraphDbBackendPromotionDecision>,
5796 performance_gate: GraphDbBackendEvalPerformanceGate,
5797 metrics: BTreeMap<String, f64>,
5798 metric_digest_command: String,
5799 warnings: Vec<String>,
5800}
5801
5802#[derive(Clone, Debug, Serialize)]
5803struct GraphDbDoctorCheck {
5804 name: String,
5805 status: String,
5806 fail_closed: bool,
5807 diagnostics: Vec<String>,
5808 repair_commands: Vec<String>,
5809}
5810
5811#[derive(Serialize)]
5812pub(crate) struct GraphDbDoctorReport {
5813 root: String,
5814 #[serde(skip_serializing_if = "Option::is_none")]
5815 scope: Option<String>,
5816 backend: String,
5817 graph_db: String,
5818 #[serde(skip_serializing_if = "Option::is_none")]
5819 convex_snapshot: Option<String>,
5820 status: String,
5821 fail_closed: bool,
5822 checks: Vec<GraphDbDoctorCheck>,
5823 repair_commands: Vec<String>,
5824 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5825 required_indexes: Vec<ConvexRequiredIndex>,
5826}
5827
5828#[derive(Serialize)]
5829struct GraphDbDriftSummary {
5830 node_upserts: usize,
5831 edge_upserts: usize,
5832 node_tombstones: usize,
5833 edge_tombstones: usize,
5834 stale_nodes: usize,
5835 stale_edges: usize,
5836 stale_projection_metadata: usize,
5837 duplicate_failures: usize,
5838 orphan_failures: usize,
5839 missing_required_indexes: usize,
5840}
5841
5842#[derive(Serialize)]
5843struct GraphDbDriftReport {
5844 root: String,
5845 #[serde(skip_serializing_if = "Option::is_none")]
5846 scope: Option<String>,
5847 graph_db: String,
5848 convex_snapshot: String,
5849 status: String,
5850 graph_reads_allowed: bool,
5851 projection_version: String,
5852 local_hash: Option<String>,
5853 snapshot_hash: Option<String>,
5854 summary: GraphDbDriftSummary,
5855 node_upserts: Vec<String>,
5856 edge_upserts: Vec<String>,
5857 node_tombstones: Vec<String>,
5858 edge_tombstones: Vec<String>,
5859 stale_nodes: Vec<String>,
5860 stale_edges: Vec<String>,
5861 diagnostics: Vec<String>,
5862 next_commands: Vec<String>,
5863 required_indexes: Vec<ConvexRequiredIndex>,
5864 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5865 warnings: Vec<String>,
5866}
5867
5868#[derive(Clone, Serialize)]
5869struct GraphDbTombstoneCounts {
5870 nodes: usize,
5871 edges: usize,
5872 total: usize,
5873}
5874
5875#[derive(Clone, Serialize)]
5876struct GraphDbOperatorCounts {
5877 nodes: usize,
5878 edges: usize,
5879 tombstones: GraphDbTombstoneCounts,
5880 #[serde(skip_serializing_if = "Option::is_none")]
5881 file_size_bytes: Option<u64>,
5882 #[serde(skip_serializing_if = "Option::is_none")]
5883 freelist_bytes: Option<u64>,
5884}
5885
5886#[derive(Clone, Serialize)]
5887struct GraphDbCompactionPolicy {
5888 status: String,
5889 tombstone_scan_rows: usize,
5890 live_rows: usize,
5891 file_size_bytes: Option<u64>,
5892 freelist_bytes: Option<u64>,
5893 safe_to_prune_tombstones: bool,
5894 requires_convex_reconciliation: bool,
5895 recommendations: Vec<String>,
5896 proof: Vec<String>,
5897}
5898
5899#[derive(Serialize)]
5900pub(crate) struct GraphDbRefreshSummary {
5901 scope: String,
5902 projection_version: String,
5903 mode: String,
5904 #[serde(skip_serializing_if = "Option::is_none")]
5905 source_watermark: Option<String>,
5906 tombstoned_nodes: usize,
5907 tombstoned_edges: usize,
5908 upserted_nodes: usize,
5909 upserted_edges: usize,
5910 unchanged_nodes: usize,
5911 unchanged_edges: usize,
5912 upserted_properties: usize,
5913 unchanged_properties: usize,
5914 deleted_properties: usize,
5915 deleted_nodes: usize,
5916 deleted_edges: usize,
5917 pruned_tombstones: usize,
5918 #[serde(skip_serializing_if = "Option::is_none")]
5919 file_size_bytes_before: Option<u64>,
5920 #[serde(skip_serializing_if = "Option::is_none")]
5921 file_size_bytes_after: Option<u64>,
5922 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5923 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5924}
5925
5926#[derive(Serialize)]
5927struct GraphDbOperatorReport {
5928 root: String,
5929 #[serde(skip_serializing_if = "Option::is_none")]
5930 scope: Option<String>,
5931 graph_db: String,
5932 operation: String,
5933 status: String,
5934 materialized: bool,
5935 freshness: GraphDbFreshnessReport,
5936 readiness: GraphEffectivenessReadiness,
5937 counts: GraphDbOperatorCounts,
5938 #[serde(skip_serializing_if = "Option::is_none")]
5939 refresh: Option<GraphDbRefreshSummary>,
5940 compaction: GraphDbCompactionPolicy,
5941 #[serde(skip_serializing_if = "Option::is_none")]
5942 recovery: Option<index::ReadOnlyRecovery>,
5943 next_commands: Vec<String>,
5944 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5945 warnings: Vec<String>,
5946}
5947
5948#[derive(Serialize)]
5949pub(crate) struct GraphDbCompactionReport {
5950 root: String,
5951 #[serde(skip_serializing_if = "Option::is_none")]
5952 scope: Option<String>,
5953 graph_db: String,
5954 applied: bool,
5955 pruned_tombstones: usize,
5956 counts_before: GraphDbOperatorCounts,
5957 counts_after: GraphDbOperatorCounts,
5958 compaction_before: GraphDbCompactionPolicy,
5959 compaction_after: GraphDbCompactionPolicy,
5960 reclaimed_bytes: i64,
5961 next_commands: Vec<String>,
5962 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5963 warnings: Vec<String>,
5964}
5965
5966#[derive(Clone, Serialize, Deserialize)]
5967struct GraphDbEvidencePath {
5968 to: String,
5969 kind: String,
5970 label: String,
5971 #[serde(skip_serializing_if = "Option::is_none")]
5972 path: Option<substrate::GraphPath>,
5973 #[serde(skip_serializing_if = "Option::is_none")]
5974 expand: Option<String>,
5975}
5976
5977#[derive(Clone, Serialize, Deserialize)]
5978struct GraphDbFixtureCoverage {
5979 test: String,
5980 fixture: String,
5981 assertions: Vec<String>,
5982}
5983
5984#[derive(Clone, Serialize, Deserialize)]
5985struct GraphDbEvidenceReport {
5986 root: String,
5987 #[serde(skip_serializing_if = "Option::is_none")]
5988 scope: Option<String>,
5989 backend: String,
5990 contract_version: String,
5991 target: String,
5992 packet_id: String,
5993 #[serde(skip_serializing_if = "Option::is_none")]
5994 projection_hash: Option<String>,
5995 freshness: GraphDbFreshnessReport,
5996 target_node: SubstrateTerseGraphNode,
5997 worker_context: Vec<SubstrateTerseGraphNode>,
5998 source_handles: Vec<SubstrateTerseGraphNode>,
5999 worker_results: Vec<SubstrateTerseGraphNode>,
6000 semantic_related: Vec<SubstrateTerseGraphNode>,
6001 shortest_paths: Vec<GraphDbEvidencePath>,
6002 #[serde(skip_serializing_if = "Option::is_none")]
6003 output_budget: Option<GraphDbOutputBudgetReport>,
6004 #[serde(default)]
6005 truncated: bool,
6006 #[serde(skip_serializing_if = "Option::is_none")]
6007 next_cursor: Option<String>,
6008 next_commands: Vec<String>,
6009 replay_commands: Vec<String>,
6010 repair_commands: Vec<String>,
6011 fixture_coverage: GraphDbFixtureCoverage,
6012 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6013 warnings: Vec<String>,
6014}
6015
6016pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6017 root: &'a Path,
6018 scope: Option<&'a str>,
6019 backend: &'a str,
6020 target: &'a str,
6021 depth: usize,
6022 limit: usize,
6023 cursor: Option<&'a str>,
6024 store: &'a S,
6025 freshness: GraphDbFreshnessReport,
6026 warnings: Vec<String>,
6027}
6028
6029impl GraphDbDoctorReport {
6030 fn new(
6031 root: &Path,
6032 scope: Option<&str>,
6033 backend: &str,
6034 graph_db: &Path,
6035 convex_snapshot: Option<&Path>,
6036 ) -> Self {
6037 Self {
6038 root: root.to_string_lossy().to_string(),
6039 scope: scope.map(str::to_string),
6040 backend: backend.to_string(),
6041 graph_db: graph_db.to_string_lossy().to_string(),
6042 convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6043 status: "ok".to_string(),
6044 fail_closed: false,
6045 checks: Vec::new(),
6046 repair_commands: Vec::new(),
6047 required_indexes: Vec::new(),
6048 }
6049 }
6050
6051 fn push_check(&mut self, check: GraphDbDoctorCheck) {
6052 self.checks.push(check);
6053 }
6054
6055 fn finalize(&mut self) {
6056 self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6057 self.status = if self.fail_closed {
6058 "fail_closed"
6059 } else {
6060 "ok"
6061 }
6062 .to_string();
6063 let mut commands = BTreeSet::new();
6064 for check in &self.checks {
6065 commands.extend(check.repair_commands.iter().cloned());
6066 }
6067 self.repair_commands = commands.into_iter().collect();
6068 }
6069
6070 fn summary(&self) -> String {
6071 self.checks
6072 .iter()
6073 .filter(|check| check.fail_closed)
6074 .flat_map(|check| check.diagnostics.iter())
6075 .take(3)
6076 .cloned()
6077 .collect::<Vec<_>>()
6078 .join("; ")
6079 }
6080}
6081
6082fn graph_db_doctor_check(
6083 name: impl Into<String>,
6084 diagnostics: Vec<String>,
6085 repair_commands: Vec<String>,
6086) -> GraphDbDoctorCheck {
6087 let fail_closed = !diagnostics.is_empty();
6088 GraphDbDoctorCheck {
6089 name: name.into(),
6090 status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6091 fail_closed,
6092 diagnostics,
6093 repair_commands: if fail_closed {
6094 repair_commands
6095 } else {
6096 Vec::new()
6097 },
6098 }
6099}
6100
6101pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6102 scope
6103 .map(|scope| format!(" --scope {}", shell_quote(scope)))
6104 .unwrap_or_default()
6105}
6106
6107fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6108 format!(
6109 "tsift graph-db --path {}{} refresh --json",
6110 shell_quote(root.to_string_lossy().as_ref()),
6111 graph_db_scope_arg(scope)
6112 )
6113}
6114
6115fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6116 graph_db_refresh_command(root, scope)
6117}
6118
6119fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6120 let backup = format!("{}.bak", graph_db.to_string_lossy());
6121 format!(
6122 "mv {} {} && {}",
6123 shell_quote(graph_db.to_string_lossy().as_ref()),
6124 shell_quote(&backup),
6125 graph_db_rebuild_command(root, scope)
6126 )
6127}
6128
6129fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6130 format!(
6131 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6132 shell_quote(root.to_string_lossy().as_ref()),
6133 graph_db_scope_arg(scope)
6134 )
6135}
6136
6137fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6138 substrate::open_graph_read_only_connection_resilient(graph_db)
6139}
6140
6141fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6142 conn.query_row(
6143 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6144 [table],
6145 |row| row.get::<_, bool>(0),
6146 )
6147 .map_err(Into::into)
6148}
6149
6150fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6151 let sql = match table {
6152 "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6153 "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6154 "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6155 other => bail!("unsupported graph count table {other}"),
6156 };
6157 conn.query_row(sql, [], |row| row.get::<_, usize>(0))
6158 .map_err(Into::into)
6159}
6160
6161fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6162 if !sqlite_table_exists(conn, "graph_tombstones")? {
6163 return Ok(GraphDbTombstoneCounts {
6164 nodes: 0,
6165 edges: 0,
6166 total: 0,
6167 });
6168 }
6169 let mut stmt =
6170 conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6171 let mut rows = stmt.query([])?;
6172 let mut nodes = 0usize;
6173 let mut edges = 0usize;
6174 while let Some(row) = rows.next()? {
6175 let row_kind: String = row.get(0)?;
6176 let count: usize = row.get(1)?;
6177 match row_kind.as_str() {
6178 "node" => nodes = count,
6179 "edge" => edges = count,
6180 _ => {}
6181 }
6182 }
6183 Ok(GraphDbTombstoneCounts {
6184 nodes,
6185 edges,
6186 total: nodes + edges,
6187 })
6188}
6189
6190fn sqlite_graph_counts_from_cache(
6191 conn: &Connection,
6192 scope: &str,
6193) -> Result<Option<GraphDbOperatorCounts>> {
6194 if !sqlite_table_exists(conn, "graph_operator_stats")? {
6195 return Ok(None);
6196 }
6197 let row = conn
6198 .query_row(
6199 r#"
6200 SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6201 FROM graph_operator_stats
6202 WHERE scope = ?1
6203 "#,
6204 [scope],
6205 |row| {
6206 Ok((
6207 row.get::<_, usize>(0)?,
6208 row.get::<_, usize>(1)?,
6209 row.get::<_, usize>(2)?,
6210 row.get::<_, usize>(3)?,
6211 row.get::<_, Option<i64>>(4)?,
6212 row.get::<_, Option<i64>>(5)?,
6213 ))
6214 },
6215 )
6216 .optional()?;
6217 Ok(row.map(
6218 |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6219 GraphDbOperatorCounts {
6220 nodes,
6221 edges,
6222 tombstones: GraphDbTombstoneCounts {
6223 nodes: tombstone_nodes,
6224 edges: tombstone_edges,
6225 total: tombstone_nodes + tombstone_edges,
6226 },
6227 file_size_bytes: file_size_bytes
6228 .and_then(|value| u64::try_from(value).ok())
6229 .or_else(|| sqlite_database_size_bytes(conn).ok()),
6230 freelist_bytes: freelist_bytes
6231 .and_then(|value| u64::try_from(value).ok())
6232 .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6233 }
6234 },
6235 ))
6236}
6237
6238fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6239 if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6240 return Ok(counts);
6241 }
6242 let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6243 sqlite_known_table_count(conn, "graph_nodes")?
6244 } else {
6245 0
6246 };
6247 let edges = if sqlite_table_exists(conn, "graph_edges")? {
6248 sqlite_known_table_count(conn, "graph_edges")?
6249 } else {
6250 0
6251 };
6252 Ok(GraphDbOperatorCounts {
6253 nodes,
6254 edges,
6255 tombstones: sqlite_tombstone_counts(conn)?,
6256 file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6257 freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6258 })
6259}
6260
6261fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6262 if !sqlite_table_exists(conn, "graph_nodes")? {
6263 return Ok(0);
6264 }
6265 let count: i64 = conn.query_row(
6266 "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6267 [],
6268 |row| row.get(0),
6269 )?;
6270 Ok(count as usize)
6271}
6272
6273pub(crate) fn graph_db_compaction_policy(
6274 root: &Path,
6275 scope: Option<&str>,
6276 counts: &GraphDbOperatorCounts,
6277 prune_confirmed: bool,
6278) -> GraphDbCompactionPolicy {
6279 let live_rows = counts.nodes + counts.edges;
6280 let tombstone_scan_rows = counts.tombstones.total;
6281 let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6282 let freelist_heavy = counts
6283 .file_size_bytes
6284 .zip(counts.freelist_bytes)
6285 .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6286 let status = if tombstone_heavy || freelist_heavy {
6287 "recommended"
6288 } else {
6289 "not_needed"
6290 }
6291 .to_string();
6292 let mut recommendations = vec![
6293 convex_refresh_command(root, scope),
6294 graph_db_refresh_command(root, scope),
6295 format!(
6296 "tsift graph-db --path {}{} compact --apply --json",
6297 shell_quote(root.to_string_lossy().as_ref()),
6298 graph_db_scope_arg(scope)
6299 ),
6300 ];
6301 if prune_confirmed {
6302 recommendations.push(format!(
6303 "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6304 shell_quote(root.to_string_lossy().as_ref()),
6305 graph_db_scope_arg(scope)
6306 ));
6307 }
6308 let proof = vec![
6309 format!("{live_rows} live graph row(s)"),
6310 format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6311 format!(
6312 "graph.db file_size={} byte(s), freelist={} byte(s)",
6313 counts.file_size_bytes.unwrap_or(0),
6314 counts.freelist_bytes.unwrap_or(0)
6315 ),
6316 ];
6317 GraphDbCompactionPolicy {
6318 status,
6319 tombstone_scan_rows,
6320 live_rows,
6321 file_size_bytes: counts.file_size_bytes,
6322 freelist_bytes: counts.freelist_bytes,
6323 safe_to_prune_tombstones: prune_confirmed,
6324 requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6325 recommendations,
6326 proof,
6327 }
6328}
6329
6330fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6331 let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
6332 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6333 Ok(page_count.saturating_mul(page_size))
6334}
6335
6336fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6337 let freelist_count: u64 = conn.query_row("PRAGMA freelist_count", [], |row| row.get(0))?;
6338 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6339 Ok(freelist_count.saturating_mul(page_size))
6340}
6341
6342fn sqlite_graph_tombstone_retention_diagnostics(
6343 conn: &Connection,
6344 scope: &str,
6345) -> Result<Vec<String>> {
6346 if !sqlite_table_exists(conn, "graph_tombstones")? {
6347 return Ok(Vec::new());
6348 }
6349 let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6350 let counts = match cached.clone() {
6351 Some(counts) => counts,
6352 None => sqlite_graph_counts(conn, scope)?,
6353 };
6354 let live_rows = counts.nodes + counts.edges;
6355 let file_size = counts.file_size_bytes.unwrap_or(0);
6356 let freelist = counts.freelist_bytes.unwrap_or(0);
6357 let stale_live_tombstones = if cached.is_some() {
6358 0
6359 } else {
6360 let mut live_keys = BTreeSet::new();
6361 if sqlite_table_exists(conn, "graph_nodes")? {
6362 let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6363 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6364 live_keys.insert(format!("node:{}", row?));
6365 }
6366 }
6367 if sqlite_table_exists(conn, "graph_edges")? {
6368 let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6369 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6370 live_keys.insert(format!("edge:{}", row?));
6371 }
6372 }
6373 let mut stale_live_tombstones = 0usize;
6374 let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6375 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6376 if live_keys.contains(&row?) {
6377 stale_live_tombstones += 1;
6378 }
6379 }
6380 stale_live_tombstones
6381 };
6382
6383 let mut diagnostics = Vec::new();
6384 if stale_live_tombstones > 0 {
6385 diagnostics.push(format!(
6386 "{stale_live_tombstones} tombstone(s) reference rows that are live again; the next graph-db refresh prunes those stale tombstones before inserting new deletion markers"
6387 ));
6388 }
6389 if counts.tombstones.total > live_rows.max(1) {
6390 let source = if cached.is_some() {
6391 "cached refresh stats"
6392 } else {
6393 "live row scan"
6394 };
6395 diagnostics.push(format!(
6396 "tombstone retention exceeds live graph rows: {} tombstone(s) vs {} live row(s) from {}; graph.db file_size={} byte(s), freelist={} byte(s), status/doctor tombstone scans inspect {} extra row(s). Run convex-sync against the remote snapshot before rebuild/compaction if a remote consumer may still need deletion reconciliation.",
6397 counts.tombstones.total,
6398 live_rows,
6399 source,
6400 file_size,
6401 freelist,
6402 counts.tombstones.total
6403 ));
6404 }
6405 Ok(diagnostics)
6406}
6407
6408fn sqlite_graph_freshness_from_conn(
6409 conn: &Connection,
6410 scope: &str,
6411) -> Result<GraphDbFreshnessReport> {
6412 if !sqlite_table_exists(conn, "graph_projection_versions")? {
6413 return Ok(GraphDbFreshnessReport {
6414 status: "missing".to_string(),
6415 fail_closed: true,
6416 projection_version: None,
6417 content_hash: None,
6418 source_watermark: None,
6419 diagnostics: vec![
6420 "graph projection metadata table is missing; refresh graph.db before trusting reads"
6421 .to_string(),
6422 ],
6423 });
6424 }
6425 let version = conn
6426 .query_row(
6427 r#"
6428 SELECT projection_version, content_hash, source_watermark
6429 FROM graph_projection_versions
6430 WHERE scope = ?1
6431 "#,
6432 [scope],
6433 |row| {
6434 Ok((
6435 row.get::<_, String>(0)?,
6436 row.get::<_, Option<String>>(1)?,
6437 row.get::<_, Option<String>>(2)?,
6438 ))
6439 },
6440 )
6441 .optional()?;
6442 let Some((projection_version, content_hash, source_watermark)) = version else {
6443 return Ok(GraphDbFreshnessReport {
6444 status: "missing".to_string(),
6445 fail_closed: true,
6446 projection_version: None,
6447 content_hash: None,
6448 source_watermark: None,
6449 diagnostics: vec![
6450 "graph projection metadata is missing; refresh graph.db before trusting reads"
6451 .to_string(),
6452 ],
6453 });
6454 };
6455
6456 let mut diagnostics = Vec::new();
6457 if projection_version != GRAPH_PROJECTION_VERSION {
6458 diagnostics.push(format!(
6459 "projection version mismatch: expected {} got {}",
6460 GRAPH_PROJECTION_VERSION, projection_version
6461 ));
6462 }
6463 if content_hash.is_none() {
6464 diagnostics.push("projection content hash is missing".to_string());
6465 }
6466 let fail_closed = !diagnostics.is_empty();
6467 Ok(GraphDbFreshnessReport {
6468 status: if fail_closed { "stale" } else { "current" }.to_string(),
6469 fail_closed,
6470 projection_version: Some(projection_version),
6471 content_hash,
6472 source_watermark,
6473 diagnostics,
6474 })
6475}
6476
6477fn graph_db_operator_next_commands(
6478 root: &Path,
6479 scope: Option<&str>,
6480 include_refresh: bool,
6481) -> Vec<String> {
6482 let mut commands = Vec::new();
6483 if include_refresh {
6484 commands.push(graph_db_refresh_command(root, scope));
6485 }
6486 commands.push(format!(
6487 "tsift graph-db --path {}{} doctor --json",
6488 shell_quote(root.to_string_lossy().as_ref()),
6489 graph_db_scope_arg(scope)
6490 ));
6491 commands.push(format!(
6492 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
6493 shell_quote(root.to_string_lossy().as_ref()),
6494 graph_db_scope_arg(scope)
6495 ));
6496 commands.push(format!(
6497 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6498 shell_quote(root.to_string_lossy().as_ref()),
6499 graph_db_scope_arg(scope)
6500 ));
6501 commands
6502}
6503
6504pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
6505 match recovery {
6506 index::ReadOnlyRecovery::SnapshotFallback => {
6507 "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
6508 }
6509 index::ReadOnlyRecovery::SnapshotFallbackWal => {
6510 "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
6511 }
6512 }
6513}
6514
6515fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
6516 let mut stmt = conn.prepare(sql)?;
6517 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6518 let mut values = BTreeSet::new();
6519 for row in rows {
6520 values.insert(row?);
6521 }
6522 Ok(values)
6523}
6524
6525fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
6526 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
6527 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
6528 let mut columns = BTreeSet::new();
6529 for row in rows {
6530 columns.insert(row?);
6531 }
6532 Ok(columns)
6533}
6534
6535fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6536 let mut diagnostics = Vec::new();
6537 let user_version: i64 =
6538 conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
6539 if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
6540 diagnostics.push(format!(
6541 "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6542 ));
6543 } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
6544 diagnostics.push(format!(
6545 "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6546 ));
6547 }
6548
6549 let tables = sqlite_string_set(
6550 conn,
6551 "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
6552 )?;
6553 let required_tables = [
6554 (
6555 "graph_nodes",
6556 vec![
6557 "id",
6558 "kind",
6559 "label",
6560 "properties_json",
6561 "provenance_json",
6562 "freshness_json",
6563 "row_hash",
6564 "source_watermark",
6565 ],
6566 ),
6567 (
6568 "graph_edges",
6569 vec![
6570 "edge_key",
6571 "from_id",
6572 "to_id",
6573 "kind",
6574 "properties_json",
6575 "provenance_json",
6576 "freshness_json",
6577 "row_hash",
6578 "source_watermark",
6579 ],
6580 ),
6581 (
6582 "graph_projection_versions",
6583 vec![
6584 "scope",
6585 "projection_version",
6586 "content_hash",
6587 "source_watermark",
6588 "observed_at_unix",
6589 ],
6590 ),
6591 (
6592 "graph_tombstones",
6593 vec!["row_key", "row_kind", "deleted_at_unix"],
6594 ),
6595 ("graph_node_properties", vec!["node_id", "key", "value"]),
6596 ("graph_edge_properties", vec!["edge_key", "key", "value"]),
6597 ];
6598 for (table, required_columns) in required_tables {
6599 if !tables.contains(table) {
6600 diagnostics.push(format!("graph.db schema drift: missing table {table}"));
6601 continue;
6602 }
6603 let columns = sqlite_column_names(conn, table)?;
6604 for column in required_columns {
6605 if !columns.contains(column) {
6606 diagnostics.push(format!(
6607 "graph.db schema drift: missing column {table}.{column}"
6608 ));
6609 }
6610 }
6611 }
6612
6613 let indexes = sqlite_string_set(
6614 conn,
6615 "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
6616 )?;
6617 for index in [
6618 "idx_graph_nodes_kind",
6619 "idx_graph_edges_from_kind",
6620 "idx_graph_edges_to_kind",
6621 "idx_graph_edges_edge_key",
6622 "idx_graph_node_properties_key_value_node",
6623 "idx_graph_edge_properties_key_value_edge",
6624 ] {
6625 if !indexes.contains(index) {
6626 diagnostics.push(format!("graph.db schema drift: missing index {index}"));
6627 }
6628 }
6629
6630 if tables.contains("graph_edges") {
6631 let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
6632 let rows = stmt.query_map([], |row| {
6633 Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
6634 })?;
6635 let mut fks = BTreeSet::new();
6636 for row in rows {
6637 fks.insert(row?);
6638 }
6639 for expected in [
6640 ("from_id".to_string(), "id".to_string()),
6641 ("to_id".to_string(), "id".to_string()),
6642 ] {
6643 if !fks.contains(&expected) {
6644 diagnostics.push(format!(
6645 "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
6646 expected.0, expected.1
6647 ));
6648 }
6649 }
6650 }
6651
6652 Ok(diagnostics)
6653}
6654
6655fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
6656 let mut stmt = conn.prepare(sql)?;
6657 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6658 let mut diagnostics = Vec::new();
6659 for row in rows {
6660 diagnostics.push(row?);
6661 }
6662 Ok(diagnostics)
6663}
6664
6665fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6666 let mut diagnostics = sqlite_query_diagnostics(
6667 conn,
6668 r#"
6669 SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
6670 FROM graph_nodes
6671 GROUP BY id
6672 HAVING COUNT(*) > 1
6673 ORDER BY id
6674 "#,
6675 )?;
6676 diagnostics.extend(sqlite_query_diagnostics(
6677 conn,
6678 r#"
6679 SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
6680 FROM graph_edges
6681 GROUP BY from_id, to_id, kind
6682 HAVING COUNT(*) > 1
6683 ORDER BY from_id, kind, to_id
6684 "#,
6685 )?);
6686 diagnostics.extend(sqlite_query_diagnostics(
6687 conn,
6688 r#"
6689 SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
6690 FROM graph_edges
6691 GROUP BY edge_key
6692 HAVING COUNT(*) > 1
6693 ORDER BY edge_key
6694 "#,
6695 )?);
6696 Ok(diagnostics)
6697}
6698
6699fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6700 sqlite_query_diagnostics(
6701 conn,
6702 r#"
6703 SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6704 FROM graph_edges e
6705 LEFT JOIN graph_nodes n ON n.id = e.from_id
6706 WHERE n.id IS NULL
6707 UNION ALL
6708 SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6709 FROM graph_edges e
6710 LEFT JOIN graph_nodes n ON n.id = e.to_id
6711 WHERE n.id IS NULL
6712 ORDER BY 1
6713 "#,
6714 )
6715}
6716
6717fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6718 let mut diagnostics = Vec::new();
6719 let mut node_stmt = conn.prepare(
6720 "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6721 )?;
6722 let node_rows = node_stmt.query_map([], |row| {
6723 Ok((
6724 row.get::<_, String>(0)?,
6725 row.get::<_, String>(1)?,
6726 row.get::<_, String>(2)?,
6727 row.get::<_, Option<String>>(3)?,
6728 ))
6729 })?;
6730 for row in node_rows {
6731 let (id, properties_json, provenance_json, freshness_json) = row?;
6732 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6733 diagnostics.push(format!(
6734 "graph_nodes {id} properties_json is invalid: {err}"
6735 ));
6736 }
6737 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6738 diagnostics.push(format!(
6739 "graph_nodes {id} provenance_json is invalid: {err}"
6740 ));
6741 }
6742 if let Some(freshness_json) = freshness_json
6743 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6744 {
6745 diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
6746 }
6747 }
6748
6749 let mut edge_stmt = conn.prepare(
6750 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6751 )?;
6752 let edge_rows = edge_stmt.query_map([], |row| {
6753 Ok((
6754 row.get::<_, String>(0)?,
6755 row.get::<_, String>(1)?,
6756 row.get::<_, String>(2)?,
6757 row.get::<_, String>(3)?,
6758 row.get::<_, String>(4)?,
6759 row.get::<_, String>(5)?,
6760 row.get::<_, Option<String>>(6)?,
6761 ))
6762 })?;
6763 for row in edge_rows {
6764 let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
6765 row?;
6766 let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
6767 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6768 diagnostics.push(format!(
6769 "graph_edges {edge} properties_json is invalid: {err}"
6770 ));
6771 }
6772 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6773 diagnostics.push(format!(
6774 "graph_edges {edge} provenance_json is invalid: {err}"
6775 ));
6776 }
6777 if let Some(freshness_json) = freshness_json
6778 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6779 {
6780 diagnostics.push(format!(
6781 "graph_edges {edge} freshness_json is invalid: {err}"
6782 ));
6783 }
6784 }
6785 Ok(diagnostics)
6786}
6787
6788fn sqlite_graph_projection_metadata_diagnostics(
6789 conn: &Connection,
6790 scope: Option<&str>,
6791) -> Result<Vec<String>> {
6792 let mut diagnostics = Vec::new();
6793 let scope_key = scope.unwrap_or("root");
6794 let version = conn
6795 .query_row(
6796 r#"
6797 SELECT projection_version, content_hash, source_watermark
6798 FROM graph_projection_versions
6799 WHERE scope = ?1
6800 "#,
6801 [scope_key],
6802 |row| {
6803 Ok((
6804 row.get::<_, String>(0)?,
6805 row.get::<_, Option<String>>(1)?,
6806 row.get::<_, Option<String>>(2)?,
6807 ))
6808 },
6809 )
6810 .optional()?;
6811 let Some((projection_version, content_hash, _source_watermark)) = version else {
6812 diagnostics.push(format!(
6813 "graph projection metadata is missing for scope {scope_key}"
6814 ));
6815 return Ok(diagnostics);
6816 };
6817 if projection_version != GRAPH_PROJECTION_VERSION {
6818 diagnostics.push(format!(
6819 "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
6820 ));
6821 }
6822 if content_hash.is_none() {
6823 diagnostics.push("projection content hash is missing".to_string());
6824 }
6825
6826 let meta_id = graph_projection_meta_id(scope);
6827 let meta_properties = conn
6828 .query_row(
6829 "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
6830 (&meta_id, GRAPH_PROJECTION_META_KIND),
6831 |row| row.get::<_, String>(0),
6832 )
6833 .optional()?;
6834 let Some(meta_properties) = meta_properties else {
6835 diagnostics.push(format!("projection_meta node {meta_id} is missing"));
6836 return Ok(diagnostics);
6837 };
6838 let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
6839 .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
6840 if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
6841 diagnostics.push(format!(
6842 "projection_meta node {meta_id} has stale projection_version"
6843 ));
6844 }
6845 if properties.get("content_hash") != content_hash.as_ref() {
6846 diagnostics.push(format!(
6847 "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
6848 ));
6849 }
6850 Ok(diagnostics)
6851}
6852
6853pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
6854 let mut node_stmt = conn.prepare(
6855 "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6856 )?;
6857 let node_rows = node_stmt.query_map([], |row| {
6858 let properties_json: String = row.get(3)?;
6859 let provenance_json: String = row.get(4)?;
6860 let freshness_json: Option<String> = row.get(5)?;
6861 Ok((
6862 row.get::<_, String>(0)?,
6863 row.get::<_, String>(1)?,
6864 row.get::<_, String>(2)?,
6865 properties_json,
6866 provenance_json,
6867 freshness_json,
6868 ))
6869 })?;
6870 let mut nodes = Vec::new();
6871 for row in node_rows {
6872 let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
6873 nodes.push(ConvexNodeRow {
6874 external_id,
6875 kind,
6876 label,
6877 properties: serde_json::from_str(&properties_json)?,
6878 provenance: serde_json::from_str(&provenance_json)?,
6879 freshness: freshness_json
6880 .map(|value| serde_json::from_str(&value))
6881 .transpose()?,
6882 });
6883 }
6884
6885 let mut edge_stmt = conn.prepare(
6886 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6887 )?;
6888 let edge_rows = edge_stmt.query_map([], |row| {
6889 let properties_json: String = row.get(4)?;
6890 let provenance_json: String = row.get(5)?;
6891 let freshness_json: Option<String> = row.get(6)?;
6892 Ok((
6893 row.get::<_, String>(0)?,
6894 row.get::<_, String>(1)?,
6895 row.get::<_, String>(2)?,
6896 row.get::<_, String>(3)?,
6897 properties_json,
6898 provenance_json,
6899 freshness_json,
6900 ))
6901 })?;
6902 let mut edges = Vec::new();
6903 for row in edge_rows {
6904 let (
6905 edge_key,
6906 from_external_id,
6907 to_external_id,
6908 kind,
6909 properties_json,
6910 provenance_json,
6911 freshness_json,
6912 ) = row?;
6913 edges.push(ConvexEdgeRow {
6914 edge_key,
6915 from_external_id,
6916 to_external_id,
6917 kind,
6918 properties: serde_json::from_str(&properties_json)?,
6919 provenance: serde_json::from_str(&provenance_json)?,
6920 freshness: freshness_json
6921 .map(|value| serde_json::from_str(&value))
6922 .transpose()?,
6923 });
6924 }
6925 Ok(ConvexProjectionRows { nodes, edges })
6926}
6927
6928fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
6929 format!("{}.{}({})", index.table, index.name, index.fields.join(","))
6930}
6931
6932fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
6933 value
6934 .get("indexes")
6935 .or_else(|| value.get("requiredIndexes"))
6936 .or_else(|| {
6937 value
6938 .get("metadata")
6939 .and_then(|metadata| metadata.get("indexes"))
6940 })
6941}
6942
6943fn convex_snapshot_declared_indexes(
6944 value: &serde_json::Value,
6945) -> Result<Option<Vec<ConvexRequiredIndex>>> {
6946 convex_snapshot_index_value(value)
6947 .map(|indexes| {
6948 serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
6949 .context("parsing Convex snapshot index metadata")
6950 })
6951 .transpose()
6952}
6953
6954fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
6955 let required = convex_required_indexes();
6956 let Some(declared) = convex_snapshot_declared_indexes(value)? else {
6957 return Ok(vec![format!(
6958 "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
6959 required
6960 .iter()
6961 .map(convex_required_index_label)
6962 .collect::<Vec<_>>()
6963 .join(", ")
6964 )]);
6965 };
6966 let declared = declared.into_iter().collect::<BTreeSet<_>>();
6967 let missing = required
6968 .iter()
6969 .filter(|index| !declared.contains(*index))
6970 .map(convex_required_index_label)
6971 .collect::<Vec<_>>();
6972 if missing.is_empty() {
6973 Ok(Vec::new())
6974 } else {
6975 Ok(vec![format!(
6976 "Convex snapshot is missing required index metadata: {}",
6977 missing.join(", ")
6978 )])
6979 }
6980}
6981
6982pub(crate) fn load_convex_projection_snapshot_value(
6983 snapshot_path: &Path,
6984) -> Result<(ConvexProjectionRows, serde_json::Value)> {
6985 let content = fs::read_to_string(snapshot_path).with_context(|| {
6986 format!(
6987 "reading Convex projection snapshot {}",
6988 snapshot_path.display()
6989 )
6990 })?;
6991 let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
6992 format!(
6993 "parsing Convex projection snapshot {}",
6994 snapshot_path.display()
6995 )
6996 })?;
6997 let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
6998 .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
6999 Ok((rows, value))
7000}
7001
7002pub(crate) fn append_sqlite_graph_doctor_checks(
7003 report: &mut GraphDbDoctorReport,
7004 root: &Path,
7005 scope: Option<&str>,
7006 graph_db: &Path,
7007) -> Option<substrate::SqliteReadOnlyConnection> {
7008 let rebuild = graph_db_rebuild_command(root, scope);
7009 let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7010 if !graph_db.exists() {
7011 report.push_check(graph_db_doctor_check(
7012 "sqlite_graph_db_exists",
7013 vec![format!("graph.db is missing at {}", graph_db.display())],
7014 vec![rebuild],
7015 ));
7016 return None;
7017 }
7018 report.push_check(graph_db_doctor_check(
7019 "sqlite_graph_db_exists",
7020 Vec::new(),
7021 vec![rebuild.clone()],
7022 ));
7023
7024 let conn = match open_sqlite_graph_db_readonly(graph_db) {
7025 Ok(conn) => conn,
7026 Err(err) => {
7027 report.push_check(graph_db_doctor_check(
7028 "sqlite_graph_db_open",
7029 vec![err.to_string()],
7030 vec![backup_rebuild],
7031 ));
7032 return None;
7033 }
7034 };
7035 report.push_check(graph_db_doctor_check(
7036 "sqlite_graph_db_open",
7037 Vec::new(),
7038 vec![rebuild.clone()],
7039 ));
7040 if let Some(recovery) = conn.recovery() {
7041 report.push_check(GraphDbDoctorCheck {
7042 name: "sqlite_graph_db_read_recovery".to_string(),
7043 status: "recovered".to_string(),
7044 fail_closed: false,
7045 diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7046 repair_commands: Vec::new(),
7047 });
7048 }
7049
7050 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7051 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7052 report.push_check(graph_db_doctor_check(
7053 "sqlite_schema",
7054 schema_diagnostics,
7055 vec![backup_rebuild.clone()],
7056 ));
7057
7058 let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7059 .unwrap_or_else(|err| {
7060 vec![format!(
7061 "graph projection metadata inspection failed: {err}"
7062 )]
7063 });
7064 report.push_check(graph_db_doctor_check(
7065 "sqlite_projection_metadata",
7066 metadata_diagnostics,
7067 vec![rebuild.clone()],
7068 ));
7069
7070 let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7071 .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7072 report.push_check(graph_db_doctor_check(
7073 "sqlite_duplicate_ids",
7074 duplicate_diagnostics,
7075 vec![backup_rebuild.clone()],
7076 ));
7077
7078 let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7079 .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7080 report.push_check(graph_db_doctor_check(
7081 "sqlite_orphan_edges",
7082 orphan_diagnostics,
7083 vec![rebuild.clone()],
7084 ));
7085
7086 let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7087 .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7088 report.push_check(graph_db_doctor_check(
7089 "sqlite_row_json",
7090 json_diagnostics,
7091 vec![backup_rebuild],
7092 ));
7093
7094 let tombstone_diagnostics =
7095 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7096 .unwrap_or_else(|err| {
7097 vec![format!(
7098 "graph tombstone retention inspection failed: {err}"
7099 )]
7100 });
7101 report.push_check(GraphDbDoctorCheck {
7102 name: "sqlite_tombstone_retention".to_string(),
7103 status: if tombstone_diagnostics.is_empty() {
7104 "ok".to_string()
7105 } else {
7106 "warning".to_string()
7107 },
7108 fail_closed: false,
7109 diagnostics: tombstone_diagnostics,
7110 repair_commands: Vec::new(),
7111 });
7112 let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7113 Ok(counts) => {
7114 let policy = graph_db_compaction_policy(root, scope, &counts, false);
7115 GraphDbDoctorCheck {
7116 name: "sqlite_compaction_policy".to_string(),
7117 status: policy.status.clone(),
7118 fail_closed: false,
7119 diagnostics: policy.proof,
7120 repair_commands: if policy.status == "recommended" {
7121 policy.recommendations
7122 } else {
7123 Vec::new()
7124 },
7125 }
7126 }
7127 Err(err) => GraphDbDoctorCheck {
7128 name: "sqlite_compaction_policy".to_string(),
7129 status: "warning".to_string(),
7130 fail_closed: false,
7131 diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7132 repair_commands: Vec::new(),
7133 },
7134 };
7135 report.push_check(compaction_check);
7136
7137 Some(conn)
7138}
7139
7140pub(crate) fn append_convex_snapshot_doctor_checks(
7141 report: &mut GraphDbDoctorReport,
7142 root: &Path,
7143 scope: Option<&str>,
7144 local_rows: Option<&ConvexProjectionRows>,
7145 snapshot_path: Option<&Path>,
7146) {
7147 let repair = convex_refresh_command(root, scope);
7148 let Some(snapshot_path) = snapshot_path else {
7149 report.push_check(graph_db_doctor_check(
7150 "convex_snapshot_present",
7151 vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7152 vec![format!(
7153 "tsift convex-sync {}{} --json > convex-rows.json",
7154 shell_quote(root.to_string_lossy().as_ref()),
7155 graph_db_scope_arg(scope)
7156 )],
7157 ));
7158 return;
7159 };
7160 report.push_check(graph_db_doctor_check(
7161 "convex_snapshot_present",
7162 Vec::new(),
7163 vec![repair.clone()],
7164 ));
7165
7166 let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7167 Ok(snapshot) => snapshot,
7168 Err(err) => {
7169 report.push_check(graph_db_doctor_check(
7170 "convex_snapshot_parse",
7171 vec![err.to_string()],
7172 vec![repair],
7173 ));
7174 return;
7175 }
7176 };
7177 report.push_check(graph_db_doctor_check(
7178 "convex_snapshot_parse",
7179 Vec::new(),
7180 vec![repair.clone()],
7181 ));
7182
7183 let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7184 report.push_check(graph_db_doctor_check(
7185 "convex_snapshot_rows",
7186 row_diagnostics,
7187 vec![repair.clone()],
7188 ));
7189
7190 let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7191 .unwrap_or_else(|err| vec![err.to_string()]);
7192 report.required_indexes = convex_required_indexes();
7193 report.push_check(graph_db_doctor_check(
7194 "convex_required_indexes",
7195 index_diagnostics,
7196 vec![
7197 "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7198 .to_string(),
7199 ],
7200 ));
7201
7202 if let Some(local_rows) = local_rows {
7203 let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7204 report.push_check(graph_db_doctor_check(
7205 "convex_projection_freshness",
7206 freshness.diagnostics,
7207 vec![repair],
7208 ));
7209 } else {
7210 report.push_check(graph_db_doctor_check(
7211 "convex_projection_freshness",
7212 vec![
7213 "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7214 .to_string(),
7215 ],
7216 vec![graph_db_rebuild_command(root, scope)],
7217 ));
7218 }
7219}
7220
7221fn graph_db_convex_snapshot_doctor_command(
7222 root: &Path,
7223 scope: Option<&str>,
7224 snapshot_path: &Path,
7225) -> String {
7226 format!(
7227 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7228 shell_quote(root.to_string_lossy().as_ref()),
7229 graph_db_scope_arg(scope),
7230 shell_quote(snapshot_path.to_string_lossy().as_ref())
7231 )
7232}
7233
7234fn graph_db_convex_snapshot_read_command(
7235 root: &Path,
7236 scope: Option<&str>,
7237 snapshot_path: &Path,
7238) -> String {
7239 format!(
7240 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7241 shell_quote(root.to_string_lossy().as_ref()),
7242 graph_db_scope_arg(scope),
7243 shell_quote(snapshot_path.to_string_lossy().as_ref())
7244 )
7245}
7246
7247fn convex_sync_snapshot_diff_command(
7248 root: &Path,
7249 scope: Option<&str>,
7250 snapshot_path: &Path,
7251) -> String {
7252 format!(
7253 "tsift convex-sync {}{} --snapshot {} --json",
7254 shell_quote(root.to_string_lossy().as_ref()),
7255 graph_db_scope_arg(scope),
7256 shell_quote(snapshot_path.to_string_lossy().as_ref())
7257 )
7258}
7259
7260pub(crate) struct GraphDbDriftInput<'a> {
7261 root: &'a Path,
7262 scope: Option<&'a str>,
7263 graph_db: &'a Path,
7264 snapshot_path: &'a Path,
7265 local: &'a ConvexProjectionRows,
7266 snapshot: &'a ConvexProjectionRows,
7267 snapshot_value: &'a serde_json::Value,
7268 warnings: Vec<String>,
7269}
7270
7271pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7272 let GraphDbDriftInput {
7273 root,
7274 scope,
7275 graph_db,
7276 snapshot_path,
7277 local,
7278 snapshot,
7279 snapshot_value,
7280 warnings,
7281 } = input;
7282 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7283 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7284 convex_rows_diff(local, Some(snapshot));
7285 let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7286 let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7287 .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7288 let local_hash = freshness.local_hash.clone();
7289 let snapshot_hash = freshness.snapshot_hash.clone();
7290 let stale_nodes = freshness.stale_nodes.clone();
7291 let stale_edges = freshness.stale_edges.clone();
7292
7293 let duplicate_failures = row_diagnostics
7294 .iter()
7295 .filter(|diagnostic| diagnostic.contains("duplicate"))
7296 .count();
7297 let orphan_failures = row_diagnostics
7298 .iter()
7299 .filter(|diagnostic| diagnostic.contains("references missing"))
7300 .count();
7301 let missing_required_indexes = index_diagnostics.len();
7302 let stale_projection_metadata =
7303 usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7304 let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7305 let has_drift = freshness.fail_closed
7306 || !node_upserts.is_empty()
7307 || !edge_upserts.is_empty()
7308 || !node_tombstones.is_empty()
7309 || !edge_tombstones.is_empty();
7310 let status = if hard_failures > 0 {
7311 "fail_closed"
7312 } else if has_drift {
7313 "drift"
7314 } else {
7315 "current"
7316 }
7317 .to_string();
7318
7319 let mut diagnostics = Vec::new();
7320 diagnostics.extend(row_diagnostics);
7321 diagnostics.extend(index_diagnostics);
7322 diagnostics.extend(freshness.diagnostics.clone());
7323 if has_drift {
7324 diagnostics.push(format!(
7325 "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7326 node_upserts.len(),
7327 edge_upserts.len(),
7328 node_tombstones.len(),
7329 edge_tombstones.len()
7330 ));
7331 }
7332
7333 let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7334 root,
7335 scope,
7336 snapshot_path,
7337 )];
7338 if status == "current" {
7339 next_commands.push(graph_db_convex_snapshot_read_command(
7340 root,
7341 scope,
7342 snapshot_path,
7343 ));
7344 } else {
7345 next_commands.push(convex_sync_snapshot_diff_command(
7346 root,
7347 scope,
7348 snapshot_path,
7349 ));
7350 next_commands.push(convex_refresh_command(root, scope));
7351 }
7352
7353 GraphDbDriftReport {
7354 root: root.to_string_lossy().to_string(),
7355 scope: scope.map(str::to_string),
7356 graph_db: graph_db.to_string_lossy().to_string(),
7357 convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7358 status: status.clone(),
7359 graph_reads_allowed: status == "current",
7360 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7361 local_hash,
7362 snapshot_hash,
7363 summary: GraphDbDriftSummary {
7364 node_upserts: node_upserts.len(),
7365 edge_upserts: edge_upserts.len(),
7366 node_tombstones: node_tombstones.len(),
7367 edge_tombstones: edge_tombstones.len(),
7368 stale_nodes: stale_nodes.len(),
7369 stale_edges: stale_edges.len(),
7370 stale_projection_metadata,
7371 duplicate_failures,
7372 orphan_failures,
7373 missing_required_indexes,
7374 },
7375 node_upserts: node_upserts
7376 .into_iter()
7377 .map(|row| row.external_id)
7378 .collect(),
7379 edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
7380 node_tombstones,
7381 edge_tombstones,
7382 stale_nodes,
7383 stale_edges,
7384 diagnostics,
7385 next_commands,
7386 required_indexes: convex_required_indexes(),
7387 warnings,
7388 }
7389}
7390
7391pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
7392 println!(
7393 "graph-db drift status: {} reads_allowed: {}",
7394 report.status, report.graph_reads_allowed
7395 );
7396 println!("graph_db: {}", report.graph_db);
7397 println!("convex_snapshot: {}", report.convex_snapshot);
7398 println!(
7399 "upserts: {} node(s), {} edge(s)",
7400 report.summary.node_upserts, report.summary.edge_upserts
7401 );
7402 println!(
7403 "tombstones: {} node(s), {} edge(s)",
7404 report.summary.node_tombstones, report.summary.edge_tombstones
7405 );
7406 for diagnostic in &report.diagnostics {
7407 println!("diagnostic: {diagnostic}");
7408 }
7409 for command in &report.next_commands {
7410 println!("next: {command}");
7411 }
7412}
7413
7414pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
7415 println!(
7416 "graph-db doctor backend: {} status: {}",
7417 report.backend, report.status
7418 );
7419 println!("graph_db: {}", report.graph_db);
7420 if let Some(snapshot) = &report.convex_snapshot {
7421 println!("convex_snapshot: {snapshot}");
7422 }
7423 for check in &report.checks {
7424 println!("check: {} {}", check.name, check.status);
7425 for diagnostic in &check.diagnostics {
7426 println!(" diagnostic: {diagnostic}");
7427 }
7428 }
7429 for command in &report.repair_commands {
7430 println!("repair: {command}");
7431 }
7432}
7433
7434pub(crate) fn graph_db_operator_report_from_disk(
7435 root: &Path,
7436 scope: Option<&str>,
7437 graph_db: &Path,
7438 operation: &str,
7439 refresh: Option<GraphDbRefreshSummary>,
7440 warnings: Vec<String>,
7441) -> Result<GraphDbOperatorReport> {
7442 if !graph_db.exists() {
7443 let next_commands = graph_db_operator_next_commands(root, scope, true);
7444 let counts = GraphDbOperatorCounts {
7445 nodes: 0,
7446 edges: 0,
7447 tombstones: GraphDbTombstoneCounts {
7448 nodes: 0,
7449 edges: 0,
7450 total: 0,
7451 },
7452 file_size_bytes: None,
7453 freelist_bytes: None,
7454 };
7455 return Ok(GraphDbOperatorReport {
7456 root: root.to_string_lossy().to_string(),
7457 scope: scope.map(str::to_string),
7458 graph_db: graph_db.to_string_lossy().to_string(),
7459 operation: operation.to_string(),
7460 status: "missing".to_string(),
7461 materialized: false,
7462 freshness: GraphDbFreshnessReport {
7463 status: "missing".to_string(),
7464 fail_closed: true,
7465 projection_version: None,
7466 content_hash: None,
7467 source_watermark: None,
7468 diagnostics: vec![
7469 "graph.db is missing; run graph-db refresh before trusting graph reads"
7470 .to_string(),
7471 ],
7472 },
7473 readiness: graph_effectiveness_blocked(
7474 "graph_db_missing",
7475 vec![
7476 "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
7477 ],
7478 next_commands.clone(),
7479 ),
7480 counts: counts.clone(),
7481 refresh,
7482 compaction: graph_db_compaction_policy(root, scope, &counts, false),
7483 recovery: None,
7484 next_commands,
7485 warnings,
7486 });
7487 }
7488
7489 let conn = open_sqlite_graph_db_readonly(graph_db)?;
7490 let recovery = conn.recovery();
7491 let mut warnings = warnings;
7492 if let Some(recovery) = recovery {
7493 warnings.push(graph_db_read_recovery_diagnostic(recovery));
7494 }
7495 let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
7496 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7497 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7498 if !schema_diagnostics.is_empty() {
7499 freshness.diagnostics.extend(schema_diagnostics);
7500 freshness.fail_closed = true;
7501 freshness.status = "stale".to_string();
7502 }
7503 let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
7504 let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
7505 warnings.extend(
7506 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7507 .unwrap_or_else(|err| {
7508 vec![format!(
7509 "graph tombstone retention inspection failed: {err}"
7510 )]
7511 }),
7512 );
7513 let status = if freshness.fail_closed {
7514 "stale"
7515 } else {
7516 "current"
7517 }
7518 .to_string();
7519
7520 Ok(GraphDbOperatorReport {
7521 root: root.to_string_lossy().to_string(),
7522 scope: scope.map(str::to_string),
7523 graph_db: graph_db.to_string_lossy().to_string(),
7524 operation: operation.to_string(),
7525 status,
7526 materialized: true,
7527 freshness,
7528 readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
7529 compaction: graph_db_compaction_policy(root, scope, &counts, false),
7530 counts,
7531 refresh,
7532 recovery,
7533 next_commands: graph_db_operator_next_commands(root, scope, false),
7534 warnings,
7535 })
7536}
7537
7538fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
7539 println!(
7540 "graph-db {} status: {} materialized: {}",
7541 report.operation, report.status, report.materialized
7542 );
7543 println!("graph_db: {}", report.graph_db);
7544 println!(
7545 "projection: version={} hash={} watermark={}",
7546 report
7547 .freshness
7548 .projection_version
7549 .as_deref()
7550 .unwrap_or("<missing>"),
7551 report
7552 .freshness
7553 .content_hash
7554 .as_deref()
7555 .unwrap_or("<missing>"),
7556 report
7557 .freshness
7558 .source_watermark
7559 .as_deref()
7560 .unwrap_or("<missing>")
7561 );
7562 println!(
7563 "rows: {} node(s), {} edge(s), {} tombstone(s)",
7564 report.counts.nodes, report.counts.edges, report.counts.tombstones.total
7565 );
7566 println!(
7567 "readiness: {} reason: {} fail_closed: {}",
7568 report.readiness.status, report.readiness.reason, report.readiness.fail_closed
7569 );
7570 if let Some(file_size) = report.counts.file_size_bytes {
7571 println!(
7572 "storage: {} byte(s), {} free byte(s)",
7573 file_size,
7574 report.counts.freelist_bytes.unwrap_or(0)
7575 );
7576 }
7577 if let Some(refresh) = &report.refresh {
7578 println!(
7579 "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
7580 refresh.tombstoned_nodes, refresh.tombstoned_edges
7581 );
7582 println!(
7583 "delta: {} node upsert(s), {} edge upsert(s), {} property row upsert(s), {} unchanged node(s), {} unchanged edge(s), {} unchanged property row(s), {} deleted property row(s), {} pruned tombstone(s)",
7584 refresh.upserted_nodes,
7585 refresh.upserted_edges,
7586 refresh.upserted_properties,
7587 refresh.unchanged_nodes,
7588 refresh.unchanged_edges,
7589 refresh.unchanged_properties,
7590 refresh.deleted_properties,
7591 refresh.pruned_tombstones
7592 );
7593 }
7594 println!(
7595 "compaction: {} tombstone_scan_rows={} live_rows={}",
7596 report.compaction.status,
7597 report.compaction.tombstone_scan_rows,
7598 report.compaction.live_rows
7599 );
7600 for proof in &report.compaction.proof {
7601 println!("compaction proof: {proof}");
7602 }
7603 if let Some(recovery) = report.recovery {
7604 println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
7605 }
7606 for diagnostic in &report.freshness.diagnostics {
7607 println!("diagnostic: {diagnostic}");
7608 }
7609 for diagnostic in &report.readiness.diagnostics {
7610 println!("readiness diagnostic: {diagnostic}");
7611 }
7612 for warning in &report.warnings {
7613 println!("warning: {warning}");
7614 }
7615 for command in &report.readiness.next_commands {
7616 println!("readiness next: {command}");
7617 }
7618 for command in &report.next_commands {
7619 println!("next: {command}");
7620 }
7621}
7622
7623pub(crate) fn print_graph_db_operator_report(
7624 report: &GraphDbOperatorReport,
7625 format: OutputFormat,
7626) -> Result<()> {
7627 if format.json_output {
7628 print_json_or_envelope(
7629 report,
7630 &format,
7631 "graph-db",
7632 &report.operation,
7633 ToolEnvelopeSummary {
7634 text: format!(
7635 "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
7636 report.operation,
7637 report.status,
7638 report.counts.nodes,
7639 report.counts.edges,
7640 report.counts.tombstones.total
7641 ),
7642 metrics: vec![
7643 envelope_metric("operation", &report.operation),
7644 envelope_metric("status", &report.status),
7645 envelope_metric("nodes", report.counts.nodes),
7646 envelope_metric("edges", report.counts.edges),
7647 envelope_metric("tombstones", report.counts.tombstones.total),
7648 envelope_metric("compaction", &report.compaction.status),
7649 envelope_metric("readiness", &report.readiness.status),
7650 ],
7651 },
7652 false,
7653 report.next_commands.clone(),
7654 )
7655 } else {
7656 print_graph_db_operator_human(report);
7657 Ok(())
7658 }
7659}
7660
7661fn status_run_command_without_notes(run: &str) -> &str {
7662 run.split_once(" (")
7663 .map(|(command, _)| command)
7664 .unwrap_or(run)
7665}
7666
7667fn status_summarize_extract_command(run: &str) -> &str {
7668 let run = status_run_command_without_notes(run);
7669 run.split(" && ")
7670 .find(|command| command.contains("summarize --extract"))
7671 .unwrap_or(run)
7672}
7673
7674fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
7675 report
7676 .recommendations
7677 .run
7678 .as_deref()
7679 .filter(|command| command.contains("summarize --extract"))
7680 .map(status_summarize_extract_command)
7681 .unwrap_or("tsift summarize --extract .")
7682 .to_string()
7683}
7684
7685fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
7686 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7687 readiness.diagnostics.push(format!(
7688 "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
7689 ));
7690 readiness
7691}
7692
7693fn graph_db_semantic_readiness(
7694 root: &Path,
7695 scope: Option<&str>,
7696 semantic_row_count: Option<usize>,
7697) -> GraphEffectivenessReadiness {
7698 if let Some(row_count) = semantic_row_count
7699 && row_count > 0
7700 {
7701 return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
7702 }
7703
7704 let report = match status::check_status(root) {
7705 Ok(report) => report,
7706 Err(err) => {
7707 return graph_effectiveness_blocked(
7708 "status_check_unavailable",
7709 vec![format!(
7710 "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
7711 )],
7712 vec![graph_db_refresh_command(root, scope)],
7713 );
7714 }
7715 };
7716
7717 match &report.summaries {
7718 status::SummaryStatus::Available {
7719 cached_files,
7720 total_indexed_files,
7721 coverage_pct,
7722 ..
7723 } => {
7724 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7725 readiness.diagnostics.push(format!(
7726 "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
7727 ));
7728 readiness
7729 }
7730 status::SummaryStatus::None { .. } => {
7731 let summarize = graph_db_status_summarize_command(&report);
7732 let index_command = report
7733 .recommendations
7734 .run
7735 .as_deref()
7736 .filter(|cmd| cmd.contains("index"))
7737 .map(str::to_string);
7738 let mut repair = Vec::new();
7739 if let Some(cmd) = index_command {
7740 repair.push(cmd);
7741 }
7742 repair.push(summarize.clone());
7743 repair.push(graph_db_refresh_command(root, scope));
7744 graph_effectiveness_blocked(
7745 "summary_cache_empty",
7746 vec![format!(
7747 "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7748 summarize,
7749 root.display(),
7750 graph_db_refresh_command(root, scope)
7751 )],
7752 repair,
7753 )
7754 }
7755 status::SummaryStatus::Unavailable => {
7756 let mut repair: Vec<String> = report
7757 .recommendations
7758 .run
7759 .clone()
7760 .into_iter()
7761 .collect();
7762 let summarize = "tsift summarize --extract .".to_string();
7763 repair.push(summarize);
7764 repair.push(graph_db_refresh_command(root, scope));
7765 graph_effectiveness_blocked(
7766 "summary_cache_unavailable",
7767 vec![
7768 "summary cache unavailable because the source index is missing; build the index, extract summaries, and refresh the graph before relying on semantic graph evidence".to_string(),
7769 ],
7770 repair,
7771 )
7772 }
7773 }
7774}
7775
7776pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
7777 let report = match status::check_status(root) {
7778 Ok(report) => report,
7779 Err(err) => {
7780 return vec![format!(
7781 "status check unavailable after graph-db refresh: {err:#}"
7782 )];
7783 }
7784 };
7785
7786 let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7787 Some(graph_db_status_summarize_command(&report))
7788 } else {
7789 None
7790 };
7791 let mut warnings = report.reminders;
7792 if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7793 let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
7794 warnings.push(format!(
7795 "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7796 run,
7797 root.display(),
7798 graph_db_refresh_command(root, scope)
7799 ));
7800 }
7801 dedupe_preserve_order(warnings)
7802}
7803
7804pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
7805 println!(
7806 "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
7807 report.applied, report.pruned_tombstones, report.reclaimed_bytes
7808 );
7809 println!("graph_db: {}", report.graph_db);
7810 println!(
7811 "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7812 report.counts_before.nodes,
7813 report.counts_before.edges,
7814 report.counts_before.tombstones.total,
7815 report.counts_before.file_size_bytes.unwrap_or(0),
7816 report.counts_before.freelist_bytes.unwrap_or(0)
7817 );
7818 println!(
7819 "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7820 report.counts_after.nodes,
7821 report.counts_after.edges,
7822 report.counts_after.tombstones.total,
7823 report.counts_after.file_size_bytes.unwrap_or(0),
7824 report.counts_after.freelist_bytes.unwrap_or(0)
7825 );
7826 for proof in &report.compaction_after.proof {
7827 println!("proof: {proof}");
7828 }
7829 for warning in &report.warnings {
7830 println!("warning: {warning}");
7831 }
7832 for command in &report.next_commands {
7833 println!("next: {command}");
7834 }
7835}
7836
7837fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
7838 raw.iter()
7839 .map(|value| {
7840 let (key, filter_value) = value
7841 .split_once('=')
7842 .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
7843 let key = key.trim();
7844 let filter_value = filter_value.trim();
7845 if key.is_empty() || filter_value.is_empty() {
7846 bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
7847 }
7848 Ok(GraphDbPropertyFilter {
7849 key: key.to_string(),
7850 value: filter_value.to_string(),
7851 })
7852 })
7853 .collect()
7854}
7855
7856fn graph_db_query_options(
7857 cursor: Option<String>,
7858 limit: Option<usize>,
7859 property_filters: &[String],
7860) -> Result<GraphDbQueryOptions> {
7861 Ok(GraphDbQueryOptions {
7862 cursor,
7863 limit: limit.filter(|limit| *limit > 0),
7864 property_filters: parse_graph_db_property_filters(property_filters)?,
7865 })
7866}
7867
7868fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
7869 GraphQueryOptions {
7870 cursor: options.cursor.clone(),
7871 limit: options.limit,
7872 property_filters: options
7873 .property_filters
7874 .iter()
7875 .map(|filter| GraphPropertyFilter {
7876 key: filter.key.clone(),
7877 value: filter.value.clone(),
7878 })
7879 .collect(),
7880 }
7881}
7882
7883fn graph_db_page_report_from_store(
7884 page: GraphQueryPage,
7885 property_filters: Vec<GraphDbPropertyFilter>,
7886) -> GraphDbPageReport {
7887 GraphDbPageReport {
7888 cursor: page.cursor,
7889 limit: page.limit,
7890 next_cursor: page.next_cursor,
7891 returned_nodes: page.returned_nodes,
7892 returned_edges: page.returned_edges,
7893 truncated: page.truncated,
7894 property_filters,
7895 diagnostics: page.diagnostics,
7896 }
7897}
7898
7899fn graph_db_neighborhood_ranking_gate(
7900 ranked_neighbor_cap: usize,
7901) -> GraphDbNeighborhoodRankingGate {
7902 GraphDbNeighborhoodRankingGate {
7903 status: "held_default_order_unchanged".to_string(),
7904 ranked_output_default: false,
7905 default_order: "stable_node_id".to_string(),
7906 default_change_gate: "community_search_quality_metrics".to_string(),
7907 required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
7908 .iter()
7909 .map(|workload| (*workload).to_string())
7910 .collect(),
7911 required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
7912 .iter()
7913 .map(|metric| (*metric).to_string())
7914 .collect(),
7915 max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
7916 min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
7917 min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
7918 min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
7919 diagnostics: vec![
7920 "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
7921 format!(
7922 "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
7923 ),
7924 "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
7925 ],
7926 }
7927}
7928
7929fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
7930 match limit {
7931 Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
7932 Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
7933 }
7934}
7935
7936fn graph_db_ranked_neighbors(
7937 center_id: &str,
7938 nodes: &[SubstrateGraphNode],
7939 edges: &[SubstrateGraphEdge],
7940 cap: usize,
7941) -> Vec<GraphDbRankedNeighbor> {
7942 resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
7943}
7944
7945fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
7946 center_id: &str,
7947 depth: usize,
7948 edge_kind: Option<&str>,
7949 limit: Option<usize>,
7950 unranked_nodes: &[SubstrateGraphNode],
7951 unranked_edges: &[SubstrateGraphEdge],
7952 store: &S,
7953) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
7954 use std::time::Instant;
7955 let max_nodes = match limit {
7956 Some(0) | None => 200,
7957 Some(n) => n.clamp(10, 500),
7958 };
7959 let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
7960 .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
7961 if let Some(kind) = edge_kind {
7962 options = options.with_edge_kind(kind);
7963 }
7964 let start = Instant::now();
7965 let result = store.ranked_neighborhood(center_id, &options)?;
7966 let latency = start.elapsed().as_micros();
7967 let Some(ranked) = result else {
7968 return Ok(None);
7969 };
7970 let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
7971 let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
7972 let overlap_count = ranked_ids.intersection(&unranked_ids).count();
7973 let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
7974 0.0
7975 } else {
7976 (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
7977 };
7978 let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
7979 let mut name_count = BTreeMap::<&str, usize>::new();
7980 for n in nodes {
7981 *name_count.entry(&n.label).or_default() += 1;
7982 }
7983 name_count.values().filter(|&&c| c > 1).count()
7984 };
7985 let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
7986 if nodes.is_empty() {
7987 return 100.0;
7988 }
7989 let with_handle = nodes
7990 .iter()
7991 .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
7992 .count();
7993 (with_handle as f64 / nodes.len() as f64) * 100.0
7994 };
7995 let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
7996 if nodes.is_empty() {
7997 return 0.0;
7998 }
7999 let semantic_kinds = [
8000 "semantic_concept",
8001 "semantic_entity",
8002 "symbol",
8003 "file",
8004 "source_handle",
8005 ];
8006 let useful = nodes
8007 .iter()
8008 .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8009 .count();
8010 let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8011 let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8012 (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8013 / nodes.len() as f64
8014 };
8015 let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8016 let edge_pairs: Vec<(String, String)> = ranked
8017 .edges
8018 .iter()
8019 .map(|e| (e.from_id.clone(), e.to_id.clone()))
8020 .collect();
8021 let cr = tsift_graph::detect_communities(&edge_pairs);
8022 let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8023 let mut fully_kept = 0usize;
8024 let mut partially_pruned = 0usize;
8025 let mut fully_pruned = 0usize;
8026 let mut pruned_kinds = BTreeSet::new();
8027 let mut pruned_labels = Vec::new();
8028 for comm in &cr.communities {
8029 let kept_in_comm: Vec<&str> = comm
8030 .members
8031 .iter()
8032 .filter(|m| kept_labels.contains(m.name.as_str()))
8033 .map(|m| m.name.as_str())
8034 .collect();
8035 if kept_in_comm.len() == comm.members.len() {
8036 fully_kept += 1;
8037 } else if kept_in_comm.is_empty() {
8038 fully_pruned += 1;
8039 for m in &comm.members {
8040 if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8041 pruned_kinds.insert(n.kind.clone());
8042 }
8043 pruned_labels.push(m.name.clone());
8044 }
8045 } else {
8046 partially_pruned += 1;
8047 }
8048 }
8049 pruned_labels.truncate(5);
8050 Some(CommunityTruncationSummary {
8051 total_communities: cr.communities.len(),
8052 fully_kept,
8053 partially_pruned,
8054 fully_pruned,
8055 pruned_community_kinds: pruned_kinds.into_iter().collect(),
8056 pruned_community_top_labels: pruned_labels,
8057 })
8058 } else {
8059 None
8060 };
8061 Ok(Some(GraphDbRankedNeighborhoodComparison {
8062 traversal_nodes: ranked.nodes.len(),
8063 traversal_edges: ranked.edges.len(),
8064 pruned_count: ranked.pruned_count,
8065 total_discovered: ranked.total_discovered,
8066 latency_micros: latency,
8067 overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8068 useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8069 / 1000.0,
8070 useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8071 .round()
8072 / 1000.0,
8073 duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8074 duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8075 handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8076 handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8077 / 100.0,
8078 community_truncation_summary,
8079 diagnostics: vec![
8080 format!(
8081 "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8082 ranked.nodes.len(),
8083 ranked.edges.len(),
8084 ranked.pruned_count,
8085 ranked.total_discovered,
8086 latency
8087 ),
8088 format!(
8089 "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8090 overlap_pct,
8091 overlap_count,
8092 unranked_ids.len(),
8093 ranked_ids.len()
8094 ),
8095 "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8096 ],
8097 }))
8098}
8099
8100struct GraphDbBudgetedSubgraph {
8101 nodes: Vec<SubstrateGraphNode>,
8102 edges: Vec<SubstrateGraphEdge>,
8103 report: GraphDbOutputBudgetReport,
8104 truncated: bool,
8105 next_cursor: Option<String>,
8106}
8107
8108const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8109const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8110const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8111
8112fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8113 match limit {
8114 Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8115 Some(limit) => limit
8116 .saturating_mul(320)
8117 .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8118 }
8119}
8120
8121fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8122 if matches!(limit, Some(0) | None) {
8123 return match kind {
8124 "source_handle" => 10,
8125 "worker_context" | "worker_result" => 8,
8126 "semantic_concept" | "semantic_entity" => 10,
8127 "file" | "symbol" | "route" => 12,
8128 _ => 8,
8129 };
8130 }
8131 let base = limit.unwrap_or(0).max(1);
8132 match kind {
8133 "source_handle" => base.saturating_add(4),
8134 "worker_context" | "worker_result" => base.saturating_add(2),
8135 "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8136 "file" | "symbol" | "route" => base.saturating_add(4),
8137 _ => base.saturating_add(1),
8138 }
8139}
8140
8141fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8142 if matches!(limit, Some(0) | None) {
8143 return match kind {
8144 "mentions" | "mentions_concept" | "mentions_entity" => 24,
8145 "semantic_relation" | "calls" | "defines" => 20,
8146 _ => 16,
8147 };
8148 }
8149 let base = limit.unwrap_or(0).max(1);
8150 match kind {
8151 "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8152 "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8153 _ => base.saturating_add(2),
8154 }
8155}
8156
8157fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8158 serde_json::to_vec(value)
8159 .map(|bytes| bytes.len().div_ceil(4).max(1))
8160 .unwrap_or(1)
8161}
8162
8163fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8164 let mut parts = vec![node.kind.clone(), node.label.clone()];
8165 for key in [
8166 "detail",
8167 "description",
8168 "source_ref",
8169 "path",
8170 "source_file",
8171 "source_symbol",
8172 "text_preview",
8173 ] {
8174 if let Some(value) = node.properties.get(key) {
8175 parts.push(value.clone());
8176 }
8177 }
8178 parts.join(" ")
8179}
8180
8181fn graph_db_semantic_scores_for_query(
8182 query: Option<&str>,
8183 nodes: &[SubstrateGraphNode],
8184) -> BTreeMap<String, f64> {
8185 let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8186 return BTreeMap::new();
8187 };
8188 let query_embedding = semantic_embedding(query);
8189 nodes
8190 .iter()
8191 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8192 .filter_map(|node| {
8193 let embedding = node
8194 .properties
8195 .get("embedding")
8196 .and_then(|value| parse_semantic_embedding_property(value))?;
8197 Some((
8198 node.id.clone(),
8199 semantic_cosine(&query_embedding, &embedding),
8200 ))
8201 })
8202 .collect()
8203}
8204
8205fn graph_db_depth_by_id(
8206 origin_ids: &[String],
8207 edges: &[SubstrateGraphEdge],
8208) -> BTreeMap<String, usize> {
8209 let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8210 for edge in edges {
8211 adjacency
8212 .entry(edge.from_id.clone())
8213 .or_default()
8214 .push(edge.to_id.clone());
8215 adjacency
8216 .entry(edge.to_id.clone())
8217 .or_default()
8218 .push(edge.from_id.clone());
8219 }
8220
8221 let mut depth_by_id = BTreeMap::<String, usize>::new();
8222 let mut queue = VecDeque::<String>::new();
8223 for origin in origin_ids {
8224 if depth_by_id.insert(origin.clone(), 0).is_none() {
8225 queue.push_back(origin.clone());
8226 }
8227 }
8228 while let Some(current) = queue.pop_front() {
8229 let depth = depth_by_id.get(¤t).copied().unwrap_or(0);
8230 for next in adjacency.get(¤t).into_iter().flatten() {
8231 if depth_by_id.contains_key(next) {
8232 continue;
8233 }
8234 depth_by_id.insert(next.clone(), depth.saturating_add(1));
8235 queue.push_back(next.clone());
8236 }
8237 }
8238 depth_by_id
8239}
8240
8241fn graph_db_source_covered_ids(
8242 nodes: &[SubstrateGraphNode],
8243 edges: &[SubstrateGraphEdge],
8244) -> BTreeSet<String> {
8245 let source_ids = nodes
8246 .iter()
8247 .filter(|node| node.kind == "source_handle")
8248 .map(|node| node.id.as_str())
8249 .collect::<BTreeSet<_>>();
8250 let mut covered = source_ids
8251 .iter()
8252 .map(|id| (*id).to_string())
8253 .collect::<BTreeSet<_>>();
8254 for edge in edges {
8255 if source_ids.contains(edge.from_id.as_str()) {
8256 covered.insert(edge.to_id.clone());
8257 }
8258 if source_ids.contains(edge.to_id.as_str()) {
8259 covered.insert(edge.from_id.clone());
8260 }
8261 }
8262 covered
8263}
8264
8265fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8266 for key in [
8267 "observed_at_unix",
8268 "completed_at_unix",
8269 "created_at_unix",
8270 "started_at_unix",
8271 ] {
8272 if let Some(value) = node.properties.get(key)
8273 && let Ok(epoch) = value.parse::<i64>()
8274 {
8275 return epoch.div_euclid(86_400).clamp(0, 40_000);
8276 }
8277 }
8278 0
8279}
8280
8281fn graph_db_node_kind_score(kind: &str) -> i64 {
8282 match kind {
8283 "source_handle" => 180,
8284 "worker_context" => 170,
8285 "worker_result" => 160,
8286 "semantic_concept" | "semantic_entity" => 150,
8287 "backlog" | "job_packet" => 130,
8288 "symbol" => 120,
8289 "file" => 110,
8290 "route" => 105,
8291 "session" => 90,
8292 _ => 40,
8293 }
8294}
8295
8296fn graph_db_edge_kind_score(kind: &str) -> i64 {
8297 match kind {
8298 "mentions_concept" | "mentions_entity" => 180,
8299 "semantic_relation" => 170,
8300 "mentions" => 165,
8301 "requests_context" | "scopes_context" | "scopes_source" => 155,
8302 "explains_result" => 150,
8303 "calls" => 145,
8304 "defines" | "handled_by" | "defines_route" => 130,
8305 "contains" | "targets" => 120,
8306 "records_memory_source" | "has_vector_handle" => 115,
8307 _ => 40,
8308 }
8309}
8310
8311fn graph_db_node_usefulness_score(
8312 node: &SubstrateGraphNode,
8313 depth_by_id: &BTreeMap<String, usize>,
8314 semantic_scores: &BTreeMap<String, f64>,
8315 source_covered_ids: &BTreeSet<String>,
8316 origin_ids: &[String],
8317) -> i64 {
8318 if origin_ids.iter().any(|origin| origin == &node.id) {
8319 return 1_000_000;
8320 }
8321 let semantic = semantic_scores
8322 .get(&node.id)
8323 .map(|score| (score.max(0.0) * 1_000.0) as i64)
8324 .unwrap_or(0);
8325 let depth_penalty = depth_by_id
8326 .get(&node.id)
8327 .map(|depth| (*depth as i64).saturating_mul(55))
8328 .unwrap_or(180);
8329 let source_coverage = if source_covered_ids.contains(&node.id)
8330 || node.properties.contains_key("source_ref")
8331 || node.properties.contains_key("path")
8332 {
8333 120
8334 } else {
8335 0
8336 };
8337 graph_db_node_kind_score(&node.kind)
8338 + semantic
8339 + source_coverage
8340 + graph_db_recency_score(node).min(80)
8341 - depth_penalty
8342}
8343
8344fn graph_db_edge_usefulness_score(
8345 edge: &SubstrateGraphEdge,
8346 node_score_by_id: &BTreeMap<String, i64>,
8347 depth_by_id: &BTreeMap<String, usize>,
8348) -> i64 {
8349 let endpoint_score = node_score_by_id
8350 .get(&edge.from_id)
8351 .copied()
8352 .unwrap_or_default()
8353 .max(
8354 node_score_by_id
8355 .get(&edge.to_id)
8356 .copied()
8357 .unwrap_or_default(),
8358 );
8359 let depth_penalty = depth_by_id
8360 .get(&edge.from_id)
8361 .into_iter()
8362 .chain(depth_by_id.get(&edge.to_id))
8363 .min()
8364 .map(|depth| (*depth as i64).saturating_mul(35))
8365 .unwrap_or(140);
8366 graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8367}
8368
8369fn graph_db_push_drop(
8370 drops: &mut BTreeMap<(String, String, String), usize>,
8371 item: &str,
8372 kind: &str,
8373 reason: &str,
8374) {
8375 *drops
8376 .entry((item.to_string(), kind.to_string(), reason.to_string()))
8377 .or_default() += 1;
8378}
8379
8380fn graph_db_budget_drop_report(
8381 drops: BTreeMap<(String, String, String), usize>,
8382) -> Vec<GraphDbDroppedByBudget> {
8383 drops
8384 .into_iter()
8385 .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
8386 item,
8387 kind,
8388 reason,
8389 dropped,
8390 })
8391 .collect()
8392}
8393
8394fn graph_db_apply_output_budget(
8395 origin_ids: &[String],
8396 semantic_scores: &BTreeMap<String, f64>,
8397 nodes: Vec<SubstrateGraphNode>,
8398 edges: Vec<SubstrateGraphEdge>,
8399 limit: Option<usize>,
8400) -> GraphDbBudgetedSubgraph {
8401 graph_db_apply_output_budget_with_depths_and_cursor(
8402 origin_ids,
8403 semantic_scores,
8404 nodes,
8405 edges,
8406 limit,
8407 None,
8408 None,
8409 )
8410}
8411
8412fn graph_db_apply_output_budget_with_depths_and_cursor(
8413 origin_ids: &[String],
8414 semantic_scores: &BTreeMap<String, f64>,
8415 nodes: Vec<SubstrateGraphNode>,
8416 edges: Vec<SubstrateGraphEdge>,
8417 limit: Option<usize>,
8418 depth_overrides: Option<&BTreeMap<String, usize>>,
8419 cursor: Option<&str>,
8420) -> GraphDbBudgetedSubgraph {
8421 let max_tokens = graph_db_output_token_cap(limit);
8422 let candidate_nodes = nodes.len();
8423 let candidate_edges = edges.len();
8424 let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
8425 if let Some(depth_overrides) = depth_overrides {
8426 for (id, depth) in depth_overrides {
8427 depth_by_id
8428 .entry(id.clone())
8429 .and_modify(|current| *current = (*current).min(*depth))
8430 .or_insert(*depth);
8431 }
8432 }
8433 let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
8434 let node_score_by_id = nodes
8435 .iter()
8436 .map(|node| {
8437 (
8438 node.id.clone(),
8439 graph_db_node_usefulness_score(
8440 node,
8441 &depth_by_id,
8442 semantic_scores,
8443 &source_covered_ids,
8444 origin_ids,
8445 ),
8446 )
8447 })
8448 .collect::<BTreeMap<_, _>>();
8449
8450 let mut node_candidates = nodes.iter().collect::<Vec<_>>();
8451 node_candidates.sort_by(|left, right| {
8452 node_score_by_id
8453 .get(&right.id)
8454 .cmp(&node_score_by_id.get(&left.id))
8455 .then_with(|| left.kind.cmp(&right.kind))
8456 .then_with(|| left.label.cmp(&right.label))
8457 .then_with(|| left.id.cmp(&right.id))
8458 });
8459
8460 let cursor_skip = if let Some(cursor) = cursor {
8461 node_candidates
8462 .iter()
8463 .position(|node| node.id == cursor)
8464 .map(|pos| pos.saturating_add(1))
8465 .unwrap_or(0)
8466 } else {
8467 0
8468 };
8469 if cursor_skip > 0 {
8470 node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
8471 }
8472
8473 let mut selected_node_ids = BTreeSet::new();
8474 let mut selected_node_counts = BTreeMap::<String, usize>::new();
8475 let mut estimated_tokens = 0usize;
8476 let mut drops = BTreeMap::<(String, String, String), usize>::new();
8477 for node in &node_candidates {
8478 let kind_count = selected_node_counts
8479 .get(&node.kind)
8480 .copied()
8481 .unwrap_or_default();
8482 if !origin_ids.iter().any(|origin| origin == &node.id)
8483 && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
8484 {
8485 graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
8486 continue;
8487 }
8488 let tokens = graph_db_estimated_tokens(node);
8489 if !origin_ids.iter().any(|origin| origin == &node.id)
8490 && estimated_tokens.saturating_add(tokens) > max_tokens
8491 {
8492 graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
8493 continue;
8494 }
8495 selected_node_ids.insert(node.id.clone());
8496 *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
8497 estimated_tokens = estimated_tokens.saturating_add(tokens);
8498 }
8499
8500 let has_remaining_candidates = node_candidates
8501 .iter()
8502 .any(|node| !selected_node_ids.contains(&node.id));
8503
8504 let mut selected_nodes = nodes
8505 .into_iter()
8506 .filter(|node| selected_node_ids.contains(&node.id))
8507 .collect::<Vec<_>>();
8508
8509 let mut edge_candidates = edges
8510 .iter()
8511 .filter(|edge| {
8512 selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
8513 })
8514 .collect::<Vec<_>>();
8515 let edge_score_by_key = edge_candidates
8516 .iter()
8517 .map(|edge| {
8518 (
8519 graph_db_edge_key(edge),
8520 graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
8521 )
8522 })
8523 .collect::<BTreeMap<_, _>>();
8524 edge_candidates.sort_by(|left, right| {
8525 edge_score_by_key
8526 .get(&graph_db_edge_key(right))
8527 .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
8528 .then_with(|| left.kind.cmp(&right.kind))
8529 .then_with(|| left.from_id.cmp(&right.from_id))
8530 .then_with(|| left.to_id.cmp(&right.to_id))
8531 });
8532
8533 let endpoint_dropped_edges = edges
8534 .iter()
8535 .filter(|edge| {
8536 !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
8537 })
8538 .count();
8539 if endpoint_dropped_edges > 0 {
8540 drops.insert(
8541 (
8542 "edge".to_string(),
8543 "*".to_string(),
8544 "endpoint_node_dropped".to_string(),
8545 ),
8546 endpoint_dropped_edges,
8547 );
8548 }
8549
8550 let mut selected_edge_ids = BTreeSet::new();
8551 let mut selected_edge_counts = BTreeMap::<String, usize>::new();
8552 for edge in edge_candidates {
8553 let kind_count = selected_edge_counts
8554 .get(&edge.kind)
8555 .copied()
8556 .unwrap_or_default();
8557 if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
8558 graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
8559 continue;
8560 }
8561 let tokens = graph_db_estimated_tokens(edge);
8562 if estimated_tokens.saturating_add(tokens) > max_tokens {
8563 graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
8564 continue;
8565 }
8566 selected_edge_ids.insert(graph_db_edge_key(edge));
8567 *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
8568 estimated_tokens = estimated_tokens.saturating_add(tokens);
8569 }
8570
8571 let selected_edges = edges
8572 .into_iter()
8573 .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
8574 .collect::<Vec<_>>();
8575 let dropped_by_budget = graph_db_budget_drop_report(drops);
8576 let truncated = has_remaining_candidates;
8577 let next_cursor = if truncated {
8578 selected_nodes.last().map(|node| node.id.clone())
8579 } else {
8580 None
8581 };
8582 let mut diagnostics = vec![
8583 "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
8584 .to_string(),
8585 format!(
8586 "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
8587 selected_nodes.len(),
8588 candidate_nodes,
8589 selected_edges.len(),
8590 candidate_edges,
8591 max_tokens
8592 ),
8593 ];
8594 if cursor.is_some() {
8595 diagnostics.push(format!(
8596 "cursor skipped {} previously returned candidate(s)",
8597 cursor_skip
8598 ));
8599 }
8600 if next_cursor.is_some() {
8601 diagnostics.push(
8602 "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
8603 );
8604 }
8605 selected_nodes.shrink_to_fit();
8606
8607 GraphDbBudgetedSubgraph {
8608 nodes: selected_nodes,
8609 edges: selected_edges,
8610 report: GraphDbOutputBudgetReport {
8611 max_tokens,
8612 estimated_tokens,
8613 selected_nodes: selected_node_ids.len(),
8614 selected_edges: selected_edge_ids.len(),
8615 candidate_nodes,
8616 candidate_edges,
8617 dropped_by_budget,
8618 diagnostics,
8619 },
8620 truncated,
8621 next_cursor,
8622 }
8623}
8624
8625fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
8626 if edge.id.is_empty() {
8627 substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
8628 } else {
8629 edge.id.clone()
8630 }
8631}
8632
8633fn graph_db_schema() -> GraphDbSchema {
8634 GraphDbSchema {
8635 contract_versions: vec![
8636 GraphDbSchemaContract {
8637 name: "graph_db_evidence",
8638 version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
8639 description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
8640 },
8641 GraphDbSchemaContract {
8642 name: "worker_prompt_packet",
8643 version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
8644 description: "conflict-matrix worker prompt packet with owned scope, scheduler fields, stable graph handles, expected tests, expansion commands, token budget, semantic ranking reasons, worker feedback closure controls, and fail-closed prompt text",
8645 },
8646 GraphDbSchemaContract {
8647 name: "conflict_matrix",
8648 version: CONFLICT_MATRIX_CONTRACT_VERSION,
8649 description: "parallel-dispatch decision report keyed by graph evidence packets, scheduler block fields, hard file/symbol/test/config gates, and soft worker-feedback closure ranking",
8650 },
8651 GraphDbSchemaContract {
8652 name: "context_pack_graph_orchestration",
8653 version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
8654 description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
8655 },
8656 GraphDbSchemaContract {
8657 name: "session_review_follow_up",
8658 version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
8659 description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
8660 },
8661 GraphDbSchemaContract {
8662 name: "dispatch_trace",
8663 version: DISPATCH_TRACE_CONTRACT_VERSION,
8664 description: "operator review trace linking backlog, job packets, worker results, source handles, semantic rows, scheduler fields, evidence packet ids, worker feedback closure controls, and worker prompt packets",
8665 },
8666 GraphDbSchemaContract {
8667 name: "dependency_dag",
8668 version: DEPENDENCY_DAG_CONTRACT_VERSION,
8669 description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
8670 },
8671 ],
8672 node_fields: vec![
8673 GraphDbSchemaField {
8674 name: "id",
8675 value_type: "string",
8676 description: "Stable provider-neutral node id",
8677 },
8678 GraphDbSchemaField {
8679 name: "kind",
8680 value_type: "string",
8681 description: "Application-defined node family such as file, symbol, or backlog",
8682 },
8683 GraphDbSchemaField {
8684 name: "label",
8685 value_type: "string",
8686 description: "Human-readable label",
8687 },
8688 GraphDbSchemaField {
8689 name: "properties",
8690 value_type: "object<string,string>",
8691 description: "Adapter-specific string properties",
8692 },
8693 GraphDbSchemaField {
8694 name: "provenance",
8695 value_type: "array",
8696 description: "Source system and source reference metadata",
8697 },
8698 GraphDbSchemaField {
8699 name: "freshness",
8700 value_type: "object|null",
8701 description: "Optional content hash and observed timestamp",
8702 },
8703 ],
8704 edge_fields: vec![
8705 GraphDbSchemaField {
8706 name: "id",
8707 value_type: "string",
8708 description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
8709 },
8710 GraphDbSchemaField {
8711 name: "from_id",
8712 value_type: "string",
8713 description: "Source node id",
8714 },
8715 GraphDbSchemaField {
8716 name: "to_id",
8717 value_type: "string",
8718 description: "Target node id",
8719 },
8720 GraphDbSchemaField {
8721 name: "kind",
8722 value_type: "string",
8723 description: "Application-defined edge relation",
8724 },
8725 GraphDbSchemaField {
8726 name: "properties",
8727 value_type: "object<string,string>",
8728 description: "Adapter-specific string properties",
8729 },
8730 GraphDbSchemaField {
8731 name: "provenance",
8732 value_type: "array",
8733 description: "Source system and source reference metadata",
8734 },
8735 GraphDbSchemaField {
8736 name: "freshness",
8737 value_type: "object|null",
8738 description: "Optional content hash and observed timestamp",
8739 },
8740 ],
8741 operations: vec![
8742 GraphDbSchemaOperation {
8743 command: "refresh",
8744 description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
8745 },
8746 GraphDbSchemaOperation {
8747 command: "status",
8748 description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
8749 },
8750 GraphDbSchemaOperation {
8751 command: "doctor",
8752 description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
8753 },
8754 GraphDbSchemaOperation {
8755 command: "drift",
8756 description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
8757 },
8758 GraphDbSchemaOperation {
8759 command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
8760 description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
8761 },
8762 GraphDbSchemaOperation {
8763 command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
8764 description: "Benchmark experimental read-only GraphStore backend prototypes against SQLite on bounded real, optional full-project, and synthetic projections across refresh/status/path tiers/evidence/conflict-matrix/dispatch-trace and emit promotion hold/eligibility gates",
8765 },
8766 GraphDbSchemaOperation {
8767 command: "evidence <target> [--depth N] [--limit N]",
8768 description: "Return a bounded versioned graph-db handoff packet for a backlog id or job packet handle, including packet_id, projection hash, worker_context rows, source_handle rows, worker_result rows, semantic_concept/entity rows, shortest paths, replay commands, repair commands, and next commands",
8769 },
8770 GraphDbSchemaOperation {
8771 command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
8772 description: "Resolve a natural-language phrase to cached semantic concept/entity seed nodes, then return an incident/outgoing GraphStore neighborhood around those seeds for general knowledge retrieval without changing stable neighborhood pagination defaults",
8773 },
8774 GraphDbSchemaOperation {
8775 command: "dispatch-trace [target...] --path <session> [--format json|html]",
8776 description: "Export a compact graph-backed dispatch trace with evidence packet ids, worker-result feedback closure summaries, graph links, and conflict-matrix worker prompt packets",
8777 },
8778 GraphDbSchemaOperation {
8779 command: "dependency-dag [target...] --path <session>",
8780 description: "Extract a versioned agent-doc dependency DAG from backlog ids, explicit depends-on text, shared file/symbol/test/config evidence, semantic overlap, and worker-result follow-up ids",
8781 },
8782 GraphDbSchemaOperation {
8783 command: "schema",
8784 description: "Return record and operation schemas",
8785 },
8786 GraphDbSchemaOperation {
8787 command: "node <id>",
8788 description: "Return one node by stable id",
8789 },
8790 GraphDbSchemaOperation {
8791 command: "edge <id>",
8792 description: "Return one edge by stable edge id",
8793 },
8794 GraphDbSchemaOperation {
8795 command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8796 description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
8797 },
8798 GraphDbSchemaOperation {
8799 command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8800 description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
8801 },
8802 GraphDbSchemaOperation {
8803 command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
8804 description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
8805 },
8806 GraphDbSchemaOperation {
8807 command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
8808 description: "Return a directed outgoing subgraph around a node using batched SQLite recursive traversal plus pushed filters/paging when available; JSON also includes additive ranked_neighbors while default nodes remain stable-id ordered",
8809 },
8810 GraphDbSchemaOperation {
8811 command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
8812 description: "Return the shortest directed path by node id, optionally bounded by hop count",
8813 },
8814 ],
8815 }
8816}
8817
8818pub(crate) fn sqlite_graph_freshness(
8819 store: &SqliteGraphStore,
8820 scope: &str,
8821) -> Result<GraphDbFreshnessReport> {
8822 let version = store.projection_version(scope)?;
8823 let Some(version) = version else {
8824 return Ok(GraphDbFreshnessReport {
8825 status: "missing".to_string(),
8826 fail_closed: true,
8827 projection_version: None,
8828 content_hash: None,
8829 source_watermark: None,
8830 diagnostics: vec![
8831 "graph projection metadata is missing; rebuild the graph before trusting reads"
8832 .to_string(),
8833 ],
8834 });
8835 };
8836 let mut diagnostics = Vec::new();
8837 let fail_closed =
8838 version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
8839 if version.projection_version != GRAPH_PROJECTION_VERSION {
8840 diagnostics.push(format!(
8841 "projection version mismatch: expected {} got {}",
8842 GRAPH_PROJECTION_VERSION, version.projection_version
8843 ));
8844 }
8845 if version.content_hash.is_none() {
8846 diagnostics.push("projection content hash is missing".to_string());
8847 }
8848 Ok(GraphDbFreshnessReport {
8849 status: if fail_closed { "stale" } else { "current" }.to_string(),
8850 fail_closed,
8851 projection_version: Some(version.projection_version),
8852 content_hash: version.content_hash,
8853 source_watermark: version.source_watermark,
8854 diagnostics,
8855 })
8856}
8857
8858pub(crate) fn convex_graph_freshness(
8859 local: &ConvexProjectionRows,
8860 snapshot: &ConvexProjectionRows,
8861 scope: Option<&str>,
8862) -> GraphDbFreshnessReport {
8863 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
8864 GraphDbFreshnessReport {
8865 status: freshness.status,
8866 fail_closed: freshness.fail_closed,
8867 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
8868 content_hash: freshness.snapshot_hash,
8869 source_watermark: None,
8870 diagnostics: freshness.diagnostics,
8871 }
8872}
8873
8874pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
8875 let (nodes, edges) = store.graph_counts()?;
8876 let files = store.file_count()?;
8877 Ok(GraphDbFreshnessReport {
8878 status: "current".to_string(),
8879 fail_closed: false,
8880 projection_version: Some("tokensave-readonly".to_string()),
8881 content_hash: None,
8882 source_watermark: Some(store.db_path().to_string_lossy().to_string()),
8883 diagnostics: vec![format!(
8884 "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
8885 nodes, edges, files
8886 )],
8887 })
8888}
8889
8890pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
8891 match TokensaveDb::discover(root) {
8892 Ok(Some(store)) => {
8893 report.push_check(GraphDbDoctorCheck {
8894 name: "tokensave_db_open".to_string(),
8895 status: "ok".to_string(),
8896 fail_closed: false,
8897 diagnostics: vec![format!(
8898 "opened tokensave database at {}",
8899 store.db_path().display()
8900 )],
8901 repair_commands: Vec::new(),
8902 });
8903 match (store.node_count(), store.edge_count(), store.file_count()) {
8904 (Ok(nodes), Ok(edges), Ok(files)) => {
8905 report.push_check(GraphDbDoctorCheck {
8906 name: "tokensave_counts".to_string(),
8907 status: "ok".to_string(),
8908 fail_closed: false,
8909 diagnostics: vec![format!(
8910 "tokensave contains {} node(s), {} edge(s), {} file(s)",
8911 nodes, edges, files
8912 )],
8913 repair_commands: Vec::new(),
8914 });
8915 }
8916 (nodes, edges, files) => {
8917 report.push_check(graph_db_doctor_check(
8918 "tokensave_counts",
8919 vec![format!(
8920 "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
8921 nodes.err(),
8922 edges.err(),
8923 files.err()
8924 )],
8925 Vec::new(),
8926 ));
8927 }
8928 }
8929 }
8930 Ok(None) => report.push_check(graph_db_doctor_check(
8931 "tokensave_db_exists",
8932 vec![format!(
8933 "tokensave database is missing at {}",
8934 root.join(".tokensave").join("tokensave.db").display()
8935 )],
8936 Vec::new(),
8937 )),
8938 Err(err) => report.push_check(graph_db_doctor_check(
8939 "tokensave_db_open",
8940 vec![err.to_string()],
8941 Vec::new(),
8942 )),
8943 }
8944}
8945
8946pub(crate) fn graph_db_resolve_evidence_target(
8947 store: &impl GraphStore,
8948 target: &str,
8949) -> Result<Option<SubstrateGraphNode>> {
8950 store.resolve_evidence_target(
8951 target,
8952 &[
8953 "backlog",
8954 "job_packet",
8955 "worker_result",
8956 "worker_context",
8957 "source_handle",
8958 ],
8959 )
8960}
8961
8962fn graph_db_reachable_nodes_by_kind(
8963 store: &impl GraphStore,
8964 from_id: &str,
8965 kind: &str,
8966 depth: usize,
8967 limit: usize,
8968) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
8969 store.reachable_nodes_by_kind(from_id, kind, depth, limit)
8970}
8971
8972fn graph_db_evidence_completed_queue_drift_warnings(
8973 store: &impl GraphStore,
8974 target: &SubstrateGraphNode,
8975 worker_results: &[SubstrateGraphNode],
8976) -> Result<Vec<String>> {
8977 let ref_id = target.properties.get("ref_id").map(String::as_str);
8978 let has_completed_result = worker_results.iter().any(|node| {
8979 node.properties.get("status").map(String::as_str) == Some("completed")
8980 && node.properties.get("ref_id").map(String::as_str) == ref_id
8981 });
8982 if !has_completed_result {
8983 return Ok(Vec::new());
8984 }
8985 let active_jobs = store
8986 .nodes_by_kind("job_packet")?
8987 .into_iter()
8988 .filter(|node| {
8989 node.properties.get("ref_id").map(String::as_str) == ref_id
8990 && node.label.starts_with("do #")
8991 })
8992 .collect::<Vec<_>>();
8993 if active_jobs.is_empty() {
8994 return Ok(Vec::new());
8995 }
8996 let repair = match (target.properties.get("path"), ref_id) {
8997 (Some(path), Some(id)) => format!(
8998 "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
8999 shell_quote(path),
9000 shell_quote(id),
9001 shell_quote(id)
9002 ),
9003 _ => {
9004 "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9005 }
9006 };
9007 Ok(vec![format!(
9008 "queue-head drift: target {} has {} active queued do packet(s) but already has a completed worker_result; {repair}; do not redispatch or reactivate the completed item",
9009 target.label,
9010 active_jobs.len()
9011 )])
9012}
9013
9014fn graph_db_evidence_next_commands(
9015 root: &Path,
9016 scope: Option<&str>,
9017 target: &SubstrateGraphNode,
9018 worker_context: &[SubstrateGraphNode],
9019 source_handles: &[SubstrateGraphNode],
9020 worker_results: &[SubstrateGraphNode],
9021 semantic_related: &[SubstrateGraphNode],
9022) -> Vec<String> {
9023 let mut commands = BTreeSet::new();
9024 if let Some(expand) = target.properties.get("expand") {
9025 commands.insert(expand.clone());
9026 }
9027 for worker in worker_context {
9028 if let Some(expand) = worker.properties.get("expand") {
9029 commands.insert(expand.clone());
9030 }
9031 }
9032 for source in source_handles {
9033 if let Some(expand) = source.properties.get("expand") {
9034 commands.insert(expand.clone());
9035 }
9036 }
9037 for result in worker_results {
9038 if let Some(expand) = result.properties.get("expand") {
9039 commands.insert(expand.clone());
9040 }
9041 }
9042 for semantic in semantic_related {
9043 if let Some(expand) = semantic.properties.get("expand") {
9044 commands.insert(expand.clone());
9045 }
9046 }
9047 commands.insert(format!(
9048 "tsift graph-db --path {}{} status --json",
9049 shell_quote(root.to_string_lossy().as_ref()),
9050 graph_db_scope_arg(scope)
9051 ));
9052 commands.insert(format!(
9053 "tsift graph-db --path {}{} doctor --json",
9054 shell_quote(root.to_string_lossy().as_ref()),
9055 graph_db_scope_arg(scope)
9056 ));
9057 commands.into_iter().collect()
9058}
9059
9060fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9061 vec![
9062 format!(
9063 "tsift graph-db --path {}{} refresh --json",
9064 shell_quote(root.to_string_lossy().as_ref()),
9065 graph_db_scope_arg(scope)
9066 ),
9067 format!(
9068 "tsift graph-db --path {}{} doctor --json",
9069 shell_quote(root.to_string_lossy().as_ref()),
9070 graph_db_scope_arg(scope)
9071 ),
9072 ]
9073}
9074
9075fn graph_db_evidence_replay_commands(
9076 root: &Path,
9077 scope: Option<&str>,
9078 target: &str,
9079 depth: usize,
9080 limit: usize,
9081) -> Vec<String> {
9082 vec![
9083 format!(
9084 "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9085 shell_quote(root.to_string_lossy().as_ref()),
9086 graph_db_scope_arg(scope),
9087 shell_quote(target),
9088 depth,
9089 limit
9090 ),
9091 format!(
9092 "tsift conflict-matrix --path {} {} --json",
9093 shell_quote(root.to_string_lossy().as_ref()),
9094 shell_quote(target)
9095 ),
9096 ]
9097}
9098
9099fn graph_db_evidence_packet_id(
9100 target: &str,
9101 target_node: &SubstrateGraphNode,
9102 freshness: &GraphDbFreshnessReport,
9103) -> String {
9104 stable_handle(
9105 "gevd",
9106 &format!(
9107 "{}:{}:{}:{}",
9108 GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9109 target,
9110 target_node.id,
9111 freshness.content_hash.as_deref().unwrap_or("no-hash")
9112 ),
9113 )
9114}
9115
9116pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9117 input: GraphDbEvidenceInput<'_, S>,
9118) -> Result<GraphDbEvidenceReport> {
9119 let GraphDbEvidenceInput {
9120 root,
9121 scope,
9122 backend,
9123 target,
9124 depth,
9125 limit,
9126 cursor,
9127 store,
9128 freshness,
9129 mut warnings,
9130 } = input;
9131 let repair_commands = graph_db_repair_commands(root, scope);
9132 if freshness.fail_closed {
9133 bail!(
9134 "graph database evidence failed closed for {} backend: {}; repair: {}",
9135 backend,
9136 freshness.diagnostics.join("; "),
9137 repair_commands.join("; ")
9138 );
9139 }
9140 let semantic_readiness = graph_db_semantic_readiness(
9141 root,
9142 scope,
9143 graph_store_semantic_node_count(store).ok(),
9144 );
9145 if semantic_readiness.fail_closed {
9146 warnings.push(format!(
9147 "graph evidence semantic readiness blocked: {} — {}",
9148 semantic_readiness.reason,
9149 semantic_readiness.diagnostics.join("; ")
9150 ));
9151 warnings.push(format!(
9152 "repair: {}",
9153 semantic_readiness.next_commands.join("; then ")
9154 ));
9155 }
9156 let target_node = graph_db_resolve_evidence_target(store, target)?
9157 .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9158 let max_rows = if limit == 0 { usize::MAX } else { limit };
9159 let mut reachable = store.reachable_nodes_by_kinds(
9160 &target_node.id,
9161 &[
9162 "worker_context",
9163 "source_handle",
9164 "worker_result",
9165 "semantic_concept",
9166 "semantic_entity",
9167 ],
9168 depth,
9169 max_rows,
9170 )?;
9171 let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9172 let source_paths = reachable.remove("source_handle").unwrap_or_default();
9173 let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9174 let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9175 semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9176 semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9177 left_path
9178 .hops
9179 .cmp(&right_path.hops)
9180 .then(left_node.kind.cmp(&right_node.kind))
9181 .then(left_node.label.cmp(&right_node.label))
9182 .then(left_node.id.cmp(&right_node.id))
9183 });
9184 if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9185 semantic_paths.truncate(max_rows);
9186 }
9187
9188 let evidence_nodes = worker_paths
9189 .iter()
9190 .chain(source_paths.iter())
9191 .chain(worker_result_paths.iter())
9192 .chain(semantic_paths.iter())
9193 .map(|(node, _)| node.clone())
9194 .collect::<Vec<_>>();
9195 let evidence_depth_by_id = worker_paths
9196 .iter()
9197 .chain(source_paths.iter())
9198 .chain(worker_result_paths.iter())
9199 .chain(semantic_paths.iter())
9200 .map(|(node, path)| (node.id.clone(), path.hops))
9201 .collect::<BTreeMap<_, _>>();
9202 let target_query = graph_db_node_search_text(&target_node);
9203 let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9204 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9205 std::slice::from_ref(&target_node.id),
9206 &semantic_scores,
9207 evidence_nodes,
9208 Vec::new(),
9209 Some(limit),
9210 Some(&evidence_depth_by_id),
9211 cursor,
9212 );
9213 let output_budget = budgeted.report;
9214 let truncated = budgeted.truncated;
9215 let next_cursor = budgeted.next_cursor;
9216 let retained_evidence_ids = budgeted
9217 .nodes
9218 .iter()
9219 .map(|node| node.id.as_str())
9220 .collect::<BTreeSet<_>>();
9221 let worker_context = worker_paths
9222 .iter()
9223 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9224 .map(|(node, _)| node.clone())
9225 .collect::<Vec<_>>();
9226 let source_handles = source_paths
9227 .iter()
9228 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9229 .map(|(node, _)| node.clone())
9230 .collect::<Vec<_>>();
9231 let worker_results = worker_result_paths
9232 .iter()
9233 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9234 .map(|(node, _)| node.clone())
9235 .collect::<Vec<_>>();
9236 let semantic_related = semantic_paths
9237 .iter()
9238 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9239 .map(|(node, _)| node.clone())
9240 .collect::<Vec<_>>();
9241 warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9242 store,
9243 &target_node,
9244 &worker_results,
9245 )?);
9246 if worker_context.is_empty()
9247 && source_handles.is_empty()
9248 && worker_results.is_empty()
9249 && semantic_related.is_empty()
9250 {
9251 warnings.push(format!(
9252 "graph-db evidence target {} resolved to a {} node but has no projection-linked context rows; add source/file tokens to the backlog text or rerun graph-db refresh after the session document is indexed",
9253 target, target_node.kind
9254 ));
9255 }
9256 let shortest_paths = worker_paths
9257 .iter()
9258 .chain(source_paths.iter())
9259 .chain(worker_result_paths.iter())
9260 .chain(semantic_paths.iter())
9261 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9262 .map(|(node, path)| GraphDbEvidencePath {
9263 to: node.id.clone(),
9264 kind: node.kind.clone(),
9265 label: node.label.clone(),
9266 path: Some(path.clone()),
9267 expand: node.properties.get("expand").cloned(),
9268 })
9269 .collect::<Vec<_>>();
9270 let next_commands = graph_db_evidence_next_commands(
9271 root,
9272 scope,
9273 &target_node,
9274 &worker_context,
9275 &source_handles,
9276 &worker_results,
9277 &semantic_related,
9278 );
9279 let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9280 let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9281 let projection_hash = freshness.content_hash.clone();
9282
9283 Ok(GraphDbEvidenceReport {
9284 root: root.to_string_lossy().to_string(),
9285 scope: scope.map(str::to_string),
9286 backend: backend.to_string(),
9287 contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9288 target: target.to_string(),
9289 packet_id,
9290 projection_hash,
9291 freshness,
9292 target_node: target_node.into(),
9293 worker_context: worker_context.into_iter().map(Into::into).collect(),
9294 source_handles: source_handles.into_iter().map(Into::into).collect(),
9295 worker_results: worker_results.into_iter().map(Into::into).collect(),
9296 semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9297 shortest_paths,
9298 output_budget: Some(output_budget),
9299 truncated,
9300 next_cursor,
9301 next_commands,
9302 replay_commands,
9303 repair_commands,
9304 fixture_coverage: GraphDbFixtureCoverage {
9305 test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9306 .to_string(),
9307 fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
9308 assertions: vec![
9309 "backlog id and job packet handle resolve to graph nodes".to_string(),
9310 "worker_context rows are reachable from queued work".to_string(),
9311 "source_handle rows are reachable through bounded shortest paths".to_string(),
9312 "worker_result rows are reachable from completed or blocked work".to_string(),
9313 ],
9314 },
9315 warnings,
9316 })
9317}
9318
9319fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
9320 println!(
9321 "graph-db evidence backend: {} target: {} [{}] packet:{}",
9322 report.backend, report.target_node.id, report.target_node.kind, report.packet_id
9323 );
9324 let page_info = if report.truncated {
9325 let cursor = report.next_cursor.as_deref().unwrap_or("?");
9326 format!(" (truncated, next_cursor: {cursor})")
9327 } else {
9328 String::new()
9329 };
9330 println!(
9331 "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
9332 report.worker_context.len(),
9333 report.source_handles.len(),
9334 report.worker_results.len(),
9335 report.semantic_related.len(),
9336 report.shortest_paths.len()
9337 );
9338 for path in &report.shortest_paths {
9339 if let Some(graph_path) = &path.path {
9340 println!(
9341 "path: {} hop(s) {}",
9342 graph_path.hops,
9343 graph_path.nodes.join(" -> ")
9344 );
9345 }
9346 }
9347 for command in &report.next_commands {
9348 println!("next: {command}");
9349 }
9350 for warning in &report.warnings {
9351 println!("warning: {warning}");
9352 }
9353}
9354
9355pub(crate) fn print_graph_db_evidence_report(
9356 report: &GraphDbEvidenceReport,
9357 format: OutputFormat,
9358) -> Result<()> {
9359 if format.json_output {
9360 let page_info = if report.truncated {
9361 let cursor = report.next_cursor.as_deref().unwrap_or("?");
9362 format!(" (truncated, next_cursor: {cursor})")
9363 } else {
9364 String::new()
9365 };
9366 print_json_or_envelope(
9367 report,
9368 &format,
9369 "graph-db",
9370 "evidence",
9371 ToolEnvelopeSummary {
9372 text: format!(
9373 "Graph DB evidence for {} returned {} worker context row(s), {} source handle(s), {} worker result row(s), {} semantic row(s), and {} shortest path(s){page_info}",
9374 report.target,
9375 report.worker_context.len(),
9376 report.source_handles.len(),
9377 report.worker_results.len(),
9378 report.semantic_related.len(),
9379 report.shortest_paths.len()
9380 ),
9381 metrics: vec![
9382 envelope_metric("backend", &report.backend),
9383 envelope_metric("worker_context", report.worker_context.len()),
9384 envelope_metric("source_handles", report.source_handles.len()),
9385 envelope_metric("worker_results", report.worker_results.len()),
9386 envelope_metric("semantic_related", report.semantic_related.len()),
9387 envelope_metric("paths", report.shortest_paths.len()),
9388 ],
9389 },
9390 report.truncated,
9391 report.next_commands.clone(),
9392 )
9393 } else {
9394 print_graph_db_evidence_human(report);
9395 Ok(())
9396 }
9397}
9398
9399pub(crate) fn graph_db_report_from_store(
9400 root: &Path,
9401 scope: Option<&str>,
9402 backend: &str,
9403 query: GraphDbQuery,
9404 store: &impl GraphStore,
9405 freshness: GraphDbFreshnessReport,
9406 warnings: Vec<String>,
9407) -> Result<GraphDbReport> {
9408 if freshness.fail_closed {
9409 bail!(
9410 "graph database read failed closed for {} backend: {}",
9411 backend,
9412 freshness.diagnostics.join("; ")
9413 );
9414 }
9415 let mut report = GraphDbReport {
9416 root: root.to_string_lossy().to_string(),
9417 scope: scope.map(str::to_string),
9418 backend: backend.to_string(),
9419 query: format!("{query:?}"),
9420 freshness,
9421 readiness: None,
9422 schema: None,
9423 node: None,
9424 edge: None,
9425 nodes: Vec::new(),
9426 edges: Vec::new(),
9427 ranked_neighbors: Vec::new(),
9428 semantic_related: Vec::new(),
9429 neighborhood_ranking_gate: None,
9430 ranked_neighborhood_comparison: None,
9431 knowledge_retrieval: None,
9432 output_budget: None,
9433 path: None,
9434 page: None,
9435 warnings,
9436 };
9437
9438 match query {
9439 GraphDbQuery::Refresh => {
9440 bail!("graph-db refresh must be handled by the refresh command path");
9441 }
9442 GraphDbQuery::Status => {
9443 bail!("graph-db status must be handled by the status command path");
9444 }
9445 GraphDbQuery::Doctor => {
9446 bail!("graph-db doctor must be handled by the doctor command path");
9447 }
9448 GraphDbQuery::Drift => {
9449 bail!("graph-db drift must be handled by the drift command path");
9450 }
9451 GraphDbQuery::Compact { .. } => {
9452 bail!("graph-db compact must be handled by the compact command path");
9453 }
9454 GraphDbQuery::BackendEval { .. } => {
9455 bail!("graph-db backend-eval must be handled by the benchmark command path");
9456 }
9457 GraphDbQuery::Evidence { .. } => {
9458 bail!("graph-db evidence must be handled by the evidence command path");
9459 }
9460 GraphDbQuery::Related {
9461 query,
9462 kind,
9463 depth,
9464 seed_limit,
9465 limit,
9466 } => {
9467 let semantic =
9468 semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
9469 let SemanticRelatedReport {
9470 items,
9471 warnings: semantic_warnings,
9472 ..
9473 } = semantic;
9474 let readiness = graph_db_semantic_readiness(
9475 root,
9476 scope,
9477 (!items.is_empty()).then_some(items.len()),
9478 );
9479 report.warnings.extend(semantic_warnings);
9480 let seed_ids = items
9481 .iter()
9482 .map(|item| item.handle.clone())
9483 .collect::<Vec<_>>();
9484 let semantic_scores = items
9485 .iter()
9486 .map(|item| (item.handle.clone(), item.score))
9487 .collect::<BTreeMap<_, _>>();
9488 let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
9489 let seed_count = seed_ids.len();
9490 let mut diagnostics = subgraph.diagnostics;
9491 let budgeted = graph_db_apply_output_budget(
9492 &seed_ids,
9493 &semantic_scores,
9494 subgraph.nodes,
9495 subgraph.edges,
9496 Some(limit),
9497 );
9498 let budget_report = budgeted.report;
9499 let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
9500 diagnostics.extend(budget_report.diagnostics.clone());
9501 diagnostics.extend(readiness.diagnostics.clone());
9502
9503 report.readiness = Some(readiness);
9504 report.semantic_related = items;
9505 if let Some(seed_id) = seed_ids.first() {
9506 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
9507 report.ranked_neighbors = graph_db_ranked_neighbors(
9508 seed_id,
9509 &budgeted.nodes,
9510 &budgeted.edges,
9511 ranked_neighbor_cap,
9512 );
9513 report.neighborhood_ranking_gate =
9514 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9515 }
9516 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9517 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9518 report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
9519 mode: "semantic_seeded_neighborhood".to_string(),
9520 query,
9521 seed_kind: semantic_related_kind_name(kind).to_string(),
9522 seed_limit,
9523 seed_count,
9524 depth,
9525 limit,
9526 node_count: report.nodes.len(),
9527 edge_count: report.edges.len(),
9528 truncated: subgraph.truncated || dropped_by_budget,
9529 traversal: "incident_plus_outgoing_edges".to_string(),
9530 freshness_boundary:
9531 "semantic rows must come from refreshed summary or tsift-memory graph records"
9532 .to_string(),
9533 privacy_boundary:
9534 "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
9535 .to_string(),
9536 diagnostics,
9537 });
9538 report.output_budget = Some(budget_report);
9539 }
9540 GraphDbQuery::Schema => {
9541 report.schema = Some(graph_db_schema());
9542 }
9543 GraphDbQuery::Node { id } => {
9544 report.node = store.node(&id)?.map(Into::into);
9545 }
9546 GraphDbQuery::Edge { id } => {
9547 report.edge = store.edge(&id)?.map(Into::into);
9548 }
9549 GraphDbQuery::Edges {
9550 edge_kind,
9551 cursor,
9552 limit,
9553 property_filters,
9554 } => {
9555 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9556 let paged = store.paged_edges(
9557 edge_kind.as_deref(),
9558 graph_db_query_options_for_store(&options),
9559 )?;
9560 report.edges = paged.edges.into_iter().map(Into::into).collect();
9561 report.page = Some(graph_db_page_report_from_store(
9562 paged.page,
9563 options.property_filters,
9564 ));
9565 }
9566 GraphDbQuery::Incident {
9567 id,
9568 edge_kind,
9569 cursor,
9570 limit,
9571 property_filters,
9572 } => {
9573 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9574 let paged = store.paged_incident_edges(
9575 &id,
9576 edge_kind.as_deref(),
9577 graph_db_query_options_for_store(&options),
9578 )?;
9579 report.edges = paged.edges.into_iter().map(Into::into).collect();
9580 report.page = Some(graph_db_page_report_from_store(
9581 paged.page,
9582 options.property_filters,
9583 ));
9584 }
9585 GraphDbQuery::Kind {
9586 kind,
9587 cursor,
9588 limit,
9589 property_filters,
9590 } => {
9591 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9592 let paged =
9593 store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
9594 report.nodes = paged.nodes.into_iter().map(Into::into).collect();
9595 report.edges = paged.edges.into_iter().map(Into::into).collect();
9596 report.page = Some(graph_db_page_report_from_store(
9597 paged.page,
9598 options.property_filters,
9599 ));
9600 }
9601 GraphDbQuery::Neighborhood {
9602 id,
9603 depth,
9604 edge_kind,
9605 cursor,
9606 limit,
9607 property_filters,
9608 } => {
9609 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9610 if let Some(paged) = store.paged_neighborhood(
9611 &id,
9612 depth,
9613 edge_kind.as_deref(),
9614 graph_db_query_options_for_store(&options),
9615 )? {
9616 let budgeted = graph_db_apply_output_budget(
9617 std::slice::from_ref(&id),
9618 &BTreeMap::new(),
9619 paged.nodes,
9620 paged.edges,
9621 options.limit,
9622 );
9623 let budget_report = budgeted.report;
9624 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
9625 let ranked_neighbors = graph_db_ranked_neighbors(
9626 &id,
9627 &budgeted.nodes,
9628 &budgeted.edges,
9629 ranked_neighbor_cap,
9630 );
9631 let comparison = graph_db_ranked_neighborhood_comparison(
9632 &id,
9633 depth,
9634 edge_kind.as_deref(),
9635 options.limit,
9636 &budgeted.nodes,
9637 &budgeted.edges,
9638 store,
9639 )?;
9640 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9641 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9642 report.ranked_neighbors = ranked_neighbors;
9643 report.neighborhood_ranking_gate =
9644 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9645 let mut page =
9646 graph_db_page_report_from_store(paged.page, options.property_filters);
9647 page.returned_nodes = report.nodes.len();
9648 page.returned_edges = report.edges.len();
9649 page.truncated |= !budget_report.dropped_by_budget.is_empty();
9650 page.diagnostics.extend(budget_report.diagnostics.clone());
9651 report.page = Some(page);
9652 report.output_budget = Some(budget_report);
9653 if let Some(comparison) = comparison {
9654 report.ranked_neighborhood_comparison = Some(comparison);
9655 }
9656 }
9657 }
9658 GraphDbQuery::Path {
9659 from,
9660 to,
9661 edge_kind,
9662 max_hops,
9663 } => {
9664 report.path =
9665 store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
9666 if let Some(max_hops) = max_hops
9667 && report.path.is_none()
9668 {
9669 report.warnings.push(format!(
9670 "no directed path found within --max-hops {}",
9671 max_hops
9672 ));
9673 }
9674 }
9675 GraphDbQuery::Map { .. } => {
9676 bail!("graph-db map must be handled by the map command path");
9677 }
9678 }
9679 Ok(report)
9680}
9681
9682pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
9683 if compact {
9684 println!(
9685 "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
9686 report.backend,
9687 report.query,
9688 report.nodes.len() + usize::from(report.node.is_some()),
9689 report.edges.len() + usize::from(report.edge.is_some()),
9690 report.freshness.status
9691 );
9692 return;
9693 }
9694 println!("graph-db backend: {}", report.backend);
9695 println!("freshness: {}", report.freshness.status);
9696 if let Some(readiness) = &report.readiness {
9697 println!(
9698 "readiness: {} reason: {} fail_closed: {}",
9699 readiness.status, readiness.reason, readiness.fail_closed
9700 );
9701 for diagnostic in &readiness.diagnostics {
9702 println!("readiness diagnostic: {diagnostic}");
9703 }
9704 for command in &readiness.next_commands {
9705 println!("readiness next: {command}");
9706 }
9707 }
9708 if let Some(schema) = &report.schema {
9709 println!(
9710 "schema: {} node fields, {} edge fields, {} operations",
9711 schema.node_fields.len(),
9712 schema.edge_fields.len(),
9713 schema.operations.len()
9714 );
9715 }
9716 if let Some(node) = &report.node {
9717 println!("node: {} [{}] {}", node.id, node.kind, node.label);
9718 }
9719 if let Some(edge) = &report.edge {
9720 let edge_full: SubstrateGraphEdge = edge.into();
9721 println!(
9722 "edge: {} {} -{}-> {}",
9723 graph_db_edge_key(&edge_full),
9724 edge.from_id,
9725 edge.kind,
9726 edge.to_id
9727 );
9728 }
9729 if let Some(knowledge) = &report.knowledge_retrieval {
9730 println!(
9731 "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
9732 knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
9733 );
9734 }
9735 for item in &report.semantic_related {
9736 println!(
9737 "semantic_seed: {:.3} [{}] {} ({})",
9738 item.score, item.kind, item.label, item.handle
9739 );
9740 }
9741 for node in &report.nodes {
9742 println!("node: {} [{}] {}", node.id, node.kind, node.label);
9743 }
9744 for edge in &report.edges {
9745 let edge_full: SubstrateGraphEdge = edge.into();
9746 println!(
9747 "edge: {} {} -{}-> {}",
9748 graph_db_edge_key(&edge_full),
9749 edge.from_id,
9750 edge.kind,
9751 edge.to_id
9752 );
9753 }
9754 for neighbor in &report.ranked_neighbors {
9755 println!(
9756 "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
9757 neighbor.rank,
9758 neighbor.score,
9759 neighbor
9760 .depth
9761 .map(|depth| depth.to_string())
9762 .unwrap_or_else(|| "unknown".to_string()),
9763 neighbor.node_id,
9764 neighbor.kind,
9765 neighbor.label
9766 );
9767 }
9768 if let Some(gate) = &report.neighborhood_ranking_gate {
9769 println!(
9770 "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
9771 gate.status, gate.default_order, gate.ranked_output_default
9772 );
9773 }
9774 if let Some(path) = &report.path {
9775 println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
9776 }
9777 if let Some(page) = &report.page {
9778 if let Some(next_cursor) = &page.next_cursor {
9779 println!("next_cursor: {next_cursor}");
9780 }
9781 for diagnostic in &page.diagnostics {
9782 println!("page: {diagnostic}");
9783 }
9784 }
9785 for warning in &report.warnings {
9786 println!("warning: {warning}");
9787 }
9788}
9789
9790pub(crate) fn graph_db_backend_eval_phase_timing(
9791 name: &str,
9792 duration_micros: u128,
9793 detail: &str,
9794) -> GraphDbBackendEvalPhaseTiming {
9795 GraphDbBackendEvalPhaseTiming {
9796 name: name.to_string(),
9797 duration_micros,
9798 detail: detail.to_string(),
9799 }
9800}
9801
9802pub(crate) fn graph_db_backend_eval_timed_phase<T>(
9803 phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
9804 name: &str,
9805 detail: &str,
9806 run: impl FnOnce() -> Result<T>,
9807) -> Result<T> {
9808 let started = Instant::now();
9809 let result = run();
9810 phases.push(graph_db_backend_eval_phase_timing(
9811 name,
9812 started.elapsed().as_micros(),
9813 detail,
9814 ));
9815 result
9816}
9817
9818pub(crate) fn graph_db_backend_eval_refresh_total_micros(
9819 phases: &[GraphDbBackendEvalPhaseTiming],
9820) -> u128 {
9821 phases
9822 .iter()
9823 .filter(|phase| phase.name != "conflict_matrix_preparation")
9824 .map(|phase| phase.duration_micros)
9825 .sum()
9826}
9827
9828pub(crate) fn graph_db_backend_eval_cached_refresh(
9829 root: &Path,
9830 scope: Option<&str>,
9831 source_watermark: Option<&str>,
9832) -> Result<
9833 Option<(
9834 TraversalGraphBuild,
9835 SqliteProjectionRefresh,
9836 Vec<GraphDbBackendEvalPhaseTiming>,
9837 )>,
9838> {
9839 let Some(source_watermark) = source_watermark else {
9840 return Ok(None);
9841 };
9842 let graph_db = graph_substrate_db_path(root, scope);
9843 if !graph_db.exists() {
9844 return Ok(None);
9845 }
9846
9847 let started = Instant::now();
9848 let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
9849 Ok(store) => store,
9850 Err(_) => return Ok(None),
9851 };
9852 if store.has_user_triggers().unwrap_or(true) {
9853 return Ok(None);
9854 }
9855 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
9856 if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
9857 return Ok(None);
9858 }
9859
9860 let phases = vec![
9861 graph_db_backend_eval_phase_timing(
9862 "source_graph_build",
9863 started.elapsed().as_micros(),
9864 "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
9865 ),
9866 graph_db_backend_eval_phase_timing(
9867 "projection_rows",
9868 0,
9869 "reused cached provider-neutral projection rows from graph.db",
9870 ),
9871 graph_db_backend_eval_phase_timing(
9872 "sqlite_open",
9873 0,
9874 "reused existing graph.db projection without opening a write transaction",
9875 ),
9876 ];
9877 let refresh = SqliteProjectionRefresh {
9878 scope: scope.unwrap_or("root").to_string(),
9879 projection_version: freshness
9880 .projection_version
9881 .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
9882 source_watermark: Some(source_watermark.to_string()),
9883 tombstoned_nodes: Vec::new(),
9884 tombstoned_edges: Vec::new(),
9885 upserted_nodes: 0,
9886 upserted_edges: 0,
9887 unchanged_nodes: 0,
9888 unchanged_edges: 0,
9889 upserted_properties: 0,
9890 unchanged_properties: 0,
9891 deleted_properties: 0,
9892 deleted_nodes: 0,
9893 deleted_edges: 0,
9894 pruned_tombstones: 0,
9895 file_size_bytes_before: None,
9896 file_size_bytes_after: None,
9897 phase_timings: Vec::new(),
9898 };
9899 Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
9900}
9901
9902pub(crate) fn graph_db_backend_eval_reused_cached_projection(
9903 phases: &[GraphDbBackendEvalPhaseTiming],
9904) -> bool {
9905 phases.iter().any(|phase| {
9906 phase.name == "source_graph_build"
9907 && phase.detail.contains("reused current graph.db projection")
9908 })
9909}
9910
9911pub(crate) fn graph_db_backend_eval_update_source_watermark(
9912 root: &Path,
9913 path_hint: &Path,
9914 scope: Option<&str>,
9915) -> Result<()> {
9916 let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
9917 return Ok(());
9918 };
9919 let graph_db = graph_substrate_db_path(root, scope);
9920 let mut store = SqliteGraphStore::open(&graph_db)?;
9921 store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
9922 Ok(())
9923}
9924
9925pub(crate) fn graph_db_backend_eval_refresh_with_profile(
9926 root: &Path,
9927 path_hint: &Path,
9928 scope: Option<&str>,
9929) -> Result<(
9930 TraversalGraphBuild,
9931 SqliteProjectionRefresh,
9932 Vec<GraphDbBackendEvalPhaseTiming>,
9933)> {
9934 let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
9935 if let Some(cached) =
9936 graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
9937 {
9938 return Ok(cached);
9939 }
9940
9941 let mut phases = Vec::new();
9942 let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
9943 "bounded session projection: index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads; skips global call-edge materialization because full-projection is the complete-call-graph regression guard"
9944 } else {
9945 "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
9946 };
9947 let source_graph = graph_db_backend_eval_timed_phase(
9948 &mut phases,
9949 "source_graph_build",
9950 source_graph_detail,
9951 || build_traversal_graph_source_with_options(root, path_hint, scope, false),
9952 )?;
9953 let projection = graph_db_backend_eval_timed_phase(
9954 &mut phases,
9955 "projection_rows",
9956 "provider-neutral GraphStore node/edge row construction before SQLite persistence",
9957 || traversal_projection_from_graph(root, scope, &source_graph),
9958 )?;
9959 let graph_db = graph_substrate_db_path(root, scope);
9960 let mut store = graph_db_backend_eval_timed_phase(
9961 &mut phases,
9962 "sqlite_open",
9963 "open the local SQLite graph.db with WAL and busy-timeout settings",
9964 || SqliteGraphStore::open(&graph_db),
9965 )?;
9966 let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
9967 .ok()
9968 .flatten();
9969 let refresh = store.replace_projection_with_version(
9970 scope.unwrap_or("root"),
9971 &projection,
9972 Some(GRAPH_PROJECTION_VERSION),
9973 refreshed_source_watermark
9974 .or(source_watermark)
9975 .or_else(|| graph_projection_content_hash(&projection)),
9976 )?;
9977 phases.extend(
9978 refresh
9979 .phase_timings
9980 .iter()
9981 .map(|phase| GraphDbBackendEvalPhaseTiming {
9982 name: phase.name.clone(),
9983 duration_micros: phase.duration_micros,
9984 detail: phase.detail.clone(),
9985 }),
9986 );
9987 Ok((source_graph, refresh, phases))
9988}
9989
9990fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
9991 root.join(".tsift/backend-eval-cache")
9992}
9993
9994fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
9995 graph_db_backend_eval_disk_cache_dir(root)
9996 .join(kind)
9997 .join(format!("{key}.json.gz"))
9998}
9999
10000fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10001 graph_db_backend_eval_disk_cache_dir(root)
10002 .join(kind)
10003 .join(format!("{key}.json"))
10004}
10005
10006#[derive(Default, Clone)]
10007struct GraphDbBackendEvalDiskCacheReadProfile {
10008 file_read_micros: u128,
10009 gzip_decode_micros: u128,
10010 serde_decode_micros: u128,
10011 legacy: bool,
10012}
10013
10014fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10015 root: &Path,
10016 kind: &str,
10017 key: &str,
10018) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10019 let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10020 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10021 let read_started = Instant::now();
10022 let read_result = fs::read(&path);
10023 profile.file_read_micros = read_started.elapsed().as_micros();
10024 if let Ok(bytes) = read_result {
10025 let decode_started = Instant::now();
10026 let mut decoder = GzDecoder::new(bytes.as_slice());
10027 let mut decoded = Vec::new();
10028 let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10029 profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10030 if decode_ok {
10031 let serde_started = Instant::now();
10032 let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10033 profile.serde_decode_micros = serde_started.elapsed().as_micros();
10034 if let Some(value) = parsed {
10035 return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10036 }
10037 }
10038 }
10039
10040 let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10041 let legacy_started = Instant::now();
10042 let bytes = fs::read(legacy_path).ok()?;
10043 profile.file_read_micros = profile
10044 .file_read_micros
10045 .saturating_add(legacy_started.elapsed().as_micros());
10046 let serde_started = Instant::now();
10047 let value = serde_json::from_slice(&bytes).ok()?;
10048 profile.serde_decode_micros = profile
10049 .serde_decode_micros
10050 .saturating_add(serde_started.elapsed().as_micros());
10051 profile.legacy = true;
10052 Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10053}
10054
10055#[derive(Default, Clone)]
10056struct GraphDbBackendEvalDiskCacheWriteProfile {
10057 serde_encode_micros: u128,
10058 gzip_encode_micros: u128,
10059 file_write_micros: u128,
10060}
10061
10062fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10063 root: &Path,
10064 kind: &str,
10065 key: &str,
10066 value: &T,
10067) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10068 let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10069 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10070 let parent = path.parent()?;
10071 if fs::create_dir_all(parent).is_err() {
10072 return None;
10073 }
10074 let serde_started = Instant::now();
10075 let bytes = serde_json::to_vec(value).ok()?;
10076 profile.serde_encode_micros = serde_started.elapsed().as_micros();
10077 let gzip_started = Instant::now();
10078 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10079 if encoder.write_all(&bytes).is_err() {
10080 return None;
10081 }
10082 let encoded = encoder.finish().ok()?;
10083 profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10084 let write_started = Instant::now();
10085 if fs::write(&path, &encoded).is_err() {
10086 return None;
10087 }
10088 profile.file_write_micros = write_started.elapsed().as_micros();
10089 Some((encoded.len() as u64, bytes.len() as u64, profile))
10090}
10091
10092fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10093 let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10094 let Ok(entries) = fs::read_dir(dir) else {
10095 return (0, 0);
10096 };
10097 let keep_name = format!("{keep_key}.json.gz");
10098 let mut pruned_files = 0usize;
10099 let mut pruned_bytes = 0u64;
10100 for entry in entries.flatten() {
10101 let path = entry.path();
10102 if !path.is_file() {
10103 continue;
10104 }
10105 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10106 continue;
10107 };
10108 if name == keep_name {
10109 continue;
10110 }
10111 let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10112 if !is_backend_eval_cache {
10113 continue;
10114 }
10115 let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10116 if fs::remove_file(&path).is_ok() {
10117 pruned_files += 1;
10118 pruned_bytes += bytes;
10119 }
10120 }
10121 (pruned_files, pruned_bytes)
10122}
10123
10124fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10125 root: &Path,
10126 source_root: &Path,
10127) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10128 let mut rows = Vec::new();
10129 let mut entries = walk::walk_files(source_root)?;
10130 entries.sort_by(|left, right| left.path.cmp(&right.path));
10131 for entry in entries {
10132 if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10133 continue;
10134 }
10135 if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10136 continue;
10137 }
10138 let bytes = fs::read(&entry.path)
10139 .with_context(|| format!("reading source input {}", entry.path.display()))?;
10140 rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10141 path: traversal_watermark_path(root, &entry.path),
10142 bytes: bytes.len() as u64,
10143 content_hash: content_hash(&bytes)?,
10144 });
10145 }
10146 Ok(rows)
10147}
10148
10149fn graph_db_backend_eval_full_projection_source_watermark(
10150 root: &Path,
10151 scope: Option<&str>,
10152) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10153 let path_hint = root;
10154 let mut detail_parts = Vec::new();
10155 let mut parts = vec![
10156 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10157 format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10158 "watermark_kind:stable_full_projection_inputs".to_string(),
10159 format!("scope:{}", scope.unwrap_or("root")),
10160 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10161 ];
10162
10163 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10164 match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10165 Some(db_path) => {
10166 let db = index::IndexDb::open_read_only_resilient(db_path)?;
10167 parts.push("index_mode:indexed".to_string());
10168 detail_parts.push("mode=indexed".to_string());
10169 parts.push(format!(
10170 "index_source_root:{}",
10171 traversal_watermark_path(root, &gate.source_root)
10172 ));
10173
10174 let symbols = db
10175 .all_symbols()?
10176 .into_iter()
10177 .filter(|symbol| {
10178 !traversal_path_is_generated_artifact(
10179 root,
10180 &gate.source_root,
10181 Path::new(&symbol.file),
10182 ) && !traversal_path_is_session_markdown(
10183 root,
10184 &gate.source_root,
10185 Path::new(&symbol.file),
10186 )
10187 })
10188 .collect::<Vec<_>>();
10189 let symbols_hash = content_hash(&symbols)?;
10190 detail_parts.push(format!("symbols={symbols_hash}"));
10191 parts.push(format!("index_symbols:{symbols_hash}"));
10192
10193 let edges = db
10194 .all_stored_edges()?
10195 .into_iter()
10196 .filter(|edge| {
10197 !traversal_path_is_generated_artifact(
10198 root,
10199 &gate.source_root,
10200 Path::new(&edge.caller_file),
10201 ) && !traversal_path_is_session_markdown(
10202 root,
10203 &gate.source_root,
10204 Path::new(&edge.caller_file),
10205 )
10206 })
10207 .collect::<Vec<_>>();
10208 let edges_hash = content_hash(&edges)?;
10209 detail_parts.push(format!("call_edges={edges_hash}"));
10210 parts.push(format!("index_call_edges:{edges_hash}"));
10211
10212 let routes = db
10213 .all_routes()?
10214 .into_iter()
10215 .filter(|route| {
10216 !traversal_path_is_generated_artifact(
10217 root,
10218 &gate.source_root,
10219 Path::new(&route.file),
10220 ) && !traversal_path_is_session_markdown(
10221 root,
10222 &gate.source_root,
10223 Path::new(&route.file),
10224 )
10225 })
10226 .collect::<Vec<_>>();
10227 let routes_hash = content_hash(&routes)?;
10228 detail_parts.push(format!("routes={routes_hash}"));
10229 parts.push(format!("index_routes:{routes_hash}"));
10230 }
10231 None => {
10232 parts.push("index_mode:raw_fallback".to_string());
10233 detail_parts.push("mode=raw_fallback".to_string());
10234 parts.push(format!(
10235 "raw_source_root:{}",
10236 traversal_watermark_path(root, &gate.source_root)
10237 ));
10238 let raw_rows =
10239 graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10240 let raw_hash = content_hash(&raw_rows)?;
10241 detail_parts.push(format!("raw_source_files={raw_hash}"));
10242 parts.push(format!("raw_source_files:{raw_hash}"));
10243 }
10244 }
10245
10246 parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10247 detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10248 let summaries_start = parts.len();
10249 push_traversal_summaries_watermark_part(root, &mut parts)?;
10250 let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10251 detail_parts.push(format!("summaries={summaries_hash}"));
10252 let value = content_hash(&parts)?;
10253 detail_parts.push(format!("watermark={value}"));
10254 Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10255 value,
10256 detail: detail_parts.join(" "),
10257 })
10258}
10259
10260fn graph_db_backend_eval_full_projection_cache_key(
10261 root: &Path,
10262 scope: Option<&str>,
10263) -> Result<(String, String, String)> {
10264 let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10265 let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10266 root,
10267 scope,
10268 &source_watermark.value,
10269 )?;
10270 Ok((source_watermark.value, key, source_watermark.detail))
10271}
10272
10273fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10274 root: &Path,
10275 scope: Option<&str>,
10276 source_watermark: &str,
10277) -> Result<String> {
10278 content_hash(&serde_json::json!({
10279 "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10280 "root": root.display().to_string(),
10281 "scope": scope.unwrap_or("root"),
10282 "source_watermark": source_watermark,
10283 }))
10284}
10285
10286pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10287 root: &Path,
10288 scope: Option<&str>,
10289) -> Result<(
10290 GraphProjection,
10291 Vec<String>,
10292 Vec<GraphDbBackendEvalPhaseTiming>,
10293 GraphDbBackendEvalFullProjectionCacheStats,
10294)> {
10295 let (source_watermark, key, source_watermark_detail) =
10296 graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10297 let lookup_started = Instant::now();
10298 if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10299 graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10300 root,
10301 "full_projection",
10302 &key,
10303 )
10304 && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
10305 && cached.key == key
10306 && cached.source_watermark == source_watermark
10307 {
10308 let lookup_overhead_micros = lookup_started
10309 .elapsed()
10310 .as_micros()
10311 .saturating_sub(read_profile.file_read_micros)
10312 .saturating_sub(read_profile.gzip_decode_micros)
10313 .saturating_sub(read_profile.serde_decode_micros);
10314 let prune_started = Instant::now();
10315 let (pruned_files, pruned_bytes) =
10316 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
10317 let prune_micros = prune_started.elapsed().as_micros();
10318 let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
10319 hit: true,
10320 disk_bytes,
10321 json_bytes,
10322 pruned_files,
10323 pruned_bytes,
10324 };
10325 let read_detail_suffix = if read_profile.legacy {
10326 " (legacy uncompressed cache path)"
10327 } else {
10328 ""
10329 };
10330 return Ok((
10331 cached.projection,
10332 cached.warnings,
10333 vec![
10334 graph_db_backend_eval_phase_timing(
10335 "full_projection.cache_lookup",
10336 lookup_overhead_micros,
10337 &format!(
10338 "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
10339 ),
10340 ),
10341 graph_db_backend_eval_phase_timing(
10342 "full_projection.cache.file_read",
10343 read_profile.file_read_micros,
10344 &format!(
10345 "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
10346 ),
10347 ),
10348 graph_db_backend_eval_phase_timing(
10349 "full_projection.cache.gzip_decode",
10350 read_profile.gzip_decode_micros,
10351 "gunzip the compressed projection cache bytes",
10352 ),
10353 graph_db_backend_eval_phase_timing(
10354 "full_projection.cache.serde_decode",
10355 read_profile.serde_decode_micros,
10356 "serde_json deserialize the decoded projection cache payload",
10357 ),
10358 graph_db_backend_eval_phase_timing(
10359 "full_projection.cache.prune",
10360 prune_micros,
10361 "prune sibling cache files older than the current key",
10362 ),
10363 graph_db_backend_eval_phase_timing(
10364 "full_projection.source_graph_build",
10365 0,
10366 "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10367 ),
10368 graph_db_backend_eval_phase_timing(
10369 "full_projection.projection_rows",
10370 0,
10371 "reused cached provider-neutral full-project projection rows",
10372 ),
10373 ],
10374 cache_stats,
10375 ));
10376 }
10377
10378 let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
10379 let mut phases = vec![graph_db_backend_eval_phase_timing(
10380 "full_projection.cache_lookup",
10381 lookup_started.elapsed().as_micros(),
10382 &format!(
10383 "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
10384 ),
10385 )];
10386 let full_source = graph_db_backend_eval_timed_phase(
10387 &mut phases,
10388 "full_projection.source_graph_build",
10389 "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
10390 || build_traversal_graph_source_with_options(root, root, scope, false),
10391 )?;
10392 let projection = graph_db_backend_eval_timed_phase(
10393 &mut phases,
10394 "full_projection.projection_rows",
10395 "provider-neutral row construction for the opt-in full-project projection dataset",
10396 || traversal_projection_from_graph(root, scope, &full_source),
10397 )?;
10398 let warnings = full_source.warnings;
10399 let refreshed_source_watermark =
10400 graph_db_backend_eval_full_projection_source_watermark(root, scope)
10401 .map(|watermark| watermark.value)
10402 .unwrap_or_else(|_| source_watermark.clone());
10403 let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10404 root,
10405 scope,
10406 &refreshed_source_watermark,
10407 )?;
10408 let cache = GraphDbBackendEvalFullProjectionCache {
10409 version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
10410 key: write_key.clone(),
10411 source_watermark: refreshed_source_watermark,
10412 projection: projection.clone(),
10413 warnings: warnings.clone(),
10414 };
10415 if let Some((disk_bytes, json_bytes, write_profile)) =
10416 graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
10417 {
10418 cache_stats.disk_bytes = disk_bytes;
10419 cache_stats.json_bytes = json_bytes;
10420 phases.push(graph_db_backend_eval_phase_timing(
10421 "full_projection.cache.serde_encode",
10422 write_profile.serde_encode_micros,
10423 "serde_json serialize the projection cache payload before compression",
10424 ));
10425 phases.push(graph_db_backend_eval_phase_timing(
10426 "full_projection.cache.gzip_encode",
10427 write_profile.gzip_encode_micros,
10428 "gzip-compress the serialized projection cache payload",
10429 ));
10430 phases.push(graph_db_backend_eval_phase_timing(
10431 "full_projection.cache.file_write",
10432 write_profile.file_write_micros,
10433 "write the compressed projection cache bytes to .tsift/backend-eval-cache",
10434 ));
10435 }
10436 let prune_started = Instant::now();
10437 let (pruned_files, pruned_bytes) =
10438 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
10439 phases.push(graph_db_backend_eval_phase_timing(
10440 "full_projection.cache.prune",
10441 prune_started.elapsed().as_micros(),
10442 "prune sibling cache files older than the current key",
10443 ));
10444 cache_stats.pruned_files = pruned_files;
10445 cache_stats.pruned_bytes = pruned_bytes;
10446 Ok((projection, warnings, phases, cache_stats))
10447}
10448
10449fn graph_db_backend_eval_timed(
10450 name: &str,
10451 run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
10452) -> (
10453 GraphDbBackendEvalOperation,
10454 Option<GraphDbBackendEvalSignature>,
10455) {
10456 let started = Instant::now();
10457 match run() {
10458 Ok((rows, value)) => (
10459 GraphDbBackendEvalOperation {
10460 name: name.to_string(),
10461 supported: true,
10462 status: "ok".to_string(),
10463 duration_micros: started.elapsed().as_micros(),
10464 rows,
10465 error: None,
10466 },
10467 Some(GraphDbBackendEvalSignature {
10468 operation: name.to_string(),
10469 value,
10470 }),
10471 ),
10472 Err(err) => (
10473 GraphDbBackendEvalOperation {
10474 name: name.to_string(),
10475 supported: false,
10476 status: "error".to_string(),
10477 duration_micros: started.elapsed().as_micros(),
10478 rows: None,
10479 error: Some(format!("{err:#}")),
10480 },
10481 None,
10482 ),
10483 }
10484}
10485
10486fn graph_db_backend_eval_parity(
10487 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10488 candidate_signatures: &[GraphDbBackendEvalSignature],
10489) -> GraphDbBackendEvalParity {
10490 let Some(sqlite_signatures) = sqlite_signatures else {
10491 return GraphDbBackendEvalParity {
10492 matches_sqlite: true,
10493 diagnostics: Vec::new(),
10494 };
10495 };
10496 let sqlite = sqlite_signatures
10497 .iter()
10498 .map(|signature| (signature.operation.as_str(), &signature.value))
10499 .collect::<BTreeMap<_, _>>();
10500 let candidate = candidate_signatures
10501 .iter()
10502 .map(|signature| (signature.operation.as_str(), &signature.value))
10503 .collect::<BTreeMap<_, _>>();
10504 let mut diagnostics = Vec::new();
10505 for (operation, sqlite_value) in sqlite {
10506 match candidate.get(operation) {
10507 Some(candidate_value) if *candidate_value == sqlite_value => {}
10508 Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
10509 None => diagnostics.push(format!(
10510 "{operation} did not complete for candidate backend"
10511 )),
10512 }
10513 }
10514 GraphDbBackendEvalParity {
10515 matches_sqlite: diagnostics.is_empty(),
10516 diagnostics,
10517 }
10518}
10519
10520pub(crate) fn graph_db_backend_eval_targets(
10521 store: &impl GraphStore,
10522 requested: &[String],
10523) -> Result<Vec<String>> {
10524 let requested = requested
10525 .iter()
10526 .filter_map(|target| normalize_conflict_target(target))
10527 .collect::<Vec<_>>();
10528 if !requested.is_empty() {
10529 return Ok(requested);
10530 }
10531
10532 for kind in ["backlog", "job_packet"] {
10533 let nodes = store.nodes_by_kind(kind)?;
10534 if let Some(node) = nodes.first() {
10535 if let Some(ref_id) = node.properties.get("ref_id") {
10536 return Ok(vec![ref_id.clone()]);
10537 }
10538 return Ok(vec![node.id.clone()]);
10539 }
10540 }
10541 Ok(Vec::new())
10542}
10543
10544fn graph_db_backend_eval_path_targets(
10545 store: &impl GraphStore,
10546 max_hops: usize,
10547) -> Result<Option<(String, String, usize)>> {
10548 let synthetic_from = "gsym-synthetic-0000";
10549 let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
10550 if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
10551 let outgoing = store.outgoing_edges(synthetic_from, None)?;
10552 if outgoing.len() > 1
10553 && let Some(edge) = outgoing.first()
10554 {
10555 return Ok(Some((
10556 edge.from_id.clone(),
10557 edge.to_id.clone(),
10558 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10559 )));
10560 }
10561 return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
10562 }
10563
10564 Ok(store.sample_edge(None)?.map(|edge| {
10565 (
10566 edge.from_id,
10567 edge.to_id,
10568 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10569 )
10570 }))
10571}
10572
10573fn graph_db_backend_eval_path_operation<S: GraphStore>(
10574 store: &S,
10575 configured_max_hops: usize,
10576) -> (
10577 GraphDbBackendEvalOperation,
10578 Option<GraphDbBackendEvalSignature>,
10579) {
10580 let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10581 "path_max_hops".to_string()
10582 } else {
10583 format!("path_max_hops_{configured_max_hops}")
10584 };
10585 graph_db_backend_eval_timed(&operation_name, || {
10586 let (from, to, effective_max_hops) =
10587 graph_db_backend_eval_path_targets(store, configured_max_hops)?
10588 .context("backend-eval path probe requires at least one traversable edge")?;
10589 let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
10590 let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10591 Some(format!(
10592 "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
10593 GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
10594 ))
10595 } else if path.is_none() && effective_max_hops == configured_max_hops {
10596 Some(format!(
10597 "path probe truncated at {configured_max_hops} hops before a route was found"
10598 ))
10599 } else {
10600 None
10601 };
10602 Ok((
10603 path.as_ref().map(|path| path.nodes.len()),
10604 serde_json::json!({
10605 "from": from,
10606 "to": to,
10607 "configured_max_hops": configured_max_hops,
10608 "effective_max_hops": effective_max_hops,
10609 "hops": path.as_ref().map(|path| path.hops),
10610 "nodes": path.as_ref().map(|path| &path.nodes),
10611 "found": path.is_some(),
10612 "warning": warning,
10613 }),
10614 ))
10615 })
10616}
10617
10618fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
10619 store: &S,
10620 depth: usize,
10621 limit: usize,
10622) -> (
10623 GraphDbBackendEvalOperation,
10624 Option<GraphDbBackendEvalSignature>,
10625) {
10626 graph_db_backend_eval_timed("neighborhood", || {
10627 let edge = match store.sample_edge(Some("calls"))? {
10628 Some(edge) => edge,
10629 None => store.sample_edge(None)?.context(
10630 "backend-eval neighborhood probe requires at least one traversable edge",
10631 )?,
10632 };
10633 let page = store
10634 .paged_neighborhood(
10635 &edge.from_id,
10636 depth,
10637 Some(&edge.kind),
10638 GraphQueryOptions {
10639 limit: Some(limit.max(1)),
10640 ..GraphQueryOptions::default()
10641 },
10642 )?
10643 .with_context(|| {
10644 format!(
10645 "backend-eval neighborhood target not found: {}",
10646 edge.from_id
10647 )
10648 })?;
10649 Ok((
10650 Some(page.nodes.len() + page.edges.len()),
10651 serde_json::json!({
10652 "center": edge.from_id,
10653 "kind": edge.kind,
10654 "depth": depth,
10655 "limit": limit.max(1),
10656 "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10657 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10658 "truncated": page.page.truncated,
10659 }),
10660 ))
10661 })
10662}
10663
10664fn graph_db_backend_eval_related_operation<S: GraphStore>(
10665 root: &Path,
10666 scope: Option<&str>,
10667 store: &S,
10668 depth: usize,
10669 limit: usize,
10670) -> (
10671 GraphDbBackendEvalOperation,
10672 Option<GraphDbBackendEvalSignature>,
10673) {
10674 graph_db_backend_eval_timed("related", || {
10675 let query = "backend evaluation";
10676 let semantic = semantic_related_report_from_store(
10677 root,
10678 scope,
10679 query,
10680 3,
10681 SemanticRelatedKind::All,
10682 store,
10683 )?;
10684 let seed_ids = semantic
10685 .items
10686 .iter()
10687 .map(|item| item.handle.clone())
10688 .collect::<Vec<_>>();
10689 let subgraph =
10690 graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
10691 Ok((
10692 Some(subgraph.nodes.len() + subgraph.edges.len()),
10693 serde_json::json!({
10694 "query": query,
10695 "seed_ids": seed_ids,
10696 "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10697 "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10698 "truncated": subgraph.truncated,
10699 "warnings": semantic.warnings,
10700 "diagnostics": subgraph.diagnostics,
10701 }),
10702 ))
10703 })
10704}
10705
10706fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
10707 serde_json::json!({
10708 "target": report.target,
10709 "target_node_id": report.target_node.id,
10710 "target_kind": report.target_node.kind,
10711 "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
10712 "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
10713 "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
10714 "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
10715 "path_count": report.shortest_paths.len(),
10716 })
10717}
10718
10719fn graph_db_backend_eval_target_resolution_signature(
10720 resolved: &[(String, SubstrateGraphNode)],
10721) -> serde_json::Value {
10722 serde_json::json!({
10723 "targets": resolved.iter().map(|(target, node)| {
10724 serde_json::json!({
10725 "target": target,
10726 "target_node_id": node.id,
10727 "target_kind": node.kind,
10728 "target_label": node.label,
10729 })
10730 }).collect::<Vec<_>>(),
10731 })
10732}
10733
10734fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
10735 serde_json::json!({
10736 "targets": report.targets,
10737 "can_parallel": report.can_parallel,
10738 "fail_closed": report.fail_closed,
10739 "cross_target_parallel_safe": report.cross_target_parallel_safe,
10740 "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
10741 "candidates": report.candidates.iter().map(|candidate| {
10742 serde_json::json!({
10743 "target": candidate.target,
10744 "risk": conflict_risk_label(candidate.risk),
10745 "owned_files": candidate.owned_files,
10746 "owned_symbols": candidate.owned_symbols,
10747 "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
10748 "previously_completed": candidate.previously_completed,
10749 "parallel_safe": candidate.parallel_safe,
10750 })
10751 }).collect::<Vec<_>>(),
10752 "conflicts": report.conflicts.iter().map(|pair| {
10753 serde_json::json!({
10754 "left": pair.left,
10755 "right": pair.right,
10756 "risk": conflict_risk_label(pair.risk),
10757 })
10758 }).collect::<Vec<_>>(),
10759 })
10760}
10761
10762fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
10763 serde_json::json!({
10764 "targets": report.targets,
10765 "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10766 "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
10767 "evidence_packet_ids": report.evidence_packet_ids,
10768 "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
10769 "truncated": report.truncated,
10770 })
10771}
10772
10773fn graph_db_backend_eval_edge_scan_probe(
10774 store: &impl GraphStore,
10775) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
10776 if let Some((edge, filter)) = store.sample_edge_with_property()? {
10777 return Ok((edge, vec![filter]));
10778 }
10779 let edge = store
10780 .sample_edge(None)?
10781 .context("backend-eval edge scan requires at least one edge")?;
10782 Ok((edge, Vec::new()))
10783}
10784
10785#[allow(clippy::too_many_arguments)]
10786fn graph_db_backend_eval_report_for_store<S: GraphStore>(
10787 backend: &str,
10788 adapter: &str,
10789 read_only: bool,
10790 root: &Path,
10791 path: &Path,
10792 scope: Option<&str>,
10793 targets: &[String],
10794 depth: usize,
10795 limit: usize,
10796 impact_limit: usize,
10797 store: &S,
10798 freshness: GraphDbFreshnessReport,
10799 refresh_operation: GraphDbBackendEvalOperation,
10800 refresh_signature: Option<GraphDbBackendEvalSignature>,
10801 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10802 extra_warnings: Vec<String>,
10803 prepared: &ConflictMatrixPreparedInputs,
10804 projection_load: &str,
10805 lock_behavior: &str,
10806 install_portability: &str,
10807) -> (
10808 GraphDbBackendEvalBackendReport,
10809 Vec<GraphDbBackendEvalSignature>,
10810) {
10811 let mut operations = vec![refresh_operation];
10812 let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
10813
10814 let (operation, signature) = graph_db_backend_eval_timed("status", || {
10815 let (nodes, edges) = store.graph_counts()?;
10816 Ok((
10817 Some(nodes + edges),
10818 serde_json::json!({
10819 "freshness": freshness.status,
10820 "nodes": nodes,
10821 "edges": edges,
10822 }),
10823 ))
10824 });
10825 operations.push(operation);
10826 signatures.extend(signature);
10827
10828 let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
10829 let edge = store
10830 .sample_edge(None)?
10831 .context("backend-eval edge lookup requires at least one edge")?;
10832 let edge_id = graph_db_edge_key(&edge);
10833 let found = store
10834 .edge(&edge_id)?
10835 .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
10836 Ok((
10837 Some(1),
10838 serde_json::json!({
10839 "edge_id": edge_id,
10840 "from_id": found.from_id,
10841 "to_id": found.to_id,
10842 "kind": found.kind,
10843 }),
10844 ))
10845 });
10846 operations.push(operation);
10847 signatures.extend(signature);
10848
10849 let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
10850 let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
10851 let page = store.paged_edges(
10852 Some(&edge.kind),
10853 GraphQueryOptions {
10854 limit: Some(limit.max(1)),
10855 property_filters: filters.clone(),
10856 ..GraphQueryOptions::default()
10857 },
10858 )?;
10859 Ok((
10860 Some(page.edges.len()),
10861 serde_json::json!({
10862 "kind": edge.kind,
10863 "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
10864 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10865 "truncated": page.page.truncated,
10866 }),
10867 ))
10868 });
10869 operations.push(operation);
10870 signatures.extend(signature);
10871
10872 let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
10873 let edge = store
10874 .sample_edge(None)?
10875 .context("backend-eval incident edge scan requires at least one edge")?;
10876 let page = store.paged_incident_edges(
10877 &edge.from_id,
10878 Some(&edge.kind),
10879 GraphQueryOptions {
10880 limit: Some(limit.max(1)),
10881 ..GraphQueryOptions::default()
10882 },
10883 )?;
10884 Ok((
10885 Some(page.edges.len()),
10886 serde_json::json!({
10887 "node_id": edge.from_id,
10888 "kind": edge.kind,
10889 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10890 "truncated": page.page.truncated,
10891 }),
10892 ))
10893 });
10894 operations.push(operation);
10895 signatures.extend(signature);
10896
10897 let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
10898 operations.push(operation);
10899 signatures.extend(signature);
10900
10901 let (operation, signature) =
10902 graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
10903 operations.push(operation);
10904 signatures.extend(signature);
10905
10906 for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
10907 .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
10908 {
10909 let (operation, signature) =
10910 graph_db_backend_eval_path_operation(store, configured_max_hops);
10911 operations.push(operation);
10912 signatures.extend(signature);
10913 }
10914
10915 let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
10916 let resolved = targets
10917 .iter()
10918 .map(|target| {
10919 let node = graph_db_resolve_evidence_target(store, target)?
10920 .with_context(|| format!("backend-eval target not found: {target}"))?;
10921 Ok((target.clone(), node))
10922 })
10923 .collect::<Result<Vec<_>>>()?;
10924 let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
10925 Ok((Some(resolved.len()), signature))
10926 });
10927 operations.push(operation);
10928 signatures.extend(signature);
10929
10930 let mut evidence_for_report = None;
10931 let mut graph_snapshot_for_trace = None;
10932 let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
10933 let resolved_targets =
10934 resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
10935 let evidence = collect_conflict_matrix_evidence_packets(
10936 root,
10937 scope,
10938 backend,
10939 &resolved_targets,
10940 depth,
10941 limit,
10942 store,
10943 freshness.clone(),
10944 )?;
10945 let report = &evidence
10946 .first()
10947 .context("backend-eval evidence requires at least one target")?
10948 .report;
10949 let rows = evidence
10950 .iter()
10951 .map(|entry| {
10952 entry.report.worker_context.len()
10953 + entry.report.source_handles.len()
10954 + entry.report.worker_results.len()
10955 + entry.report.semantic_related.len()
10956 })
10957 .sum();
10958 let signature = graph_db_backend_eval_evidence_signature(report);
10959 evidence_for_report = Some((resolved_targets, evidence));
10960 Ok((Some(rows), signature))
10961 });
10962 operations.push(operation);
10963 signatures.extend(signature);
10964
10965 let mut conflict_for_trace = None;
10966 let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
10967 let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
10968 let graph =
10969 conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
10970 let shared_preparation =
10971 conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
10972 ConflictMatrixGraphPreparedInputs {
10973 targets,
10974 graph,
10975 evidence,
10976 shared_preparation,
10977 }
10978 } else {
10979 prepare_conflict_matrix_graph_orchestration(
10980 root,
10981 scope,
10982 backend,
10983 targets,
10984 prepared,
10985 depth,
10986 limit,
10987 store,
10988 freshness.clone(),
10989 )?
10990 };
10991 let report = build_conflict_matrix_report_from_prepared_graph(
10992 root,
10993 path,
10994 scope,
10995 depth,
10996 limit,
10997 impact_limit,
10998 freshness.clone(),
10999 extra_warnings.clone(),
11000 prepared,
11001 &graph_prepared,
11002 )?;
11003 let signature = graph_db_backend_eval_conflict_signature(&report);
11004 let rows = report.candidates.len() + report.conflicts.len();
11005 conflict_for_trace = Some(report);
11006 graph_snapshot_for_trace = Some(graph_prepared.graph);
11007 Ok((Some(rows), signature))
11008 });
11009 operations.push(operation);
11010 signatures.extend(signature);
11011
11012 let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11013 let conflict = conflict_for_trace
11014 .take()
11015 .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11016 let graph = graph_snapshot_for_trace
11017 .take()
11018 .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11019 let report = build_dispatch_trace_report_from_conflict_snapshot(
11020 root,
11021 scope,
11022 conflict,
11023 graph.nodes,
11024 graph.edges,
11025 depth,
11026 limit,
11027 Vec::new(),
11028 )?;
11029 Ok((
11030 Some(report.nodes.len() + report.edges.len()),
11031 graph_db_backend_eval_dispatch_signature(&report),
11032 ))
11033 });
11034 operations.push(operation);
11035 signatures.extend(signature);
11036
11037 let total_micros = operations
11038 .iter()
11039 .map(|operation| operation.duration_micros)
11040 .sum();
11041 let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11042 (
11043 GraphDbBackendEvalBackendReport {
11044 backend: backend.to_string(),
11045 adapter: adapter.to_string(),
11046 read_only,
11047 projection_load: projection_load.to_string(),
11048 operations,
11049 total_micros,
11050 parity,
11051 lock_behavior: lock_behavior.to_string(),
11052 install_portability: install_portability.to_string(),
11053 },
11054 signatures,
11055 )
11056}
11057
11058pub(crate) fn graph_db_backend_eval_refresh_operation(
11059 duration_micros: u128,
11060 rows: usize,
11061 value: serde_json::Value,
11062) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11063 (
11064 GraphDbBackendEvalOperation {
11065 name: "refresh".to_string(),
11066 supported: true,
11067 status: "ok".to_string(),
11068 duration_micros,
11069 rows: Some(rows),
11070 error: None,
11071 },
11072 GraphDbBackendEvalSignature {
11073 operation: "refresh".to_string(),
11074 value,
11075 },
11076 )
11077}
11078
11079pub(crate) fn graph_db_backend_eval_synthetic_projection(
11080 nodes: usize,
11081 fanout: usize,
11082) -> GraphProjection {
11083 let nodes = nodes.max(12);
11084 let symbol_count = nodes.saturating_sub(9).max(1);
11085 let source = GraphProvenance::new("backend-eval", "synthetic");
11086 let mut projection_nodes = vec![
11087 SubstrateGraphNode::new(
11088 "projection:tsift-traversal:synthetic",
11089 GRAPH_PROJECTION_META_KIND,
11090 "synthetic projection",
11091 )
11092 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11093 .with_property(
11094 "content_hash",
11095 format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11096 )
11097 .with_provenance(source.clone()),
11098 SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11099 .with_property("ref_id", "synthetic-session"),
11100 SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11101 .with_property("ref_id", "synthetic")
11102 .with_property("path", "tasks/software/synthetic.md")
11103 .with_property("line", "1")
11104 .with_property(
11105 "expand",
11106 "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11107 ),
11108 SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11109 .with_property("ref_id", "synthetic"),
11110 SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11111 .with_property("target", "synthetic")
11112 .with_property("summary", "Synthetic worker owns synthetic.rs")
11113 .with_property(
11114 "expand",
11115 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11116 ),
11117 SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11118 .with_property("file", "synthetic.rs")
11119 .with_property("start", "1")
11120 .with_property("end", "80")
11121 .with_property(
11122 "expand",
11123 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11124 ),
11125 SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11126 .with_property("path", "synthetic.rs"),
11127 SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11128 .with_property("handle", "gsem-synthetic")
11129 .with_property("label", "backend evaluation")
11130 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11131 .with_property(
11132 "embedding",
11133 semantic_embedding_property("backend evaluation"),
11134 ),
11135 SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11136 .with_property("ref_id", "synthetic")
11137 .with_property("status", "completed")
11138 .with_property("touched_files", "synthetic.rs")
11139 .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11140 ];
11141 for idx in 0..symbol_count {
11142 projection_nodes.push(
11143 SubstrateGraphNode::new(
11144 format!("gsym-synthetic-{idx:04}"),
11145 "symbol",
11146 format!("synthetic_symbol_{idx:04}"),
11147 )
11148 .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11149 .with_property("path", "synthetic.rs")
11150 .with_property("line", (idx + 1).to_string()),
11151 );
11152 }
11153
11154 let mut projection_edges = vec![
11155 SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11156 SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11157 SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11158 SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11159 SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11160 SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11161 SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11162 SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11163 SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11164 ];
11165 for idx in 0..symbol_count {
11166 let from = format!("gsym-synthetic-{idx:04}");
11167 for offset in 1..=fanout.max(1).min(symbol_count) {
11168 let to_idx = (idx + offset) % symbol_count;
11169 if to_idx != idx {
11170 projection_edges.push(SubstrateGraphEdge::new(
11171 from.clone(),
11172 format!("gsym-synthetic-{to_idx:04}"),
11173 "calls",
11174 ));
11175 }
11176 }
11177 }
11178
11179 GraphProjection {
11180 nodes: projection_nodes,
11181 edges: projection_edges
11182 .into_iter()
11183 .map(|edge| {
11184 edge.with_property("dataset", "synthetic")
11185 .with_provenance(source.clone())
11186 })
11187 .collect(),
11188 }
11189}
11190
11191pub(crate) fn graph_db_backend_eval_promotion(
11192 datasets: &[GraphDbBackendEvalDataset],
11193 candidates: &[GraphDbExperimentalBackend],
11194) -> Vec<GraphDbBackendPromotionDecision> {
11195 let mut decisions = Vec::new();
11196 for candidate in candidates {
11197 let mut reasons = Vec::new();
11198 let mut faster_everywhere = true;
11199 let mut parity_everywhere = true;
11200 for dataset in datasets {
11201 let Some(sqlite_report) = dataset
11202 .backends
11203 .iter()
11204 .find(|backend| backend.backend == "sqlite")
11205 else {
11206 parity_everywhere = false;
11207 faster_everywhere = false;
11208 reasons.push(format!(
11209 "{} dataset is missing SQLite baseline",
11210 dataset.name
11211 ));
11212 continue;
11213 };
11214 let sqlite_total = sqlite_report.total_micros;
11215 let Some(candidate_report) = dataset
11216 .backends
11217 .iter()
11218 .find(|backend| backend.backend == candidate.name())
11219 else {
11220 parity_everywhere = false;
11221 reasons.push(format!("{} dataset did not run", dataset.name));
11222 continue;
11223 };
11224 if !candidate_report.parity.matches_sqlite {
11225 parity_everywhere = false;
11226 reasons.push(format!("{} parity differed from SQLite", dataset.name));
11227 }
11228 if candidate_report.total_micros >= sqlite_total {
11229 faster_everywhere = false;
11230 reasons.push(format!(
11231 "{} total {}us did not beat SQLite {}us",
11232 dataset.name, candidate_report.total_micros, sqlite_total
11233 ));
11234 }
11235 let sqlite_operations = sqlite_report
11236 .operations
11237 .iter()
11238 .map(|operation| (operation.name.as_str(), operation.duration_micros))
11239 .collect::<BTreeMap<_, _>>();
11240 for operation in &candidate_report.operations {
11241 if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11242 && operation.duration_micros >= *sqlite_duration
11243 {
11244 faster_everywhere = false;
11245 reasons.push(format!(
11246 "{} {} operation {}us did not beat SQLite {}us",
11247 dataset.name, operation.name, operation.duration_micros, sqlite_duration
11248 ));
11249 }
11250 }
11251 if candidate_report
11252 .operations
11253 .iter()
11254 .any(|operation| operation.status != "ok")
11255 {
11256 parity_everywhere = false;
11257 reasons.push(format!("{} has failed benchmark operations", dataset.name));
11258 }
11259 }
11260 let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11261 reasons.push(reason.to_string());
11262 reasons.push(
11263 "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11264 .to_string(),
11265 );
11266 "hold"
11267 } else if parity_everywhere && faster_everywhere {
11268 reasons.push(
11269 "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11270 .to_string(),
11271 );
11272 "eligible"
11273 } else {
11274 reasons.push(
11275 "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11276 .to_string(),
11277 );
11278 "hold"
11279 };
11280 decisions.push(GraphDbBackendPromotionDecision {
11281 backend: candidate.name().to_string(),
11282 decision: decision.to_string(),
11283 reasons: dedupe_preserve_order(reasons),
11284 gate: candidate.promotion_gate(),
11285 });
11286 }
11287 decisions
11288}
11289
11290pub(crate) fn graph_db_backend_eval_metrics(
11291 datasets: &[GraphDbBackendEvalDataset],
11292) -> BTreeMap<String, f64> {
11293 let mut metrics = BTreeMap::new();
11294 for dataset in datasets {
11295 let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11296 metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11297 metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11298 metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11299 for backend in &dataset.backends {
11300 let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
11301 metrics.insert(
11302 format!("{prefix}.total_duration_micros"),
11303 backend.total_micros as f64,
11304 );
11305 append_graph_db_backend_eval_normalized_duration_metric(
11306 &mut metrics,
11307 &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
11308 backend.total_micros,
11309 graph_rows,
11310 );
11311 for operation in &backend.operations {
11312 metrics.insert(
11313 format!("{prefix}.{}.duration_micros", operation.name),
11314 operation.duration_micros as f64,
11315 );
11316 append_graph_db_backend_eval_normalized_duration_metric(
11317 &mut metrics,
11318 &format!(
11319 "{prefix}.{}.duration_micros_per_1k_graph_rows",
11320 operation.name
11321 ),
11322 operation.duration_micros,
11323 graph_rows,
11324 );
11325 if let Some(rows) = operation.rows {
11326 metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
11327 }
11328 }
11329 }
11330 }
11331 metrics
11332}
11333
11334pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
11335 dataset.nodes + dataset.edges
11336}
11337
11338pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
11339 metrics: &mut BTreeMap<String, f64>,
11340 key: &str,
11341 duration_micros: u128,
11342 graph_rows: usize,
11343) {
11344 if graph_rows == 0 {
11345 return;
11346 }
11347 metrics.insert(
11348 key.to_string(),
11349 duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
11350 );
11351}
11352
11353pub(crate) fn append_graph_db_backend_eval_phase_metrics(
11354 metrics: &mut BTreeMap<String, f64>,
11355 dataset: &str,
11356 graph_rows: usize,
11357 phases: &[GraphDbBackendEvalPhaseTiming],
11358) {
11359 for phase in phases {
11360 metrics.insert(
11361 format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
11362 phase.duration_micros as f64,
11363 );
11364 append_graph_db_backend_eval_normalized_duration_metric(
11365 metrics,
11366 &format!(
11367 "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
11368 phase.name
11369 ),
11370 phase.duration_micros,
11371 graph_rows,
11372 );
11373 }
11374}
11375
11376fn graph_db_backend_eval_base_command(
11377 root: &Path,
11378 scope: Option<&str>,
11379 full_projection: bool,
11380) -> String {
11381 let full_projection_arg = if full_projection {
11382 " --full-projection"
11383 } else {
11384 ""
11385 };
11386 format!(
11387 "tsift graph-db --path {}{} --json backend-eval{}",
11388 shell_quote(root.to_string_lossy().as_ref()),
11389 graph_db_scope_arg(scope),
11390 full_projection_arg
11391 )
11392}
11393
11394pub(crate) fn graph_db_backend_eval_metric_digest_command(
11395 root: &Path,
11396 scope: Option<&str>,
11397 full_projection: bool,
11398) -> String {
11399 format!(
11400 "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11401 graph_db_backend_eval_base_command(root, scope, full_projection)
11402 )
11403}
11404
11405fn graph_db_backend_eval_repeated_sample_command(
11406 root: &Path,
11407 scope: Option<&str>,
11408 full_projection: bool,
11409) -> String {
11410 format!(
11411 "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11412 graph_db_backend_eval_base_command(root, scope, full_projection)
11413 )
11414}
11415
11416fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
11417 let mut required_metrics = Vec::new();
11418 for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
11419 required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
11420 required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
11421 for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
11422 required_metrics.push(format!(
11423 "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
11424 ));
11425 required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
11426 }
11427 }
11428 GraphDbHopCapPromotionGate {
11429 status: "hold_64_default_until_gate_passes".to_string(),
11430 current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
11431 candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
11432 required_backend: perf_gate::BASELINE_BACKEND.to_string(),
11433 required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
11434 .iter()
11435 .map(|workload| (*workload).to_string())
11436 .collect(),
11437 required_metrics,
11438 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11439 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11440 decision_rule:
11441 "keep 64 as the user-facing default until each candidate tier has repeated real, full_projection, and synthetic_deep_chain SQLite samples within the latency-regression budget and returning useful path rows; full_projection samples are binding only after a cold populate leg proves a cache-hit leg"
11442 .to_string(),
11443 }
11444}
11445
11446fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
11447 let candidate_backends = [
11448 GraphDbExperimentalBackend::Falkordb,
11449 GraphDbExperimentalBackend::Kuzu,
11450 GraphDbExperimentalBackend::Surrealdb,
11451 ]
11452 .into_iter()
11453 .map(|backend| GraphDbBackendAdapterSpikeCandidate {
11454 backend: backend.name().to_string(),
11455 adapter_label: backend.adapter_label().to_string(),
11456 projection_load: backend.projection_load().to_string(),
11457 lock_behavior: backend.lock_behavior().to_string(),
11458 install_portability: backend.install_portability().to_string(),
11459 })
11460 .collect();
11461
11462 GraphDbBackendAdapterSpikeGate {
11463 status: "hold_real_optional_adapter_required".to_string(),
11464 candidate_backends,
11465 required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
11466 .iter()
11467 .map(|workload| (*workload).to_string())
11468 .collect(),
11469 required_checks: vec![
11470 "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
11471 "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
11472 "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
11473 "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
11474 "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
11475 .to_string(),
11476 "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
11477 "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
11478 ],
11479 decision_rule:
11480 "do not promote a read-only prototype; FalkorDB, Kuzu, or SurrealDB can only advance after a real optional adapter proves projection writes/load, lock semantics, install portability, full parity, and faster-than-SQLite results across every required workload"
11481 .to_string(),
11482 evidence_plan: "plans/gback-evidence.md".to_string(),
11483 }
11484}
11485
11486pub(crate) fn graph_db_backend_eval_performance_gate(
11487 root: &Path,
11488 scope: Option<&str>,
11489 full_projection: bool,
11490) -> GraphDbBackendEvalPerformanceGate {
11491 let mut required_metrics = vec![
11492 "real.sqlite.refresh.duration_micros".to_string(),
11493 "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
11494 "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
11495 "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
11496 "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11497 "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11498 "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
11499 "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
11500 "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11501 "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
11502 "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
11503 "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
11504 "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
11505 "real.sqlite.conflict_matrix.duration_micros".to_string(),
11506 "real.sqlite.dispatch_trace.duration_micros".to_string(),
11507 "real.sqlite.path_max_hops.duration_micros".to_string(),
11508 "real.sqlite.path_max_hops_128.duration_micros".to_string(),
11509 "real.sqlite.path_max_hops_256.duration_micros".to_string(),
11510 "real.sqlite.path_max_hops_512.duration_micros".to_string(),
11511 "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
11512 "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
11513 "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
11514 "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
11515 "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11516 "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11517 "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
11518 .to_string(),
11519 "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11520 .to_string(),
11521 "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11522 "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11523 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
11524 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
11525 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
11526 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
11527 "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11528 .to_string(),
11529 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
11530 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
11531 .to_string(),
11532 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
11533 .to_string(),
11534 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
11535 .to_string(),
11536 ];
11537 if full_projection {
11538 required_metrics.extend([
11539 "full_projection.cache.hit".to_string(),
11540 "full_projection.cache.disk_bytes".to_string(),
11541 "full_projection.cache.compression_ratio".to_string(),
11542 "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
11543 "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11544 "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
11545 .to_string(),
11546 "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
11547 .to_string(),
11548 "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
11549 "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
11550 "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
11551 "full_projection.sqlite.neighborhood.duration_micros".to_string(),
11552 "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
11553 "full_projection.sqlite.evidence.duration_micros".to_string(),
11554 "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
11555 "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
11556 "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
11557 "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
11558 "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
11559 "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
11560 ]);
11561 }
11562 GraphDbBackendEvalPerformanceGate {
11563 baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
11564 ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
11565 .to_string(),
11566 opt_in_real_profile:
11567 "pass --full-projection to add the full-project dataset when checking for large projection regressions"
11568 .to_string(),
11569 full_projection_cache_hit_gate: if full_projection {
11570 "binding full_projection performance evidence requires a cold populate leg followed by cache-leg samples with full_projection.cache.hit=1; cache-miss samples are diagnostics, not backend or hop-cap promotion proof"
11571 .to_string()
11572 } else {
11573 "not evaluated until --full-projection is enabled".to_string()
11574 },
11575 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11576 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11577 normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
11578 required_metrics,
11579 digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
11580 repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
11581 root,
11582 scope,
11583 full_projection,
11584 ),
11585 hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
11586 backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
11587 }
11588}
11589
11590#[cfg(feature = "backend-surrealdb")]
11591fn graph_db_backend_eval_path_segment(value: &str) -> String {
11592 value
11593 .chars()
11594 .map(|ch| {
11595 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
11596 ch
11597 } else {
11598 '_'
11599 }
11600 })
11601 .collect()
11602}
11603
11604#[cfg(feature = "backend-surrealdb")]
11605fn graph_db_backend_eval_surrealdb_store_path(
11606 root: &Path,
11607 scope: Option<&str>,
11608 dataset: &str,
11609) -> PathBuf {
11610 root.join(".tsift/backend-eval-cache/surrealdb")
11611 .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
11612 .join(graph_db_backend_eval_path_segment(dataset))
11613 .join("surrealkv")
11614}
11615
11616pub(crate) struct GraphDbBackendEvalOptions<'a> {
11617 path: &'a Path,
11618 scope: Option<&'a str>,
11619 candidates: &'a [String],
11620 targets: &'a [String],
11621 full_projection: bool,
11622}
11623
11624#[allow(clippy::too_many_arguments)]
11625pub(crate) fn graph_db_backend_eval_dataset(
11626 name: &str,
11627 root: &Path,
11628 path: &Path,
11629 scope: Option<&str>,
11630 targets: &[String],
11631 depth: usize,
11632 limit: usize,
11633 impact_limit: usize,
11634 candidates: &[GraphDbExperimentalBackend],
11635 sqlite_store: &SqliteGraphStore,
11636 sqlite_freshness: GraphDbFreshnessReport,
11637 sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
11638 sqlite_rows: ConvexProjectionRows,
11639 extra_warnings: Vec<String>,
11640 prepared: &ConflictMatrixPreparedInputs,
11641) -> Result<GraphDbBackendEvalDataset> {
11642 let (nodes, edges) = sqlite_store.graph_counts()?;
11643 let (sqlite_operation, sqlite_signature) = sqlite_refresh;
11644 let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
11645 "sqlite",
11646 "SQLite GraphStore correctness baseline",
11647 false,
11648 root,
11649 path,
11650 scope,
11651 targets,
11652 depth,
11653 limit,
11654 impact_limit,
11655 sqlite_store,
11656 sqlite_freshness,
11657 sqlite_operation,
11658 Some(sqlite_signature),
11659 None,
11660 extra_warnings.clone(),
11661 prepared,
11662 "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
11663 "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
11664 "bundled rusqlite baseline; no external service or runtime required",
11665 );
11666
11667 let mut backends = vec![sqlite_report];
11668 for candidate in candidates {
11669 #[cfg(feature = "backend-surrealdb")]
11670 if *candidate == GraphDbExperimentalBackend::Surrealdb {
11671 let started = Instant::now();
11672 let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
11673 let (store, warm_start) =
11674 SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
11675 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11676 let rows = candidate_nodes + candidate_edges;
11677 let mut refresh_meta = serde_json::json!({
11678 "nodes": candidate_nodes,
11679 "edges": candidate_edges,
11680 });
11681 if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
11682 refresh_meta["warm_start"] = serde_json::json!("cache_hit");
11683 }
11684 let refresh = graph_db_backend_eval_refresh_operation(
11685 started.elapsed().as_micros(),
11686 rows,
11687 refresh_meta,
11688 );
11689 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11690 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11691 candidate.name(),
11692 "SurrealDB SurrealKV optional adapter spike",
11693 false,
11694 root,
11695 path,
11696 scope,
11697 targets,
11698 depth,
11699 limit,
11700 impact_limit,
11701 &store,
11702 freshness,
11703 refresh.0,
11704 Some(refresh.1),
11705 Some(&sqlite_signatures),
11706 extra_warnings.clone(),
11707 prepared,
11708 "provider-neutral rows written into an embedded/file-backed SurrealDB SurrealKV store through the optional tsift-surrealdb adapter; warm-start reuses existing store when row hash matches",
11709 "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
11710 "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
11711 );
11712 backends.push(candidate_report);
11713 continue;
11714 }
11715 let started = Instant::now();
11716 let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
11717 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11718 let rows = candidate_nodes + candidate_edges;
11719 let refresh = graph_db_backend_eval_refresh_operation(
11720 started.elapsed().as_micros(),
11721 rows,
11722 serde_json::json!({
11723 "nodes": candidate_nodes,
11724 "edges": candidate_edges,
11725 }),
11726 );
11727 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11728 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11729 candidate.name(),
11730 candidate.adapter_label(),
11731 true,
11732 root,
11733 path,
11734 scope,
11735 targets,
11736 depth,
11737 limit,
11738 impact_limit,
11739 &store,
11740 freshness,
11741 refresh.0,
11742 Some(refresh.1),
11743 Some(&sqlite_signatures),
11744 extra_warnings.clone(),
11745 prepared,
11746 candidate.projection_load(),
11747 candidate.lock_behavior(),
11748 candidate.install_portability(),
11749 );
11750 backends.push(candidate_report);
11751 }
11752
11753 Ok(GraphDbBackendEvalDataset {
11754 name: name.to_string(),
11755 target_count: targets.len(),
11756 nodes,
11757 edges,
11758 backends,
11759 })
11760}
11761
11762pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
11763 println!(
11764 "graph-db backend-eval baseline:{} candidates:{}",
11765 report.baseline_backend,
11766 report.candidates.join(", ")
11767 );
11768 for phase in &report.phase_timings {
11769 println!(
11770 "phase:{} {}us {}",
11771 phase.name, phase.duration_micros, phase.detail
11772 );
11773 }
11774 for dataset in &report.datasets {
11775 println!(
11776 "dataset:{} targets:{} rows:{}",
11777 dataset.name,
11778 dataset.target_count,
11779 dataset.nodes + dataset.edges
11780 );
11781 for backend in &dataset.backends {
11782 println!(
11783 " backend:{} total:{}us parity:{}",
11784 backend.backend, backend.total_micros, backend.parity.matches_sqlite
11785 );
11786 println!(" projection-load: {}", backend.projection_load);
11787 println!(" lock-behavior: {}", backend.lock_behavior);
11788 println!(" install-portability: {}", backend.install_portability);
11789 for operation in &backend.operations {
11790 println!(
11791 " {} {} {}us",
11792 operation.name, operation.status, operation.duration_micros
11793 );
11794 }
11795 for diagnostic in &backend.parity.diagnostics {
11796 println!(" parity: {diagnostic}");
11797 }
11798 }
11799 }
11800 for decision in &report.promotion {
11801 println!("promotion {}: {}", decision.backend, decision.decision);
11802 println!(" gate: {}", decision.gate.status);
11803 for reason in &decision.reasons {
11804 println!(" reason: {reason}");
11805 }
11806 for check in &decision.gate.required_checks {
11807 println!(" check: {check}");
11808 }
11809 }
11810 println!("metric-digest: {}", report.metric_digest_command);
11811 println!(
11812 "repeat-samples: {}",
11813 report.performance_gate.repeated_sample_command
11814 );
11815}
11816
11817fn traversal_expand_command(root: &Path, handle: &str) -> String {
11818 format!(
11819 "tsift traverse {} --path {} --depth 1 --limit 50",
11820 shell_quote(handle),
11821 shell_quote(root.to_string_lossy().as_ref())
11822 )
11823}
11824
11825fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
11826 let display = relativize(file, root);
11827 let handle = stable_handle("gfil", &format!("file:{display}"));
11828 TraversalNode {
11829 handle: handle.clone(),
11830 kind: "file".to_string(),
11831 label: display.clone(),
11832 ref_id: Some(display.clone()),
11833 path: Some(display),
11834 line: None,
11835 detail: None,
11836 properties: BTreeMap::new(),
11837 expand: traversal_expand_command(root, &handle),
11838 }
11839}
11840
11841fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
11842 let mut node = traversal_file_node(root, file);
11843 if let Some(path) = node.path.clone() {
11844 node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
11845 node.expand = source_read_command(root, &path, 1, 80);
11846 }
11847 node
11848}
11849
11850fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
11851 let file = relativize(&symbol.file, root);
11852 let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
11853 let handle = stable_handle("gsym", &key);
11854 TraversalNode {
11855 handle: handle.clone(),
11856 kind: "symbol".to_string(),
11857 label: symbol.name.clone(),
11858 ref_id: Some(symbol.name.clone()),
11859 path: Some(file),
11860 line: Some(symbol.line),
11861 detail: Some(format!("{} {}", symbol.language, symbol.kind)),
11862 properties: BTreeMap::new(),
11863 expand: traversal_expand_command(root, &handle),
11864 }
11865}
11866
11867fn traversal_ast_span_expand_command(
11868 root: &Path,
11869 file: &str,
11870 symbol: &index::StoredSymbol,
11871 span: &AstSpanPreview,
11872) -> String {
11873 if symbol.language == "markdown" {
11874 markdown_ast_command(root, file, Some(&span.handle))
11875 } else {
11876 let line_count = span
11877 .end_line
11878 .saturating_sub(span.start_line)
11879 .saturating_add(1)
11880 .max(1);
11881 source_read_command(root, file, span.start_line, line_count)
11882 }
11883}
11884
11885fn traversal_ast_span_node(
11886 root: &Path,
11887 symbol: &index::StoredSymbol,
11888 source: &[u8],
11889 symbols: &[index::StoredSymbol],
11890) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
11891 let span = stored_symbol_ast_span(symbol, source, symbols, usize::MAX)?;
11892 let file = relativize(&symbol.file, root);
11893 let mut properties = BTreeMap::new();
11894 properties.insert("layer".to_string(), "ast_navigation".to_string());
11895 properties.insert("language".to_string(), symbol.language.clone());
11896 properties.insert("symbol_kind".to_string(), symbol.kind.clone());
11897 properties.insert("node_kind".to_string(), span.node_kind.clone());
11898 properties.insert("start_byte".to_string(), span.start_byte.to_string());
11899 properties.insert("end_byte".to_string(), span.end_byte.to_string());
11900 properties.insert("end_line".to_string(), span.end_line.to_string());
11901 if let Some(body_start_byte) = span.body_start_byte {
11902 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
11903 }
11904 if let Some(body_end_byte) = span.body_end_byte {
11905 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
11906 }
11907 if let Some(body_start_line) = span.body_start_line {
11908 properties.insert("body_start_line".to_string(), body_start_line.to_string());
11909 }
11910 if let Some(body_end_line) = span.body_end_line {
11911 properties.insert("body_end_line".to_string(), body_end_line.to_string());
11912 }
11913 if let Some(parent_handle) = &span.parent_handle {
11914 properties.insert("parent_handle".to_string(), parent_handle.clone());
11915 }
11916 if !span.child_handles.is_empty() {
11917 properties.insert("child_handles".to_string(), span.child_handles.join(","));
11918 }
11919 if let Some(parent_module) = &symbol.parent_module {
11920 properties.insert("parent_module".to_string(), parent_module.clone());
11921 }
11922 if let Some(markdown) = &span.markdown {
11923 properties.insert(
11924 "markdown_block_kind".to_string(),
11925 markdown_ast_block_kind(&symbol.kind),
11926 );
11927 if let Some(heading_level) = markdown.heading_level {
11928 properties.insert("heading_level".to_string(), heading_level.to_string());
11929 }
11930 if !markdown.section_path.is_empty() {
11931 properties.insert(
11932 "section_path".to_string(),
11933 markdown.section_path.join(" > "),
11934 );
11935 }
11936 if let Some(section_handle) = &markdown.section_handle {
11937 properties.insert("section_handle".to_string(), section_handle.clone());
11938 }
11939 if let Some(list_depth) = markdown.list_depth {
11940 properties.insert("list_depth".to_string(), list_depth.to_string());
11941 }
11942 if let Some(fence_language) = &markdown.fence_language {
11943 properties.insert("fence_language".to_string(), fence_language.clone());
11944 }
11945 }
11946
11947 let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
11948 let node = TraversalNode {
11949 handle: span.handle.clone(),
11950 kind: "ast_span".to_string(),
11951 label: symbol.name.clone(),
11952 ref_id: Some(symbol.name.clone()),
11953 path: Some(file.clone()),
11954 line: Some(line),
11955 detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
11956 properties,
11957 expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
11958 };
11959 let entry = TraversalAstSpanIndexEntry {
11960 handle: span.handle,
11961 symbol_handle: String::new(),
11962 file_handle: None,
11963 file,
11964 name: symbol.name.clone(),
11965 kind: symbol.kind.clone(),
11966 language: symbol.language.clone(),
11967 node_kind: span.node_kind,
11968 start_byte: span.start_byte,
11969 end_byte: span.end_byte,
11970 parent_module: symbol.parent_module.clone(),
11971 markdown: span.markdown,
11972 };
11973 Some((node, entry))
11974}
11975
11976fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
11977 let handle = stable_handle("gsym", &format!("symbol:{name}"));
11978 TraversalNode {
11979 handle: handle.clone(),
11980 kind: "symbol".to_string(),
11981 label: name.to_string(),
11982 ref_id: Some(name.to_string()),
11983 path: None,
11984 line: None,
11985 detail: Some("unresolved call target".to_string()),
11986 properties: BTreeMap::new(),
11987 expand: traversal_expand_command(root, &handle),
11988 }
11989}
11990
11991fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
11992 let file = relativize(&route.file, root);
11993 let method = route.method.as_deref().unwrap_or("any");
11994 let key = format!(
11995 "route:{file}:{}:{}:{}",
11996 route.line, method, route.route_path
11997 );
11998 let handle = stable_handle("grte", &key);
11999 TraversalNode {
12000 handle: handle.clone(),
12001 kind: "route".to_string(),
12002 label: format!("{} {}", method.to_uppercase(), route.route_path),
12003 ref_id: Some(route.route_path.clone()),
12004 path: Some(file),
12005 line: Some(route.line),
12006 detail: Some(format!(
12007 "{} route handled by {}",
12008 route.framework, route.handler_name
12009 )),
12010 properties: BTreeMap::new(),
12011 expand: traversal_expand_command(root, &handle),
12012 }
12013}
12014
12015fn traversal_cargo_workspace_node(
12016 root: &Path,
12017 workspace: &multiplicity::CargoWorkspaceInfo,
12018) -> TraversalNode {
12019 let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12020 .to_string_lossy()
12021 .replace('\\', "/");
12022 let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12023 .to_string_lossy()
12024 .replace('\\', "/");
12025 let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12026 let mut properties = BTreeMap::new();
12027 properties.insert("layer".to_string(), "cargo_workspace".to_string());
12028 properties.insert("workspace_root".to_string(), workspace_root.clone());
12029 properties.insert("members".to_string(), workspace.members.join(","));
12030 properties.insert(
12031 "default_members".to_string(),
12032 workspace.default_members.join(","),
12033 );
12034 TraversalNode {
12035 handle: handle.clone(),
12036 kind: "cargo_workspace".to_string(),
12037 label: if workspace_root.is_empty() {
12038 "root cargo workspace".to_string()
12039 } else {
12040 workspace_root
12041 },
12042 ref_id: Some(workspace.id.clone()),
12043 path: Some(manifest),
12044 line: None,
12045 detail: Some("Cargo workspace manifest".to_string()),
12046 properties,
12047 expand: traversal_expand_command(root, &handle),
12048 }
12049}
12050
12051fn traversal_cargo_package_node(
12052 root: &Path,
12053 package: &multiplicity::CargoPackageInfo,
12054) -> TraversalNode {
12055 let manifest = relativize_pathbuf(&package.manifest_path, root)
12056 .to_string_lossy()
12057 .replace('\\', "/");
12058 let package_root = relativize_pathbuf(&package.package_root, root)
12059 .to_string_lossy()
12060 .replace('\\', "/");
12061 let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12062 .to_string_lossy()
12063 .replace('\\', "/");
12064 let handle = stable_handle(
12065 "gcpk",
12066 &format!("cargo-package:{manifest}:{}", package.name),
12067 );
12068 let mut properties = BTreeMap::new();
12069 properties.insert("layer".to_string(), "cargo_package".to_string());
12070 properties.insert("package_name".to_string(), package.name.clone());
12071 properties.insert(
12072 "normalized_name".to_string(),
12073 package.normalized_name.clone(),
12074 );
12075 properties.insert("package_root".to_string(), package_root.clone());
12076 properties.insert("workspace_root".to_string(), workspace_root);
12077 properties.insert("features".to_string(), package.features.join(","));
12078 properties.insert("targets".to_string(), package.targets.join(","));
12079 properties.insert(
12080 "dependencies".to_string(),
12081 package
12082 .dependencies
12083 .iter()
12084 .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12085 .collect::<Vec<_>>()
12086 .join(","),
12087 );
12088 TraversalNode {
12089 handle: handle.clone(),
12090 kind: "cargo_package".to_string(),
12091 label: package.name.clone(),
12092 ref_id: Some(package.scope_id.clone()),
12093 path: Some(manifest),
12094 line: None,
12095 detail: Some(format!(
12096 "Cargo package in {}",
12097 if package_root.is_empty() {
12098 "."
12099 } else {
12100 package_root.as_str()
12101 }
12102 )),
12103 properties,
12104 expand: traversal_expand_command(root, &handle),
12105 }
12106}
12107
12108fn traversal_session_node(
12109 root: &Path,
12110 markdown_path: &Path,
12111 session_id: Option<&str>,
12112) -> TraversalNode {
12113 let display = relativize_pathbuf(markdown_path, root)
12114 .to_string_lossy()
12115 .replace('\\', "/");
12116 let handle = stable_handle("gses", &format!("session:{display}"));
12117 TraversalNode {
12118 handle: handle.clone(),
12119 kind: "session".to_string(),
12120 label: session_id.unwrap_or(&display).to_string(),
12121 ref_id: session_id.map(str::to_string),
12122 path: Some(display),
12123 line: None,
12124 detail: Some("agent-doc session artifact".to_string()),
12125 properties: BTreeMap::new(),
12126 expand: traversal_expand_command(root, &handle),
12127 }
12128}
12129
12130fn traversal_backlog_node(
12131 root: &Path,
12132 markdown_path: &Path,
12133 id: &str,
12134 text: &str,
12135 line: i64,
12136) -> TraversalNode {
12137 let display = relativize_pathbuf(markdown_path, root)
12138 .to_string_lossy()
12139 .replace('\\', "/");
12140 let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12141 TraversalNode {
12142 handle: handle.clone(),
12143 kind: "backlog".to_string(),
12144 label: format!("#{id}"),
12145 ref_id: Some(id.to_string()),
12146 path: Some(display),
12147 line: Some(line),
12148 detail: Some(text.to_string()),
12149 properties: BTreeMap::new(),
12150 expand: traversal_expand_command(root, &handle),
12151 }
12152}
12153
12154fn traversal_job_packet_node(
12155 root: &Path,
12156 markdown_path: &Path,
12157 label: &str,
12158 ref_id: Option<&str>,
12159 detail: &str,
12160 line: i64,
12161) -> TraversalNode {
12162 let display = relativize_pathbuf(markdown_path, root)
12163 .to_string_lossy()
12164 .replace('\\', "/");
12165 let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12166 TraversalNode {
12167 handle: handle.clone(),
12168 kind: "job_packet".to_string(),
12169 label: label.to_string(),
12170 ref_id: ref_id.map(str::to_string),
12171 path: Some(display),
12172 line: Some(line),
12173 detail: Some(detail.to_string()),
12174 properties: BTreeMap::new(),
12175 expand: traversal_expand_command(root, &handle),
12176 }
12177}
12178
12179#[derive(Clone, Debug)]
12180struct ParsedWorkerResult {
12181 id: String,
12182 status: String,
12183 touched_files: Vec<String>,
12184 tests: Vec<String>,
12185 follow_up_ids: Vec<String>,
12186}
12187
12188fn traversal_worker_result_node(
12189 root: &Path,
12190 markdown_path: &Path,
12191 parsed: &ParsedWorkerResult,
12192 line_text: &str,
12193 line: i64,
12194) -> TraversalNode {
12195 let display = relativize_pathbuf(markdown_path, root)
12196 .to_string_lossy()
12197 .replace('\\', "/");
12198 let handle = stable_handle(
12199 "wres",
12200 &format!(
12201 "worker-result:{display}:{}:{}:{}",
12202 parsed.id, parsed.status, line
12203 ),
12204 );
12205 let mut properties = BTreeMap::new();
12206 properties.insert("status".to_string(), parsed.status.clone());
12207 if !parsed.touched_files.is_empty() {
12208 properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12209 }
12210 if !parsed.tests.is_empty() {
12211 properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12212 }
12213 if !parsed.follow_up_ids.is_empty() {
12214 properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12215 }
12216 TraversalNode {
12217 handle: handle.clone(),
12218 kind: "worker_result".to_string(),
12219 label: format!("{} #{}", parsed.status, parsed.id),
12220 ref_id: Some(parsed.id.clone()),
12221 path: Some(display),
12222 line: Some(line),
12223 detail: Some(line_text.trim().to_string()),
12224 properties,
12225 expand: traversal_expand_command(root, &handle),
12226 }
12227}
12228
12229fn traversal_tokens(input: &str) -> BTreeSet<String> {
12230 input
12231 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12232 .flat_map(|part| part.split(['_', '-']))
12233 .map(str::trim)
12234 .filter(|part| part.len() >= 3)
12235 .map(|part| part.to_ascii_lowercase())
12236 .collect()
12237}
12238
12239fn traversal_ast_span_contains(
12240 parent: &TraversalAstSpanIndexEntry,
12241 child: &TraversalAstSpanIndexEntry,
12242) -> bool {
12243 parent.handle != child.handle
12244 && parent.file == child.file
12245 && parent.start_byte <= child.start_byte
12246 && parent.end_byte >= child.end_byte
12247}
12248
12249fn traversal_ast_parent_handle<'a>(
12250 entry: &TraversalAstSpanIndexEntry,
12251 entries: &'a [TraversalAstSpanIndexEntry],
12252) -> Option<&'a str> {
12253 entries
12254 .iter()
12255 .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12256 .min_by_key(|candidate| {
12257 (
12258 candidate.end_byte.saturating_sub(candidate.start_byte),
12259 candidate.start_byte,
12260 candidate.end_byte,
12261 candidate.kind.as_str(),
12262 candidate.name.as_str(),
12263 candidate.node_kind.as_str(),
12264 )
12265 })
12266 .map(|candidate| candidate.handle.as_str())
12267}
12268
12269fn traversal_ast_enclosing_module_handle<'a>(
12270 entry: &TraversalAstSpanIndexEntry,
12271 entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12272 parent_by_handle: &BTreeMap<String, String>,
12273) -> Option<&'a str> {
12274 let mut current = parent_by_handle.get(&entry.handle);
12275 while let Some(handle) = current {
12276 let Some(parent) = entries_by_handle.get(handle) else {
12277 break;
12278 };
12279 if matches!(parent.kind.as_str(), "module" | "mod")
12280 || entry
12281 .parent_module
12282 .as_deref()
12283 .is_some_and(|module| module == parent.name)
12284 {
12285 return Some(parent.handle.as_str());
12286 }
12287 current = parent_by_handle.get(&parent.handle);
12288 }
12289 None
12290}
12291
12292fn link_ast_navigation_edges(
12293 graph: &mut TraversalGraphBuild,
12294 entries: &[TraversalAstSpanIndexEntry],
12295) {
12296 let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12297 let entries_by_handle = entries
12298 .iter()
12299 .map(|entry| (entry.handle.clone(), entry.clone()))
12300 .collect::<BTreeMap<_, _>>();
12301 let mut parent_by_handle = BTreeMap::<String, String>::new();
12302 let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
12303
12304 for entry in entries {
12305 entries_by_file
12306 .entry(entry.file.clone())
12307 .or_default()
12308 .push(entry.clone());
12309 }
12310
12311 for file_entries in entries_by_file.values() {
12312 for entry in file_entries {
12313 let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
12314 if let Some(parent) = &parent {
12315 parent_by_handle.insert(entry.handle.clone(), parent.clone());
12316 }
12317 let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
12318 children_by_parent
12319 .entry(sibling_key)
12320 .or_default()
12321 .push(entry.clone());
12322 }
12323 }
12324
12325 for entry in entries {
12326 let parent = parent_by_handle.get(&entry.handle);
12327 if let Some(parent) = parent {
12328 graph.add_edge(
12329 parent,
12330 &entry.handle,
12331 "contains",
12332 Some("AST parent contains child span".to_string()),
12333 1,
12334 );
12335 graph.add_edge(
12336 parent,
12337 &entry.handle,
12338 "child",
12339 Some("AST child span".to_string()),
12340 1,
12341 );
12342 graph.add_edge(
12343 &entry.handle,
12344 parent,
12345 "parent",
12346 Some("AST parent span".to_string()),
12347 1,
12348 );
12349 } else if let Some(file_handle) = &entry.file_handle {
12350 graph.add_edge(
12351 file_handle,
12352 &entry.handle,
12353 "contains",
12354 Some("file contains top-level AST span".to_string()),
12355 1,
12356 );
12357 }
12358
12359 if let Some(module_handle) =
12360 traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
12361 {
12362 graph.add_edge(
12363 &entry.handle,
12364 module_handle,
12365 "enclosing_module",
12366 Some("nearest enclosing module AST span".to_string()),
12367 1,
12368 );
12369 }
12370
12371 if entry.language == "markdown"
12372 && let Some(markdown) = &entry.markdown
12373 && let Some(section_handle) = &markdown.section_handle
12374 && section_handle != &entry.handle
12375 {
12376 graph.add_edge(
12377 section_handle,
12378 &entry.handle,
12379 "contains_markdown_block",
12380 Some("Markdown section contains block".to_string()),
12381 1,
12382 );
12383 graph.add_edge(
12384 &entry.handle,
12385 section_handle,
12386 "enclosing_section",
12387 Some("Markdown enclosing section".to_string()),
12388 1,
12389 );
12390 }
12391 }
12392
12393 for siblings in children_by_parent.values_mut() {
12394 siblings.sort_by(|left, right| {
12395 left.start_byte
12396 .cmp(&right.start_byte)
12397 .then(left.end_byte.cmp(&right.end_byte))
12398 .then(left.kind.cmp(&right.kind))
12399 .then(left.name.cmp(&right.name))
12400 .then(left.node_kind.cmp(&right.node_kind))
12401 .then(left.handle.cmp(&right.handle))
12402 });
12403 for pair in siblings.windows(2) {
12404 let previous = &pair[0];
12405 let next = &pair[1];
12406 graph.add_edge(
12407 &previous.handle,
12408 &next.handle,
12409 "next_sibling",
12410 Some("next AST sibling span".to_string()),
12411 1,
12412 );
12413 graph.add_edge(
12414 &next.handle,
12415 &previous.handle,
12416 "previous_sibling",
12417 Some("previous AST sibling span".to_string()),
12418 1,
12419 );
12420 }
12421 }
12422}
12423
12424fn traversal_markdown_embedded_symbol_node(
12425 root: &Path,
12426 entry: &TraversalAstSpanIndexEntry,
12427 markdown: &MarkdownSpanMetadata,
12428 embedded: &MarkdownEmbeddedSymbol,
12429) -> TraversalNode {
12430 let mut properties = BTreeMap::new();
12431 properties.insert("layer".to_string(), "embedded_code".to_string());
12432 properties.insert("embedded".to_string(), "true".to_string());
12433 properties.insert("language".to_string(), embedded.language.clone());
12434 properties.insert("symbol_kind".to_string(), embedded.kind.clone());
12435 properties.insert("node_kind".to_string(), embedded.node_kind.clone());
12436 properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
12437 properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
12438 properties.insert("end_line".to_string(), embedded.end_line.to_string());
12439 properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
12440 properties.insert(
12441 "markdown_block_kind".to_string(),
12442 markdown_ast_block_kind(&entry.kind),
12443 );
12444 if let Some(body_start_byte) = embedded.body_start_byte {
12445 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12446 }
12447 if let Some(body_end_byte) = embedded.body_end_byte {
12448 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12449 }
12450 if let Some(body_start_line) = embedded.body_start_line {
12451 properties.insert("body_start_line".to_string(), body_start_line.to_string());
12452 }
12453 if let Some(body_end_line) = embedded.body_end_line {
12454 properties.insert("body_end_line".to_string(), body_end_line.to_string());
12455 }
12456 if let Some(fence_language) = &markdown.fence_language {
12457 properties.insert("fence_language".to_string(), fence_language.clone());
12458 }
12459 if !markdown.section_path.is_empty() {
12460 properties.insert(
12461 "section_path".to_string(),
12462 markdown.section_path.join(" > "),
12463 );
12464 }
12465 if let Some(section_handle) = &markdown.section_handle {
12466 properties.insert("section_handle".to_string(), section_handle.clone());
12467 }
12468 let line_count = embedded
12469 .end_line
12470 .saturating_sub(embedded.start_line)
12471 .saturating_add(1)
12472 .max(1);
12473 TraversalNode {
12474 handle: embedded.handle.clone(),
12475 kind: "ast_span".to_string(),
12476 label: embedded.name.clone(),
12477 ref_id: Some(embedded.name.clone()),
12478 path: Some(entry.file.clone()),
12479 line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
12480 detail: Some(format!(
12481 "{} {} embedded in Markdown fence",
12482 embedded.language, embedded.kind
12483 )),
12484 properties,
12485 expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
12486 }
12487}
12488
12489fn link_markdown_embedded_code_edges(
12490 graph: &mut TraversalGraphBuild,
12491 root: &Path,
12492 entries: &[TraversalAstSpanIndexEntry],
12493) {
12494 for entry in entries {
12495 let Some(markdown) = &entry.markdown else {
12496 continue;
12497 };
12498 for embedded in &markdown.embedded_symbols {
12499 let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
12500 graph.add_node(node);
12501 graph.add_edge(
12502 &entry.handle,
12503 &embedded.handle,
12504 "contains",
12505 Some("Markdown fence contains embedded AST symbol".to_string()),
12506 1,
12507 );
12508 graph.add_edge(
12509 &entry.handle,
12510 &embedded.handle,
12511 "child",
12512 Some("embedded code symbol".to_string()),
12513 1,
12514 );
12515 graph.add_edge(
12516 &entry.handle,
12517 &embedded.handle,
12518 "contains_embedded_symbol",
12519 Some("Markdown fence contains embedded code symbol".to_string()),
12520 1,
12521 );
12522 graph.add_edge(
12523 &embedded.handle,
12524 &entry.handle,
12525 "parent",
12526 Some("Markdown fence parent span".to_string()),
12527 1,
12528 );
12529 graph.add_edge(
12530 &embedded.handle,
12531 &entry.handle,
12532 "embedded_in_fence",
12533 Some("embedded code symbol belongs to Markdown fence".to_string()),
12534 1,
12535 );
12536 if let Some(section_handle) = &markdown.section_handle
12537 && section_handle != &entry.handle
12538 {
12539 graph.add_edge(
12540 section_handle,
12541 &embedded.handle,
12542 "contains_embedded_code",
12543 Some("Markdown section contains embedded code symbol".to_string()),
12544 1,
12545 );
12546 graph.add_edge(
12547 &embedded.handle,
12548 section_handle,
12549 "enclosing_section",
12550 Some("Markdown enclosing section".to_string()),
12551 1,
12552 );
12553 }
12554 }
12555 }
12556}
12557
12558fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
12559 let mut tokens = traversal_tokens(&node.label);
12560 if let Some(ref_id) = &node.ref_id {
12561 tokens.extend(traversal_tokens(ref_id));
12562 }
12563 if let Some(path) = &node.path {
12564 tokens.extend(traversal_tokens(path));
12565 }
12566 if let Some(detail) = &node.detail {
12567 tokens.extend(traversal_tokens(detail));
12568 }
12569 tokens
12570}
12571
12572fn parse_agent_doc_session_id(content: &str) -> Option<String> {
12573 content.lines().find_map(|line| {
12574 let trimmed = line.trim();
12575 trimmed
12576 .strip_prefix("agent_doc_session:")
12577 .map(str::trim)
12578 .filter(|value| !value.is_empty())
12579 .map(str::to_string)
12580 })
12581}
12582
12583fn parse_backlog_line(line: &str) -> Option<(String, String)> {
12584 let trimmed = line.trim();
12585 if !trimmed.starts_with("- [") {
12586 return None;
12587 }
12588 let start = trimmed.find("[#")?;
12589 let after_start = start + 2;
12590 let rest = &trimmed[after_start..];
12591 let end = rest.find(']')?;
12592 let id = rest[..end].trim();
12593 if id.is_empty() {
12594 return None;
12595 }
12596 let text = rest[end + 1..].trim().to_string();
12597 Some((id.to_string(), text))
12598}
12599
12600fn parse_queue_dispatch_line(line: &str) -> Option<String> {
12601 let trimmed = line.trim();
12602 ["dispatch ", "preset "].iter().find_map(|prefix| {
12603 trimmed
12604 .strip_prefix(prefix)
12605 .map(str::trim)
12606 .filter(|value| !value.is_empty())
12607 .map(str::to_string)
12608 })
12609}
12610
12611fn parse_queue_do_line(line: &str) -> Option<String> {
12612 let trimmed = line.trim();
12613 let rest = trimmed.strip_prefix("- do [#")?;
12614 let end = rest.find(']')?;
12615 let id = rest[..end].trim();
12616 (!id.is_empty()).then(|| id.to_string())
12617}
12618
12619fn markdown_code_spans(input: &str) -> Vec<String> {
12620 input
12621 .split('`')
12622 .enumerate()
12623 .filter(|(idx, _)| idx % 2 == 1)
12624 .map(|(_, part)| part.trim().to_string())
12625 .filter(|part| !part.is_empty())
12626 .collect()
12627}
12628
12629fn push_traversal_token_index(
12630 index: &mut HashMap<String, Vec<usize>>,
12631 tokens: &BTreeSet<String>,
12632 entry_index: usize,
12633) {
12634 for token in tokens {
12635 index.entry(token.clone()).or_default().push(entry_index);
12636 }
12637}
12638
12639impl<'a> TraversalCodeLookup<'a> {
12640 fn new(
12641 symbols: &'a [TraversalSymbolIndexEntry],
12642 files: &'a [TraversalFileIndexEntry],
12643 routes: &'a [TraversalRouteIndexEntry],
12644 multiplicities: &'a [TraversalMultiplicityIndexEntry],
12645 ) -> Self {
12646 let mut symbol_index = HashMap::new();
12647 for (idx, entry) in symbols.iter().enumerate() {
12648 push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
12649 }
12650 let mut file_index = HashMap::new();
12651 let mut file_path_index = HashMap::new();
12652 for (idx, entry) in files.iter().enumerate() {
12653 push_traversal_token_index(&mut file_index, &entry.tokens, idx);
12654 if let Some(path) = entry.node.path.as_ref() {
12655 file_path_index.insert(path.clone(), path.clone());
12656 }
12657 }
12658 let mut route_index = HashMap::new();
12659 for (idx, entry) in routes.iter().enumerate() {
12660 push_traversal_token_index(&mut route_index, &entry.tokens, idx);
12661 }
12662 let mut multiplicity_index = HashMap::new();
12663 for (idx, entry) in multiplicities.iter().enumerate() {
12664 push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
12665 }
12666 Self {
12667 symbols,
12668 files,
12669 routes,
12670 multiplicities,
12671 symbol_index,
12672 file_index,
12673 route_index,
12674 multiplicity_index,
12675 file_path_index,
12676 }
12677 }
12678
12679 fn touched_files_for_line(&self, line: &str) -> Vec<String> {
12680 let mut touched_files = BTreeSet::new();
12681 for candidate in markdown_code_spans(line)
12682 .into_iter()
12683 .chain(line.split_whitespace().map(str::to_string))
12684 {
12685 for path in traversal_path_candidates(&candidate) {
12686 if let Some(file) = self.file_path_index.get(&path) {
12687 touched_files.insert(file.clone());
12688 }
12689 }
12690 }
12691 touched_files.into_iter().collect()
12692 }
12693}
12694
12695fn traversal_path_candidates(candidate: &str) -> Vec<String> {
12696 let trimmed = candidate.trim_matches(|ch: char| {
12697 matches!(
12698 ch,
12699 '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
12700 )
12701 });
12702 if trimmed.is_empty() {
12703 return Vec::new();
12704 }
12705 let mut candidates = vec![trimmed.to_string()];
12706 if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
12707 && !path.is_empty()
12708 && line_suffix.chars().all(|ch| ch.is_ascii_digit())
12709 {
12710 candidates.push(path.to_string());
12711 }
12712 candidates
12713}
12714
12715fn parse_worker_result_line(
12716 line: &str,
12717 lookup: &TraversalCodeLookup<'_>,
12718) -> Vec<ParsedWorkerResult> {
12719 if line.trim_start().starts_with("- [") {
12720 return Vec::new();
12721 }
12722 let lower = line.to_ascii_lowercase();
12723 let status =
12724 if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
12725 {
12726 "completed"
12727 } else if lower.contains("blocked") || lower.contains("externally blocked") {
12728 "blocked"
12729 } else {
12730 return Vec::new();
12731 };
12732 let result_prefix_end = ["follow-up", "follow up", "next:"]
12733 .iter()
12734 .filter_map(|marker| lower.find(marker))
12735 .min()
12736 .unwrap_or(line.len());
12737 let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
12738 if ids.is_empty() {
12739 return Vec::new();
12740 }
12741 let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
12742 let all_ids = extract_conflict_target_refs(line);
12743
12744 let touched_files = lookup.touched_files_for_line(line);
12745 let tests = markdown_code_spans(line)
12746 .into_iter()
12747 .filter(|span| span.to_ascii_lowercase().contains("test"))
12748 .collect::<Vec<_>>();
12749
12750 ids.iter()
12751 .map(|id| ParsedWorkerResult {
12752 id: id.clone(),
12753 status: status.to_string(),
12754 touched_files: touched_files.clone(),
12755 tests: tests.clone(),
12756 follow_up_ids: all_ids
12757 .iter()
12758 .filter(|other| *other != id && !result_ids.contains(*other))
12759 .cloned()
12760 .collect(),
12761 })
12762 .collect()
12763}
12764
12765fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
12766 let hinted_path = if path_hint.is_absolute() {
12767 path_hint.to_path_buf()
12768 } else {
12769 root.join(path_hint)
12770 };
12771 if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
12772 return Some(hinted_path);
12773 }
12774 None
12775}
12776
12777fn traversal_markdown_content_looks_like_session(content: &str) -> bool {
12778 parse_agent_doc_session_id(content).is_some()
12779 || content.contains("<!-- agent:exchange")
12780 || content.contains("<!-- agent:backlog")
12781 || content.contains("## Backlog")
12782}
12783
12784fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
12785 let candidate = if path.is_absolute() {
12786 path.to_path_buf()
12787 } else {
12788 source_root.join(path)
12789 };
12790 if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
12791 return false;
12792 }
12793 if !matches!(
12794 candidate.extension().and_then(|ext| ext.to_str()),
12795 Some("md" | "mdx")
12796 ) {
12797 return false;
12798 }
12799 fs::read_to_string(&candidate)
12800 .map(|content| traversal_markdown_content_looks_like_session(&content))
12801 .unwrap_or(false)
12802}
12803
12804fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
12805 if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
12806 return Ok(vec![hinted_path]);
12807 }
12808 let mut files = Vec::new();
12809 let walker = ignore::WalkBuilder::new(root)
12810 .hidden(true)
12811 .git_ignore(true)
12812 .git_global(true)
12813 .git_exclude(true)
12814 .build();
12815 for result in walker {
12816 let entry =
12817 result.with_context(|| format!("walking markdown files under {}", root.display()))?;
12818 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
12819 continue;
12820 }
12821 if traversal_path_is_generated_artifact(root, root, entry.path()) {
12822 continue;
12823 }
12824 if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
12825 files.push(entry.path().to_path_buf());
12826 }
12827 }
12828 files.sort();
12829 Ok(files)
12830}
12831
12832fn traversal_watermark_path(root: &Path, path: &Path) -> String {
12833 path.strip_prefix(root)
12834 .unwrap_or(path)
12835 .to_string_lossy()
12836 .replace('\\', "/")
12837}
12838
12839fn push_traversal_metadata_watermark_part(
12840 root: &Path,
12841 path: &Path,
12842 label: &str,
12843 parts: &mut Vec<String>,
12844) {
12845 let display = traversal_watermark_path(root, path);
12846 match fs::metadata(path) {
12847 Ok(metadata) => {
12848 let (secs, nanos) = metadata
12849 .modified()
12850 .ok()
12851 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
12852 .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
12853 .unwrap_or((0, 0));
12854 parts.push(format!(
12855 "{label}:{display}:len={}:mtime={secs}.{nanos}",
12856 metadata.len()
12857 ));
12858 }
12859 Err(_) => parts.push(format!("{label}:{display}:missing")),
12860 }
12861}
12862
12863#[derive(Serialize)]
12864struct TraversalSummaryWatermarkRow<'a> {
12865 symbol_name: &'a str,
12866 file_path: &'a str,
12867 entities: &'a Option<Vec<summarize::Entity>>,
12868 relationships: &'a Option<Vec<summarize::Relationship>>,
12869 concept_labels: &'a Option<Vec<String>>,
12870}
12871
12872fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
12873 let summaries_db = root.join(".tsift/summaries.db");
12874 if !summaries_db.exists() {
12875 parts.push("summaries_db:absent".to_string());
12876 return Ok(());
12877 }
12878
12879 match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
12880 .and_then(|summary_db| summary_db.all())
12881 {
12882 Ok(summaries) => {
12883 let rows = summaries
12884 .iter()
12885 .map(|summary| TraversalSummaryWatermarkRow {
12886 symbol_name: &summary.symbol_name,
12887 file_path: &summary.file_path,
12888 entities: &summary.entities,
12889 relationships: &summary.relationships,
12890 concept_labels: &summary.concept_labels,
12891 })
12892 .collect::<Vec<_>>();
12893 parts.push(format!(
12894 "summaries_db:rows={}:semantic_hash={}",
12895 rows.len(),
12896 content_hash(&rows)?
12897 ));
12898 }
12899 Err(_) => {
12900 push_traversal_metadata_watermark_part(
12901 root,
12902 &summaries_db,
12903 "summaries_db_unreadable",
12904 parts,
12905 );
12906 }
12907 }
12908 Ok(())
12909}
12910
12911#[cfg(test)]
12912fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
12913 resolution::relative_path_is_generated_artifact(relative)
12914}
12915
12916fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
12917 resolution::path_is_generated_artifact(root, source_root, path)
12918}
12919
12920fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
12921 resolution::index_snapshot_part_is_generated(root, source_root, part)
12922}
12923
12924pub(crate) fn traversal_source_watermark(
12925 root: &Path,
12926 path_hint: &Path,
12927 scope: Option<&str>,
12928 session_only: bool,
12929) -> Result<Option<String>> {
12930 let mut parts = vec![
12931 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
12932 format!("scope:{}", scope.unwrap_or("root")),
12933 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
12934 format!("session_only:{session_only}"),
12935 ];
12936
12937 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
12938 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
12939 Ok(targets) => targets,
12940 Err(_) => return Ok(None),
12941 };
12942 let Some(target) = targets.into_iter().next() else {
12943 return Ok(None);
12944 };
12945 let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
12946 Ok(db) => db,
12947 Err(_) => return Ok(None),
12948 };
12949 parts.push(format!("index_label:{}", target.label));
12950 parts.push(format!(
12951 "index_scope:{}",
12952 target.scope_name.as_deref().unwrap_or("root")
12953 ));
12954 parts.push(format!(
12955 "index_source_root:{}",
12956 traversal_watermark_path(root, &target.source_root)
12957 ));
12958 let mut snapshot_rows = 0usize;
12959 for part in db.source_snapshot_parts()? {
12960 if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
12961 continue;
12962 }
12963 snapshot_rows += 1;
12964 parts.push(format!("index_snapshot:{part}"));
12965 }
12966 parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
12967 }
12968
12969 let markdown_files = markdown_files_for_traversal(root, path_hint)?;
12970 parts.push(format!("markdown_count:{}", markdown_files.len()));
12971 for markdown_path in markdown_files {
12972 push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
12973 }
12974
12975 push_traversal_summaries_watermark_part(root, &mut parts)?;
12976
12977 Ok(Some(content_hash(&parts)?))
12978}
12979
12980fn ranked_symbol_matches<'a>(
12981 query_tokens: &BTreeSet<String>,
12982 entries: &'a [TraversalSymbolIndexEntry],
12983 index: &HashMap<String, Vec<usize>>,
12984) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
12985 let mut scores = BTreeMap::<usize, usize>::new();
12986 for token in query_tokens {
12987 if let Some(indices) = index.get(token) {
12988 for idx in indices {
12989 *scores.entry(*idx).or_default() += 1;
12990 }
12991 }
12992 }
12993 let mut matches = scores
12994 .into_iter()
12995 .map(|(idx, score)| (score, &entries[idx]))
12996 .collect::<Vec<_>>();
12997 matches.sort_by(|(left_score, left), (right_score, right)| {
12998 right_score
12999 .cmp(left_score)
13000 .then_with(|| left.node.label.cmp(&right.node.label))
13001 .then_with(|| left.handle.cmp(&right.handle))
13002 });
13003 matches
13004}
13005
13006fn ranked_file_matches<'a>(
13007 query_tokens: &BTreeSet<String>,
13008 entries: &'a [TraversalFileIndexEntry],
13009 index: &HashMap<String, Vec<usize>>,
13010) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13011 let mut scores = BTreeMap::<usize, usize>::new();
13012 for token in query_tokens {
13013 if let Some(indices) = index.get(token) {
13014 for idx in indices {
13015 *scores.entry(*idx).or_default() += 1;
13016 }
13017 }
13018 }
13019 let mut matches = scores
13020 .into_iter()
13021 .map(|(idx, score)| (score, &entries[idx]))
13022 .collect::<Vec<_>>();
13023 matches.sort_by(|(left_score, left), (right_score, right)| {
13024 right_score
13025 .cmp(left_score)
13026 .then_with(|| left.node.label.cmp(&right.node.label))
13027 .then_with(|| left.handle.cmp(&right.handle))
13028 });
13029 matches
13030}
13031
13032fn ranked_route_matches<'a>(
13033 query_tokens: &BTreeSet<String>,
13034 entries: &'a [TraversalRouteIndexEntry],
13035 index: &HashMap<String, Vec<usize>>,
13036) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13037 let mut scores = BTreeMap::<usize, usize>::new();
13038 for token in query_tokens {
13039 if let Some(indices) = index.get(token) {
13040 for idx in indices {
13041 *scores.entry(*idx).or_default() += 1;
13042 }
13043 }
13044 }
13045 let mut matches = scores
13046 .into_iter()
13047 .map(|(idx, score)| (score, &entries[idx]))
13048 .collect::<Vec<_>>();
13049 matches.sort_by(|(left_score, left), (right_score, right)| {
13050 right_score
13051 .cmp(left_score)
13052 .then_with(|| left.node.label.cmp(&right.node.label))
13053 .then_with(|| left.handle.cmp(&right.handle))
13054 });
13055 matches
13056}
13057
13058fn ranked_multiplicity_matches<'a>(
13059 query_tokens: &BTreeSet<String>,
13060 entries: &'a [TraversalMultiplicityIndexEntry],
13061 index: &HashMap<String, Vec<usize>>,
13062) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13063 let mut scores = BTreeMap::<usize, usize>::new();
13064 for token in query_tokens {
13065 if let Some(indices) = index.get(token) {
13066 for idx in indices {
13067 *scores.entry(*idx).or_default() += 1;
13068 }
13069 }
13070 }
13071 let mut matches = scores
13072 .into_iter()
13073 .map(|(idx, score)| (score, &entries[idx]))
13074 .collect::<Vec<_>>();
13075 matches.sort_by(|(left_score, left), (right_score, right)| {
13076 right_score
13077 .cmp(left_score)
13078 .then_with(|| left.node.kind.cmp(&right.node.kind))
13079 .then_with(|| left.node.label.cmp(&right.node.label))
13080 .then_with(|| left.handle.cmp(&right.handle))
13081 });
13082 matches
13083}
13084
13085fn link_backlog_to_code_nodes(
13086 graph: &mut TraversalGraphBuild,
13087 backlog: &TraversalNode,
13088 text: &str,
13089 lookup: &TraversalCodeLookup<'_>,
13090 limit: usize,
13091) {
13092 let mut query_tokens = traversal_tokens(text);
13093 if let Some(ref_id) = &backlog.ref_id {
13094 query_tokens.extend(traversal_tokens(ref_id));
13095 }
13096 if query_tokens.is_empty() {
13097 return;
13098 }
13099
13100 for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13101 .into_iter()
13102 .take(limit)
13103 {
13104 graph.add_edge(
13105 &backlog.handle,
13106 &entry.handle,
13107 "mentions",
13108 Some("backlog text matches symbol tokens".to_string()),
13109 score,
13110 );
13111 }
13112
13113 for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13114 .into_iter()
13115 .take(limit.min(5))
13116 {
13117 graph.add_edge(
13118 &backlog.handle,
13119 &entry.handle,
13120 "mentions",
13121 Some("backlog text matches file tokens".to_string()),
13122 score,
13123 );
13124 }
13125
13126 for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13127 .into_iter()
13128 .take(limit.min(5))
13129 {
13130 graph.add_edge(
13131 &backlog.handle,
13132 &entry.handle,
13133 "mentions",
13134 Some("backlog text matches route tokens".to_string()),
13135 score,
13136 );
13137 }
13138
13139 for (score, entry) in ranked_multiplicity_matches(
13140 &query_tokens,
13141 lookup.multiplicities,
13142 &lookup.multiplicity_index,
13143 )
13144 .into_iter()
13145 .take(limit.min(5))
13146 {
13147 graph.add_edge(
13148 &backlog.handle,
13149 &entry.handle,
13150 "mentions",
13151 Some("backlog text matches multiplicity tokens".to_string()),
13152 score,
13153 );
13154 }
13155}
13156
13157fn load_agent_doc_traversal_nodes(
13158 root: &Path,
13159 path_hint: &Path,
13160 graph: &mut TraversalGraphBuild,
13161 lookup: &TraversalCodeLookup<'_>,
13162) -> Result<()> {
13163 for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13164 let content = match fs::read_to_string(&markdown_path) {
13165 Ok(content) => content,
13166 Err(err) => {
13167 graph.warnings.push(format!(
13168 "session artifact unavailable: {}: {err}",
13169 markdown_path.display()
13170 ));
13171 continue;
13172 }
13173 };
13174 if !traversal_markdown_content_looks_like_session(&content) {
13175 continue;
13176 }
13177
13178 let session_id = parse_agent_doc_session_id(&content);
13179 let session = traversal_session_node(root, &markdown_path, session_id.as_deref());
13180 graph.add_node(session.clone());
13181 let lines = content.lines().collect::<Vec<_>>();
13182 let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13183 for (idx, line) in lines.iter().enumerate() {
13184 let Some((id, text)) = parse_backlog_line(line) else {
13185 continue;
13186 };
13187 let backlog = traversal_backlog_node(root, &markdown_path, &id, &text, idx as i64 + 1);
13188 graph.add_node(backlog.clone());
13189 backlog_by_id.insert(id.clone(), backlog.clone());
13190 graph.add_edge(
13191 &session.handle,
13192 &backlog.handle,
13193 "contains",
13194 Some("session backlog item".to_string()),
13195 1,
13196 );
13197 link_backlog_to_code_nodes(graph, &backlog, &text, lookup, 8);
13198 }
13199
13200 let mut in_queue = false;
13201 let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13202 for (idx, line) in lines.iter().enumerate() {
13203 let trimmed = line.trim();
13204 if trimmed.starts_with("<!-- agent:queue") {
13205 in_queue = true;
13206 continue;
13207 }
13208 if trimmed.starts_with("<!-- /agent:queue") {
13209 in_queue = false;
13210 continue;
13211 }
13212 if !in_queue {
13213 continue;
13214 }
13215 if let Some(dispatch) = parse_queue_dispatch_line(line) {
13216 let dispatch_ref = dispatch.strip_prefix('#').unwrap_or(dispatch.as_str());
13217 let node = traversal_job_packet_node(
13218 root,
13219 &markdown_path,
13220 &format!("dispatch {dispatch}"),
13221 Some(dispatch_ref),
13222 "agent-doc dispatch preset",
13223 idx as i64 + 1,
13224 );
13225 graph.add_node(node.clone());
13226 graph.add_edge(
13227 &session.handle,
13228 &node.handle,
13229 "contains",
13230 Some("session queued dispatch".to_string()),
13231 1,
13232 );
13233 continue;
13234 }
13235 if let Some(id) = parse_queue_do_line(line) {
13236 let detail = backlog_by_id
13237 .get(&id)
13238 .and_then(|node| node.detail.clone())
13239 .unwrap_or_else(|| "queued backlog item".to_string());
13240 let node = traversal_job_packet_node(
13241 root,
13242 &markdown_path,
13243 &format!("do #{id}"),
13244 Some(&id),
13245 &detail,
13246 idx as i64 + 1,
13247 );
13248 graph.add_node(node.clone());
13249 graph.add_edge(
13250 &session.handle,
13251 &node.handle,
13252 "contains",
13253 Some("session queued job packet".to_string()),
13254 1,
13255 );
13256 if let Some(backlog) = backlog_by_id.get(&id) {
13257 graph.add_edge(
13258 &node.handle,
13259 &backlog.handle,
13260 "targets",
13261 Some("queued backlog item".to_string()),
13262 1,
13263 );
13264 }
13265 job_by_id.insert(id, node);
13266 }
13267 }
13268
13269 let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13270 for (idx, line) in lines.iter().enumerate() {
13271 for parsed in parse_worker_result_line(line, lookup) {
13272 let line_no = idx as i64 + 1;
13273 if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13274 continue;
13275 }
13276 let result =
13277 traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13278 graph.add_node(result.clone());
13279 graph.add_edge(
13280 &session.handle,
13281 &result.handle,
13282 "contains",
13283 Some("session worker result".to_string()),
13284 1,
13285 );
13286 if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13287 graph.add_edge(
13288 &backlog.handle,
13289 &result.handle,
13290 "has_result",
13291 Some(format!("worker result {}", parsed.status)),
13292 1,
13293 );
13294 }
13295 if let Some(job) = job_by_id.get(&parsed.id) {
13296 graph.add_edge(
13297 &job.handle,
13298 &result.handle,
13299 "has_result",
13300 Some(format!("queued worker result {}", parsed.status)),
13301 1,
13302 );
13303 }
13304 let mut result_text = line.to_string();
13305 if !parsed.touched_files.is_empty() {
13306 result_text.push(' ');
13307 result_text.push_str(&parsed.touched_files.join(" "));
13308 }
13309 link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13310 }
13311 }
13312 }
13313 Ok(())
13314}
13315
13316#[derive(Debug, Clone)]
13317struct AgentDocIndexGate {
13318 db_path: Option<PathBuf>,
13319 source_root: PathBuf,
13320 diagnostics: Vec<String>,
13321}
13322
13323#[derive(Clone, Hash, PartialEq, Eq)]
13324struct AgentDocIndexGateCacheKey {
13325 root: PathBuf,
13326 path_hint: PathBuf,
13327 scope: Option<String>,
13328 packet_label: String,
13329}
13330
13331fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13332 std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13333> {
13334 static CACHE: std::sync::OnceLock<
13335 std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13336 > = std::sync::OnceLock::new();
13337 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13338}
13339
13340fn prepare_agent_doc_index_gate_cached(
13341 root: &Path,
13342 path_hint: &Path,
13343 scope: Option<&str>,
13344 packet_label: &str,
13345) -> (AgentDocIndexGate, String) {
13346 let key = AgentDocIndexGateCacheKey {
13347 root: root.to_path_buf(),
13348 path_hint: path_hint.to_path_buf(),
13349 scope: scope.map(str::to_string),
13350 packet_label: packet_label.to_string(),
13351 };
13352 if let Ok(cache) = agent_doc_index_gate_cache().lock()
13353 && let Some(cached) = cache.get(&key)
13354 {
13355 return (
13356 cached.clone(),
13357 "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13358 );
13359 }
13360 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13361 if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13362 cache.insert(key, gate.clone());
13363 }
13364 (
13365 gate,
13366 "fresh inspection/refresh — cache miss on this preparation key".to_string(),
13367 )
13368}
13369
13370fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
13371 match state {
13372 SearchIndexState::Fresh => None,
13373 SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
13374 SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
13375 }
13376}
13377
13378fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
13379 rebuild_search_target_detail(&RebuildSearchTarget {
13380 label: target.label.clone(),
13381 reason,
13382 reindex_cmd: target.reindex_cmd.clone(),
13383 })
13384}
13385
13386fn index_refresh_diagnostic(
13387 target: &SearchIndexTarget,
13388 reason: RebuildSearchReason,
13389 summary: &index::IndexSummary,
13390 packet_label: &str,
13391) -> String {
13392 let changed = summary.new + summary.modified + summary.deleted;
13393 format!(
13394 "index refreshed: {}; updated {} changed file{} before {}",
13395 index_reason_detail(target, reason),
13396 changed,
13397 if changed == 1 { "" } else { "s" },
13398 packet_label
13399 )
13400}
13401
13402fn index_refresh_fallback_diagnostic(
13403 target: &SearchIndexTarget,
13404 reason: RebuildSearchReason,
13405 err: &anyhow::Error,
13406 packet_label: &str,
13407) -> String {
13408 format!(
13409 "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
13410 index_reason_detail(target, reason),
13411 packet_label
13412 )
13413}
13414
13415fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
13416 if let Some(scope_name) = scope
13417 && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
13418 {
13419 return scope.source_root;
13420 }
13421 if let Some(scope_name) = scope
13422 && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
13423 {
13424 return package.package_root;
13425 }
13426 if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
13427 return scope.source_root;
13428 }
13429 if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
13430 return package.package_root;
13431 }
13432 if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
13433 return scope.source_root;
13434 }
13435 root.to_path_buf()
13436}
13437
13438fn prepare_agent_doc_index_gate(
13439 root: &Path,
13440 path_hint: &Path,
13441 scope: Option<&str>,
13442 packet_label: &str,
13443) -> AgentDocIndexGate {
13444 let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
13445 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13446 Ok(targets) => targets,
13447 Err(err) => {
13448 return AgentDocIndexGate {
13449 db_path: None,
13450 source_root: fallback_source_root,
13451 diagnostics: vec![format!(
13452 "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13453 )],
13454 };
13455 }
13456 };
13457 let Some(target) = targets.into_iter().next() else {
13458 return AgentDocIndexGate {
13459 db_path: None,
13460 source_root: fallback_source_root,
13461 diagnostics: vec![format!(
13462 "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
13463 )],
13464 };
13465 };
13466
13467 let state = match inspect_search_index(&target) {
13468 Ok(state) => state,
13469 Err(err) => {
13470 return AgentDocIndexGate {
13471 db_path: None,
13472 source_root: target.source_root,
13473 diagnostics: vec![format!(
13474 "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13475 )],
13476 };
13477 }
13478 };
13479
13480 let Some(reason) = index_reason_for_state(state) else {
13481 return AgentDocIndexGate {
13482 db_path: Some(target.db_path),
13483 source_root: target.source_root,
13484 diagnostics: Vec::new(),
13485 };
13486 };
13487
13488 match apply_search_index_update(root, &target) {
13489 Ok(summary) => {
13490 index::inspect_scope_invalidate_all();
13496 let diagnostics = vec![index_refresh_diagnostic(
13497 &target,
13498 reason,
13499 &summary,
13500 packet_label,
13501 )];
13502 AgentDocIndexGate {
13503 db_path: Some(target.db_path),
13504 source_root: target.source_root,
13505 diagnostics,
13506 }
13507 }
13508 Err(err) => {
13509 let diagnostics = vec![index_refresh_fallback_diagnostic(
13510 &target,
13511 reason,
13512 &err,
13513 packet_label,
13514 )];
13515 AgentDocIndexGate {
13516 db_path: None,
13517 source_root: target.source_root,
13518 diagnostics,
13519 }
13520 }
13521 }
13522}
13523
13524fn add_raw_source_file_nodes(
13525 root: &Path,
13526 source_root: &Path,
13527 graph: &mut TraversalGraphBuild,
13528 file_entries: &mut Vec<TraversalFileIndexEntry>,
13529) -> Result<()> {
13530 let mut entries = walk::walk_files(source_root)?;
13531 entries.sort_by(|left, right| left.path.cmp(&right.path));
13532 for entry in entries {
13533 let file = entry.path.to_string_lossy();
13534 let node = traversal_raw_source_file_node(root, file.as_ref());
13535 let entry = TraversalFileIndexEntry {
13536 handle: node.handle.clone(),
13537 tokens: traversal_node_tokens(&node),
13538 node: node.clone(),
13539 };
13540 graph.add_node(node);
13541 file_entries.push(entry);
13542 }
13543 Ok(())
13544}
13545
13546fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
13547 if scope_root.is_empty() {
13548 return true;
13549 }
13550 path == scope_root || path.starts_with(&format!("{scope_root}/"))
13551}
13552
13553fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
13554 let path = Path::new(file);
13555 if path.is_absolute() {
13556 return path.to_path_buf();
13557 }
13558 let source_candidate = source_root.join(path);
13559 if source_candidate.exists() {
13560 source_candidate
13561 } else {
13562 root.join(path)
13563 }
13564}
13565
13566fn cargo_import_alias_from_line(line: &str) -> Option<String> {
13567 let trimmed = line.trim();
13568 let rest = trimmed
13569 .strip_prefix("pub use ")
13570 .or_else(|| trimmed.strip_prefix("use "))
13571 .or_else(|| trimmed.strip_prefix("extern crate "))?;
13572 let alias = rest
13573 .split([':', ';', ' ', '\t'])
13574 .next()
13575 .unwrap_or_default()
13576 .trim();
13577 (!alias.is_empty()).then(|| alias.to_string())
13578}
13579
13580fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
13581 let mut aliases = BTreeSet::new();
13582 for entry in walk::walk_files(&package.package_root)? {
13583 if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
13584 continue;
13585 }
13586 let content = fs::read_to_string(&entry.path)
13587 .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
13588 aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
13589 }
13590 Ok(aliases)
13591}
13592
13593fn load_multiplicity_traversal_nodes(
13594 root: &Path,
13595 source_root: &Path,
13596 graph: &mut TraversalGraphBuild,
13597 file_handle_by_path: &HashMap<String, String>,
13598 multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
13599) -> Result<()> {
13600 let inventory = multiplicity::discover_cargo_inventory(source_root)?;
13601 let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
13602 for workspace in &inventory.workspaces {
13603 let node = traversal_cargo_workspace_node(root, workspace);
13604 workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
13605 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13606 handle: node.handle.clone(),
13607 tokens: traversal_node_tokens(&node),
13608 node: node.clone(),
13609 });
13610 graph.add_node(node);
13611 }
13612
13613 let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
13614 let mut package_nodes = Vec::new();
13615 for package in &inventory.packages {
13616 let node = traversal_cargo_package_node(root, package);
13617 package_handle_by_name
13618 .entry(package.name.clone())
13619 .or_default()
13620 .push(node.handle.clone());
13621 package_handle_by_name
13622 .entry(package.normalized_name.clone())
13623 .or_default()
13624 .push(node.handle.clone());
13625 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13626 handle: node.handle.clone(),
13627 tokens: traversal_node_tokens(&node),
13628 node: node.clone(),
13629 });
13630 graph.add_node(node.clone());
13631 package_nodes.push((package, node));
13632 }
13633
13634 for (package, node) in &package_nodes {
13635 if let Some(workspace_handle) =
13636 workspace_handle_by_root.get(&package.relative_workspace_root)
13637 {
13638 graph.add_edge(
13639 workspace_handle,
13640 &node.handle,
13641 "contains_package",
13642 Some("Cargo workspace member package".to_string()),
13643 1,
13644 );
13645 }
13646 let package_root = relativize_pathbuf(&package.package_root, root)
13647 .to_string_lossy()
13648 .replace('\\', "/");
13649 for (file, handle) in file_handle_by_path {
13650 if relative_path_inside_scope(file, &package_root) {
13651 graph.add_edge(
13652 &node.handle,
13653 handle,
13654 "owns_file",
13655 Some("Cargo package owns source file".to_string()),
13656 1,
13657 );
13658 }
13659 }
13660 for dependency in &package.dependencies {
13661 if let Some(handles) = package_handle_by_name.get(&dependency.name)
13662 && handles.len() == 1
13663 {
13664 graph.add_edge(
13665 &node.handle,
13666 &handles[0],
13667 "declares_dependency",
13668 Some(format!("{} Cargo dependency", dependency.kind)),
13669 1,
13670 );
13671 }
13672 }
13673 for alias in cargo_import_aliases(package)? {
13674 if let Some(handles) = package_handle_by_name.get(&alias)
13675 && handles.len() == 1
13676 && handles[0] != node.handle
13677 {
13678 graph.add_edge(
13679 &node.handle,
13680 &handles[0],
13681 "uses_crate",
13682 Some("Rust use/extern crate reference".to_string()),
13683 1,
13684 );
13685 graph.add_edge(
13686 &node.handle,
13687 &handles[0],
13688 "imports",
13689 Some("Rust use/extern crate import".to_string()),
13690 1,
13691 );
13692 }
13693 }
13694 }
13695
13696 Ok(())
13697}
13698
13699fn build_traversal_graph_source_with_options(
13700 root: &Path,
13701 path_hint: &Path,
13702 scope: Option<&str>,
13703 session_only: bool,
13704) -> Result<TraversalGraphBuild> {
13705 let mut graph = TraversalGraphBuild::default();
13706 let mut symbol_entries = Vec::new();
13707 let mut file_entries = Vec::new();
13708 let mut route_entries = Vec::new();
13709 let mut multiplicity_entries = Vec::new();
13710 let mut file_handle_by_path = HashMap::<String, String>::new();
13711 let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
13712 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13713 let (gate, _cache_detail) =
13714 prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
13715 graph.warnings.extend(gate.diagnostics);
13716 let gate_source_root = gate.source_root.clone();
13717
13718 match gate.db_path {
13719 Some(db_path) if db_path.exists() => {
13720 let db = index::IndexDb::open_read_only_resilient(&db_path)?;
13721 let file_paths = db.file_paths()?;
13722 for file in file_paths {
13723 if traversal_path_is_generated_artifact(
13724 root,
13725 &gate_source_root,
13726 Path::new(&file),
13727 ) {
13728 continue;
13729 }
13730 let node = traversal_file_node(root, &file);
13731 let entry = TraversalFileIndexEntry {
13732 handle: node.handle.clone(),
13733 tokens: traversal_node_tokens(&node),
13734 node: node.clone(),
13735 };
13736 if let Some(path) = entry.node.path.as_ref() {
13737 file_handle_by_path.insert(path.clone(), entry.handle.clone());
13738 }
13739 graph.add_node(node);
13740 file_entries.push(entry);
13741 }
13742
13743 let symbols = db.all_symbols()?;
13744 let mut symbol_by_file_name_line = HashMap::new();
13745 let mut span_by_file_name_line = HashMap::new();
13746 let mut first_symbol_by_name = BTreeMap::<String, String>::new();
13747 let mut first_span_by_name = BTreeMap::<String, String>::new();
13748 let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
13749 let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
13750 for symbol in symbols.iter().filter(|symbol| {
13751 !traversal_path_is_generated_artifact(
13752 root,
13753 &gate_source_root,
13754 Path::new(&symbol.file),
13755 )
13756 }) {
13757 let node = traversal_symbol_node(root, symbol);
13758 let file = relativize(&symbol.file, root);
13759 symbol_by_file_name_line.insert(
13760 format!("{file}:{}:{}", symbol.line, symbol.name),
13761 node.handle.clone(),
13762 );
13763 first_symbol_by_name
13764 .entry(symbol.name.clone())
13765 .or_insert_with(|| node.handle.clone());
13766 let entry = TraversalSymbolIndexEntry {
13767 handle: node.handle.clone(),
13768 tokens: traversal_node_tokens(&node),
13769 node: node.clone(),
13770 };
13771 graph.add_node(node.clone());
13772 if let Some(file_handle) = file_handle_by_path.get(&file) {
13773 graph.add_edge(
13774 file_handle,
13775 &node.handle,
13776 "defines",
13777 Some("file defines symbol".to_string()),
13778 1,
13779 );
13780 }
13781 if !source_by_file.contains_key(&symbol.file) {
13782 let source_path =
13783 traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
13784 source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
13785 }
13786 if let Some(Some(source)) = source_by_file.get(&symbol.file)
13787 && let Some((ast_node, mut ast_entry)) =
13788 traversal_ast_span_node(root, symbol, source, &symbols)
13789 {
13790 ast_entry.symbol_handle = node.handle.clone();
13791 ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
13792 span_by_file_name_line.insert(
13793 format!("{file}:{}:{}", symbol.line, symbol.name),
13794 ast_node.handle.clone(),
13795 );
13796 first_span_by_name
13797 .entry(symbol.name.clone())
13798 .or_insert_with(|| ast_node.handle.clone());
13799 graph.add_node(ast_node.clone());
13800 graph.add_edge(
13801 &node.handle,
13802 &ast_node.handle,
13803 "has_ast_span",
13804 Some("symbol projects to indexed AST span".to_string()),
13805 1,
13806 );
13807 graph.add_edge(
13808 &ast_node.handle,
13809 &node.handle,
13810 "represents_symbol",
13811 Some("AST span represents indexed symbol".to_string()),
13812 1,
13813 );
13814 ast_entries.push(ast_entry);
13815 }
13816 symbol_entries.push(entry);
13817 }
13818 link_ast_navigation_edges(&mut graph, &ast_entries);
13819 link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
13820
13821 if !bounded_session_projection {
13822 for edge in db.all_stored_edges()? {
13823 if traversal_path_is_generated_artifact(
13824 root,
13825 &gate_source_root,
13826 Path::new(&edge.caller_file),
13827 ) {
13828 continue;
13829 }
13830 let caller_file = relativize(&edge.caller_file, root);
13831 let caller_key =
13832 format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
13833 let Some(caller_handle) =
13834 symbol_by_file_name_line.get(&caller_key).cloned()
13835 else {
13836 continue;
13837 };
13838 let callee_handle = if let Some(handle) =
13839 first_symbol_by_name.get(&edge.callee_name)
13840 {
13841 handle.clone()
13842 } else {
13843 let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
13844 let handle = node.handle.clone();
13845 graph.add_node(node);
13846 handle
13847 };
13848 graph.add_edge(
13849 &caller_handle,
13850 &callee_handle,
13851 "calls",
13852 Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
13853 1,
13854 );
13855 if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
13856 && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
13857 {
13858 graph.add_edge(
13859 caller_span,
13860 callee_span,
13861 "calls",
13862 Some(format!(
13863 "AST call site {}:{}",
13864 caller_file, edge.call_site_line
13865 )),
13866 1,
13867 );
13868 }
13869 }
13870 }
13871
13872 for route in db.all_routes()? {
13873 if traversal_path_is_generated_artifact(
13874 root,
13875 &gate_source_root,
13876 Path::new(&route.file),
13877 ) {
13878 continue;
13879 }
13880 let node = traversal_route_node(root, &route);
13881 let entry = TraversalRouteIndexEntry {
13882 handle: node.handle.clone(),
13883 tokens: traversal_node_tokens(&node),
13884 node: node.clone(),
13885 };
13886 graph.add_node(node.clone());
13887 if let Some(path) = node.path.as_ref()
13888 && let Some(file_handle) = file_handle_by_path.get(path)
13889 {
13890 graph.add_edge(
13891 file_handle,
13892 &node.handle,
13893 "defines_route",
13894 Some("file declares route".to_string()),
13895 1,
13896 );
13897 }
13898 let handler_handle =
13899 if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
13900 handle.clone()
13901 } else {
13902 let node = traversal_unresolved_symbol_node(root, &route.handler_name);
13903 let handle = node.handle.clone();
13904 graph.add_node(node);
13905 handle
13906 };
13907 graph.add_edge(
13908 &entry.handle,
13909 &handler_handle,
13910 "handled_by",
13911 Some("route handler reference".to_string()),
13912 1,
13913 );
13914 if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
13915 graph.add_edge(
13916 &entry.handle,
13917 handler_span,
13918 "handled_by",
13919 Some("route handler AST span".to_string()),
13920 1,
13921 );
13922 graph.add_edge(
13923 handler_span,
13924 &entry.handle,
13925 "handles_route",
13926 Some("AST span handles route".to_string()),
13927 1,
13928 );
13929 }
13930 route_entries.push(entry);
13931 }
13932 }
13933 _ => {
13934 add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
13935 .with_context(|| {
13936 format!(
13937 "loading raw source fallback nodes from {}",
13938 gate_source_root.display()
13939 )
13940 })?;
13941 for entry in &file_entries {
13942 if let Some(path) = entry.node.path.as_ref() {
13943 file_handle_by_path.insert(path.clone(), entry.handle.clone());
13944 }
13945 }
13946 }
13947 }
13948 load_multiplicity_traversal_nodes(
13949 root,
13950 &gate_source_root,
13951 &mut graph,
13952 &file_handle_by_path,
13953 &mut multiplicity_entries,
13954 )?;
13955 }
13956
13957 let code_lookup = TraversalCodeLookup::new(
13958 &symbol_entries,
13959 &file_entries,
13960 &route_entries,
13961 &multiplicity_entries,
13962 );
13963 load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
13964 Ok(graph)
13965}
13966
13967#[cfg(test)]
13968fn build_traversal_graph_source(
13969 root: &Path,
13970 path_hint: &Path,
13971 scope: Option<&str>,
13972) -> Result<TraversalGraphBuild> {
13973 build_traversal_graph_source_with_options(root, path_hint, scope, false)
13974}
13975
13976pub(crate) fn write_traversal_graph_store_with_options(
13977 root: &Path,
13978 path_hint: &Path,
13979 scope: Option<&str>,
13980 session_only: bool,
13981) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
13982 let source_graph =
13983 build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
13984 let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
13985 let graph_db = graph_substrate_db_path(root, scope);
13986 let mut store = SqliteGraphStore::open(&graph_db)?;
13987 let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
13988 .ok()
13989 .flatten()
13990 .or_else(|| graph_projection_content_hash(&projection));
13991 let refresh = store.replace_projection_with_version(
13992 scope.unwrap_or("root"),
13993 &projection,
13994 Some(GRAPH_PROJECTION_VERSION),
13995 source_watermark,
13996 )?;
13997 Ok((source_graph, refresh))
13998}
13999
14000pub(crate) fn write_traversal_graph_store(
14001 root: &Path,
14002 path_hint: &Path,
14003 scope: Option<&str>,
14004) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14005 write_traversal_graph_store_with_options(root, path_hint, scope, false)
14006}
14007
14008fn refresh_traversal_graph_store_with_options(
14009 root: &Path,
14010 path_hint: &Path,
14011 scope: Option<&str>,
14012 session_only: bool,
14013) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14014 let (source_graph, refresh) =
14015 write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14016 let graph_db = graph_substrate_db_path(root, scope);
14017 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14018 let mut graph = traversal_graph_from_store(root, &store)?;
14019 graph.warnings = source_graph.warnings;
14020 Ok((graph, refresh))
14021}
14022
14023fn refresh_traversal_graph_store(
14024 root: &Path,
14025 path_hint: &Path,
14026 scope: Option<&str>,
14027) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14028 refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14029}
14030
14031pub(crate) fn build_traversal_graph(
14032 root: &Path,
14033 path_hint: &Path,
14034 scope: Option<&str>,
14035) -> Result<TraversalGraphBuild> {
14036 let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14037 Ok(graph)
14038}
14039
14040fn traversal_query_kind_priority(kind: &str) -> usize {
14041 match kind {
14042 "backlog" => 0,
14043 "job_packet" => 1,
14044 "worker_result" => 2,
14045 "symbol" => 3,
14046 "ast_span" => 4,
14047 "file" => 5,
14048 "route" => 6,
14049 "cargo_package" => 7,
14050 "cargo_workspace" => 8,
14051 "session" => 9,
14052 "semantic_concept" => 10,
14053 "semantic_entity" => 11,
14054 _ => 12,
14055 }
14056}
14057
14058fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14059 let trimmed = query.trim();
14060 if trimmed.is_empty() {
14061 return None;
14062 }
14063 let kind_priority = traversal_query_kind_priority(&node.kind);
14064 if node.handle == trimmed {
14065 return Some((0, kind_priority, node.handle.clone()));
14066 }
14067 if node.path.as_deref() == Some(trimmed) {
14068 let path_priority = if node.kind == "file" {
14069 0
14070 } else {
14071 kind_priority.saturating_add(1)
14072 };
14073 return Some((1, path_priority, node.handle.clone()));
14074 }
14075 let normalized_backlog = trimmed.trim_start_matches('#');
14076 if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14077 {
14078 return Some((2, kind_priority, node.handle.clone()));
14079 }
14080 if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14081 return Some((3, kind_priority, node.handle.clone()));
14082 }
14083 None
14084}
14085
14086fn resolve_traversal_node<'a>(
14087 graph: &'a TraversalGraphBuild,
14088 query: &str,
14089) -> Option<&'a TraversalNode> {
14090 graph
14091 .nodes
14092 .values()
14093 .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14094 .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14095 .map(|(_, node)| node)
14096}
14097
14098fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14099 let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14100 for edge in edges {
14101 adj.entry(edge.from.clone())
14102 .or_default()
14103 .insert(edge.to.clone());
14104 adj.entry(edge.to.clone())
14105 .or_default()
14106 .insert(edge.from.clone());
14107 }
14108 adj.into_iter()
14109 .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14110 .collect()
14111}
14112
14113fn traversal_shortest_handles(
14114 edges: &[TraversalEdge],
14115 from: &str,
14116 to: &str,
14117) -> Option<Vec<String>> {
14118 if from == to {
14119 return Some(vec![from.to_string()]);
14120 }
14121 let adj = traversal_adjacency(edges);
14122 if !adj.contains_key(from) || !adj.contains_key(to) {
14123 return None;
14124 }
14125 let mut visited = BTreeSet::new();
14126 let mut queue = VecDeque::new();
14127 let mut parent = BTreeMap::<String, String>::new();
14128 visited.insert(from.to_string());
14129 queue.push_back(from.to_string());
14130 while let Some(current) = queue.pop_front() {
14131 if let Some(neighbors) = adj.get(¤t) {
14132 for neighbor in neighbors {
14133 if visited.insert(neighbor.clone()) {
14134 parent.insert(neighbor.clone(), current.clone());
14135 if neighbor == to {
14136 let mut path = vec![to.to_string()];
14137 let mut cursor = to.to_string();
14138 while let Some(prev) = parent.get(&cursor) {
14139 path.push(prev.clone());
14140 cursor = prev.clone();
14141 }
14142 path.reverse();
14143 return Some(path);
14144 }
14145 queue.push_back(neighbor.clone());
14146 }
14147 }
14148 }
14149 }
14150 None
14151}
14152
14153fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14154 let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14155 for edge in edges {
14156 let neighbor = if edge.from == current {
14157 edge.to.as_str()
14158 } else if edge.to == current {
14159 edge.from.as_str()
14160 } else {
14161 continue;
14162 };
14163 let score = traversal_relation_score(edge, current);
14164 best_score_by_neighbor
14165 .entry(neighbor.to_string())
14166 .and_modify(|best| *best = (*best).max(score))
14167 .or_insert(score);
14168 }
14169 let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14170 ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14171 right_score
14172 .cmp(left_score)
14173 .then_with(|| left_handle.cmp(right_handle))
14174 });
14175 ranked.into_iter().map(|(handle, _)| handle).collect()
14176}
14177
14178fn traversal_neighborhood_handles(
14179 edges: &[TraversalEdge],
14180 origin: &str,
14181 depth: usize,
14182 limit: usize,
14183) -> BTreeSet<String> {
14184 let mut seen = BTreeSet::new();
14185 let mut queue = VecDeque::new();
14186 seen.insert(origin.to_string());
14187 queue.push_back((origin.to_string(), 0usize));
14188 while let Some((current, current_depth)) = queue.pop_front() {
14189 if current_depth >= depth {
14190 continue;
14191 }
14192 for neighbor in traversal_scored_neighbors(edges, ¤t) {
14193 if limit > 0 && seen.len() >= limit {
14194 return seen;
14195 }
14196 if seen.insert(neighbor.clone()) {
14197 queue.push_back((neighbor, current_depth + 1));
14198 }
14199 }
14200 }
14201 seen
14202}
14203
14204fn traversal_edges_between(
14205 handles: &BTreeSet<String>,
14206 edges: &[TraversalEdge],
14207) -> Vec<TraversalEdge> {
14208 edges
14209 .iter()
14210 .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14211 .cloned()
14212 .collect()
14213}
14214
14215fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14216 let mut result = Vec::new();
14217 for pair in path.windows(2) {
14218 if let Some(edge) = edges.iter().find(|edge| {
14219 (edge.from == pair[0] && edge.to == pair[1])
14220 || (edge.from == pair[1] && edge.to == pair[0])
14221 }) {
14222 result.push(edge.clone());
14223 }
14224 }
14225 result
14226}
14227
14228fn sorted_traversal_nodes<'a>(
14229 nodes: impl IntoIterator<Item = &'a TraversalNode>,
14230) -> Vec<TraversalNode> {
14231 let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14232 nodes.sort_by(|left, right| {
14233 left.kind
14234 .cmp(&right.kind)
14235 .then_with(|| left.label.cmp(&right.label))
14236 .then_with(|| left.path.cmp(&right.path))
14237 .then_with(|| left.handle.cmp(&right.handle))
14238 });
14239 nodes
14240}
14241
14242fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14243 let base = match edge.relation.as_str() {
14244 "mentions" => 100,
14245 "contains" => 80,
14246 "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14247 "contains_embedded_symbol" | "embedded_in_fence" => 77,
14248 "contains_markdown_block"
14249 | "contains_embedded_code"
14250 | "enclosing_module"
14251 | "enclosing_section" => 76,
14252 "calls" => {
14253 if edge.from == origin {
14254 70
14255 } else {
14256 65
14257 }
14258 }
14259 "handled_by" | "handles_route" => 68,
14260 "defines_route" => 62,
14261 "imports" => 62,
14262 "previous_sibling" | "next_sibling" => 54,
14263 "mentions_concept" | "mentions_entity" => 66,
14264 "semantic_relation" => 64,
14265 "tagged_concept" | "related_concept" => 58,
14266 "defines" => {
14267 if edge.from == origin {
14268 60
14269 } else {
14270 55
14271 }
14272 }
14273 _ => 10,
14274 };
14275 base + edge.weight
14276}
14277
14278fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
14279 match edge.relation.as_str() {
14280 "mentions" => "matched from backlog/session text".to_string(),
14281 "contains" => "contained in the selected session artifact".to_string(),
14282 "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
14283 "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
14284 "parent" => "parent AST span".to_string(),
14285 "child" => "child AST span".to_string(),
14286 "previous_sibling" => "previous AST sibling".to_string(),
14287 "next_sibling" => "next AST sibling".to_string(),
14288 "contains_markdown_block" => "Markdown section block".to_string(),
14289 "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
14290 "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
14291 "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
14292 "enclosing_module" => "nearest enclosing module".to_string(),
14293 "enclosing_section" => "nearest enclosing Markdown section".to_string(),
14294 "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
14295 "defines" => "file that defines the selected symbol".to_string(),
14296 "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
14297 "defines_route" => "file that declares the selected route".to_string(),
14298 "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
14299 "handled_by" => "route handled by the selected symbol".to_string(),
14300 "handles_route" => "route handled by the selected AST span".to_string(),
14301 "imports" => "import dependency from the selected package".to_string(),
14302 "mentions_concept" => "cached summary concept for the selected source".to_string(),
14303 "mentions_entity" => "cached summary entity for the selected source".to_string(),
14304 "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
14305 "tagged_concept" => "concept label attached to the selected entity".to_string(),
14306 "related_concept" => "co-occurring cached summary concept".to_string(),
14307 "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
14308 "calls" => "caller of the selected symbol".to_string(),
14309 other => format!("connected by {other}"),
14310 }
14311}
14312
14313fn traversal_recommendations(
14314 graph: &TraversalGraphBuild,
14315 origin: Option<&str>,
14316 shortest_path: Option<&[String]>,
14317 limit: usize,
14318) -> Vec<TraversalRecommendation> {
14319 let Some(origin) = origin else {
14320 return Vec::new();
14321 };
14322 let mut recommendations = Vec::new();
14323 let mut seen = BTreeSet::new();
14324
14325 if let Some(path) = shortest_path
14326 && path.len() > 1
14327 && path.first().is_some_and(|handle| handle == origin)
14328 && let Some(next) = graph.nodes.get(&path[1])
14329 {
14330 seen.insert(next.handle.clone());
14331 recommendations.push(TraversalRecommendation {
14332 handle: next.handle.clone(),
14333 kind: next.kind.clone(),
14334 label: next.label.clone(),
14335 reason: "next hop on shortest path".to_string(),
14336 score: 1_000,
14337 expand: next.expand.clone(),
14338 });
14339 }
14340
14341 let mut candidates = graph
14342 .edges
14343 .iter()
14344 .filter_map(|edge| {
14345 let neighbor = if edge.from == origin {
14346 edge.to.as_str()
14347 } else if edge.to == origin {
14348 edge.from.as_str()
14349 } else {
14350 return None;
14351 };
14352 let node = graph.nodes.get(neighbor)?;
14353 Some((traversal_relation_score(edge, origin), edge, node))
14354 })
14355 .collect::<Vec<_>>();
14356 candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
14357 right_score
14358 .cmp(left_score)
14359 .then_with(|| left.kind.cmp(&right.kind))
14360 .then_with(|| left.label.cmp(&right.label))
14361 .then_with(|| left.handle.cmp(&right.handle))
14362 });
14363
14364 let max = if limit == 0 { usize::MAX } else { limit };
14365 for (score, edge, node) in candidates {
14366 if recommendations.len() >= max {
14367 break;
14368 }
14369 if seen.insert(node.handle.clone()) {
14370 recommendations.push(TraversalRecommendation {
14371 handle: node.handle.clone(),
14372 kind: node.kind.clone(),
14373 label: node.label.clone(),
14374 reason: traversal_recommendation_reason(edge, origin),
14375 score,
14376 expand: node.expand.clone(),
14377 });
14378 }
14379 }
14380
14381 recommendations
14382}
14383
14384fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
14385 let scale = nodes.saturating_add(edges);
14386 if scale <= 80 {
14387 ExplorationBudget {
14388 project_size: "small".to_string(),
14389 max_source_windows: 8,
14390 lines_per_window: 96,
14391 relationship_limit: 40,
14392 }
14393 } else if scale <= 800 {
14394 ExplorationBudget {
14395 project_size: "medium".to_string(),
14396 max_source_windows: 6,
14397 lines_per_window: 80,
14398 relationship_limit: 32,
14399 }
14400 } else {
14401 ExplorationBudget {
14402 project_size: "large".to_string(),
14403 max_source_windows: 4,
14404 lines_per_window: 64,
14405 relationship_limit: 24,
14406 }
14407 }
14408}
14409
14410fn exploration_node_label(node: &TraversalNode) -> String {
14411 format!("{}:{}", node.kind, node.label)
14412}
14413
14414fn exploration_source_window_for_node(
14415 root: &Path,
14416 node: &TraversalNode,
14417 budget: &ExplorationBudget,
14418) -> Option<ExplorationSourceWindow> {
14419 let file = node.path.as_ref()?;
14420 let anchor = node
14421 .line
14422 .and_then(|line| usize::try_from(line).ok())
14423 .and_then(|line| line.checked_add(1))
14424 .unwrap_or(1);
14425 let context_before = budget.lines_per_window / 3;
14426 let start = anchor.saturating_sub(context_before).max(1);
14427 let end = start
14428 .saturating_add(budget.lines_per_window)
14429 .saturating_sub(1);
14430 let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
14431 Some(ExplorationSourceWindow {
14432 handle,
14433 file: file.clone(),
14434 start,
14435 end,
14436 reason: format!("cluster around {}", exploration_node_label(node)),
14437 expand: source_read_command(root, file, start, budget.lines_per_window),
14438 })
14439}
14440
14441fn build_exploration_packet(
14442 root: &Path,
14443 totals: &TraversalTotals,
14444 selected_nodes: &[TraversalNode],
14445 selected_edges: &[TraversalEdge],
14446) -> ExplorationPacket {
14447 let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
14448 let node_by_handle = selected_nodes
14449 .iter()
14450 .map(|node| (node.handle.as_str(), node))
14451 .collect::<BTreeMap<_, _>>();
14452 let relationship_map = selected_edges
14453 .iter()
14454 .take(budget.relationship_limit)
14455 .filter_map(|edge| {
14456 let from = node_by_handle.get(edge.from.as_str())?;
14457 let to = node_by_handle.get(edge.to.as_str())?;
14458 Some(ExplorationRelation {
14459 from: exploration_node_label(from),
14460 relation: edge.relation.clone(),
14461 to: exploration_node_label(to),
14462 label: edge.label.clone(),
14463 })
14464 })
14465 .collect::<Vec<_>>();
14466
14467 let mut seen_windows = BTreeSet::new();
14468 let mut source_windows = Vec::new();
14469 for node in selected_nodes {
14470 if source_windows.len() >= budget.max_source_windows {
14471 break;
14472 }
14473 let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
14474 continue;
14475 };
14476 let key = (window.file.clone(), window.start, window.end);
14477 if seen_windows.insert(key) {
14478 source_windows.push(window);
14479 }
14480 }
14481
14482 ExplorationPacket {
14483 budget,
14484 relationship_map,
14485 source_windows,
14486 worker_context: Vec::new(),
14487 no_reread_guidance:
14488 "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
14489 .to_string(),
14490 }
14491}
14492
14493pub(crate) fn traversal_report(
14494 root: &Path,
14495 scope: Option<&str>,
14496 graph: TraversalGraphBuild,
14497 query: Option<&str>,
14498 target: Option<&str>,
14499 depth: usize,
14500 limit: usize,
14501) -> Result<TraversalReport> {
14502 let totals = TraversalTotals {
14503 nodes: graph.nodes.len(),
14504 edges: graph.edges.len(),
14505 };
14506 let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
14507 let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
14508 if let Some(query) = query
14509 && origin_node.is_none()
14510 {
14511 bail!("traversal node not found: {}", query);
14512 }
14513 if let Some(target) = target
14514 && target_node.is_none()
14515 {
14516 bail!("traversal target not found: {}", target);
14517 }
14518
14519 let (mode, selected_nodes, selected_edges, shortest_path) =
14520 if let (Some(origin), Some(target)) = (origin_node, target_node) {
14521 if let Some(handles) =
14522 traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
14523 {
14524 let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
14525 let nodes = handles
14526 .iter()
14527 .filter_map(|handle| graph.nodes.get(handle).cloned())
14528 .collect::<Vec<_>>();
14529 let edges = traversal_path_edges(&handles, &graph.edges);
14530 let path = TraversalPathReport {
14531 from: origin.clone(),
14532 to: target.clone(),
14533 hops: handles.len().saturating_sub(1),
14534 nodes: nodes.clone(),
14535 edges: edges.clone(),
14536 };
14537 (
14538 "path".to_string(),
14539 nodes,
14540 traversal_edges_between(&handle_set, &graph.edges),
14541 Some(path),
14542 )
14543 } else {
14544 (
14545 "path".to_string(),
14546 vec![origin.clone(), target.clone()],
14547 Vec::new(),
14548 None,
14549 )
14550 }
14551 } else if let Some(origin) = origin_node {
14552 let handles =
14553 traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
14554 let nodes =
14555 sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
14556 let edges = traversal_edges_between(&handles, &graph.edges);
14557 ("neighborhood".to_string(), nodes, edges, None)
14558 } else {
14559 let mut nodes = sorted_traversal_nodes(graph.nodes.values());
14560 let truncated_nodes = limit > 0 && nodes.len() > limit;
14561 if truncated_nodes {
14562 nodes.truncate(limit);
14563 }
14564 let handles = nodes
14565 .iter()
14566 .map(|node| node.handle.clone())
14567 .collect::<BTreeSet<_>>();
14568 let mut edges = traversal_edges_between(&handles, &graph.edges);
14569 let truncated_edges = limit > 0 && edges.len() > limit;
14570 if truncated_edges {
14571 edges.truncate(limit);
14572 }
14573 ("export".to_string(), nodes, edges, None)
14574 };
14575
14576 let shortest_handles = shortest_path.as_ref().map(|path| {
14577 path.nodes
14578 .iter()
14579 .map(|node| node.handle.clone())
14580 .collect::<Vec<_>>()
14581 });
14582 let recommendations = traversal_recommendations(
14583 &graph,
14584 origin_node.map(|node| node.handle.as_str()),
14585 shortest_handles.as_deref(),
14586 if limit == 0 { 10 } else { limit.min(10) },
14587 );
14588 let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
14589 let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
14590
14591 Ok(TraversalReport {
14592 root: root.to_string_lossy().to_string(),
14593 scope: scope.map(str::to_string),
14594 mode,
14595 totals,
14596 query: query.map(str::to_string),
14597 target: target.map(str::to_string),
14598 nodes: selected_nodes,
14599 edges: selected_edges,
14600 shortest_path,
14601 recommendations,
14602 exploration,
14603 truncated,
14604 warnings: graph.warnings,
14605 })
14606}
14607
14608fn html_escape(input: &str) -> String {
14609 input
14610 .replace('&', "&")
14611 .replace('<', "<")
14612 .replace('>', ">")
14613 .replace('"', """)
14614 .replace('\'', "'")
14615}
14616
14617pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
14618 let json = serde_json::to_string(report)?.replace("</", "<\\/");
14619 let mut html = String::new();
14620 html.push_str(
14621 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
14622 );
14623 html.push_str(
14624 r#"<style>
14625:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
14626@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
14627*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif;line-height:1.4}.page{max-width:1280px;margin:0 auto;padding:20px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:14px}.top h1{font-size:22px;margin:0}.meta{color:var(--muted);font-size:13px}.toolbar{display:flex;gap:8px;align-items:center}.toolbar input{min-width:220px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--text);padding:8px 10px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:14px;min-height:650px}.graph-panel,.side{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.graph-panel{position:relative}.legend{position:absolute;left:12px;top:12px;display:flex;flex-wrap:wrap;gap:6px;max-width:calc(100% - 24px)}.legend span{font-size:12px;background:color-mix(in srgb,var(--panel) 86%,transparent);border:1px solid var(--line);border-radius:999px;padding:4px 8px}.side{padding:14px;overflow:auto}.side h2{font-size:15px;margin:0 0 8px}.selected{border-top:1px solid var(--line);margin-top:12px;padding-top:12px}.list{display:grid;gap:8px}.row{border:1px solid var(--line);border-radius:6px;padding:8px;cursor:pointer}.row:hover{border-color:var(--accent)}.kind{font-size:11px;text-transform:uppercase;color:var(--muted);letter-spacing:.04em}.label{font-weight:650;overflow-wrap:anywhere}.handle,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted)}svg{width:100%;height:650px;display:block}.edge{stroke:var(--edge);stroke-width:1.4;opacity:.72}.edge.semantic{stroke:var(--semantic);stroke-width:1.8}.node{stroke:var(--panel);stroke-width:2;cursor:pointer}.node.semantic{stroke:var(--semantic);stroke-width:2.5}.node-label{font-size:12px;paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round;fill:var(--text);pointer-events:none}.hidden{display:none}@media(max-width:900px){.top{display:block}.toolbar{margin-top:12px}.layout{grid-template-columns:1fr}.side{max-height:360px}svg{height:560px}}
14628</style>"#,
14629 );
14630 html.push_str("</head><body>");
14631 html.push_str("<div class=\"page\">");
14632 html.push_str(&format!(
14633 "<header class=\"top\"><div><h1>tsift traversal graph</h1><div class=\"meta\">mode <code>{}</code> | nodes <code>{}</code>/<code>{}</code> | edges <code>{}</code>/<code>{}</code></div></div><div class=\"toolbar\"><input id=\"filter\" type=\"search\" placeholder=\"Filter nodes\"></div></header>",
14634 html_escape(&report.mode),
14635 report.nodes.len(),
14636 report.totals.nodes,
14637 report.edges.len(),
14638 report.totals.edges
14639 ));
14640 html.push_str(
14641 r#"<main class="layout"><section class="graph-panel"><div id="legend" class="legend"></div><svg id="graph-canvas" role="img" aria-label="Traversal graph"></svg></section><aside class="side"><h2>Nodes</h2><div id="node-list" class="list"></div><div id="selected" class="selected"></div></aside></main>"#,
14642 );
14643 html.push_str("<script id=\"graph-data\" type=\"application/json\">");
14644 html.push_str(&json);
14645 html.push_str(
14646 r##"</script><script>
14647const report = JSON.parse(document.getElementById("graph-data").textContent);
14648const svg = document.getElementById("graph-canvas");
14649const list = document.getElementById("node-list");
14650const selected = document.getElementById("selected");
14651const filter = document.getElementById("filter");
14652const legend = document.getElementById("legend");
14653const nodes = report.nodes.map((node, index) => ({...node, index}));
14654const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
14655const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
14656const colorByKind = new Map([
14657 ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
14658 ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
14659 ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
14660 ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
14661]);
14662function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
14663function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
14664function text(value){ return value == null ? "" : String(value); }
14665function matches(node, query){
14666 if (!query) return true;
14667 const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
14668 return haystack.includes(query);
14669}
14670function layout(){
14671 const rect = svg.getBoundingClientRect();
14672 const width = rect.width || 900;
14673 const height = rect.height || 650;
14674 const cx = width / 2;
14675 const cy = height / 2;
14676 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14677 const counts = new Map();
14678 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
14679 const offsets = new Map();
14680 for (const node of nodes) {
14681 const group = kinds.indexOf(node.kind);
14682 const index = offsets.get(node.kind) || 0;
14683 offsets.set(node.kind, index + 1);
14684 const groupCount = counts.get(node.kind) || 1;
14685 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
14686 const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
14687 node.x = cx + Math.cos(angle) * ring;
14688 node.y = cy + Math.sin(angle) * ring;
14689 }
14690}
14691function draw(){
14692 const query = filter.value.trim().toLowerCase();
14693 const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
14694 svg.innerHTML = "";
14695 for (const edge of edges) {
14696 if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
14697 const from = nodeByHandle.get(edge.from);
14698 const to = nodeByHandle.get(edge.to);
14699 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
14700 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
14701 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
14702 line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
14703 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
14704 svg.appendChild(line);
14705 }
14706 for (const node of nodes) {
14707 if (!visible.has(node.handle)) continue;
14708 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
14709 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
14710 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
14711 circle.setAttribute("fill", color(node.kind));
14712 circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
14713 circle.addEventListener("click", () => selectNode(node));
14714 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
14715 svg.appendChild(circle);
14716 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
14717 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
14718 label.setAttribute("class", "node-label");
14719 label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
14720 svg.appendChild(label);
14721 }
14722 renderList(query);
14723}
14724function renderLegend(){
14725 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14726 legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">●</b> ${kind}</span>`).join("");
14727}
14728function renderList(query){
14729 const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
14730 list.innerHTML = rows.map(node => `<div class="row" data-handle="${node.handle}"><div class="kind">${node.kind}</div><div class="label">${escapeHtml(node.label)}</div><div class="handle">${node.handle}</div></div>`).join("");
14731 for (const row of list.querySelectorAll(".row")) {
14732 row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
14733 }
14734}
14735function selectNode(node){
14736 const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
14737 selected.innerHTML = `<h2>${escapeHtml(node.label)}</h2><div class="kind">${node.kind}</div><p class="handle">${node.handle}</p>${node.path ? `<p>${escapeHtml(node.path)}${node.line != null ? ":" + node.line : ""}</p>` : ""}${node.detail ? `<p>${escapeHtml(node.detail)}</p>` : ""}<p><code>${escapeHtml(node.expand)}</code></p><h2>Edges</h2><div class="list">${adjacent.map(edge => `<div class="row"><div class="kind">${edge.relation}</div><div>${escapeHtml(edge.from)} -> ${escapeHtml(edge.to)}</div>${edge.label ? `<div>${escapeHtml(edge.label)}</div>` : ""}</div>`).join("") || "<div class=\"meta\">No visible edges.</div>"}</div>`;
14738}
14739function escapeHtml(value){
14740 return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));
14741}
14742filter.addEventListener("input", draw);
14743window.addEventListener("resize", () => { layout(); draw(); });
14744renderLegend();
14745layout();
14746draw();
14747if (nodes.length) selectNode(nodes[0]);
14748</script></div></body></html>"##,
14749 );
14750 Ok(html)
14751}
14752
14753fn semantic_related_report_from_store(
14754 root: &Path,
14755 scope: Option<&str>,
14756 query: &str,
14757 limit: usize,
14758 kind: SemanticRelatedKind,
14759 store: &impl GraphStore,
14760) -> Result<SemanticRelatedReport> {
14761 if query.trim().is_empty() {
14762 bail!("semantic query cannot be empty");
14763 }
14764
14765 let query_embedding = semantic_embedding(query);
14766 let node_kinds: &[&str] = match kind {
14767 SemanticRelatedKind::Concept => &["semantic_concept"],
14768 SemanticRelatedKind::Entity => &["semantic_entity"],
14769 SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
14770 };
14771
14772 let mut items = Vec::new();
14773 for node_kind in node_kinds {
14774 for node in store.nodes_by_kind(node_kind)? {
14775 let Some(embedding) = node
14776 .properties
14777 .get("embedding")
14778 .and_then(|value| parse_semantic_embedding_property(value))
14779 else {
14780 continue;
14781 };
14782 let score = semantic_cosine(&query_embedding, &embedding);
14783 items.push(SemanticRelatedItem {
14784 handle: node
14785 .properties
14786 .get("handle")
14787 .cloned()
14788 .unwrap_or_else(|| node.id.clone()),
14789 kind: node.kind,
14790 label: node.label,
14791 score,
14792 file_path: node
14793 .properties
14794 .get("source_file")
14795 .or_else(|| node.properties.get("path"))
14796 .cloned(),
14797 source_symbol: node.properties.get("source_symbol").cloned(),
14798 detail: node
14799 .properties
14800 .get("description")
14801 .or_else(|| node.properties.get("detail"))
14802 .cloned(),
14803 expand: node
14804 .properties
14805 .get("expand")
14806 .cloned()
14807 .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
14808 });
14809 }
14810 }
14811
14812 items.sort_by(|left, right| {
14813 right
14814 .score
14815 .partial_cmp(&left.score)
14816 .unwrap_or(Ordering::Equal)
14817 .then_with(|| left.kind.cmp(&right.kind))
14818 .then_with(|| left.label.cmp(&right.label))
14819 .then_with(|| left.handle.cmp(&right.handle))
14820 });
14821 if limit > 0 && items.len() > limit {
14822 items.truncate(limit);
14823 }
14824
14825 let mut warnings = Vec::new();
14826 if items.is_empty() {
14827 warnings.push(
14828 "no semantic graph rows found; run `tsift summarize --extract <path>` first"
14829 .to_string(),
14830 );
14831 }
14832
14833 Ok(SemanticRelatedReport {
14834 root: root.to_string_lossy().to_string(),
14835 scope: scope.map(str::to_string),
14836 query: query.to_string(),
14837 embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
14838 count: items.len(),
14839 items,
14840 warnings,
14841 })
14842}
14843
14844fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
14845 Ok(store.nodes_by_kind("semantic_concept")?.len()
14846 + store.nodes_by_kind("semantic_entity")?.len())
14847}
14848
14849fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
14850 if limit == 0 {
14851 return 0;
14852 }
14853 limit.saturating_mul(4).clamp(
14854 GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
14855 GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
14856 )
14857}
14858
14859fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
14860 if limit == 0 {
14861 return usize::MAX;
14862 }
14863 limit.saturating_mul(3).max(limit).max(seed_count)
14864}
14865
14866fn graph_db_semantic_edge_other_id<'a>(
14867 edge: &'a SubstrateGraphEdge,
14868 current_id: &str,
14869) -> Option<&'a str> {
14870 if edge.from_id == current_id {
14871 Some(edge.to_id.as_str())
14872 } else if edge.to_id == current_id {
14873 Some(edge.from_id.as_str())
14874 } else {
14875 None
14876 }
14877}
14878
14879fn graph_db_semantic_edge_score(edge: &SubstrateGraphEdge, current_id: &str) -> i64 {
14880 let mut score = resolution::edge_kind_rank_score(&edge.kind).saturating_mul(10);
14881 score += if edge.from_id == current_id { 8 } else { 4 };
14882 score += match edge.kind.as_str() {
14883 "mentions_concept" | "mentions_entity" | "tagged_concept" | "tagged_entity"
14884 | "related_concept" => 30,
14885 "semantic_relation" => 28,
14886 "calls" => 24,
14887 "mentions" => 22,
14888 "requests_context" | "scopes_context" | "scopes_source" | "explains_result" => 18,
14889 "defines" | "contains" | "belongs_to" => 12,
14890 _ => 0,
14891 };
14892 score
14893}
14894
14895fn graph_db_semantic_seeded_neighborhood(
14896 store: &impl GraphStore,
14897 seed_ids: &[String],
14898 depth: usize,
14899 limit: usize,
14900) -> Result<GraphDbSemanticSeededSubgraph> {
14901 let seed_rank = seed_ids
14902 .iter()
14903 .enumerate()
14904 .map(|(idx, seed)| (seed.clone(), idx))
14905 .collect::<BTreeMap<_, _>>();
14906 let mut nodes = BTreeMap::<String, SubstrateGraphNode>::new();
14907 let mut edges = BTreeMap::<String, SubstrateGraphEdge>::new();
14908 let mut node_score_by_id = BTreeMap::<String, i64>::new();
14909 let mut queue = VecDeque::<(String, usize)>::new();
14910 let mut seen_at_depth = BTreeMap::<String, usize>::new();
14911 let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
14912 let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
14913 let mut skipped_by_edge_cap = 0usize;
14914 let mut skipped_by_node_cap = 0usize;
14915 let mut diagnostics = vec![
14916 "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
14917 "seed expansion traverses both outgoing and incident edges so code, markdown, conversation, and memory adapters can link into semantic rows without reversing their edge direction".to_string(),
14918 format!(
14919 "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
14920 if edge_scan_cap == 0 {
14921 "unbounded".to_string()
14922 } else {
14923 edge_scan_cap.to_string()
14924 },
14925 if node_discovery_cap == usize::MAX {
14926 "unbounded".to_string()
14927 } else {
14928 node_discovery_cap.to_string()
14929 }
14930 ),
14931 ];
14932
14933 for (idx, seed_id) in seed_ids.iter().enumerate() {
14934 if let Some(node) = store.node(seed_id)? {
14935 nodes.entry(seed_id.clone()).or_insert(node);
14936 node_score_by_id
14937 .entry(seed_id.clone())
14938 .or_insert(1_000_000i64.saturating_sub(idx as i64));
14939 queue.push_back((seed_id.clone(), 0));
14940 seen_at_depth.entry(seed_id.clone()).or_insert(0);
14941 } else {
14942 diagnostics.push(format!(
14943 "semantic seed {seed_id} was not present in the graph store"
14944 ));
14945 }
14946 }
14947
14948 while let Some((current_id, current_depth)) = queue.pop_front() {
14949 if current_depth >= depth {
14950 continue;
14951 }
14952
14953 let mut expansion_edges_by_key = BTreeMap::<String, SubstrateGraphEdge>::new();
14954 for edge in store.outgoing_edges(¤t_id, None)? {
14955 expansion_edges_by_key
14956 .entry(graph_db_edge_key(&edge))
14957 .or_insert(edge);
14958 }
14959 for edge in store.incident_edges(¤t_id, None)? {
14960 expansion_edges_by_key
14961 .entry(graph_db_edge_key(&edge))
14962 .or_insert(edge);
14963 }
14964 let mut expansion_edges = expansion_edges_by_key.into_values().collect::<Vec<_>>();
14965 expansion_edges.sort_by(|left, right| {
14966 graph_db_semantic_edge_score(right, ¤t_id)
14967 .cmp(&graph_db_semantic_edge_score(left, ¤t_id))
14968 .then_with(|| graph_db_edge_key(left).cmp(&graph_db_edge_key(right)))
14969 });
14970 if edge_scan_cap > 0 && expansion_edges.len() > edge_scan_cap {
14971 skipped_by_edge_cap += expansion_edges.len() - edge_scan_cap;
14972 expansion_edges.truncate(edge_scan_cap);
14973 }
14974
14975 for edge in expansion_edges {
14976 let Some(other_id) = graph_db_semantic_edge_other_id(&edge, ¤t_id) else {
14977 continue;
14978 };
14979 let other_known = nodes.contains_key(other_id);
14980 if !other_known && nodes.len() >= node_discovery_cap {
14981 skipped_by_node_cap += 1;
14982 continue;
14983 }
14984 let other_id = other_id.to_string();
14985 let edge_score = graph_db_semantic_edge_score(&edge, ¤t_id)
14986 .saturating_add((depth.saturating_sub(current_depth) as i64).saturating_mul(5));
14987 node_score_by_id
14988 .entry(other_id.clone())
14989 .and_modify(|score| *score = (*score).max(edge_score))
14990 .or_insert(edge_score);
14991 let edge_key = graph_db_edge_key(&edge);
14992 edges.entry(edge_key).or_insert_with(|| edge.clone());
14993 if let std::collections::btree_map::Entry::Vacant(entry) = nodes.entry(other_id.clone())
14994 && let Some(node) = store.node(&other_id)?
14995 {
14996 entry.insert(node);
14997 }
14998 if !nodes.contains_key(&other_id) {
14999 continue;
15000 }
15001 let next_depth = current_depth + 1;
15002 let should_queue = seen_at_depth
15003 .get(&other_id)
15004 .is_none_or(|seen_depth| next_depth < *seen_depth);
15005 if should_queue {
15006 seen_at_depth.insert(other_id.clone(), next_depth);
15007 queue.push_back((other_id, next_depth));
15008 }
15009 }
15010 }
15011
15012 if skipped_by_edge_cap > 0 {
15013 diagnostics.push(format!(
15014 "semantic-seeded expansion skipped {skipped_by_edge_cap} lower-scoring incident/outgoing edge(s) after per-node caps"
15015 ));
15016 }
15017 if skipped_by_node_cap > 0 {
15018 diagnostics.push(format!(
15019 "semantic-seeded expansion skipped {skipped_by_node_cap} lower-scoring node discovery edge(s) after the discovery cap"
15020 ));
15021 }
15022
15023 let mut nodes = nodes.into_values().collect::<Vec<_>>();
15024 nodes.sort_by(|left, right| {
15025 seed_rank
15026 .get(&left.id)
15027 .copied()
15028 .unwrap_or(usize::MAX)
15029 .cmp(&seed_rank.get(&right.id).copied().unwrap_or(usize::MAX))
15030 .then_with(|| {
15031 node_score_by_id
15032 .get(&right.id)
15033 .copied()
15034 .unwrap_or_default()
15035 .cmp(&node_score_by_id.get(&left.id).copied().unwrap_or_default())
15036 })
15037 .then(left.id.cmp(&right.id))
15038 });
15039
15040 let before_limit = nodes.len();
15041 let truncated = limit > 0 && nodes.len() > limit;
15042 if truncated {
15043 nodes.truncate(limit);
15044 diagnostics.push(format!(
15045 "semantic-seeded neighborhood truncated from {before_limit} to {limit} node(s)"
15046 ));
15047 }
15048
15049 let node_ids = nodes
15050 .iter()
15051 .map(|node| node.id.as_str())
15052 .collect::<BTreeSet<_>>();
15053 let mut edges = edges
15054 .into_values()
15055 .filter(|edge| {
15056 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
15057 })
15058 .collect::<Vec<_>>();
15059 edges.sort_by_key(graph_db_edge_key);
15060
15061 Ok(GraphDbSemanticSeededSubgraph {
15062 nodes,
15063 edges,
15064 truncated,
15065 diagnostics,
15066 })
15067}
15068
15069#[allow(clippy::too_many_arguments)]
15070fn cmd_semantic_related(
15071 query: &str,
15072 path: &Path,
15073 scope: Option<&str>,
15074 limit: usize,
15075 kind: SemanticRelatedKind,
15076 json_output: bool,
15077 compact: bool,
15078 pretty: bool,
15079 terse: bool,
15080 schema: bool,
15081) -> Result<()> {
15082 let root = lint::resolve_project_root_or_canonical_path(path)?;
15083 write_traversal_graph_store(&root, path, scope)?;
15084 let graph_db = graph_substrate_db_path(&root, scope);
15085 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15086 let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15087 if let Some(recovery) = store.read_only_recovery() {
15088 report
15089 .warnings
15090 .push(graph_db_read_recovery_diagnostic(recovery));
15091 }
15092
15093 if json_output {
15094 println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15095 } else if compact {
15096 for item in &report.items {
15097 println!(
15098 "{:.3}\t{}\t{}\t{}",
15099 item.score, item.kind, item.label, item.handle
15100 );
15101 }
15102 for warning in &report.warnings {
15103 eprintln!("warning: {warning}");
15104 }
15105 } else {
15106 println!(
15107 "Related semantic graph rows for {:?} ({})",
15108 report.query, report.embedding_model
15109 );
15110 for item in &report.items {
15111 println!(
15112 " {:.3} [{}] {} ({})",
15113 item.score, item.kind, item.label, item.handle
15114 );
15115 if let Some(detail) = &item.detail {
15116 println!(" {}", detail);
15117 }
15118 if let Some(file_path) = &item.file_path {
15119 println!(" file: {}", file_path);
15120 }
15121 println!(" expand: {}", item.expand);
15122 }
15123 for warning in &report.warnings {
15124 eprintln!("warning: {warning}");
15125 }
15126 }
15127
15128 Ok(())
15129}
15130
15131#[derive(Serialize)]
15132struct SourceLinePreview {
15133 line: usize,
15134 text: String,
15135}
15136
15137#[derive(Serialize)]
15138pub(crate) struct SourceRangePreview {
15139 start: usize,
15140 end: usize,
15141 total_lines: usize,
15142 truncated_before: bool,
15143 truncated_after: bool,
15144}
15145
15146#[derive(Serialize)]
15147struct SourceExpandCommands {
15148 #[serde(skip_serializing_if = "Option::is_none")]
15149 before: Option<String>,
15150 #[serde(skip_serializing_if = "Option::is_none")]
15151 after: Option<String>,
15152 #[serde(skip_serializing_if = "Option::is_none")]
15153 body: Option<String>,
15154 file: String,
15155 #[serde(skip_serializing_if = "Option::is_none")]
15156 markdown_ast: Option<String>,
15157}
15158
15159#[derive(Serialize)]
15160struct SourceSymbolRef {
15161 handle: String,
15162 name: String,
15163 kind: String,
15164 language: String,
15165 file: String,
15166 line: usize,
15167 #[serde(skip_serializing_if = "Option::is_none")]
15168 end_line: Option<usize>,
15169 #[serde(skip_serializing_if = "Option::is_none")]
15170 signature: Option<String>,
15171 #[serde(skip_serializing_if = "Option::is_none")]
15172 span: Option<AstSpanPreview>,
15173 expand: String,
15174}
15175
15176#[derive(Serialize)]
15177struct SourceSummaryRef {
15178 handle: String,
15179 symbol_name: String,
15180 file_path: String,
15181 summary: String,
15182 expand: String,
15183}
15184
15185#[derive(Serialize)]
15186struct SourceReadReport {
15187 handle: String,
15188 root: String,
15189 file: String,
15190 range: SourceRangePreview,
15191 preview: Vec<SourceLinePreview>,
15192 symbols: Vec<SourceSymbolRef>,
15193 summaries: Vec<SourceSummaryRef>,
15194 #[serde(skip_serializing_if = "Option::is_none")]
15195 markdown: Option<SourceReadMarkdownProjection>,
15196 expand: SourceExpandCommands,
15197 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15198 warnings: Vec<String>,
15199}
15200
15201#[derive(Serialize)]
15202struct SourceReadAstExpandCommands {
15203 window: String,
15204 file_window: String,
15205 #[serde(skip_serializing_if = "Option::is_none")]
15206 markdown_ast: Option<String>,
15207}
15208
15209#[derive(Serialize)]
15210struct SourceReadAstReport {
15211 handle: String,
15212 root: String,
15213 file: String,
15214 range: SourceRangePreview,
15215 symbols: Vec<SourceSymbolRef>,
15216 summaries: Vec<SourceSummaryRef>,
15217 #[serde(skip_serializing_if = "Option::is_none")]
15218 markdown: Option<SourceReadMarkdownProjection>,
15219 expand: SourceReadAstExpandCommands,
15220 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15221 warnings: Vec<String>,
15222}
15223
15224#[derive(Serialize)]
15225struct SymbolReadTarget {
15226 handle: String,
15227 name: String,
15228 kind: String,
15229 language: String,
15230 file: String,
15231 line: usize,
15232 #[serde(skip_serializing_if = "Option::is_none")]
15233 end_line: Option<usize>,
15234 #[serde(skip_serializing_if = "Option::is_none")]
15235 signature: Option<String>,
15236 #[serde(skip_serializing_if = "Option::is_none")]
15237 parent_module: Option<String>,
15238 #[serde(skip_serializing_if = "Option::is_none")]
15239 visibility: Option<String>,
15240 #[serde(skip_serializing_if = "Option::is_none")]
15241 span: Option<AstSpanPreview>,
15242}
15243
15244#[derive(Serialize)]
15245struct SymbolReadExpandCommands {
15246 source_window: String,
15247 #[serde(skip_serializing_if = "Option::is_none")]
15248 body: Option<String>,
15249 file: String,
15250 explain: String,
15251 callers: String,
15252 callees: String,
15253 #[serde(skip_serializing_if = "Option::is_none")]
15254 markdown_ast: Option<String>,
15255}
15256
15257#[derive(Serialize)]
15258struct SymbolReadReport {
15259 handle: String,
15260 root: String,
15261 query: String,
15262 symbol: SymbolReadTarget,
15263 range: SourceRangePreview,
15264 body: Vec<SourceLinePreview>,
15265 child_symbols: Vec<SourceSymbolRef>,
15266 summaries: Vec<SourceSummaryRef>,
15267 expand: SymbolReadExpandCommands,
15268 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15269 warnings: Vec<String>,
15270}
15271
15272#[derive(Clone)]
15273pub(crate) struct MarkdownAstRawNode {
15274 handle: String,
15275 span_handle: String,
15276 name: String,
15277 kind: String,
15278 block_kind: String,
15279 node_kind: String,
15280 start_byte: usize,
15281 end_byte: usize,
15282 body_start_byte: Option<usize>,
15283 body_end_byte: Option<usize>,
15284}
15285
15286#[derive(Clone)]
15287pub(crate) struct MarkdownAstProjection {
15288 source_hash: String,
15289 nodes: Vec<MarkdownAstRawNode>,
15290 parse_duration_micros: u128,
15291 cache_hit: bool,
15292}
15293
15294#[derive(Clone)]
15295struct MarkdownAstCacheEntry {
15296 source_hash: String,
15297 nodes: Vec<MarkdownAstRawNode>,
15298 parse_duration_micros: u128,
15299}
15300
15301static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15302 OnceLock::new();
15303
15304#[derive(Serialize, Clone)]
15305struct MarkdownAstNodeMetadata {
15306 #[serde(skip_serializing_if = "Option::is_none")]
15307 heading_level: Option<usize>,
15308 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15309 section_path: Vec<String>,
15310 #[serde(skip_serializing_if = "Option::is_none")]
15311 section_handle: Option<String>,
15312 #[serde(skip_serializing_if = "Option::is_none")]
15313 list_depth: Option<usize>,
15314 #[serde(skip_serializing_if = "Option::is_none")]
15315 list_marker: Option<String>,
15316 #[serde(skip_serializing_if = "Option::is_none")]
15317 list_order: Option<usize>,
15318 #[serde(skip_serializing_if = "Option::is_none")]
15319 fence_language: Option<String>,
15320 #[serde(skip_serializing_if = "Option::is_none")]
15321 fence_marker: Option<String>,
15322 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15323 embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15324}
15325
15326#[derive(Serialize, Clone)]
15327struct MarkdownAstNodeExpand {
15328 source_window: String,
15329 source_body: String,
15330 symbol_read: String,
15331 edit_intents: String,
15332}
15333
15334#[derive(Serialize, Clone)]
15335struct MarkdownAstCacheReport {
15336 source_hash: String,
15337 cache_hit: bool,
15338 parse_duration_micros: u128,
15339 node_count: usize,
15340 section_count: usize,
15341 list_item_count: usize,
15342 code_block_count: usize,
15343}
15344
15345#[derive(Serialize, Clone)]
15346struct MarkdownAstPhaseTiming {
15347 name: String,
15348 duration_micros: u128,
15349 detail: String,
15350}
15351
15352#[derive(Serialize, Clone)]
15353struct MarkdownAstOutlineEntry {
15354 handle: String,
15355 span_handle: String,
15356 name: String,
15357 kind: String,
15358 block_kind: String,
15359 line: usize,
15360 end_line: usize,
15361 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15362 section_path: Vec<String>,
15363 child_count: usize,
15364 expand: String,
15365}
15366
15367#[derive(Serialize, Clone)]
15368struct MarkdownAstProjectionPreview {
15369 mode: String,
15370 total_nodes: usize,
15371 returned_nodes: usize,
15372 omitted_nodes: usize,
15373 selected_node: Option<String>,
15374 cache: MarkdownAstCacheReport,
15375 outline: Vec<MarkdownAstOutlineEntry>,
15376 phase_timings: Vec<MarkdownAstPhaseTiming>,
15377}
15378
15379#[derive(Serialize)]
15380struct SourceReadMarkdownProjection {
15381 handle: String,
15382 mode: String,
15383 total_nodes: usize,
15384 visible_nodes: usize,
15385 outline: Vec<MarkdownAstOutlineEntry>,
15386 expand: String,
15387}
15388
15389#[derive(Serialize, Clone)]
15390struct SourceByteRangePreview {
15391 start: usize,
15392 end: usize,
15393}
15394
15395#[derive(Serialize, Clone)]
15396struct MarkdownAstNode {
15397 handle: String,
15398 span_handle: String,
15399 name: String,
15400 kind: String,
15401 block_kind: String,
15402 node_kind: String,
15403 line: usize,
15404 end_line: usize,
15405 byte_span: SourceByteRangePreview,
15406 #[serde(skip_serializing_if = "Option::is_none")]
15407 body_byte_span: Option<SourceByteRangePreview>,
15408 parent_handle: Option<String>,
15409 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15410 child_handles: Vec<String>,
15411 metadata: MarkdownAstNodeMetadata,
15412 expand: MarkdownAstNodeExpand,
15413}
15414
15415#[derive(Serialize)]
15416struct MarkdownAstExpandCommands {
15417 file: String,
15418 source_read: String,
15419 edit_intents: String,
15420}
15421
15422#[derive(Serialize)]
15423struct MarkdownAstReport {
15424 handle: String,
15425 root: String,
15426 file: String,
15427 range: SourceRangePreview,
15428 projection: MarkdownAstProjectionPreview,
15429 nodes: Vec<MarkdownAstNode>,
15430 expand: MarkdownAstExpandCommands,
15431 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15432 warnings: Vec<String>,
15433}
15434
15435pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
15436 let candidate = if file.is_absolute() {
15437 file.to_path_buf()
15438 } else {
15439 root.join(file)
15440 };
15441 let canonical = candidate
15442 .canonicalize()
15443 .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
15444 if !canonical.is_file() {
15445 bail!("source file is not a regular file: {}", canonical.display());
15446 }
15447 let canonical_root = root
15448 .canonicalize()
15449 .with_context(|| format!("canonicalizing project root {}", root.display()))?;
15450 if !canonical.starts_with(&canonical_root) {
15451 bail!(
15452 "source file {} is outside project root {}",
15453 canonical.display(),
15454 canonical_root.display()
15455 );
15456 }
15457 Ok(canonical)
15458}
15459
15460pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
15461 source_read_window_command(root, file, start, lines)
15462}
15463
15464pub(crate) fn source_read_window_command(
15465 root: &Path,
15466 file: &str,
15467 start: usize,
15468 lines: usize,
15469) -> String {
15470 format!(
15471 "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
15472 shell_quote(file),
15473 shell_quote(&root.to_string_lossy()),
15474 start,
15475 lines
15476 )
15477}
15478
15479pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
15480 format!(
15481 "tsift --envelope source-read {} --path {} --budget normal",
15482 shell_quote(file),
15483 shell_quote(&root.to_string_lossy())
15484 )
15485}
15486
15487pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
15488 format!(
15489 "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
15490 shell_quote(symbol),
15491 shell_quote(&root.to_string_lossy()),
15492 shell_quote(file)
15493 )
15494}
15495
15496fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
15497 format!(
15498 "tsift --envelope explain {} --path {} --budget normal",
15499 shell_quote(symbol),
15500 shell_quote(&root.to_string_lossy())
15501 )
15502}
15503
15504fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
15505 format!(
15506 "tsift graph {} --path {} --{} --json",
15507 shell_quote(symbol),
15508 shell_quote(&root.to_string_lossy()),
15509 relation
15510 )
15511}
15512
15513fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
15514 format!(
15515 "tsift summarize {} --path {} --json",
15516 shell_quote(symbol),
15517 shell_quote(&root.to_string_lossy())
15518 )
15519}
15520
15521pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
15522 let mut command = format!(
15523 "tsift --envelope markdown-ast {} --path {} --budget normal",
15524 shell_quote(file),
15525 shell_quote(&root.to_string_lossy())
15526 );
15527 if let Some(node) = node {
15528 command.push_str(" --node ");
15529 command.push_str(&shell_quote(node));
15530 }
15531 command
15532}
15533
15534fn markdown_edit_intents_command(root: &Path) -> String {
15535 format!(
15536 "tsift --envelope edit-intents --path {} --budget normal",
15537 shell_quote(&root.to_string_lossy())
15538 )
15539}
15540
15541pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
15542 usize::try_from(symbol.line)
15543 .ok()
15544 .and_then(|line| line.checked_add(1))
15545 .unwrap_or(1)
15546}
15547
15548fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
15549 symbol
15550 .end_line
15551 .and_then(|line| usize::try_from(line).ok())
15552 .and_then(|line| line.checked_add(1))
15553}
15554
15555fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
15556 value.and_then(|byte| usize::try_from(byte).ok())
15557}
15558
15559fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
15560 let byte = byte.min(source.len());
15561 source[..byte]
15562 .iter()
15563 .filter(|value| **value == b'\n')
15564 .count()
15565 .saturating_add(1)
15566}
15567
15568fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
15569 source_line_for_byte(source, end_byte.saturating_sub(1))
15570}
15571
15572fn ast_span_handle(
15573 file: &str,
15574 name: &str,
15575 kind: &str,
15576 start_byte: usize,
15577 end_byte: usize,
15578) -> String {
15579 stable_handle(
15580 "span",
15581 &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
15582 )
15583}
15584
15585pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
15586 Some((
15587 symbol_span_byte(symbol.start_byte)?,
15588 symbol_span_byte(symbol.end_byte)?,
15589 ))
15590}
15591
15592pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
15593 Some((
15594 symbol_span_byte(symbol.start_byte)?,
15595 symbol_span_byte(symbol.end_byte)?,
15596 ))
15597}
15598
15599pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
15600 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15601 Some(ast_span_handle(
15602 &symbol.file,
15603 &symbol.name,
15604 &symbol.kind,
15605 start_byte,
15606 end_byte,
15607 ))
15608}
15609
15610fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
15611 left.file == right.file
15612 && left.name == right.name
15613 && left.kind == right.kind
15614 && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
15615}
15616
15617fn stored_symbol_parent_span_handle(
15618 symbol: &index::StoredSymbol,
15619 symbols: &[index::StoredSymbol],
15620) -> Option<String> {
15621 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15622 symbols
15623 .iter()
15624 .filter(|candidate| {
15625 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15626 return false;
15627 }
15628 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15629 else {
15630 return false;
15631 };
15632 candidate_start <= start_byte && candidate_end >= end_byte
15633 })
15634 .min_by_key(|candidate| {
15635 stored_symbol_span_bounds(candidate)
15636 .map(|(start, end)| end.saturating_sub(start))
15637 .unwrap_or(usize::MAX)
15638 })
15639 .and_then(stored_symbol_span_handle)
15640}
15641
15642fn stored_symbol_child_span_handles(
15643 symbol: &index::StoredSymbol,
15644 symbols: &[index::StoredSymbol],
15645 limit: usize,
15646) -> Vec<String> {
15647 let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
15648 return Vec::new();
15649 };
15650 symbols
15651 .iter()
15652 .filter(|candidate| {
15653 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15654 return false;
15655 }
15656 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15657 else {
15658 return false;
15659 };
15660 candidate_start >= start_byte && candidate_end <= end_byte
15661 })
15662 .take(limit)
15663 .filter_map(stored_symbol_span_handle)
15664 .collect()
15665}
15666
15667fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
15668 let start = start_byte.min(source.len());
15669 let line_end = source[start..]
15670 .iter()
15671 .position(|value| *value == b'\n')
15672 .map(|pos| start + pos)
15673 .unwrap_or(source.len());
15674 let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
15675 let marker = line.trim_start();
15676 let level = marker.chars().take_while(|ch| *ch == '#').count();
15677 (1..=6).contains(&level).then_some(level)
15678}
15679
15680fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
15681 let start = start_byte.min(source.len());
15682 let line_start = source[..start]
15683 .iter()
15684 .rposition(|value| *value == b'\n')
15685 .map(|pos| pos + 1)
15686 .unwrap_or(0);
15687 source[line_start..start]
15688 .iter()
15689 .map(|byte| match byte {
15690 b'\t' => 4,
15691 b' ' => 1,
15692 _ => 0,
15693 })
15694 .sum::<usize>()
15695 / 2
15696}
15697
15698fn markdown_enclosing_heading_symbols<'a>(
15699 file: &str,
15700 start_byte: usize,
15701 end_byte: usize,
15702 symbols: &'a [index::StoredSymbol],
15703) -> Vec<&'a index::StoredSymbol> {
15704 let mut headings = symbols
15705 .iter()
15706 .filter(|candidate| candidate.file == file && candidate.kind == "heading")
15707 .filter(|candidate| {
15708 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15709 else {
15710 return false;
15711 };
15712 candidate_start <= start_byte && candidate_end >= end_byte
15713 })
15714 .collect::<Vec<_>>();
15715 headings.sort_by(|left, right| {
15716 stored_symbol_span_bounds(left)
15717 .map(|(start, _)| start)
15718 .unwrap_or(usize::MAX)
15719 .cmp(
15720 &stored_symbol_span_bounds(right)
15721 .map(|(start, _)| start)
15722 .unwrap_or(usize::MAX),
15723 )
15724 .then(left.name.cmp(&right.name))
15725 });
15726 headings
15727}
15728
15729fn markdown_stored_symbol_metadata(
15730 symbol: &index::StoredSymbol,
15731 source: &[u8],
15732 symbols: &[index::StoredSymbol],
15733) -> Option<MarkdownSpanMetadata> {
15734 if symbol.language != "markdown" {
15735 return None;
15736 }
15737 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15738 let section_symbols =
15739 markdown_enclosing_heading_symbols(&symbol.file, start_byte, end_byte, symbols);
15740 let section_path = section_symbols
15741 .iter()
15742 .map(|heading| heading.name.clone())
15743 .collect::<Vec<_>>();
15744 let section_handle = section_symbols
15745 .last()
15746 .and_then(|heading| stored_symbol_span_handle(heading));
15747 let heading_level = (symbol.kind == "heading")
15748 .then(|| markdown_heading_level(source, start_byte))
15749 .flatten();
15750 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15751 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15752 let embedded_symbols = if symbol.kind == "code_block" {
15753 markdown_embedded_symbols(
15754 &symbol.file,
15755 source,
15756 symbol_span_byte(symbol.body_start_byte),
15757 symbol_span_byte(symbol.body_end_byte),
15758 fence_language.as_deref(),
15759 )
15760 } else {
15761 Vec::new()
15762 };
15763
15764 (heading_level.is_some()
15765 || !section_path.is_empty()
15766 || section_handle.is_some()
15767 || list_depth.is_some()
15768 || fence_language.is_some()
15769 || !embedded_symbols.is_empty())
15770 .then_some(MarkdownSpanMetadata {
15771 heading_level,
15772 section_path,
15773 section_handle,
15774 list_depth,
15775 fence_language,
15776 embedded_symbols,
15777 })
15778}
15779
15780fn markdown_symbol_hit_metadata(
15781 symbol: &index::SymbolHit,
15782 source: &[u8],
15783 start_byte: usize,
15784) -> Option<MarkdownSpanMetadata> {
15785 if symbol.language != "markdown" {
15786 return None;
15787 }
15788 let heading_level = (symbol.kind == "heading")
15789 .then(|| markdown_heading_level(source, start_byte))
15790 .flatten();
15791 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15792 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15793 let embedded_symbols = if symbol.kind == "code_block" {
15794 markdown_embedded_symbols(
15795 &symbol.file,
15796 source,
15797 symbol_span_byte(symbol.body_start_byte),
15798 symbol_span_byte(symbol.body_end_byte),
15799 fence_language.as_deref(),
15800 )
15801 } else {
15802 Vec::new()
15803 };
15804 (heading_level.is_some()
15805 || list_depth.is_some()
15806 || fence_language.is_some()
15807 || !embedded_symbols.is_empty())
15808 .then_some(MarkdownSpanMetadata {
15809 heading_level,
15810 section_path: Vec::new(),
15811 section_handle: None,
15812 list_depth,
15813 fence_language,
15814 embedded_symbols,
15815 })
15816}
15817
15818fn is_markdown_path(path: &Path) -> bool {
15819 path.extension()
15820 .and_then(|ext| ext.to_str())
15821 .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
15822 .unwrap_or(false)
15823}
15824
15825fn markdown_ast_block_kind(kind: &str) -> String {
15826 match kind {
15827 "heading" => "section",
15828 "code_block" => "fenced_code_block",
15829 "list_item" => "list_item",
15830 other => other,
15831 }
15832 .to_string()
15833}
15834
15835fn markdown_embedded_language_key(language: &str) -> Option<String> {
15836 let key = language
15837 .split_whitespace()
15838 .next()
15839 .unwrap_or("")
15840 .trim()
15841 .trim_start_matches("language-")
15842 .trim_start_matches("lang-")
15843 .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
15844 .to_ascii_lowercase();
15845 (!key.is_empty()).then_some(key)
15846}
15847
15848fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
15849 let key = markdown_embedded_language_key(language)?;
15850 let extension = match key.as_str() {
15851 "rust" => "rs",
15852 "python" => "py",
15853 "typescript" => "ts",
15854 "javascript" => "js",
15855 "kotlin" => "kt",
15856 "shell" | "sh" | "zsh" => "bash",
15857 other => other,
15858 };
15859 let lang = graph::Lang::from_extension(extension)?;
15860 (lang.name() != "markdown").then_some(lang)
15861}
15862
15863fn markdown_embedded_ast_span_handle(
15864 file: &str,
15865 language: &str,
15866 name: &str,
15867 kind: &str,
15868 start_byte: usize,
15869 end_byte: usize,
15870) -> String {
15871 stable_handle(
15872 "span",
15873 &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
15874 )
15875}
15876
15877fn markdown_embedded_symbols(
15878 file: &str,
15879 source: &[u8],
15880 body_start_byte: Option<usize>,
15881 body_end_byte: Option<usize>,
15882 fence_language: Option<&str>,
15883) -> Vec<MarkdownEmbeddedSymbol> {
15884 let Some(fence_language) = fence_language else {
15885 return Vec::new();
15886 };
15887 let Some(lang) = markdown_embedded_lang(fence_language) else {
15888 return Vec::new();
15889 };
15890 let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
15891 return Vec::new();
15892 };
15893 let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
15894 else {
15895 return Vec::new();
15896 };
15897 if body.is_empty() {
15898 return Vec::new();
15899 }
15900
15901 let Ok(symbols) = lang.extract_symbols(body) else {
15902 return Vec::new();
15903 };
15904 let language = lang.name().to_string();
15905 symbols
15906 .into_iter()
15907 .map(|symbol| {
15908 let start_byte = body_start_byte.saturating_add(symbol.start_byte);
15909 let end_byte = body_start_byte.saturating_add(symbol.end_byte);
15910 let body_start = symbol
15911 .body_start_byte
15912 .map(|byte| body_start_byte.saturating_add(byte));
15913 let body_end = symbol
15914 .body_end_byte
15915 .map(|byte| body_start_byte.saturating_add(byte));
15916 let start_line = source_line_for_byte(source, start_byte);
15917 let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
15918 MarkdownEmbeddedSymbol {
15919 handle: markdown_embedded_ast_span_handle(
15920 file,
15921 &language,
15922 &symbol.name,
15923 &symbol.kind,
15924 start_byte,
15925 end_byte,
15926 ),
15927 name: symbol.name,
15928 kind: symbol.kind,
15929 language: language.clone(),
15930 node_kind: symbol.node_kind,
15931 start_byte,
15932 end_byte,
15933 start_line,
15934 end_line,
15935 body_start_byte: body_start,
15936 body_end_byte: body_end,
15937 body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
15938 body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
15939 }
15940 })
15941 .collect()
15942}
15943
15944fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
15945 let start = start_byte.min(source.len());
15946 let line_start = source[..start]
15947 .iter()
15948 .rposition(|value| *value == b'\n')
15949 .map(|pos| pos + 1)
15950 .unwrap_or(0);
15951 let line_end = source[start..]
15952 .iter()
15953 .position(|value| *value == b'\n')
15954 .map(|pos| start + pos)
15955 .unwrap_or(source.len());
15956 std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
15957}
15958
15959fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
15960 let line = markdown_source_line(source, start_byte);
15961 let trimmed = line.trim_start();
15962 for marker in ["-", "*", "+"] {
15963 if trimmed
15964 .strip_prefix(marker)
15965 .and_then(|rest| rest.strip_prefix(' '))
15966 .is_some()
15967 {
15968 return (Some(marker.to_string()), None);
15969 }
15970 }
15971
15972 let digit_end = trimmed
15973 .find(|ch: char| !ch.is_ascii_digit())
15974 .unwrap_or(trimmed.len());
15975 let (digits, rest) = trimmed.split_at(digit_end);
15976 if !digits.is_empty() {
15977 for marker in [".", ")"] {
15978 if rest
15979 .strip_prefix(marker)
15980 .and_then(|value| value.strip_prefix(' '))
15981 .is_some()
15982 {
15983 return (
15984 Some(format!("{digits}{marker}")),
15985 digits.parse::<usize>().ok(),
15986 );
15987 }
15988 }
15989 }
15990 (None, None)
15991}
15992
15993fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
15994 let line = markdown_source_line(source, start_byte);
15995 let trimmed = line.trim_start();
15996 ["```", "~~~"]
15997 .into_iter()
15998 .find(|marker| trimmed.starts_with(marker))
15999 .map(str::to_string)
16000}
16001
16002fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16003 let mut nodes = graph::Lang::Markdown
16004 .extract_symbols(source)
16005 .context("extracting Markdown AST nodes")?
16006 .into_iter()
16007 .map(|symbol| {
16008 let body_start_byte = symbol.body_start_byte;
16009 let body_end_byte = symbol.body_end_byte;
16010 let span_handle = ast_span_handle(
16011 file,
16012 &symbol.name,
16013 &symbol.kind,
16014 symbol.start_byte,
16015 symbol.end_byte,
16016 );
16017 MarkdownAstRawNode {
16018 handle: stable_handle(
16019 "mdast",
16020 &format!(
16021 "{}:{}:{}:{}:{}",
16022 file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16023 ),
16024 ),
16025 span_handle,
16026 name: symbol.name,
16027 kind: symbol.kind.clone(),
16028 block_kind: markdown_ast_block_kind(&symbol.kind),
16029 node_kind: symbol.node_kind,
16030 start_byte: symbol.start_byte,
16031 end_byte: symbol.end_byte,
16032 body_start_byte,
16033 body_end_byte,
16034 }
16035 })
16036 .collect::<Vec<_>>();
16037 nodes.sort_by(|left, right| {
16038 left.start_byte
16039 .cmp(&right.start_byte)
16040 .then(left.end_byte.cmp(&right.end_byte))
16041 .then(left.kind.cmp(&right.kind))
16042 .then(left.name.cmp(&right.name))
16043 });
16044 Ok(nodes)
16045}
16046
16047pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16048 let source_hash = blake3::hash(source).to_hex().to_string();
16049 let cache_key = format!("{file}:{source_hash}");
16050 let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16051 if let Some(entry) = cache
16052 .lock()
16053 .expect("markdown ast cache poisoned")
16054 .get(&cache_key)
16055 {
16056 return Ok(MarkdownAstProjection {
16057 source_hash: entry.source_hash.clone(),
16058 nodes: entry.nodes.clone(),
16059 parse_duration_micros: entry.parse_duration_micros,
16060 cache_hit: true,
16061 });
16062 }
16063
16064 let started = Instant::now();
16065 let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16066 let parse_duration_micros = started.elapsed().as_micros();
16067 cache.lock().expect("markdown ast cache poisoned").insert(
16068 cache_key,
16069 MarkdownAstCacheEntry {
16070 source_hash: source_hash.clone(),
16071 nodes: nodes.clone(),
16072 parse_duration_micros,
16073 },
16074 );
16075 Ok(MarkdownAstProjection {
16076 source_hash,
16077 nodes,
16078 parse_duration_micros,
16079 cache_hit: false,
16080 })
16081}
16082
16083fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16084 MarkdownAstCacheReport {
16085 source_hash: projection.source_hash.clone(),
16086 cache_hit: projection.cache_hit,
16087 parse_duration_micros: projection.parse_duration_micros,
16088 node_count: projection.nodes.len(),
16089 section_count: projection
16090 .nodes
16091 .iter()
16092 .filter(|node| node.kind == "heading")
16093 .count(),
16094 list_item_count: projection
16095 .nodes
16096 .iter()
16097 .filter(|node| node.kind == "list_item")
16098 .count(),
16099 code_block_count: projection
16100 .nodes
16101 .iter()
16102 .filter(|node| node.kind == "code_block")
16103 .count(),
16104 }
16105}
16106
16107fn markdown_ast_node_direct_child_count(
16108 node: &MarkdownAstRawNode,
16109 nodes: &[MarkdownAstRawNode],
16110) -> usize {
16111 nodes
16112 .iter()
16113 .filter(|candidate| {
16114 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16115 })
16116 .count()
16117}
16118
16119fn markdown_ast_outline_entry(
16120 root: &Path,
16121 file: &str,
16122 source: &[u8],
16123 nodes: &[MarkdownAstRawNode],
16124 node: &MarkdownAstRawNode,
16125 max_bytes: usize,
16126) -> MarkdownAstOutlineEntry {
16127 let line = source_line_for_byte(source, node.start_byte);
16128 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16129 MarkdownAstOutlineEntry {
16130 handle: node.handle.clone(),
16131 span_handle: node.span_handle.clone(),
16132 name: truncate_for_budget(&node.name, max_bytes),
16133 kind: node.kind.clone(),
16134 block_kind: node.block_kind.clone(),
16135 line,
16136 end_line,
16137 section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16138 child_count: markdown_ast_node_direct_child_count(node, nodes),
16139 expand: markdown_ast_command(root, file, Some(&node.handle)),
16140 }
16141}
16142
16143fn markdown_ast_outline_entries(
16144 root: &Path,
16145 file: &str,
16146 source: &[u8],
16147 nodes: &[MarkdownAstRawNode],
16148 limit: usize,
16149 max_bytes: usize,
16150) -> Vec<MarkdownAstOutlineEntry> {
16151 let mut headings = nodes
16152 .iter()
16153 .filter(|node| node.kind == "heading")
16154 .collect::<Vec<_>>();
16155 let mut blocks = nodes
16156 .iter()
16157 .filter(|node| node.kind != "heading")
16158 .collect::<Vec<_>>();
16159 headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16160 blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16161 headings
16162 .into_iter()
16163 .chain(blocks)
16164 .take(limit)
16165 .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16166 .collect()
16167}
16168
16169fn markdown_ast_node_intersects_lines(
16170 source: &[u8],
16171 node: &MarkdownAstRawNode,
16172 start: usize,
16173 end: usize,
16174) -> bool {
16175 let line = source_line_for_byte(source, node.start_byte);
16176 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16177 line <= end && end_line >= start
16178}
16179
16180fn source_read_markdown_projection(
16181 root: &Path,
16182 file: &str,
16183 source: &[u8],
16184 start: usize,
16185 end: usize,
16186 budget: ResponseBudget,
16187) -> Result<SourceReadMarkdownProjection> {
16188 let projection = markdown_ast_projection(file, source)?;
16189 let visible_nodes = projection
16190 .nodes
16191 .iter()
16192 .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16193 .collect::<Vec<_>>();
16194 let mut outline_nodes = visible_nodes.clone();
16195 outline_nodes.sort_by_key(|node| {
16196 (
16197 node.kind != "heading",
16198 node.start_byte,
16199 node.end_byte,
16200 node.name.as_str(),
16201 )
16202 });
16203 let outline = outline_nodes
16204 .into_iter()
16205 .take(budget.preview_items())
16206 .map(|node| {
16207 markdown_ast_outline_entry(
16208 root,
16209 file,
16210 source,
16211 &projection.nodes,
16212 node,
16213 budget.preview_bytes(),
16214 )
16215 })
16216 .collect::<Vec<_>>();
16217 Ok(SourceReadMarkdownProjection {
16218 handle: stable_handle(
16219 "mdproj",
16220 &format!("{file}:{start}:{end}:{}", projection.source_hash),
16221 ),
16222 mode: "window_outline".to_string(),
16223 total_nodes: projection.nodes.len(),
16224 visible_nodes: visible_nodes.len(),
16225 outline,
16226 expand: markdown_ast_command(root, file, None),
16227 })
16228}
16229
16230fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16231 if parent.handle == child.handle {
16232 return false;
16233 }
16234 parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16235}
16236
16237fn markdown_ast_parent_handle(
16238 node: &MarkdownAstRawNode,
16239 nodes: &[MarkdownAstRawNode],
16240) -> Option<String> {
16241 nodes
16242 .iter()
16243 .filter(|candidate| markdown_ast_contains(candidate, node))
16244 .min_by_key(|candidate| {
16245 (
16246 candidate.end_byte.saturating_sub(candidate.start_byte),
16247 candidate.start_byte,
16248 )
16249 })
16250 .map(|candidate| candidate.handle.clone())
16251}
16252
16253fn markdown_ast_child_handles(
16254 node: &MarkdownAstRawNode,
16255 nodes: &[MarkdownAstRawNode],
16256 limit: usize,
16257) -> Vec<String> {
16258 nodes
16259 .iter()
16260 .filter(|candidate| {
16261 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16262 })
16263 .take(limit)
16264 .map(|candidate| candidate.handle.clone())
16265 .collect()
16266}
16267
16268fn markdown_ast_section_nodes<'a>(
16269 node: &MarkdownAstRawNode,
16270 nodes: &'a [MarkdownAstRawNode],
16271) -> Vec<&'a MarkdownAstRawNode> {
16272 let mut headings = nodes
16273 .iter()
16274 .filter(|candidate| candidate.kind == "heading")
16275 .filter(|candidate| {
16276 candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16277 })
16278 .collect::<Vec<_>>();
16279 headings.sort_by(|left, right| {
16280 left.start_byte
16281 .cmp(&right.start_byte)
16282 .then(left.end_byte.cmp(&right.end_byte))
16283 .then(left.name.cmp(&right.name))
16284 });
16285 headings
16286}
16287
16288fn markdown_ast_node_metadata(
16289 file: &str,
16290 node: &MarkdownAstRawNode,
16291 source: &[u8],
16292 nodes: &[MarkdownAstRawNode],
16293) -> MarkdownAstNodeMetadata {
16294 let section_nodes = markdown_ast_section_nodes(node, nodes);
16295 let section_path = section_nodes
16296 .iter()
16297 .map(|heading| heading.name.clone())
16298 .collect::<Vec<_>>();
16299 let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16300 let heading_level = (node.kind == "heading")
16301 .then(|| markdown_heading_level(source, node.start_byte))
16302 .flatten();
16303 let (list_marker, list_order) = if node.kind == "list_item" {
16304 markdown_list_attributes(source, node.start_byte)
16305 } else {
16306 (None, None)
16307 };
16308 let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16309 let embedded_symbols = if node.kind == "code_block" {
16310 markdown_embedded_symbols(
16311 file,
16312 source,
16313 node.body_start_byte,
16314 node.body_end_byte,
16315 fence_language.as_deref(),
16316 )
16317 } else {
16318 Vec::new()
16319 };
16320 MarkdownAstNodeMetadata {
16321 heading_level,
16322 section_path,
16323 section_handle,
16324 list_depth: (node.kind == "list_item")
16325 .then(|| markdown_list_depth(source, node.start_byte)),
16326 list_marker,
16327 list_order,
16328 fence_language,
16329 fence_marker: (node.kind == "code_block")
16330 .then(|| markdown_fence_marker(source, node.start_byte))
16331 .flatten(),
16332 embedded_symbols,
16333 }
16334}
16335
16336fn markdown_ast_node_expand(
16337 root: &Path,
16338 file: &str,
16339 node: &MarkdownAstRawNode,
16340 source: &[u8],
16341) -> MarkdownAstNodeExpand {
16342 let start_line = source_line_for_byte(source, node.start_byte);
16343 let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16344 let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16345 let body_start_line = node
16346 .body_start_byte
16347 .map(|byte| source_line_for_byte(source, byte))
16348 .unwrap_or(start_line);
16349 let body_end_line = node
16350 .body_end_byte
16351 .map(|byte| source_line_for_end_byte(source, byte))
16352 .unwrap_or(end_line)
16353 .max(body_start_line);
16354 let body_line_count = body_end_line
16355 .saturating_sub(body_start_line)
16356 .saturating_add(1)
16357 .max(1);
16358 MarkdownAstNodeExpand {
16359 source_window: source_read_command(root, file, start_line, line_count),
16360 source_body: source_read_command(root, file, body_start_line, body_line_count),
16361 symbol_read: source_symbol_read_command(root, &node.name, file),
16362 edit_intents: markdown_edit_intents_command(root),
16363 }
16364}
16365
16366fn markdown_ast_node(
16367 root: &Path,
16368 file: &str,
16369 node: &MarkdownAstRawNode,
16370 source: &[u8],
16371 nodes: &[MarkdownAstRawNode],
16372 child_limit: usize,
16373) -> MarkdownAstNode {
16374 let line = source_line_for_byte(source, node.start_byte);
16375 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16376 let body_byte_span = node
16377 .body_start_byte
16378 .zip(node.body_end_byte)
16379 .map(|(start, end)| SourceByteRangePreview { start, end });
16380 MarkdownAstNode {
16381 handle: node.handle.clone(),
16382 span_handle: node.span_handle.clone(),
16383 name: node.name.clone(),
16384 kind: node.kind.clone(),
16385 block_kind: node.block_kind.clone(),
16386 node_kind: node.node_kind.clone(),
16387 line,
16388 end_line,
16389 byte_span: SourceByteRangePreview {
16390 start: node.start_byte,
16391 end: node.end_byte,
16392 },
16393 body_byte_span,
16394 parent_handle: markdown_ast_parent_handle(node, nodes),
16395 child_handles: markdown_ast_child_handles(node, nodes, child_limit),
16396 metadata: markdown_ast_node_metadata(file, node, source, nodes),
16397 expand: markdown_ast_node_expand(root, file, node, source),
16398 }
16399}
16400
16401pub(crate) fn stored_symbol_ast_span(
16402 symbol: &index::StoredSymbol,
16403 source: &[u8],
16404 symbols: &[index::StoredSymbol],
16405 child_limit: usize,
16406) -> Option<AstSpanPreview> {
16407 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16408 let node_kind = symbol.node_kind.clone()?;
16409 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16410 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16411 Some(AstSpanPreview {
16412 handle: ast_span_handle(
16413 &symbol.file,
16414 &symbol.name,
16415 &symbol.kind,
16416 start_byte,
16417 end_byte,
16418 ),
16419 node_kind,
16420 start_byte,
16421 end_byte,
16422 start_line: source_line_for_byte(source, start_byte),
16423 end_line: source_line_for_end_byte(source, end_byte),
16424 body_start_byte,
16425 body_end_byte,
16426 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16427 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16428 parent_handle: stored_symbol_parent_span_handle(symbol, symbols),
16429 child_handles: stored_symbol_child_span_handles(symbol, symbols, child_limit),
16430 markdown: markdown_stored_symbol_metadata(symbol, source, symbols),
16431 })
16432}
16433
16434pub(crate) fn symbol_hit_ast_span(symbol: &index::SymbolHit, source: &[u8]) -> Option<AstSpanPreview> {
16435 let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
16436 let node_kind = symbol.node_kind.clone()?;
16437 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16438 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16439 Some(AstSpanPreview {
16440 handle: ast_span_handle(
16441 &symbol.file,
16442 &symbol.name,
16443 &symbol.kind,
16444 start_byte,
16445 end_byte,
16446 ),
16447 node_kind,
16448 start_byte,
16449 end_byte,
16450 start_line: source_line_for_byte(source, start_byte),
16451 end_line: source_line_for_end_byte(source, end_byte),
16452 body_start_byte,
16453 body_end_byte,
16454 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16455 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16456 parent_handle: None,
16457 child_handles: Vec::new(),
16458 markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
16459 })
16460}
16461
16462pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
16463 usize::try_from(symbol.line)
16464 .ok()
16465 .and_then(|line| line.checked_add(1))
16466 .unwrap_or(1)
16467}
16468
16469pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
16470 symbol
16471 .end_line
16472 .and_then(|line| usize::try_from(line).ok())
16473 .and_then(|line| line.checked_add(1))
16474}
16475
16476fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
16477 if end == 0 {
16478 return false;
16479 }
16480 let symbol_start = source_symbol_line(symbol);
16481 let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
16482 symbol_start <= end && symbol_end >= start
16483}
16484
16485#[allow(clippy::too_many_arguments)]
16486fn load_source_symbols(
16487 root: &Path,
16488 file_abs: &Path,
16489 file_display: &str,
16490 source: &[u8],
16491 scope: Option<&str>,
16492 start: usize,
16493 end: usize,
16494 limit: usize,
16495 max_bytes: usize,
16496 warnings: &mut Vec<String>,
16497) -> Vec<SourceSymbolRef> {
16498 let db_path = match resolve_query_db_path(root, file_abs, scope) {
16499 Ok(path) => path,
16500 Err(err) => {
16501 warnings.push(format!("index refs unavailable: {err:#}"));
16502 return Vec::new();
16503 }
16504 };
16505 if !db_path.exists() {
16506 warnings.push(format!(
16507 "index refs unavailable: no index found at {}",
16508 db_path.display()
16509 ));
16510 return Vec::new();
16511 }
16512
16513 let db = match index::IndexDb::open_read_only_resilient(&db_path) {
16514 Ok(db) => db,
16515 Err(err) => {
16516 warnings.push(format!("index refs unavailable: {err:#}"));
16517 return Vec::new();
16518 }
16519 };
16520
16521 let file_key = file_abs.to_string_lossy().to_string();
16522 let symbols = match db.symbols_for_file(&file_key) {
16523 Ok(symbols) => symbols,
16524 Err(err) => {
16525 warnings.push(format!("symbol refs unavailable: {err:#}"));
16526 return Vec::new();
16527 }
16528 };
16529
16530 symbols
16531 .iter()
16532 .filter(|symbol| source_symbol_intersects(symbol, start, end))
16533 .take(limit)
16534 .map(|symbol| {
16535 let line = source_symbol_line(symbol);
16536 let end_line = source_symbol_end_line(symbol);
16537 let handle = stable_handle(
16538 "ssym",
16539 &format!("{}:{}:{}", file_display, symbol.name, line),
16540 );
16541 SourceSymbolRef {
16542 handle,
16543 name: truncate_for_budget(&symbol.name, max_bytes),
16544 kind: symbol.kind.clone(),
16545 language: symbol.language.clone(),
16546 file: file_display.to_string(),
16547 line,
16548 end_line,
16549 signature: symbol
16550 .signature
16551 .clone()
16552 .map(|signature| truncate_for_budget(&signature, max_bytes)),
16553 span: stored_symbol_ast_span(symbol, source, &symbols, limit),
16554 expand: source_symbol_read_command(root, &symbol.name, file_display),
16555 }
16556 })
16557 .collect()
16558}
16559
16560fn load_source_summaries(
16561 root: &Path,
16562 file_display: &str,
16563 limit: usize,
16564 max_bytes: usize,
16565 warnings: &mut Vec<String>,
16566) -> Vec<SourceSummaryRef> {
16567 let db_path = root.join(".tsift/summaries.db");
16568 if !db_path.exists() {
16569 return Vec::new();
16570 }
16571 let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
16572 Ok(db) => db,
16573 Err(err) => {
16574 warnings.push(format!("summary refs unavailable: {err:#}"));
16575 return Vec::new();
16576 }
16577 };
16578 let summaries = match db.get_by_file(file_display) {
16579 Ok(summaries) => summaries,
16580 Err(err) => {
16581 warnings.push(format!("summary refs unavailable: {err:#}"));
16582 return Vec::new();
16583 }
16584 };
16585
16586 summaries
16587 .into_iter()
16588 .take(limit)
16589 .map(|summary| SourceSummaryRef {
16590 handle: stable_handle(
16591 "sum",
16592 &format!(
16593 "{}:{}:{}",
16594 summary.file_path, summary.symbol_name, summary.id
16595 ),
16596 ),
16597 symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
16598 file_path: summary.file_path,
16599 summary: truncate_for_budget(&summary.summary, max_bytes),
16600 expand: source_summary_expand_command(root, &summary.symbol_name),
16601 })
16602 .collect()
16603}
16604
16605fn cmd_markdown_ast(
16606 file: &Path,
16607 path: &Path,
16608 node: Option<&str>,
16609 format: OutputFormat,
16610 absolute: bool,
16611 budget: ResponseBudget,
16612) -> Result<()> {
16613 let root = lint::resolve_project_root_or_canonical_path(path)?;
16614 let file_abs = resolve_source_file(&root, file)?;
16615 if !is_markdown_path(&file_abs) {
16616 bail!(
16617 "markdown-ast only supports Markdown files (.md/.mdx): {}",
16618 file_abs.display()
16619 );
16620 }
16621 let file_display = if absolute {
16622 file_abs.to_string_lossy().to_string()
16623 } else {
16624 relativize_pathbuf(&file_abs, &root)
16625 .to_string_lossy()
16626 .to_string()
16627 };
16628 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16629 let text = String::from_utf8_lossy(&source);
16630 let total_lines = text.lines().count();
16631 let projection = markdown_ast_projection(&file_display, &source)?;
16632 let raw_nodes = &projection.nodes;
16633 let max_items = budget.preview_items();
16634 let max_bytes = budget.preview_bytes();
16635
16636 let selected_nodes = if let Some(handle) = node {
16637 let matches = raw_nodes
16638 .iter()
16639 .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
16640 .collect::<Vec<_>>();
16641 if matches.is_empty() {
16642 bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
16643 }
16644 matches
16645 } else {
16646 raw_nodes.iter().take(max_items).collect::<Vec<_>>()
16647 };
16648 let nodes = selected_nodes
16649 .into_iter()
16650 .map(|raw| {
16651 let mut node =
16652 markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
16653 node.name = truncate_for_budget(&node.name, max_bytes);
16654 node
16655 })
16656 .collect::<Vec<_>>();
16657 let outline_started = Instant::now();
16658 let outline = markdown_ast_outline_entries(
16659 &root,
16660 &file_display,
16661 &source,
16662 raw_nodes,
16663 max_items,
16664 max_bytes,
16665 );
16666 let outline_duration_micros = outline_started.elapsed().as_micros();
16667 let projection_preview = MarkdownAstProjectionPreview {
16668 mode: if node.is_some() {
16669 "selected_node".to_string()
16670 } else {
16671 "outline_first".to_string()
16672 },
16673 total_nodes: raw_nodes.len(),
16674 returned_nodes: nodes.len(),
16675 omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
16676 selected_node: node.map(str::to_string),
16677 cache: markdown_ast_cache_report(&projection),
16678 outline,
16679 phase_timings: vec![
16680 MarkdownAstPhaseTiming {
16681 name: "parse_extract".to_string(),
16682 duration_micros: projection.parse_duration_micros,
16683 detail: if projection.cache_hit {
16684 "reused cached tree-sitter Markdown symbol extraction".to_string()
16685 } else {
16686 "tree-sitter Markdown symbol extraction".to_string()
16687 },
16688 },
16689 MarkdownAstPhaseTiming {
16690 name: "outline_projection".to_string(),
16691 duration_micros: outline_duration_micros,
16692 detail: "outline-first section/block preview construction".to_string(),
16693 },
16694 ],
16695 };
16696 let report = MarkdownAstReport {
16697 handle: stable_handle("mdastrep", &file_display),
16698 root: root.to_string_lossy().to_string(),
16699 file: file_display.clone(),
16700 range: SourceRangePreview {
16701 start: 1,
16702 end: total_lines,
16703 total_lines,
16704 truncated_before: false,
16705 truncated_after: false,
16706 },
16707 projection: projection_preview,
16708 nodes,
16709 expand: MarkdownAstExpandCommands {
16710 file: markdown_ast_command(&root, &file_display, None),
16711 source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
16712 edit_intents: markdown_edit_intents_command(&root),
16713 },
16714 warnings: Vec::new(),
16715 };
16716
16717 if format.json_output {
16718 let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
16719 let mut follow_up = vec![
16720 report.expand.file.clone(),
16721 report.expand.source_read.clone(),
16722 report.expand.edit_intents.clone(),
16723 ];
16724 follow_up.extend(
16725 report
16726 .nodes
16727 .iter()
16728 .map(|node| node.expand.source_window.clone()),
16729 );
16730 print_json_or_envelope(
16731 &report,
16732 &format,
16733 "markdown-ast",
16734 "ast",
16735 ToolEnvelopeSummary {
16736 text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
16737 metrics: vec![
16738 envelope_metric("nodes", report.nodes.len()),
16739 envelope_metric("total_nodes", report.projection.total_nodes),
16740 envelope_metric(
16741 "parse_duration_micros",
16742 report.projection.cache.parse_duration_micros,
16743 ),
16744 envelope_metric("total_lines", report.range.total_lines),
16745 ],
16746 },
16747 truncated,
16748 follow_up,
16749 )?;
16750 } else if format.compact {
16751 println!(
16752 "markdown-ast {} nodes:{} handle:{}",
16753 report.file,
16754 report.nodes.len(),
16755 report.handle
16756 );
16757 for node in &report.nodes {
16758 println!(
16759 " {} {} {}:{}-{}",
16760 node.handle, node.kind, node.name, node.line, node.end_line
16761 );
16762 }
16763 if node.is_none() && raw_nodes.len() > report.nodes.len() {
16764 println!("expand: {}", report.expand.file);
16765 }
16766 } else {
16767 println!(
16768 "Markdown AST `{}` nodes {} of {} ({})",
16769 report.file,
16770 report.nodes.len(),
16771 raw_nodes.len(),
16772 report.handle
16773 );
16774 for node in &report.nodes {
16775 println!(
16776 " {} `{}` {}:{}-{} — {}",
16777 node.handle,
16778 node.name,
16779 node.kind,
16780 node.line,
16781 node.end_line,
16782 node.expand.source_window
16783 );
16784 }
16785 if node.is_none() && raw_nodes.len() > report.nodes.len() {
16786 println!();
16787 println!("Expand:");
16788 println!(" file: {}", report.expand.file);
16789 }
16790 }
16791
16792 Ok(())
16793}
16794
16795#[allow(clippy::too_many_arguments)]
16796fn cmd_source_read(
16797 file: &Path,
16798 path: &Path,
16799 style: SourceReadStyle,
16800 start: usize,
16801 lines: usize,
16802 end: Option<usize>,
16803 scope: Option<&str>,
16804 format: OutputFormat,
16805 absolute: bool,
16806 budget: ResponseBudget,
16807) -> Result<()> {
16808 if start == 0 {
16809 bail!("--start is 1-based and must be greater than zero");
16810 }
16811 if lines == 0 {
16812 bail!("--lines must be greater than zero");
16813 }
16814 if let Some(end) = end
16815 && end < start
16816 {
16817 bail!("--end must be greater than or equal to --start");
16818 }
16819
16820 let root = lint::resolve_project_root_or_canonical_path(path)?;
16821 let file_abs = resolve_source_file(&root, file)?;
16822 let file_display = if absolute {
16823 file_abs.to_string_lossy().to_string()
16824 } else {
16825 relativize_pathbuf(&file_abs, &root)
16826 .to_string_lossy()
16827 .to_string()
16828 };
16829
16830 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16831 let text = String::from_utf8_lossy(&source);
16832 let all_lines: Vec<&str> = text.lines().collect();
16833 let total_lines = all_lines.len();
16834 if total_lines > 0 && start > total_lines {
16835 bail!(
16836 "--start {} is beyond end of {} ({} lines)",
16837 start,
16838 file_display,
16839 total_lines
16840 );
16841 }
16842 let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
16843 let end_line = requested_end.min(total_lines);
16844 let mut warnings = Vec::new();
16845 let max_items = budget.preview_items();
16846 let max_bytes = budget.preview_bytes();
16847 if style == SourceReadStyle::Ast {
16848 let symbols = load_source_symbols(
16849 &root,
16850 &file_abs,
16851 &file_display,
16852 &source,
16853 scope,
16854 start,
16855 end_line,
16856 max_items,
16857 max_bytes,
16858 &mut warnings,
16859 );
16860 let summaries =
16861 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
16862 let markdown = if is_markdown_path(&file_abs) {
16863 match source_read_markdown_projection(
16864 &root,
16865 &file_display,
16866 &source,
16867 start,
16868 end_line,
16869 budget,
16870 ) {
16871 Ok(markdown) => Some(markdown),
16872 Err(err) => {
16873 warnings.push(format!("markdown projection unavailable: {err:#}"));
16874 None
16875 }
16876 }
16877 } else {
16878 None
16879 };
16880 let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
16881 let report = SourceReadAstReport {
16882 handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
16883 root: root.to_string_lossy().to_string(),
16884 file: file_display.clone(),
16885 range: SourceRangePreview {
16886 start,
16887 end: end_line,
16888 total_lines,
16889 truncated_before: start > 1,
16890 truncated_after: end_line < total_lines,
16891 },
16892 symbols,
16893 summaries,
16894 markdown,
16895 expand: SourceReadAstExpandCommands {
16896 window: source_read_window_command(&root, &file_display, start, window_lines),
16897 file_window: source_read_window_command(
16898 &root,
16899 &file_display,
16900 1,
16901 total_lines.max(window_lines),
16902 ),
16903 markdown_ast: is_markdown_path(&file_abs)
16904 .then(|| markdown_ast_command(&root, &file_display, None)),
16905 },
16906 warnings,
16907 };
16908
16909 if format.json_output {
16910 let truncated = report.range.truncated_before
16911 || report.range.truncated_after
16912 || report.symbols.len() >= max_items
16913 || report.summaries.len() >= max_items;
16914 let follow_up = [
16915 Some(report.expand.window.clone()),
16916 Some(report.expand.file_window.clone()),
16917 report.expand.markdown_ast.clone(),
16918 ]
16919 .into_iter()
16920 .flatten()
16921 .collect::<Vec<_>>();
16922 print_json_or_envelope(
16923 &report,
16924 &format,
16925 "source-read",
16926 "ast",
16927 ToolEnvelopeSummary {
16928 text: format!(
16929 "source ast {}:{}-{}",
16930 report.file, report.range.start, report.range.end
16931 ),
16932 metrics: vec![
16933 envelope_metric("symbols", report.symbols.len()),
16934 envelope_metric("summaries", report.summaries.len()),
16935 envelope_metric(
16936 "markdown_nodes",
16937 report
16938 .markdown
16939 .as_ref()
16940 .map_or(0, |markdown| markdown.visible_nodes),
16941 ),
16942 ],
16943 },
16944 truncated,
16945 follow_up,
16946 )?;
16947 } else if format.compact {
16948 println!(
16949 "source-ast {}:{}-{} / {} handle:{}",
16950 report.file,
16951 report.range.start,
16952 report.range.end,
16953 report.range.total_lines,
16954 report.handle
16955 );
16956 for symbol in &report.symbols {
16957 println!(
16958 " {} {}:{} {}",
16959 symbol.name, symbol.file, symbol.line, symbol.expand
16960 );
16961 }
16962 if !report.summaries.is_empty() {
16963 println!("summaries[{}]", report.summaries.len());
16964 }
16965 for warning in &report.warnings {
16966 eprintln!("warning: {warning}");
16967 }
16968 } else {
16969 println!(
16970 "Source AST `{}` lines {}-{} of {} ({})",
16971 report.file,
16972 report.range.start,
16973 report.range.end,
16974 report.range.total_lines,
16975 report.handle
16976 );
16977 if !report.symbols.is_empty() {
16978 println!();
16979 println!("Symbol refs:");
16980 for symbol in &report.symbols {
16981 println!(
16982 " {} `{}` {}:{} — {}",
16983 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
16984 );
16985 }
16986 }
16987 if !report.summaries.is_empty() {
16988 println!();
16989 println!("Summary refs:");
16990 for summary in &report.summaries {
16991 println!(
16992 " {} `{}` — {}",
16993 summary.handle, summary.symbol_name, summary.expand
16994 );
16995 }
16996 }
16997 println!();
16998 println!("Expand:");
16999 println!(" window: {}", report.expand.window);
17000 println!(" file window: {}", report.expand.file_window);
17001 if let Some(markdown_ast) = &report.expand.markdown_ast {
17002 println!(" markdown: {}", markdown_ast);
17003 }
17004 for warning in &report.warnings {
17005 eprintln!("warning: {warning}");
17006 }
17007 }
17008
17009 return Ok(());
17010 }
17011 let max_bytes = budget.preview_bytes();
17012 let token_cap = budget.body_token_cap();
17013 let (preview, preview_end, body_truncated) = if total_lines == 0 {
17014 (Vec::new(), end_line, false)
17015 } else {
17016 let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17017 (capped.preview, capped.capped_end, capped.was_capped)
17018 };
17019 let effective_end = if body_truncated { preview_end } else { end_line };
17020
17021 if body_truncated {
17022 warnings.push(format!(
17023 "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17024 ));
17025 }
17026 let symbols = load_source_symbols(
17027 &root,
17028 &file_abs,
17029 &file_display,
17030 &source,
17031 scope,
17032 start,
17033 effective_end,
17034 max_items,
17035 max_bytes,
17036 &mut warnings,
17037 );
17038 let summaries =
17039 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17040 let markdown = if is_markdown_path(&file_abs) {
17041 match source_read_markdown_projection(
17042 &root,
17043 &file_display,
17044 &source,
17045 start,
17046 effective_end,
17047 budget,
17048 ) {
17049 Ok(markdown) => Some(markdown),
17050 Err(err) => {
17051 warnings.push(format!("markdown projection unavailable: {err:#}"));
17052 None
17053 }
17054 }
17055 } else {
17056 None
17057 };
17058
17059 let expand = SourceExpandCommands {
17060 before: (start > 1).then(|| {
17061 let before_start = start.saturating_sub(lines).max(1);
17062 source_read_window_command(&root, &file_display, before_start, start - before_start)
17063 }),
17064 after: (effective_end < total_lines)
17065 .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17066 body: body_truncated.then(|| {
17067 let remaining = end_line.saturating_sub(effective_end);
17068 source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17069 }),
17070 file: source_read_ast_command(&root, &file_display),
17071 markdown_ast: is_markdown_path(&file_abs)
17072 .then(|| markdown_ast_command(&root, &file_display, None)),
17073 };
17074
17075 let report = SourceReadReport {
17076 handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17077 root: root.to_string_lossy().to_string(),
17078 file: file_display,
17079 range: SourceRangePreview {
17080 start,
17081 end: effective_end,
17082 total_lines,
17083 truncated_before: start > 1,
17084 truncated_after: effective_end < total_lines,
17085 },
17086 preview,
17087 symbols,
17088 summaries,
17089 markdown,
17090 expand,
17091 warnings,
17092 };
17093
17094 if format.json_output {
17095 let truncated = report.range.truncated_before || report.range.truncated_after;
17096 let follow_up = [
17097 report.expand.before.clone(),
17098 report.expand.after.clone(),
17099 report.expand.body.clone(),
17100 Some(report.expand.file.clone()),
17101 report.expand.markdown_ast.clone(),
17102 ]
17103 .into_iter()
17104 .flatten()
17105 .collect::<Vec<_>>();
17106 print_json_or_envelope(
17107 &report,
17108 &format,
17109 "source-read",
17110 "window",
17111 ToolEnvelopeSummary {
17112 text: format!(
17113 "source window {}:{}-{}",
17114 report.file, report.range.start, report.range.end
17115 ),
17116 metrics: vec![
17117 envelope_metric("lines", report.preview.len()),
17118 envelope_metric("symbols", report.symbols.len()),
17119 envelope_metric("summaries", report.summaries.len()),
17120 envelope_metric(
17121 "markdown_nodes",
17122 report
17123 .markdown
17124 .as_ref()
17125 .map_or(0, |markdown| markdown.visible_nodes),
17126 ),
17127 ],
17128 },
17129 truncated,
17130 follow_up,
17131 )?;
17132 } else if format.compact {
17133 println!(
17134 "source {}:{}-{} / {} handle:{}",
17135 report.file,
17136 report.range.start,
17137 report.range.end,
17138 report.range.total_lines,
17139 report.handle
17140 );
17141 for line in &report.preview {
17142 println!("{:>5} {}", line.line, line.text);
17143 }
17144 if !report.symbols.is_empty() {
17145 println!("syms[{}]:", report.symbols.len());
17146 for symbol in &report.symbols {
17147 println!(" {} {}:{}", symbol.name, symbol.file, symbol.line);
17148 }
17149 }
17150 if report.range.truncated_before || report.range.truncated_after {
17151 println!("expand: {}", report.expand.file);
17152 }
17153 } else {
17154 println!(
17155 "Source window `{}` lines {}-{} of {} ({})",
17156 report.file,
17157 report.range.start,
17158 report.range.end,
17159 report.range.total_lines,
17160 report.handle
17161 );
17162 for line in &report.preview {
17163 println!("{:>5} | {}", line.line, line.text);
17164 }
17165 if !report.symbols.is_empty() {
17166 println!();
17167 println!("Symbol refs:");
17168 for symbol in &report.symbols {
17169 println!(
17170 " {} `{}` {}:{} — {}",
17171 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17172 );
17173 }
17174 }
17175 if !report.summaries.is_empty() {
17176 println!();
17177 println!("Summary refs:");
17178 for summary in &report.summaries {
17179 println!(
17180 " {} `{}` — {}",
17181 summary.handle, summary.symbol_name, summary.expand
17182 );
17183 }
17184 }
17185 if report.range.truncated_before || report.range.truncated_after {
17186 println!();
17187 println!("Expand:");
17188 if let Some(before) = &report.expand.before {
17189 println!(" before: {}", before);
17190 }
17191 if let Some(after) = &report.expand.after {
17192 println!(" after: {}", after);
17193 }
17194 println!(" file: {}", report.expand.file);
17195 }
17196 for warning in &report.warnings {
17197 eprintln!("warning: {warning}");
17198 }
17199 }
17200
17201 Ok(())
17202}
17203
17204#[allow(clippy::too_many_arguments)]
17205fn cmd_symbol_read(
17206 symbol: &str,
17207 file_hint: Option<&Path>,
17208 path: &Path,
17209 scope: Option<&str>,
17210 format: OutputFormat,
17211 absolute: bool,
17212 budget: ResponseBudget,
17213) -> Result<()> {
17214 let root = lint::resolve_project_root_or_canonical_path(path)?;
17215 let hinted_file_abs = file_hint
17216 .map(|file| resolve_source_file(&root, file))
17217 .transpose()?;
17218 let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17219 let db_path = resolve_query_db_path(&root, path_hint, scope)?;
17220 if !db_path.exists() {
17221 bail!(
17222 "index refs unavailable: no index found at {}",
17223 db_path.display()
17224 );
17225 }
17226 let db = index::IndexDb::open_read_only_resilient(&db_path)
17227 .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17228 let search_limit = budget.follow_up_items().max(10);
17229 let hits = db
17230 .symbol_search(symbol, search_limit)
17231 .with_context(|| format!("searching symbols for {symbol:?}"))?;
17232 let selected = hits
17233 .into_iter()
17234 .find(|hit| {
17235 let Some(hinted_file_abs) = &hinted_file_abs else {
17236 return true;
17237 };
17238 resolve_source_file(&root, Path::new(&hit.file))
17239 .map(|hit_file| hit_file == *hinted_file_abs)
17240 .unwrap_or(false)
17241 })
17242 .with_context(|| {
17243 let hint = file_hint
17244 .map(|file| format!(" in {}", file.display()))
17245 .unwrap_or_default();
17246 format!("no indexed symbol matched {symbol:?}{hint}")
17247 })?;
17248
17249 let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17250 let file_display = if absolute {
17251 file_abs.to_string_lossy().to_string()
17252 } else {
17253 relativize_pathbuf(&file_abs, &root)
17254 .to_string_lossy()
17255 .to_string()
17256 };
17257 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17258 let content_hash = blake3::hash(&source).to_hex().to_string();
17259 let text = String::from_utf8_lossy(&source);
17260 let all_lines: Vec<&str> = text.lines().collect();
17261 let total_lines = all_lines.len();
17262 let file_symbols = db
17263 .symbols_for_file(&file_abs.to_string_lossy())
17264 .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17265 let max_items = budget.preview_items();
17266 let max_bytes = budget.preview_bytes();
17267 let selected_start = symbol_hit_line(&selected);
17268 let selected_end = symbol_hit_end_line(&selected)
17269 .unwrap_or(selected_start)
17270 .max(selected_start);
17271 let stored_target = file_symbols.iter().find(|candidate| {
17272 candidate.name == selected.name
17273 && candidate.kind == selected.kind
17274 && source_symbol_line(candidate) == selected_start
17275 });
17276 let target_span = stored_target
17277 .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17278 .or_else(|| symbol_hit_ast_span(&selected, &source));
17279 let target_start = target_span
17280 .as_ref()
17281 .map(|span| span.start_line)
17282 .unwrap_or(selected_start);
17283 let target_end = target_span
17284 .as_ref()
17285 .map(|span| span.end_line)
17286 .or_else(|| stored_target.and_then(source_symbol_end_line))
17287 .unwrap_or(selected_end)
17288 .max(target_start);
17289 let target_bounds = stored_target
17290 .and_then(stored_symbol_span_bounds)
17291 .or_else(|| symbol_hit_span_bounds(&selected));
17292 let target_end = stored_target
17293 .and_then(source_symbol_end_line)
17294 .unwrap_or(target_end)
17295 .max(target_start);
17296 let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17297 let line_capped_end = target_start
17298 .saturating_add(body_line_budget)
17299 .saturating_sub(1)
17300 .min(target_end)
17301 .min(total_lines.max(target_start));
17302 let token_cap = budget.body_token_cap();
17303 let (body, effective_preview_end, body_truncated) = if total_lines == 0 || target_start > total_lines {
17304 (Vec::new(), line_capped_end, false)
17305 } else {
17306 let capped = build_token_capped_preview(&all_lines, target_start, line_capped_end, max_bytes, token_cap);
17307 (capped.preview, capped.capped_end, capped.was_capped)
17308 };
17309 let preview_end = if body_truncated { effective_preview_end } else { line_capped_end };
17310 let child_symbols = file_symbols
17311 .iter()
17312 .filter(|candidate| {
17313 if let Some((target_start_byte, target_end_byte)) = target_bounds {
17314 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17315 else {
17316 return false;
17317 };
17318 return candidate_start >= target_start_byte
17319 && candidate_end <= target_end_byte
17320 && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17321 }
17322 let line = source_symbol_line(candidate);
17323 line > target_start && line <= target_end
17324 })
17325 .take(max_items)
17326 .map(|symbol| {
17327 let line = source_symbol_line(symbol);
17328 let end_line = source_symbol_end_line(symbol);
17329 SourceSymbolRef {
17330 handle: stable_handle(
17331 "ssym",
17332 &format!("{}:{}:{}", file_display, symbol.name, line),
17333 ),
17334 name: truncate_for_budget(&symbol.name, max_bytes),
17335 kind: symbol.kind.clone(),
17336 language: symbol.language.clone(),
17337 file: file_display.clone(),
17338 line,
17339 end_line,
17340 signature: symbol
17341 .signature
17342 .clone()
17343 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17344 span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
17345 expand: source_symbol_read_command(&root, &symbol.name, &file_display),
17346 }
17347 })
17348 .collect::<Vec<_>>();
17349 let mut warnings = Vec::new();
17350 if body_truncated {
17351 warnings.push(format!(
17352 "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
17353 ));
17354 }
17355 let summaries =
17356 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17357 let symbol_handle = stable_handle(
17358 "sread",
17359 &format!("{}:{}:{}", file_display, selected.name, target_start),
17360 );
17361 let source_lines = preview_end
17362 .saturating_sub(target_start)
17363 .saturating_add(1)
17364 .max(1);
17365 let expand = SymbolReadExpandCommands {
17366 source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
17367 body: body_truncated.then(|| {
17368 let remaining = target_end.saturating_sub(preview_end);
17369 source_read_window_command(&root, &file_display, preview_end + 1, remaining)
17370 }),
17371 file: source_read_ast_command(&root, &file_display),
17372 explain: source_symbol_expand_command(&root, &selected.name),
17373 callers: source_symbol_graph_command(&root, &selected.name, "callers"),
17374 callees: source_symbol_graph_command(&root, &selected.name, "callees"),
17375 markdown_ast: (selected.language == "markdown").then(|| {
17376 markdown_ast_command(
17377 &root,
17378 &file_display,
17379 target_span.as_ref().map(|span| span.handle.as_str()),
17380 )
17381 }),
17382 };
17383 let report = SymbolReadReport {
17384 handle: symbol_handle.clone(),
17385 root: root.to_string_lossy().to_string(),
17386 query: symbol.to_string(),
17387 symbol: SymbolReadTarget {
17388 handle: symbol_handle,
17389 name: selected.name.clone(),
17390 kind: selected.kind.clone(),
17391 language: selected.language.clone(),
17392 file: file_display.clone(),
17393 line: target_start,
17394 end_line: Some(target_end),
17395 signature: stored_target
17396 .and_then(|stored| stored.signature.clone())
17397 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17398 parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
17399 visibility: stored_target.and_then(|stored| stored.visibility.clone()),
17400 span: target_span,
17401 },
17402 range: SourceRangePreview {
17403 start: target_start,
17404 end: preview_end,
17405 total_lines,
17406 truncated_before: false,
17407 truncated_after: preview_end < target_end,
17408 },
17409 body,
17410 child_symbols,
17411 summaries,
17412 expand,
17413 warnings,
17414 };
17415
17416 if format.json_output {
17417 let truncated = report.range.truncated_after
17418 || report.body.iter().any(|line| line.text.len() >= max_bytes)
17419 || report.child_symbols.len() >= max_items;
17420 let follow_up = [
17421 Some(report.expand.source_window.clone()),
17422 report.expand.body.clone(),
17423 Some(report.expand.file.clone()),
17424 Some(report.expand.explain.clone()),
17425 Some(report.expand.callers.clone()),
17426 Some(report.expand.callees.clone()),
17427 ]
17428 .into_iter()
17429 .flatten()
17430 .chain(report.expand.markdown_ast.clone())
17431 .collect::<Vec<_>>();
17432 print_json_or_envelope(
17433 &report,
17434 &format,
17435 "symbol-read",
17436 "symbol",
17437 ToolEnvelopeSummary {
17438 text: format!(
17439 "symbol {} {}:{}-{}",
17440 report.symbol.name, report.symbol.file, report.range.start, report.range.end
17441 ),
17442 metrics: vec![
17443 envelope_metric("body_lines", report.body.len()),
17444 envelope_metric("child_symbols", report.child_symbols.len()),
17445 envelope_metric("summaries", report.summaries.len()),
17446 ],
17447 },
17448 truncated,
17449 follow_up,
17450 )?;
17451 } else if format.compact {
17452 println!(
17453 "symbol {} {}:{}-{} handle:{} hash:{}",
17454 report.symbol.name,
17455 report.symbol.file,
17456 report.range.start,
17457 report.range.end,
17458 report.handle,
17459 content_hash
17460 );
17461 for line in &report.body {
17462 println!("{:>5} {}", line.line, line.text);
17463 }
17464 if !report.child_symbols.is_empty() {
17465 println!("children[{}]:", report.child_symbols.len());
17466 for child in &report.child_symbols {
17467 println!(" {} {}:{}", child.name, child.file, child.line);
17468 }
17469 }
17470 } else {
17471 println!(
17472 "Symbol `{}` in `{}` lines {}-{} ({})",
17473 report.symbol.name,
17474 report.symbol.file,
17475 report.range.start,
17476 report.range.end,
17477 report.handle
17478 );
17479 for line in &report.body {
17480 println!("{:>5} | {}", line.line, line.text);
17481 }
17482 if !report.child_symbols.is_empty() {
17483 println!();
17484 println!("Child symbols:");
17485 for child in &report.child_symbols {
17486 println!(
17487 " {} `{}` {}:{} — {}",
17488 child.handle, child.name, child.file, child.line, child.expand
17489 );
17490 }
17491 }
17492 println!();
17493 println!("Expand:");
17494 println!(" source: {}", report.expand.source_window);
17495 println!(" file: {}", report.expand.file);
17496 println!(" explain: {}", report.expand.explain);
17497 println!(" callers: {}", report.expand.callers);
17498 println!(" callees: {}", report.expand.callees);
17499 for warning in &report.warnings {
17500 eprintln!("warning: {warning}");
17501 }
17502 }
17503
17504 Ok(())
17505}
17506
17507#[allow(clippy::too_many_arguments)]
17508#[derive(Serialize)]
17509struct ExplainBudgetDefinitionPreview {
17510 handle: String,
17511 #[serde(skip_serializing_if = "Option::is_none")]
17512 tag_alias: Option<String>,
17513 kind: String,
17514 name: String,
17515 file: String,
17516 line: i64,
17517 expand: String,
17518}
17519
17520#[derive(Serialize)]
17521struct ExplainBudgetEdgePreview {
17522 handle: String,
17523 #[serde(skip_serializing_if = "Option::is_none")]
17524 tag_alias: Option<String>,
17525 name: String,
17526 file: String,
17527 line: i64,
17528 expand: String,
17529}
17530
17531#[derive(Serialize)]
17532struct ExplainBudgetCommunityPreview {
17533 size: usize,
17534 members: Vec<String>,
17535}
17536
17537#[derive(Serialize)]
17538struct ExplainBudgetReport {
17539 symbol: String,
17540 max_items: usize,
17541 max_bytes: usize,
17542 definition_total: usize,
17543 callers_total: usize,
17544 callers_truncated_by_limit: bool,
17545 callees_total: usize,
17546 callees_truncated_by_limit: bool,
17547 truncated: bool,
17548 definitions: Vec<ExplainBudgetDefinitionPreview>,
17549 callers: Vec<ExplainBudgetEdgePreview>,
17550 callees: Vec<ExplainBudgetEdgePreview>,
17551 #[serde(skip_serializing_if = "Option::is_none")]
17552 community: Option<ExplainBudgetCommunityPreview>,
17553}
17554
17555#[allow(clippy::too_many_arguments)]
17556pub(crate) fn build_explain_budget_report(
17557 symbol: &str,
17558 _root: &Path,
17559 symbols: &[index::StoredSymbol],
17560 callers: &[index::StoredEdge],
17561 callers_total: usize,
17562 callers_truncated_by_limit: bool,
17563 callees: &[index::StoredEdge],
17564 callees_total: usize,
17565 callees_truncated_by_limit: bool,
17566 community: Option<&graph::Community>,
17567 budget: ResponseBudget,
17568) -> ExplainBudgetReport {
17569 let max_items = budget.preview_items();
17570 let max_bytes = budget.preview_bytes();
17571 let definitions = symbols
17572 .iter()
17573 .take(max_items)
17574 .map(|entry| {
17575 let symbol_ref = build_compact_symbol_ref(
17576 "edef",
17577 &format!(
17578 "{}:{}:{}:{}",
17579 entry.kind, entry.name, entry.file, entry.line
17580 ),
17581 &entry.name,
17582 entry.tags.as_deref(),
17583 max_bytes,
17584 );
17585 ExplainBudgetDefinitionPreview {
17586 handle: symbol_ref.handle,
17587 tag_alias: symbol_ref.tag_alias,
17588 kind: entry.kind.clone(),
17589 name: symbol_ref.name,
17590 file: truncate_for_budget(&entry.file, max_bytes),
17591 line: entry.line,
17592 expand: format!(
17593 "tsift search {} --exact --path {} --limit 20",
17594 shell_quote(&entry.name),
17595 shell_quote(&entry.file)
17596 ),
17597 }
17598 })
17599 .collect();
17600 let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
17601 .iter()
17602 .take(max_items)
17603 .map(|entry| {
17604 let symbol_ref = build_compact_symbol_ref(
17605 "ecall",
17606 &format!(
17607 "{}:{}:{}:{}",
17608 entry.caller_name, entry.caller_file, entry.call_site_line, symbol
17609 ),
17610 &entry.caller_name,
17611 None,
17612 max_bytes,
17613 );
17614 ExplainBudgetEdgePreview {
17615 handle: symbol_ref.handle,
17616 tag_alias: symbol_ref.tag_alias,
17617 name: symbol_ref.name,
17618 file: truncate_for_budget(&entry.caller_file, max_bytes),
17619 line: entry.call_site_line,
17620 expand: format!(
17621 "tsift explain {} --path {} --limit 0",
17622 shell_quote(&entry.caller_name),
17623 shell_quote(&entry.caller_file)
17624 ),
17625 }
17626 })
17627 .collect();
17628 let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
17629 .iter()
17630 .take(max_items)
17631 .map(|entry| {
17632 let symbol_ref = build_compact_symbol_ref(
17633 "eces",
17634 &format!(
17635 "{}:{}:{}:{}",
17636 entry.callee_name, entry.caller_file, entry.call_site_line, symbol
17637 ),
17638 &entry.callee_name,
17639 None,
17640 max_bytes,
17641 );
17642 ExplainBudgetEdgePreview {
17643 handle: symbol_ref.handle,
17644 tag_alias: symbol_ref.tag_alias,
17645 name: symbol_ref.name,
17646 file: truncate_for_budget(&entry.caller_file, max_bytes),
17647 line: entry.call_site_line,
17648 expand: format!(
17649 "tsift explain {} --path {} --limit 0",
17650 shell_quote(&entry.callee_name),
17651 shell_quote(&entry.caller_file)
17652 ),
17653 }
17654 })
17655 .collect();
17656 let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
17657 size: entry.members.len(),
17658 members: entry
17659 .members
17660 .iter()
17661 .take(max_items)
17662 .map(|member| truncate_for_budget(&member.name, max_bytes))
17663 .collect(),
17664 });
17665
17666 ExplainBudgetReport {
17667 symbol: symbol.to_string(),
17668 max_items,
17669 max_bytes,
17670 definition_total: symbols.len(),
17671 callers_total,
17672 callers_truncated_by_limit,
17673 callees_total,
17674 callees_truncated_by_limit,
17675 truncated: symbols.len() > max_items
17676 || callers_total > callers_preview.len()
17677 || callees_total > callees_preview.len()
17678 || community
17679 .map(|entry| entry.members.len() > max_items)
17680 .unwrap_or(false),
17681 definitions,
17682 callers: callers_preview,
17683 callees: callees_preview,
17684 community: community_preview,
17685 }
17686}
17687
17688pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
17689 println!(
17690 "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
17691 shell_quote(&report.symbol),
17692 report.definitions.len(),
17693 report.definition_total,
17694 report.callers.len(),
17695 report.callers_total,
17696 report.callees.len(),
17697 report.callees_total
17698 );
17699 for entry in &report.definitions {
17700 println!(
17701 "def {} {} {}:{} expand:{}",
17702 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17703 entry.kind,
17704 entry.file,
17705 entry.line,
17706 entry.expand
17707 );
17708 }
17709 for entry in &report.callers {
17710 println!(
17711 "caller {} {}:{} expand:{}",
17712 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17713 entry.file,
17714 entry.line,
17715 entry.expand
17716 );
17717 }
17718 for entry in &report.callees {
17719 println!(
17720 "callee {} {}:{} expand:{}",
17721 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17722 entry.file,
17723 entry.line,
17724 entry.expand
17725 );
17726 }
17727 if let Some(community) = &report.community {
17728 println!(
17729 "community size:{} members:{}",
17730 community.size,
17731 community.members.join(", ")
17732 );
17733 }
17734 if report.truncated {
17735 println!(
17736 "budget truncated items:{} bytes:{}",
17737 report.max_items, report.max_bytes
17738 );
17739 }
17740}
17741
17742const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
17752 ".git",
17753 "node_modules",
17754 "target",
17755 "__pycache__",
17756 ".venv",
17757 "vendor",
17758];
17759
17760const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
17761 "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
17762 "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
17763 "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
17764 "sh", "bash", "zsh", "sql", "css", "tsx",
17765];
17766
17767pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
17768 let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
17769 .iter()
17770 .map(|ext| (*ext).to_string())
17771 .collect::<BTreeSet<_>>();
17772
17773 let config_path = root.join(".naming.toml");
17774 if !config_path.exists() {
17775 return extensions;
17776 }
17777
17778 match tagpath::config::resolve(&config_path) {
17779 Ok(config) => {
17780 if let Some(grammars) = config.grammars {
17781 for grammar in grammars.languages.values() {
17782 for ext in &grammar.extensions {
17783 if let Some(normalized) = normalize_extension(ext) {
17784 extensions.insert(normalized);
17785 }
17786 }
17787 }
17788 }
17789 }
17790 Err(err) => {
17791 eprintln!("tagpath_policy_hint_config_unreadable: {err}");
17792 }
17793 }
17794 extensions
17795}
17796
17797pub(crate) fn tagpath_audit_policy_hints(
17798 rel_path: &str,
17799 supported_extensions: &BTreeSet<String>,
17800) -> Vec<String> {
17801 let path = Path::new(rel_path);
17802 let mut hints = BTreeSet::new();
17803 if let Some(parent) = path.parent() {
17804 for component in parent.components() {
17805 if let std::path::Component::Normal(name) = component {
17806 let name = name.to_string_lossy();
17807 if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
17808 hints.insert(format!("skip_dir:{name}"));
17809 }
17810 }
17811 }
17812 }
17813 if path
17814 .extension()
17815 .and_then(|ext| ext.to_str())
17816 .and_then(normalize_extension)
17817 .is_some_and(|ext| !supported_extensions.contains(&ext))
17818 {
17819 hints.insert("extension_unsupported".to_string());
17820 }
17821 hints.into_iter().collect()
17822}
17823
17824fn normalize_extension(ext: &str) -> Option<String> {
17825 let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
17826 if normalized.is_empty() {
17827 None
17828 } else {
17829 Some(normalized)
17830 }
17831}
17832
17833pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
17834 match status {
17835 diff_digest::DiffDigestFileStatus::Added => "added",
17836 diff_digest::DiffDigestFileStatus::Modified => "modified",
17837 diff_digest::DiffDigestFileStatus::Deleted => "deleted",
17838 }
17839}
17840
17841pub(crate) fn diff_digest_summary_label(
17842 state: diff_digest::DiffDigestSummaryState,
17843) -> &'static str {
17844 match state {
17845 diff_digest::DiffDigestSummaryState::Current => "current",
17846 diff_digest::DiffDigestSummaryState::Stale => "stale",
17847 diff_digest::DiffDigestSummaryState::Missing => "missing",
17848 diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
17849 }
17850}
17851
17852fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
17853 match state {
17854 test_digest::TestDigestSummaryState::Current => "current",
17855 test_digest::TestDigestSummaryState::Stale => "stale",
17856 test_digest::TestDigestSummaryState::Missing => "missing",
17857 test_digest::TestDigestSummaryState::Unavailable => "unavailable",
17858 }
17859}
17860
17861fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
17862 match state {
17863 log_digest::LogDigestSummaryState::Current => "current",
17864 log_digest::LogDigestSummaryState::Stale => "stale",
17865 log_digest::LogDigestSummaryState::Missing => "missing",
17866 log_digest::LogDigestSummaryState::Unavailable => "unavailable",
17867 }
17868}
17869
17870pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
17871 match mode {
17872 diff_digest::DiffDigestMode::WorkingTree => "worktree",
17873 diff_digest::DiffDigestMode::Cached => "cached",
17874 diff_digest::DiffDigestMode::Revision => "revision",
17875 }
17876}
17877
17878pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
17879 match (&report.mode, &report.revision) {
17880 (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
17881 (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
17882 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17883 format!("revision {revision}")
17884 }
17885 (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
17886 }
17887}
17888
17889pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
17890 match (&report.mode, &report.revision) {
17891 (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
17892 (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
17893 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17894 format!("No diff found for revision {revision}.")
17895 }
17896 (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
17897 }
17898}
17899
17900fn cmd_impact(
17901 path: &Path,
17902 cached: bool,
17903 revision: Option<&str>,
17904 scope: Option<&str>,
17905 limit: usize,
17906 format: OutputFormat,
17907) -> Result<()> {
17908 let report = impact::compute(
17909 path,
17910 impact::ImpactOptions {
17911 cached,
17912 revision,
17913 scope,
17914 limit,
17915 },
17916 )?;
17917 if format.json_output {
17918 println!(
17919 "{}",
17920 to_json_schema(
17921 &report,
17922 format.pretty,
17923 format.terse,
17924 format.ultra_terse,
17925 format.schema
17926 )?
17927 );
17928 return Ok(());
17929 }
17930
17931 if format.compact {
17932 println!(
17933 "impact mode:{} changed:{} symbols:{} tests:{}/{}",
17934 diff_digest_mode_label(report.mode),
17935 report.changed_files.len(),
17936 report.changed_symbols.len(),
17937 report.affected_tests.len(),
17938 report.affected_tests_total
17939 );
17940 for target in &report.affected_tests {
17941 println!(
17942 "{} reasons:{} command:{}",
17943 target.path,
17944 target.reasons.len(),
17945 target.commands.join(" && ")
17946 );
17947 }
17948 for warning in &report.warnings {
17949 println!("warning {warning}");
17950 }
17951 return Ok(());
17952 }
17953
17954 println!("Impact ({})", diff_digest_mode_label(report.mode));
17955 println!(" changed files: {}", report.changed_files.len());
17956 println!(" changed symbols: {}", report.changed_symbols.len());
17957 println!(
17958 " affected tests: {}/{}",
17959 report.affected_tests.len(),
17960 report.affected_tests_total
17961 );
17962 for target in &report.affected_tests {
17963 println!();
17964 println!("{}", target.path);
17965 for reason in &target.reasons {
17966 println!(" - {reason}");
17967 }
17968 if !target.symbols.is_empty() {
17969 println!(" symbols: {}", target.symbols.join(", "));
17970 }
17971 for command in &target.commands {
17972 println!(" run: {}", command);
17973 }
17974 }
17975 for warning in &report.warnings {
17976 println!("warning: {warning}");
17977 }
17978 Ok(())
17979}
17980
17981pub(crate) fn render_test_digest_from_input(
17982 path: &Path,
17983 input: &str,
17984 runner: Option<&str>,
17985 format: OutputFormat,
17986) -> Result<()> {
17987 let report = test_digest::compute(path, input, runner)?;
17988 if format.json_output {
17989 println!(
17990 "{}",
17991 to_json_schema(
17992 &report,
17993 format.pretty,
17994 format.terse,
17995 format.ultra_terse,
17996 format.schema
17997 )?
17998 );
17999 return Ok(());
18000 }
18001
18002 if report.failure_groups.is_empty() {
18003 println!("No failures detected (runner: {}).", report.runner);
18004 for warning in &report.warnings {
18005 println!("warning: {warning}");
18006 }
18007 return Ok(());
18008 }
18009
18010 if format.compact {
18011 println!(
18012 "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18013 report.runner,
18014 report.failures,
18015 report.grouped_failures,
18016 report.counts.passed.unwrap_or(0),
18017 report.counts.failed.unwrap_or(report.grouped_failures),
18018 report.counts.skipped.unwrap_or(0),
18019 );
18020 for failure in &report.failure_groups {
18021 let tests = truncate_for_compact(&failure.tests.join(","), 60);
18022 let location = match (&failure.path, failure.line) {
18023 (Some(path), Some(line)) => format!("{path}:{line}"),
18024 (Some(path), None) => path.clone(),
18025 _ => "-".to_string(),
18026 };
18027 println!(
18028 "{} tests:{} count:{} summaries:{} msg:{}",
18029 location,
18030 tests,
18031 failure.occurrences,
18032 test_digest_summary_label(failure.summary_state),
18033 truncate_for_compact(&failure.message, 80)
18034 );
18035 }
18036 for warning in &report.warnings {
18037 println!("warning: {warning}");
18038 }
18039 return Ok(());
18040 }
18041
18042 println!("Test digest ({})", report.runner);
18043 println!(" failures: {}", report.failures);
18044 println!(" failure groups: {}", report.grouped_failures);
18045 if let Some(passed) = report.counts.passed {
18046 println!(" passed: {}", passed);
18047 }
18048 if let Some(failed) = report.counts.failed {
18049 println!(" failed: {}", failed);
18050 }
18051 if let Some(skipped) = report.counts.skipped {
18052 println!(" skipped: {}", skipped);
18053 }
18054
18055 for failure in &report.failure_groups {
18056 println!();
18057 match (&failure.path, failure.line, failure.column) {
18058 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18059 (Some(path), Some(line), None) => println!("{path}:{line}"),
18060 (Some(path), None, _) => println!("{path}"),
18061 (None, _, _) => println!("(no file anchor)"),
18062 }
18063 println!(" tests: {}", failure.tests.join(", "));
18064 println!(" occurrences: {}", failure.occurrences);
18065 println!(" message: {}", failure.message);
18066 println!(
18067 " cached summaries: {}",
18068 test_digest_summary_label(failure.summary_state)
18069 );
18070 for summary in &failure.current_summaries {
18071 println!(
18072 " - {}: {}",
18073 summary.symbol,
18074 truncate_for_compact(&summary.summary, 160)
18075 );
18076 }
18077 }
18078 for warning in &report.warnings {
18079 println!("warning: {warning}");
18080 }
18081 Ok(())
18082}
18083
18084#[derive(Clone, Serialize, Deserialize)]
18085struct DispatchTraceSummary {
18086 backlog: usize,
18087 job_packet: usize,
18088 worker_result: usize,
18089 worker_context: usize,
18090 source_handle: usize,
18091 semantic_rows: usize,
18092}
18093
18094#[derive(Clone, Serialize, Deserialize)]
18095struct DispatchTraceReport {
18096 contract_version: String,
18097 root: String,
18098 #[serde(skip_serializing_if = "Option::is_none")]
18099 scope: Option<String>,
18100 targets: Vec<String>,
18101 projection_freshness: GraphDbFreshnessReport,
18102 projection_hashes: Vec<String>,
18103 evidence_packet_ids: Vec<String>,
18104 shared_preparation: ConflictMatrixSharedPreparationSummary,
18105 worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18106 worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18107 summary: DispatchTraceSummary,
18108 nodes: Vec<SubstrateTerseGraphNode>,
18109 edges: Vec<SubstrateTerseGraphEdge>,
18110 conflict_matrix_decisions: Vec<String>,
18111 replay_commands: Vec<String>,
18112 repair_commands: Vec<String>,
18113 truncated: bool,
18114 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18115 warnings: Vec<String>,
18116}
18117
18118fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18119 matches!(
18120 kind,
18121 "session"
18122 | "backlog"
18123 | "job_packet"
18124 | "worker_result"
18125 | "worker_context"
18126 | "source_handle"
18127 | "semantic_concept"
18128 | "semantic_entity"
18129 | "file"
18130 | "symbol"
18131 | "route"
18132 )
18133}
18134
18135fn dispatch_trace_kind_rank(kind: &str) -> usize {
18136 match kind {
18137 "backlog" => 0,
18138 "job_packet" => 1,
18139 "worker_result" => 2,
18140 "worker_context" => 3,
18141 "source_handle" => 4,
18142 "file" => 5,
18143 "symbol" => 6,
18144 "route" => 7,
18145 "semantic_concept" => 8,
18146 "semantic_entity" => 9,
18147 "session" => 10,
18148 _ => 99,
18149 }
18150}
18151
18152fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18153 DispatchTraceSummary {
18154 backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18155 job_packet: nodes
18156 .iter()
18157 .filter(|node| node.kind == "job_packet")
18158 .count(),
18159 worker_result: nodes
18160 .iter()
18161 .filter(|node| node.kind == "worker_result")
18162 .count(),
18163 worker_context: nodes
18164 .iter()
18165 .filter(|node| node.kind == "worker_context")
18166 .count(),
18167 source_handle: nodes
18168 .iter()
18169 .filter(|node| node.kind == "source_handle")
18170 .count(),
18171 semantic_rows: nodes
18172 .iter()
18173 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18174 .count(),
18175 }
18176}
18177
18178fn dispatch_trace_shared_preparation_summary(
18179 graph_nodes: &[SubstrateGraphNode],
18180 graph_edges: &[SubstrateGraphEdge],
18181 conflict: &ConflictMatrixReport,
18182) -> ConflictMatrixSharedPreparationSummary {
18183 ConflictMatrixSharedPreparationSummary {
18184 evidence_cache_status: conflict
18185 .inputs
18186 .shared_preparation
18187 .evidence_cache_status
18188 .clone(),
18189 graph_nodes: graph_nodes.len(),
18190 graph_edges: graph_edges.len(),
18191 evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18192 source_handles: conflict
18193 .candidates
18194 .iter()
18195 .map(|candidate| candidate.source_handles.len())
18196 .sum(),
18197 worker_context: conflict
18198 .candidates
18199 .iter()
18200 .map(|candidate| candidate.worker_context_handles.len())
18201 .sum(),
18202 worker_results: conflict
18203 .candidates
18204 .iter()
18205 .map(|candidate| candidate.worker_feedback.total)
18206 .sum(),
18207 semantic_rows: conflict
18208 .candidates
18209 .iter()
18210 .map(|candidate| candidate.semantic_related.len())
18211 .sum(),
18212 dispatch_trace_snapshot_nodes: graph_nodes.len(),
18213 dispatch_trace_snapshot_edges: graph_edges.len(),
18214 }
18215}
18216
18217fn dispatch_trace_collect_ids(
18218 targets: &[String],
18219 candidates: &[ConflictMatrixCandidate],
18220 graph_nodes: &[SubstrateGraphNode],
18221 graph_edges: &[SubstrateGraphEdge],
18222 depth: usize,
18223 limit: usize,
18224) -> (BTreeSet<String>, bool) {
18225 let target_refs = targets
18226 .iter()
18227 .map(|target| target.trim_start_matches('#').to_string())
18228 .collect::<BTreeSet<_>>();
18229 let mut ids = BTreeSet::new();
18230 for candidate in candidates {
18231 ids.insert(candidate.target_node_id.clone());
18232 for source in &candidate.source_handles {
18233 ids.insert(source.handle.clone());
18234 }
18235 for handle in &candidate.worker_context_handles {
18236 ids.insert(handle.clone());
18237 }
18238 for semantic in &candidate.semantic_related {
18239 ids.insert(semantic.handle.clone());
18240 }
18241 }
18242 for node in graph_nodes {
18243 if !dispatch_trace_allowed_node_kind(&node.kind) {
18244 continue;
18245 }
18246 if node
18247 .properties
18248 .get("ref_id")
18249 .is_some_and(|ref_id| target_refs.contains(ref_id))
18250 {
18251 ids.insert(node.id.clone());
18252 }
18253 }
18254
18255 let node_by_id = graph_nodes
18256 .iter()
18257 .map(|node| (node.id.as_str(), node))
18258 .collect::<BTreeMap<_, _>>();
18259 let max_nodes = if limit == 0 {
18260 usize::MAX
18261 } else {
18262 limit
18263 .saturating_mul(targets.len().max(1))
18264 .saturating_mul(12)
18265 .max(64)
18266 };
18267 let mut truncated = false;
18268 for _ in 0..depth.max(1) {
18269 let before = ids.len();
18270 let current_ids = ids.clone();
18271 for edge in graph_edges {
18272 if ids.len() >= max_nodes {
18273 truncated = true;
18274 break;
18275 }
18276 let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18277 if !touches {
18278 continue;
18279 }
18280 for endpoint in [&edge.from_id, &edge.to_id] {
18281 let Some(node) = node_by_id.get(endpoint.as_str()) else {
18282 continue;
18283 };
18284 if dispatch_trace_allowed_node_kind(&node.kind) {
18285 ids.insert(endpoint.clone());
18286 }
18287 }
18288 }
18289 if ids.len() == before || truncated {
18290 break;
18291 }
18292 }
18293 (ids, truncated)
18294}
18295
18296#[allow(clippy::too_many_arguments)]
18297fn build_dispatch_trace_report_from_conflict_snapshot(
18298 root: &Path,
18299 scope: Option<&str>,
18300 conflict: ConflictMatrixReport,
18301 graph_nodes: Vec<SubstrateGraphNode>,
18302 graph_edges: Vec<SubstrateGraphEdge>,
18303 depth: usize,
18304 limit: usize,
18305 extra_warnings: Vec<String>,
18306) -> Result<DispatchTraceReport> {
18307 let shared_preparation =
18308 dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18309 let (ids, truncated) = dispatch_trace_collect_ids(
18310 &conflict.targets,
18311 &conflict.candidates,
18312 &graph_nodes,
18313 &graph_edges,
18314 depth,
18315 limit,
18316 );
18317 let mut nodes = graph_nodes
18318 .into_iter()
18319 .filter(|node| ids.contains(&node.id))
18320 .collect::<Vec<_>>();
18321 nodes.sort_by(|left, right| {
18322 dispatch_trace_kind_rank(&left.kind)
18323 .cmp(&dispatch_trace_kind_rank(&right.kind))
18324 .then(left.id.cmp(&right.id))
18325 });
18326 let node_ids = nodes
18327 .iter()
18328 .map(|node| node.id.as_str())
18329 .collect::<BTreeSet<_>>();
18330 let mut edges = graph_edges
18331 .into_iter()
18332 .filter(|edge| {
18333 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18334 })
18335 .collect::<Vec<_>>();
18336 edges.sort_by(|left, right| {
18337 left.from_id
18338 .cmp(&right.from_id)
18339 .then(left.kind.cmp(&right.kind))
18340 .then(left.to_id.cmp(&right.to_id))
18341 });
18342 let mut warnings = conflict.warnings;
18343 warnings.extend(extra_warnings);
18344
18345 Ok(DispatchTraceReport {
18346 contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
18347 root: conflict.root,
18348 scope: conflict.scope,
18349 targets: conflict.targets,
18350 projection_freshness: conflict.orchestration.projection_freshness,
18351 projection_hashes: conflict.orchestration.projection_hashes,
18352 evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
18353 shared_preparation,
18354 worker_prompt_packets: conflict.worker_prompt_packets,
18355 worker_feedback: conflict
18356 .candidates
18357 .iter()
18358 .map(|candidate| candidate.worker_feedback.clone())
18359 .collect(),
18360 summary: dispatch_trace_summary(&nodes),
18361 nodes: nodes.into_iter().map(Into::into).collect(),
18362 edges: edges.into_iter().map(Into::into).collect(),
18363 conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
18364 replay_commands: conflict.next_commands,
18365 repair_commands: graph_db_repair_commands(root, scope),
18366 truncated,
18367 warnings,
18368 })
18369}
18370
18371fn build_dispatch_trace_report(
18372 path: &Path,
18373 scope: Option<&str>,
18374 raw_targets: &[String],
18375 depth: usize,
18376 limit: usize,
18377 impact_limit: usize,
18378) -> Result<DispatchTraceReport> {
18379 let root = lint::resolve_project_root_or_canonical_path(path)?;
18380 let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
18381 if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
18382 write_traversal_graph_store(&root, path, scope)
18383 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
18384 }
18385 let graph_db = graph_substrate_db_path(&root, scope);
18386 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
18387 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
18388 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
18389 let extra_warnings = store
18390 .read_only_recovery()
18391 .map(graph_db_read_recovery_diagnostic)
18392 .into_iter()
18393 .collect::<Vec<_>>();
18394 let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
18395 let graph_prepared = prepare_conflict_matrix_graph_orchestration(
18396 &root,
18397 scope,
18398 "sqlite",
18399 raw_targets,
18400 &prepared,
18401 depth,
18402 limit,
18403 &store,
18404 freshness.clone(),
18405 )?;
18406 let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
18407 &prepared.preparation_cache.source_watermark,
18408 &prepared.preparation_cache.document_watermark,
18409 &prepared.preparation_cache.staged_diff_watermark,
18410 &[
18411 &format!("targets:{}", raw_targets.join(",")),
18412 &format!("depth:{depth}"),
18413 &format!("limit:{limit}"),
18414 ],
18415 );
18416 if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
18417 &root,
18418 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18419 &dt_cache_key,
18420 ) {
18421 return Ok(cached_report);
18422 }
18423 let conflict = build_conflict_matrix_report_from_prepared_graph(
18424 &root,
18425 path,
18426 scope,
18427 depth,
18428 limit,
18429 impact_limit,
18430 freshness,
18431 extra_warnings.clone(),
18432 &prepared,
18433 &graph_prepared,
18434 )?;
18435 let report = build_dispatch_trace_report_from_conflict_snapshot(
18436 &root,
18437 scope,
18438 conflict,
18439 graph_prepared.graph.nodes,
18440 graph_prepared.graph.edges,
18441 depth,
18442 limit,
18443 extra_warnings,
18444 )?;
18445 cycle_packet_cache::cycle_packet_write_cache(
18446 &root,
18447 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18448 &dt_cache_key,
18449 &report,
18450 );
18451 Ok(report)
18452}
18453
18454fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
18455 let json = serde_json::to_string(report)?.replace("</", "<\\/");
18456 let mut html = String::new();
18457 html.push_str(
18458 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
18459 );
18460 html.push_str(
18461 r#"<style>
18462:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
18463@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
18464*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif;line-height:1.4}.page{max-width:1280px;margin:0 auto;padding:20px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:14px}.top h1{font-size:22px;margin:0}.meta{color:var(--muted);font-size:13px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:14px}.panel,.side{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.side{padding:14px;overflow:auto;max-height:720px}.side h2{font-size:15px;margin:12px 0 8px}.side h2:first-child{margin-top:0}.list{display:grid;gap:8px}.row{border:1px solid var(--line);border-radius:6px;padding:8px}.kind{font-size:11px;text-transform:uppercase;color:var(--muted);letter-spacing:.04em}.label{font-weight:650;overflow-wrap:anywhere}.handle,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);overflow-wrap:anywhere}svg{width:100%;height:680px;display:block}.edge{stroke:var(--edge);stroke-width:1.4;opacity:.72}.node{stroke:var(--panel);stroke-width:2}.node-label{font-size:12px;paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round;fill:var(--text)}@media(max-width:900px){.top{display:block}.layout{grid-template-columns:1fr}.side{max-height:none}svg{height:560px}}
18465</style>"#,
18466 );
18467 html.push_str("</head><body><div class=\"page\">");
18468 html.push_str(&format!(
18469 "<header class=\"top\"><div><h1>tsift dispatch trace</h1><div class=\"meta\">targets <code>{}</code> | evidence <code>{}</code> | nodes <code>{}</code> | worker_prompt_packets <code>{}</code></div></div><div class=\"meta\"><code>{}</code></div></header>",
18470 html_escape(&report.targets.join(", ")),
18471 report.evidence_packet_ids.len(),
18472 report.nodes.len(),
18473 report.worker_prompt_packets.len(),
18474 html_escape(&report.contract_version)
18475 ));
18476 html.push_str(
18477 r#"<main class="layout"><section class="panel"><svg id="graph-canvas" role="img" aria-label="Dispatch trace graph"></svg></section><aside class="side"><h2>Worker Prompt Packets</h2><div id="packets" class="list"></div><h2>Worker Feedback</h2><div id="feedback" class="list"></div><h2>Nodes</h2><div id="nodes" class="list"></div></aside></main>"#,
18478 );
18479 html.push_str("<script id=\"trace-data\" type=\"application/json\">");
18480 html.push_str(&json);
18481 html.push_str(
18482 r##"</script><script>
18483const report = JSON.parse(document.getElementById("trace-data").textContent);
18484const svg = document.getElementById("graph-canvas");
18485const nodeList = document.getElementById("nodes");
18486const packets = document.getElementById("packets");
18487const feedback = document.getElementById("feedback");
18488const nodes = report.nodes.map((node, index) => ({...node, index}));
18489const nodeById = new Map(nodes.map(node => [node.id, node]));
18490const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
18491const colorByKind = new Map([["backlog","#dc2626"],["job_packet","#ea580c"],["worker_result","#15803d"],["worker_context","#475569"],["source_handle","#64748b"],["semantic_concept","#9a3412"],["semantic_entity","#b45309"],["file","#2563eb"],["symbol","#16a34a"],["route","#7c3aed"],["session","#0891b2"]]);
18492function color(kind){return colorByKind.get(kind)||"#6b7280";}
18493function text(value){return value == null ? "" : String(value);}
18494function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));}
18495function layout(){
18496 const rect = svg.getBoundingClientRect();
18497 const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
18498 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
18499 const counts = new Map();
18500 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
18501 const offsets = new Map();
18502 for (const node of nodes) {
18503 const group = kinds.indexOf(node.kind);
18504 const index = offsets.get(node.kind) || 0;
18505 offsets.set(node.kind, index + 1);
18506 const total = counts.get(node.kind) || 1;
18507 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
18508 const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
18509 node.x = cx + Math.cos(angle) * ring;
18510 node.y = cy + Math.sin(angle) * ring;
18511 }
18512}
18513function draw(){
18514 svg.innerHTML = "";
18515 for (const edge of edges) {
18516 const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
18517 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
18518 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
18519 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
18520 line.setAttribute("class", "edge");
18521 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
18522 svg.appendChild(line);
18523 }
18524 for (const node of nodes) {
18525 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
18526 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
18527 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
18528 circle.setAttribute("fill", color(node.kind));
18529 circle.setAttribute("class", "node");
18530 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
18531 svg.appendChild(circle);
18532 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
18533 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
18534 label.setAttribute("class", "node-label");
18535 label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
18536 svg.appendChild(label);
18537 }
18538}
18539packets.innerHTML = report.worker_prompt_packets.map(packet => `<div class="row"><div class="kind">${escapeHtml(packet.contract_version)} - ${escapeHtml(packet.risk)} - parallel_safe ${packet.parallel_safe ? "true" : "false"} - closure ${packet.worker_feedback ? packet.worker_feedback.closure_rank_score : 0}</div><div class="label">${escapeHtml(packet.title)}</div><div class="handle">${escapeHtml(packet.packet_id)}</div><div class="handle">blocks ${escapeHtml((packet.blocks||[]).join(", ") || "none")} | blocked_by ${escapeHtml((packet.blocked_by||[]).join(", ") || "none")}</div></div>`).join("") || "<div class=\"meta\">No packets.</div>";
18540feedback.innerHTML = report.worker_feedback.map(item => `<div class="row"><div class="kind">completed ${item.completed} - blocked ${item.blocked} - closure ${item.closure_rank_score}</div><div>files ${escapeHtml((item.touched_files||[]).join(", ") || "none")}</div><div>tests ${escapeHtml((item.expected_tests||[]).join(" && ") || "none")}</div>${item.repeated_blockage ? "<div class=\"label\">Repeated blockage</div>" : ""}${(item.stale_expected_tests||[]).length ? `<div class="label">Stale tests: ${escapeHtml(item.stale_expected_tests.join(", "))}</div>` : ""}${(item.follow_up_debt||[]).length ? `<div class="label">Follow-up debt: ${escapeHtml(item.follow_up_debt.join(", "))}</div>` : ""}</div>`).join("") || "<div class=\"meta\">No worker results.</div>";
18541nodeList.innerHTML = nodes.map(node => `<div class="row"><div class="kind">${escapeHtml(node.kind)}</div><div class="label">${escapeHtml(node.label)}</div><div class="handle">${escapeHtml(node.id)}</div></div>`).join("");
18542window.addEventListener("resize", () => { layout(); draw(); });
18543layout(); draw();
18544</script></div></body></html>"##,
18545 );
18546 Ok(html)
18547}
18548
18549struct DispatchTraceOptions<'a> {
18550 path: &'a Path,
18551 scope: Option<&'a str>,
18552 raw_targets: &'a [String],
18553 depth: usize,
18554 limit: usize,
18555 impact_limit: usize,
18556 trace_format: DispatchTraceFormat,
18557}
18558
18559fn cmd_dispatch_trace(
18560 options: DispatchTraceOptions<'_>,
18561 output_format: OutputFormat,
18562) -> Result<()> {
18563 let report = build_dispatch_trace_report(
18564 options.path,
18565 options.scope,
18566 options.raw_targets,
18567 options.depth,
18568 options.limit,
18569 options.impact_limit,
18570 )?;
18571 match options.trace_format {
18572 DispatchTraceFormat::Json => {
18573 if output_format.envelope {
18574 print_json_or_envelope(
18575 &report,
18576 &output_format,
18577 "dispatch-trace",
18578 "operator-review",
18579 ToolEnvelopeSummary {
18580 text: format!(
18581 "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
18582 report.targets.len(),
18583 report.nodes.len(),
18584 report.worker_prompt_packets.len()
18585 ),
18586 metrics: vec![
18587 envelope_metric("targets", report.targets.len()),
18588 envelope_metric("nodes", report.nodes.len()),
18589 envelope_metric("edges", report.edges.len()),
18590 envelope_metric(
18591 "worker_prompt_packets",
18592 report.worker_prompt_packets.len(),
18593 ),
18594 ],
18595 },
18596 report.truncated,
18597 report.replay_commands.clone(),
18598 )
18599 } else {
18600 println!(
18601 "{}",
18602 to_json_schema(
18603 &report,
18604 output_format.pretty,
18605 output_format.terse,
18606 output_format.ultra_terse,
18607 output_format.schema
18608 )?
18609 );
18610 Ok(())
18611 }
18612 }
18613 DispatchTraceFormat::Html => {
18614 println!("{}", dispatch_trace_html(&report)?);
18615 Ok(())
18616 }
18617 }
18618}
18619
18620#[derive(Clone, Debug)]
18621struct DependencyDagProfile {
18622 id: String,
18623 graph_node_id: String,
18624 label: String,
18625 path: Option<String>,
18626 line: Option<i64>,
18627 detail: Option<String>,
18628 source_files: BTreeSet<String>,
18629 source_symbols: BTreeSet<String>,
18630 config_files: BTreeSet<String>,
18631 expected_tests: BTreeSet<String>,
18632 semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
18633 worker_feedback: ConflictMatrixWorkerFeedback,
18634}
18635
18636#[derive(Clone, Debug, Serialize)]
18637struct DependencyDagNode {
18638 id: String,
18639 graph_node_id: String,
18640 label: String,
18641 #[serde(skip_serializing_if = "Option::is_none")]
18642 path: Option<String>,
18643 #[serde(skip_serializing_if = "Option::is_none")]
18644 line: Option<i64>,
18645 #[serde(skip_serializing_if = "Option::is_none")]
18646 detail: Option<String>,
18647 source_files: Vec<String>,
18648 source_symbols: Vec<String>,
18649 config_files: Vec<String>,
18650 expected_tests: Vec<String>,
18651 semantic_refs: Vec<ConflictMatrixSemanticRef>,
18652 worker_feedback: ConflictMatrixWorkerFeedback,
18653}
18654
18655#[derive(Clone, Debug, Serialize)]
18656struct DependencyDagEdge {
18657 from: String,
18658 to: String,
18659 kind: String,
18660 weight: usize,
18661 reasons: Vec<String>,
18662 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18663 shared_files: Vec<String>,
18664 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18665 shared_symbols: Vec<String>,
18666 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18667 shared_tests: Vec<String>,
18668 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18669 shared_config_files: Vec<String>,
18670 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18671 shared_semantic_refs: Vec<String>,
18672}
18673
18674#[derive(Clone, Debug, Serialize)]
18675struct DependencyDagTopoBatch {
18676 batch: usize,
18677 targets: Vec<String>,
18678}
18679
18680#[derive(Clone, Debug, Serialize)]
18681struct DependencyDagCycleDiagnostics {
18682 has_cycles: bool,
18683 blocked_nodes: Vec<String>,
18684 cycle_edges: Vec<DependencyDagEdge>,
18685}
18686
18687#[derive(Serialize)]
18688struct DependencyDagSummary {
18689 nodes: usize,
18690 edges: usize,
18691 topo_batches: usize,
18692 has_cycles: bool,
18693}
18694
18695#[derive(Serialize)]
18696struct DependencyDagReport {
18697 contract_version: &'static str,
18698 root: String,
18699 #[serde(skip_serializing_if = "Option::is_none")]
18700 scope: Option<String>,
18701 path: String,
18702 targets: Vec<String>,
18703 projection_freshness: GraphDbFreshnessReport,
18704 projection_hashes: Vec<String>,
18705 nodes: Vec<DependencyDagNode>,
18706 edges: Vec<DependencyDagEdge>,
18707 topo_batches: Vec<DependencyDagTopoBatch>,
18708 cycle_diagnostics: DependencyDagCycleDiagnostics,
18709 summary: DependencyDagSummary,
18710 replay_commands: Vec<String>,
18711 repair_commands: Vec<String>,
18712 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18713 warnings: Vec<String>,
18714}
18715
18716fn dependency_dag_backlog_node_for_target(
18717 store: &impl GraphStore,
18718 target: &str,
18719) -> Result<SubstrateGraphNode> {
18720 let resolved = graph_db_resolve_evidence_target(store, target)?
18721 .with_context(|| format!("dependency-dag target not found: {target}"))?;
18722 if resolved.kind == "backlog" {
18723 return Ok(resolved);
18724 }
18725 let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
18726 bail!(
18727 "dependency-dag target {} resolved to {} without a backlog ref_id",
18728 target,
18729 resolved.kind
18730 );
18731 };
18732 store
18733 .nodes_by_kind("backlog")?
18734 .into_iter()
18735 .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
18736 .min_by(|left, right| {
18737 left.properties
18738 .get("line")
18739 .and_then(|value| value.parse::<i64>().ok())
18740 .cmp(
18741 &right
18742 .properties
18743 .get("line")
18744 .and_then(|value| value.parse::<i64>().ok()),
18745 )
18746 .then(left.id.cmp(&right.id))
18747 })
18748 .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
18749}
18750
18751fn dependency_dag_resolve_backlog_nodes(
18752 root: &Path,
18753 path: &Path,
18754 store: &impl GraphStore,
18755 raw_targets: &[String],
18756) -> Result<Vec<SubstrateGraphNode>> {
18757 let mut nodes = Vec::new();
18758 let mut seen = BTreeSet::new();
18759 if raw_targets.is_empty() {
18760 let hinted_path = if path.is_absolute() {
18761 path.to_path_buf()
18762 } else {
18763 root.join(path)
18764 };
18765 let hinted_markdown = hinted_path
18766 .extension()
18767 .and_then(|ext| ext.to_str())
18768 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
18769 let hinted_rel = hinted_markdown.then(|| {
18770 relativize_pathbuf(&hinted_path, root)
18771 .to_string_lossy()
18772 .replace('\\', "/")
18773 });
18774 for node in store.nodes_by_kind("backlog")? {
18775 if let Some(expected_path) = &hinted_rel
18776 && node.properties.get("path") != Some(expected_path)
18777 {
18778 continue;
18779 }
18780 if seen.insert(node.id.clone()) {
18781 nodes.push(node);
18782 }
18783 }
18784 if nodes.is_empty() && hinted_rel.is_some() {
18785 for node in store.nodes_by_kind("backlog")? {
18786 if seen.insert(node.id.clone()) {
18787 nodes.push(node);
18788 }
18789 }
18790 }
18791 } else {
18792 for target in raw_targets {
18793 let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
18794 let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
18795 if seen.insert(node.id.clone()) {
18796 nodes.push(node);
18797 }
18798 }
18799 }
18800 if nodes.is_empty() {
18801 bail!("dependency-dag needs at least one resolvable backlog id");
18802 }
18803 nodes.sort_by(|left, right| {
18804 left.properties
18805 .get("line")
18806 .and_then(|value| value.parse::<i64>().ok())
18807 .cmp(
18808 &right
18809 .properties
18810 .get("line")
18811 .and_then(|value| value.parse::<i64>().ok()),
18812 )
18813 .then(left.id.cmp(&right.id))
18814 });
18815 Ok(nodes)
18816}
18817
18818fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
18819 node.properties
18820 .get("ref_id")
18821 .cloned()
18822 .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
18823}
18824
18825fn dependency_dag_node_profile(
18826 root: &Path,
18827 store: &impl GraphStore,
18828 node: &SubstrateGraphNode,
18829 graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
18830 graph_edges: &[SubstrateGraphEdge],
18831 depth: usize,
18832 limit: usize,
18833) -> Result<DependencyDagProfile> {
18834 let id = dependency_dag_node_id(node);
18835 let mut source_files = BTreeSet::new();
18836 let mut source_symbols = BTreeSet::new();
18837 for edge in graph_edges
18838 .iter()
18839 .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
18840 {
18841 let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
18842 continue;
18843 };
18844 match target.kind.as_str() {
18845 "file" | "route" => {
18846 if let Some(path) = target.properties.get("path") {
18847 source_files.insert(path.clone());
18848 }
18849 }
18850 "symbol" => {
18851 source_symbols.insert(target.label.clone());
18852 if let Some(path) = target.properties.get("path") {
18853 source_files.insert(path.clone());
18854 }
18855 }
18856 _ => {}
18857 }
18858 }
18859
18860 let max_rows = if limit == 0 { usize::MAX } else { limit };
18861 for (source, _) in
18862 graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
18863 {
18864 let terse: SubstrateTerseGraphNode = (&source).into();
18865 if let Some(handle) = conflict_matrix_source_handle(&terse) {
18866 source_files.insert(handle.file);
18867 }
18868 }
18869
18870 let worker_results = graph_nodes_by_id
18871 .values()
18872 .filter(|candidate| {
18873 candidate.kind == "worker_result"
18874 && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
18875 })
18876 .map(SubstrateTerseGraphNode::from)
18877 .collect::<Vec<_>>();
18878 let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
18879 let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
18880 let config_files = source_files
18881 .iter()
18882 .filter(|file| is_planner_config_path(file))
18883 .cloned()
18884 .collect();
18885
18886 let mut semantic_refs = BTreeMap::new();
18887 for kind in ["semantic_concept", "semantic_entity"] {
18888 for (semantic, _) in
18889 graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
18890 {
18891 let terse: SubstrateTerseGraphNode = (&semantic).into();
18892 let item = conflict_matrix_semantic_ref(root, &terse);
18893 semantic_refs
18894 .entry(format!("{}:{}", item.kind, item.label))
18895 .or_insert(item);
18896 }
18897 }
18898
18899 Ok(DependencyDagProfile {
18900 id,
18901 graph_node_id: node.id.clone(),
18902 label: node.label.clone(),
18903 path: node.properties.get("path").cloned(),
18904 line: node
18905 .properties
18906 .get("line")
18907 .and_then(|value| value.parse::<i64>().ok()),
18908 detail: node.properties.get("detail").cloned(),
18909 source_files,
18910 source_symbols,
18911 config_files,
18912 expected_tests,
18913 semantic_refs,
18914 worker_feedback,
18915 })
18916}
18917
18918fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
18919 let lower = text.to_ascii_lowercase();
18920 let mut refs = Vec::new();
18921 for marker in markers {
18922 let mut offset = 0usize;
18923 while let Some(pos) = lower[offset..].find(marker) {
18924 let start = offset + pos + marker.len();
18925 let segment = text[start..]
18926 .split(['\n', '.'])
18927 .next()
18928 .unwrap_or(&text[start..]);
18929 refs.extend(extract_conflict_target_refs(segment));
18930 offset = start;
18931 }
18932 }
18933 dedupe_preserve_order(refs)
18934}
18935
18936fn dependency_dag_push_edge(
18937 edges: &mut Vec<DependencyDagEdge>,
18938 seen: &mut BTreeSet<(String, String, String)>,
18939 edge: DependencyDagEdge,
18940) {
18941 if edge.from == edge.to {
18942 return;
18943 }
18944 if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
18945 edges.push(edge);
18946 }
18947}
18948
18949fn dependency_dag_explicit_edges(
18950 profiles: &[DependencyDagProfile],
18951 target_ids: &BTreeSet<String>,
18952 edges: &mut Vec<DependencyDagEdge>,
18953 seen: &mut BTreeSet<(String, String, String)>,
18954) {
18955 for profile in profiles {
18956 let detail = profile.detail.as_deref().unwrap_or_default();
18957 for dep in dependency_dag_marker_refs(
18958 detail,
18959 &[
18960 "depends on",
18961 "depends-on",
18962 "deps:",
18963 "after",
18964 "blocked by",
18965 "requires",
18966 ],
18967 ) {
18968 if target_ids.contains(&dep) {
18969 dependency_dag_push_edge(
18970 edges,
18971 seen,
18972 DependencyDagEdge {
18973 from: dep.clone(),
18974 to: profile.id.clone(),
18975 kind: "explicit_depends_on".to_string(),
18976 weight: 1000,
18977 reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
18978 shared_files: Vec::new(),
18979 shared_symbols: Vec::new(),
18980 shared_tests: Vec::new(),
18981 shared_config_files: Vec::new(),
18982 shared_semantic_refs: Vec::new(),
18983 },
18984 );
18985 }
18986 }
18987 for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
18988 if target_ids.contains(&downstream) {
18989 dependency_dag_push_edge(
18990 edges,
18991 seen,
18992 DependencyDagEdge {
18993 from: profile.id.clone(),
18994 to: downstream.clone(),
18995 kind: "explicit_before".to_string(),
18996 weight: 900,
18997 reasons: vec![format!(
18998 "{} declares it should run before #{downstream}",
18999 profile.id
19000 )],
19001 shared_files: Vec::new(),
19002 shared_symbols: Vec::new(),
19003 shared_tests: Vec::new(),
19004 shared_config_files: Vec::new(),
19005 shared_semantic_refs: Vec::new(),
19006 },
19007 );
19008 }
19009 }
19010 }
19011}
19012
19013fn dependency_dag_worker_follow_up_edges(
19014 profiles: &[DependencyDagProfile],
19015 target_ids: &BTreeSet<String>,
19016 edges: &mut Vec<DependencyDagEdge>,
19017 seen: &mut BTreeSet<(String, String, String)>,
19018) {
19019 for profile in profiles {
19020 for follow_up in &profile.worker_feedback.follow_up_ids {
19021 if target_ids.contains(follow_up) {
19022 dependency_dag_push_edge(
19023 edges,
19024 seen,
19025 DependencyDagEdge {
19026 from: profile.id.clone(),
19027 to: follow_up.clone(),
19028 kind: "worker_result_follow_up".to_string(),
19029 weight: 700,
19030 reasons: vec![format!(
19031 "worker_result for #{} references follow-up #{}",
19032 profile.id, follow_up
19033 )],
19034 shared_files: Vec::new(),
19035 shared_symbols: Vec::new(),
19036 shared_tests: Vec::new(),
19037 shared_config_files: Vec::new(),
19038 shared_semantic_refs: Vec::new(),
19039 },
19040 );
19041 }
19042 }
19043 }
19044}
19045
19046fn dependency_dag_overlap_edges(
19047 profiles: &[DependencyDagProfile],
19048 edges: &mut Vec<DependencyDagEdge>,
19049 seen: &mut BTreeSet<(String, String, String)>,
19050) {
19051 for left_idx in 0..profiles.len() {
19052 for right_idx in (left_idx + 1)..profiles.len() {
19053 let left = &profiles[left_idx];
19054 let right = &profiles[right_idx];
19055 let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19056 let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19057 let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19058 let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19059 let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19060 let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19061 let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19062 if shared_files.is_empty()
19063 && shared_symbols.is_empty()
19064 && shared_tests.is_empty()
19065 && shared_config_files.is_empty()
19066 && shared_semantic_refs.is_empty()
19067 {
19068 continue;
19069 }
19070 let kind = if shared_files.is_empty()
19071 && shared_symbols.is_empty()
19072 && shared_tests.is_empty()
19073 && shared_config_files.is_empty()
19074 {
19075 "semantic_relation"
19076 } else {
19077 "shared_resource"
19078 };
19079 let mut reasons = Vec::new();
19080 if !shared_files.is_empty() {
19081 reasons.push(format!("shared files: {}", shared_files.join(", ")));
19082 }
19083 if !shared_symbols.is_empty() {
19084 reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19085 }
19086 if !shared_tests.is_empty() {
19087 reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19088 }
19089 if !shared_config_files.is_empty() {
19090 reasons.push(format!(
19091 "shared config files: {}",
19092 shared_config_files.join(", ")
19093 ));
19094 }
19095 if !shared_semantic_refs.is_empty() {
19096 reasons.push(format!(
19097 "shared semantic refs: {}",
19098 shared_semantic_refs.join(", ")
19099 ));
19100 }
19101 let weight = shared_files.len() * 100
19102 + shared_config_files.len() * 100
19103 + shared_symbols.len() * 40
19104 + shared_tests.len() * 10
19105 + shared_semantic_refs.len() * 5;
19106 dependency_dag_push_edge(
19107 edges,
19108 seen,
19109 DependencyDagEdge {
19110 from: left.id.clone(),
19111 to: right.id.clone(),
19112 kind: kind.to_string(),
19113 weight,
19114 reasons,
19115 shared_files,
19116 shared_symbols,
19117 shared_tests,
19118 shared_config_files,
19119 shared_semantic_refs,
19120 },
19121 );
19122 }
19123 }
19124}
19125
19126fn dependency_dag_topo_batches(
19127 targets: &[String],
19128 edges: &[DependencyDagEdge],
19129) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19130 let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19131 let order = targets
19132 .iter()
19133 .enumerate()
19134 .map(|(idx, id)| (id.clone(), idx))
19135 .collect::<BTreeMap<_, _>>();
19136 let mut indegree = targets
19137 .iter()
19138 .map(|id| (id.clone(), 0usize))
19139 .collect::<BTreeMap<_, _>>();
19140 let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19141 let mut seen_pairs = BTreeSet::<(String, String)>::new();
19142 for edge in edges {
19143 if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19144 continue;
19145 }
19146 if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19147 continue;
19148 }
19149 *indegree.entry(edge.to.clone()).or_default() += 1;
19150 outgoing
19151 .entry(edge.from.clone())
19152 .or_default()
19153 .push(edge.to.clone());
19154 }
19155 for values in outgoing.values_mut() {
19156 values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19157 values.dedup();
19158 }
19159
19160 let mut processed = BTreeSet::new();
19161 let mut batches = Vec::new();
19162 loop {
19163 let mut ready = targets
19164 .iter()
19165 .filter(|id| !processed.contains(*id))
19166 .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19167 .cloned()
19168 .collect::<Vec<_>>();
19169 ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19170 if ready.is_empty() {
19171 break;
19172 }
19173 for id in &ready {
19174 processed.insert(id.clone());
19175 for next in outgoing.get(id).into_iter().flatten() {
19176 if let Some(value) = indegree.get_mut(next) {
19177 *value = value.saturating_sub(1);
19178 }
19179 }
19180 }
19181 batches.push(DependencyDagTopoBatch {
19182 batch: batches.len() + 1,
19183 targets: ready,
19184 });
19185 }
19186
19187 let blocked_nodes = targets
19188 .iter()
19189 .filter(|id| !processed.contains(*id))
19190 .cloned()
19191 .collect::<Vec<_>>();
19192 let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19193 let cycle_edges = edges
19194 .iter()
19195 .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19196 .cloned()
19197 .collect::<Vec<_>>();
19198 (
19199 batches,
19200 DependencyDagCycleDiagnostics {
19201 has_cycles: !blocked_nodes.is_empty(),
19202 blocked_nodes,
19203 cycle_edges,
19204 },
19205 )
19206}
19207
19208fn dependency_dag_replay_commands(
19209 path: &Path,
19210 scope: Option<&str>,
19211 targets: &[String],
19212 depth: usize,
19213 limit: usize,
19214) -> Vec<String> {
19215 let target_args = targets
19216 .iter()
19217 .map(|target| shell_quote(target))
19218 .collect::<Vec<_>>()
19219 .join(" ");
19220 let mut command = format!(
19221 "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19222 shell_quote(path.to_string_lossy().as_ref()),
19223 scope
19224 .map(|scope| format!(" --scope {}", shell_quote(scope)))
19225 .unwrap_or_default(),
19226 depth,
19227 limit
19228 );
19229 if !target_args.is_empty() {
19230 command.push(' ');
19231 command.push_str(&target_args);
19232 }
19233 vec![command]
19234}
19235
19236fn build_dependency_dag_report(
19237 path: &Path,
19238 scope: Option<&str>,
19239 raw_targets: &[String],
19240 depth: usize,
19241 limit: usize,
19242) -> Result<DependencyDagReport> {
19243 let root = lint::resolve_project_root_or_canonical_path(path)?;
19244 write_traversal_graph_store(&root, path, scope)
19245 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19246 let graph_db = graph_substrate_db_path(&root, scope);
19247 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19248 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19249 let mut warnings = Vec::new();
19250 if let Some(recovery) = store.read_only_recovery() {
19251 warnings.push(graph_db_read_recovery_diagnostic(recovery));
19252 }
19253 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19254 if freshness.fail_closed {
19255 bail!(
19256 "dependency-dag graph projection failed closed: {}; repair: {}",
19257 freshness.diagnostics.join("; "),
19258 graph_db_repair_commands(&root, scope).join("; ")
19259 );
19260 }
19261
19262 let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19263 let graph_nodes = store.all_nodes()?;
19264 let graph_edges = store.all_edges()?;
19265 let graph_nodes_by_id = graph_nodes
19266 .into_iter()
19267 .map(|node| (node.id.clone(), node))
19268 .collect::<BTreeMap<_, _>>();
19269 let profiles = target_nodes
19270 .iter()
19271 .map(|node| {
19272 dependency_dag_node_profile(
19273 &root,
19274 &store,
19275 node,
19276 &graph_nodes_by_id,
19277 &graph_edges,
19278 depth,
19279 limit,
19280 )
19281 })
19282 .collect::<Result<Vec<_>>>()?;
19283 let targets = profiles
19284 .iter()
19285 .map(|profile| profile.id.clone())
19286 .collect::<Vec<_>>();
19287 let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19288
19289 let mut edges = Vec::new();
19290 let mut seen_edges = BTreeSet::new();
19291 dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19292 dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19293 dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19294 edges.sort_by(|left, right| {
19295 left.from
19296 .cmp(&right.from)
19297 .then(left.to.cmp(&right.to))
19298 .then(left.kind.cmp(&right.kind))
19299 });
19300 let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19301
19302 let nodes = profiles
19303 .into_iter()
19304 .map(|profile| DependencyDagNode {
19305 id: profile.id,
19306 graph_node_id: profile.graph_node_id,
19307 label: profile.label,
19308 path: profile.path,
19309 line: profile.line,
19310 detail: profile.detail,
19311 source_files: sorted_set(&profile.source_files),
19312 source_symbols: sorted_set(&profile.source_symbols),
19313 config_files: sorted_set(&profile.config_files),
19314 expected_tests: sorted_set(&profile.expected_tests),
19315 semantic_refs: profile.semantic_refs.into_values().collect(),
19316 worker_feedback: profile.worker_feedback,
19317 })
19318 .collect::<Vec<_>>();
19319 let projection_hashes = freshness
19320 .content_hash
19321 .clone()
19322 .into_iter()
19323 .collect::<Vec<_>>();
19324 let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19325 let repair_commands = graph_db_repair_commands(&root, scope);
19326 let summary = DependencyDagSummary {
19327 nodes: nodes.len(),
19328 edges: edges.len(),
19329 topo_batches: topo_batches.len(),
19330 has_cycles: cycle_diagnostics.has_cycles,
19331 };
19332
19333 Ok(DependencyDagReport {
19334 contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19335 root: root.to_string_lossy().to_string(),
19336 scope: scope.map(str::to_string),
19337 path: path.to_string_lossy().to_string(),
19338 targets,
19339 projection_freshness: freshness,
19340 projection_hashes,
19341 nodes,
19342 edges,
19343 topo_batches,
19344 cycle_diagnostics,
19345 summary,
19346 replay_commands,
19347 repair_commands,
19348 warnings,
19349 })
19350}
19351
19352fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
19353 if compact {
19354 println!(
19355 "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
19356 report.targets.len(),
19357 report.edges.len(),
19358 report.topo_batches.len(),
19359 report.cycle_diagnostics.has_cycles
19360 );
19361 } else {
19362 println!("Dependency DAG");
19363 println!(" targets: {}", report.targets.join(", "));
19364 println!(" edges: {}", report.edges.len());
19365 println!(" cycles: {}", report.cycle_diagnostics.has_cycles);
19366 }
19367 for batch in &report.topo_batches {
19368 println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
19369 }
19370 for edge in &report.edges {
19371 println!(
19372 "edge {} -> {} kind:{} weight:{}",
19373 edge.from, edge.to, edge.kind, edge.weight
19374 );
19375 for reason in &edge.reasons {
19376 println!(" reason: {reason}");
19377 }
19378 }
19379 if report.cycle_diagnostics.has_cycles {
19380 println!(
19381 "cycle blocked nodes: {}",
19382 report.cycle_diagnostics.blocked_nodes.join(", ")
19383 );
19384 }
19385 for command in &report.replay_commands {
19386 println!("replay: {command}");
19387 }
19388 for command in &report.repair_commands {
19389 println!("repair: {command}");
19390 }
19391 for warning in &report.warnings {
19392 println!("warning: {warning}");
19393 }
19394}
19395
19396fn cmd_dependency_dag(
19397 path: &Path,
19398 scope: Option<&str>,
19399 raw_targets: &[String],
19400 depth: usize,
19401 limit: usize,
19402 format: OutputFormat,
19403) -> Result<()> {
19404 let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
19405 if format.json_output {
19406 print_json_or_envelope(
19407 &report,
19408 &format,
19409 "dependency-dag",
19410 "topological-planning",
19411 ToolEnvelopeSummary {
19412 text: format!(
19413 "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
19414 report.targets.len(),
19415 report.edges.len(),
19416 report.topo_batches.len(),
19417 report.cycle_diagnostics.has_cycles
19418 ),
19419 metrics: vec![
19420 envelope_metric("targets", report.targets.len()),
19421 envelope_metric("edges", report.edges.len()),
19422 envelope_metric("topo_batches", report.topo_batches.len()),
19423 envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
19424 ],
19425 },
19426 report.cycle_diagnostics.has_cycles,
19427 report.replay_commands.clone(),
19428 )
19429 } else {
19430 print_dependency_dag_human(&report, format.compact);
19431 Ok(())
19432 }
19433}
19434
19435pub(crate) fn render_log_digest_from_input(
19436 path: &Path,
19437 input: &str,
19438 format: OutputFormat,
19439) -> Result<()> {
19440 let report = log_digest::compute(path, input)?;
19441 if format.json_output {
19442 println!(
19443 "{}",
19444 to_json_schema(
19445 &report,
19446 format.pretty,
19447 format.terse,
19448 format.ultra_terse,
19449 format.schema
19450 )?
19451 );
19452 return Ok(());
19453 }
19454
19455 if format.compact {
19456 println!(
19457 "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
19458 report.non_empty_lines,
19459 report.signal_groups,
19460 report.repeated_line_groups,
19461 report.file_ref_groups,
19462 report.symbol_ref_groups,
19463 report.stack_groups
19464 );
19465 for signal in &report.signals {
19466 let location = match (&signal.path, signal.line) {
19467 (Some(path), Some(line)) => format!("{path}:{line}"),
19468 (Some(path), None) => path.clone(),
19469 _ => "-".to_string(),
19470 };
19471 println!(
19472 "{} sev:{} count:{} sums:{} msg:{}",
19473 location,
19474 signal.severity,
19475 signal.occurrences,
19476 log_digest_summary_label(signal.summary_state),
19477 truncate_for_compact(&signal.message, 80)
19478 );
19479 }
19480 for repeated in &report.repeated_lines {
19481 println!(
19482 "repeat count:{} line:{}",
19483 repeated.occurrences,
19484 truncate_for_compact(&repeated.line, 80)
19485 );
19486 }
19487 for symbol in &report.symbol_refs {
19488 println!(
19489 "sym:{} count:{} sums:{}",
19490 symbol.symbol,
19491 symbol.occurrences,
19492 log_digest_summary_label(symbol.summary_state)
19493 );
19494 }
19495 for warning in &report.warnings {
19496 println!("warning: {warning}");
19497 }
19498 return Ok(());
19499 }
19500
19501 println!("Log digest");
19502 println!(" lines: {}", report.total_lines);
19503 println!(" non-empty lines: {}", report.non_empty_lines);
19504 println!(" signal groups: {}", report.signal_groups);
19505 println!(
19506 " repeated lines: {}",
19507 report.repeated_line_groups
19508 );
19509 println!(
19510 " repeated line instances: {}",
19511 report.repeated_line_occurrences
19512 );
19513 println!(" file refs: {}", report.file_ref_groups);
19514 println!(" symbol refs: {}", report.symbol_ref_groups);
19515 println!(" stack groups: {}", report.stack_groups);
19516
19517 if !report.signals.is_empty() {
19518 println!();
19519 println!("Signals:");
19520 for signal in &report.signals {
19521 match (&signal.path, signal.line, signal.column) {
19522 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
19523 (Some(path), Some(line), None) => println!("{path}:{line}"),
19524 (Some(path), None, _) => println!("{path}"),
19525 (None, _, _) => println!("(no file anchor)"),
19526 }
19527 println!(" severity: {}", signal.severity);
19528 println!(" occurrences: {}", signal.occurrences);
19529 println!(" message: {}", signal.message);
19530 println!(
19531 " cached summaries: {}",
19532 log_digest_summary_label(signal.summary_state)
19533 );
19534 for summary in &signal.current_summaries {
19535 println!(
19536 " - {}: {}",
19537 summary.symbol,
19538 truncate_for_compact(&summary.summary, 160)
19539 );
19540 }
19541 }
19542 }
19543
19544 if !report.repeated_lines.is_empty() {
19545 println!();
19546 println!("Repeated lines:");
19547 for repeated in &report.repeated_lines {
19548 println!(
19549 " {}x {}",
19550 repeated.occurrences,
19551 truncate_for_compact(&repeated.line, 180)
19552 );
19553 }
19554 }
19555
19556 if !report.file_refs.is_empty() {
19557 println!();
19558 println!("Anchored files:");
19559 for file_ref in &report.file_refs {
19560 match (file_ref.line, file_ref.column) {
19561 (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
19562 (Some(line), None) => println!("{}:{}", file_ref.path, line),
19563 (None, _) => println!("{}", file_ref.path),
19564 }
19565 println!(" occurrences: {}", file_ref.occurrences);
19566 println!(
19567 " cached summaries: {}",
19568 log_digest_summary_label(file_ref.summary_state)
19569 );
19570 for summary in &file_ref.current_summaries {
19571 println!(
19572 " - {}: {}",
19573 summary.symbol,
19574 truncate_for_compact(&summary.summary, 160)
19575 );
19576 }
19577 }
19578 }
19579
19580 if !report.symbol_refs.is_empty() {
19581 println!();
19582 println!("Symbol candidates:");
19583 for symbol in &report.symbol_refs {
19584 println!("{}", symbol.symbol);
19585 println!(" occurrences: {}", symbol.occurrences);
19586 println!(
19587 " cached summaries: {}",
19588 log_digest_summary_label(symbol.summary_state)
19589 );
19590 for summary in &symbol.current_summaries {
19591 println!(
19592 " - {}: {}",
19593 summary.symbol,
19594 truncate_for_compact(&summary.summary, 160)
19595 );
19596 }
19597 }
19598 }
19599
19600 if !report.stack_traces.is_empty() {
19601 println!();
19602 println!("Stack groups:");
19603 for stack in &report.stack_traces {
19604 println!(" occurrences: {}", stack.occurrences);
19605 for frame in &stack.frames {
19606 println!(" - {}", frame);
19607 }
19608 }
19609 }
19610
19611 for warning in &report.warnings {
19612 println!("warning: {warning}");
19613 }
19614 Ok(())
19615}
19616
19617pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
19618 match trend {
19619 metric_digest::MetricDigestTrend::Improved => "improved",
19620 metric_digest::MetricDigestTrend::Regressed => "regressed",
19621 metric_digest::MetricDigestTrend::Flat => "flat",
19622 metric_digest::MetricDigestTrend::Unknown => "changed",
19623 }
19624}
19625
19626pub(crate) fn metric_digest_gate_label(
19627 decision: metric_digest::CommunitySearchGateDecision,
19628) -> &'static str {
19629 match decision {
19630 metric_digest::CommunitySearchGateDecision::Pass => "pass",
19631 metric_digest::CommunitySearchGateDecision::Block => "block",
19632 }
19633}
19634
19635fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
19636 let input = fs::read_to_string(fixture_path)
19637 .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
19638 let report = dci_benchmark::compute(&input)?;
19639
19640 if format.json_output {
19641 println!(
19642 "{}",
19643 to_json_schema(
19644 &report,
19645 format.pretty,
19646 format.terse,
19647 format.ultra_terse,
19648 format.schema
19649 )?
19650 );
19651 return Ok(());
19652 }
19653
19654 if format.compact {
19655 println!(
19656 "dci tasks:{} strategies:{} warnings:{}",
19657 report.tasks_loaded,
19658 report.strategies_compared,
19659 report.warnings.len()
19660 );
19661 for summary in &report.strategy_summaries {
19662 println!(
19663 "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
19664 summary.strategy,
19665 summary.rank,
19666 summary.localized,
19667 summary.task_runs,
19668 dci_benchmark::format_number(summary.localization_rate * 100.0),
19669 dci_benchmark::format_number(summary.avg_useful_hits),
19670 dci_benchmark::format_number(summary.zero_output_rate * 100.0),
19671 dci_benchmark::format_number(summary.avg_tool_calls),
19672 dci_benchmark::format_number(summary.avg_latency_ms),
19673 dci_benchmark::format_number(summary.avg_estimated_tokens),
19674 dci_benchmark::format_number(summary.avg_output_tokens)
19675 );
19676 }
19677 if let Some(gate) = &report.memory_retrieval_gate {
19678 println!(
19679 "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
19680 gate.decision,
19681 gate.baseline_strategy,
19682 dci_benchmark::format_number(gate.min_avg_useful_hits),
19683 gate.max_zero_output_failures,
19684 gate.diagnostics.len()
19685 );
19686 }
19687 for warning in &report.warnings {
19688 println!("warning: {warning}");
19689 }
19690 return Ok(());
19691 }
19692
19693 println!("DCI benchmark");
19694 if let Some(description) = &report.description {
19695 println!(" description: {}", description);
19696 }
19697 println!(" tasks loaded: {}", report.tasks_loaded);
19698 println!(" strategies compared: {}", report.strategies_compared);
19699
19700 println!();
19701 println!("Strategy summary:");
19702 for summary in &report.strategy_summaries {
19703 println!(
19704 " #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
19705 summary.rank,
19706 summary.strategy,
19707 summary.localized,
19708 summary.task_runs,
19709 summary.localization_rate * 100.0,
19710 dci_benchmark::format_number(summary.avg_useful_hits),
19711 summary.zero_output_rate * 100.0,
19712 dci_benchmark::format_number(summary.avg_tool_calls),
19713 dci_benchmark::format_number(summary.avg_latency_ms),
19714 dci_benchmark::format_number(summary.avg_estimated_tokens),
19715 dci_benchmark::format_number(summary.avg_output_tokens)
19716 );
19717 }
19718
19719 if let Some(gate) = &report.memory_retrieval_gate {
19720 println!();
19721 println!("Memory retrieval gate:");
19722 println!(" decision: {}", gate.decision);
19723 println!(
19724 " baseline: {}, min avg useful hits {}, max zero-output failures {}",
19725 gate.baseline_strategy,
19726 dci_benchmark::format_number(gate.min_avg_useful_hits),
19727 gate.max_zero_output_failures
19728 );
19729 for row in &gate.rows {
19730 println!(
19731 " {}: status {}, avg useful hits {}, zero-output failures {}",
19732 row.strategy,
19733 row.status,
19734 dci_benchmark::format_number(row.avg_useful_hits),
19735 row.zero_output_failures
19736 );
19737 }
19738 for diagnostic in &gate.diagnostics {
19739 println!(" diagnostic: {diagnostic}");
19740 }
19741 }
19742
19743 println!();
19744 println!("Task winners:");
19745 for row in &report.task_rows {
19746 let label = row
19747 .label
19748 .as_ref()
19749 .map(|value| format!(" ({value})"))
19750 .unwrap_or_default();
19751 println!(" {}{}", row.task_id, label);
19752 println!(" localized: {}", row.best_localization.join(", "));
19753 println!(" most useful hits: {}", row.most_useful_hits.join(", "));
19754 println!(
19755 " lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
19756 row.lowest_tool_calls.as_deref().unwrap_or("-"),
19757 row.lowest_latency.as_deref().unwrap_or("-"),
19758 row.lowest_token_budget.as_deref().unwrap_or("-"),
19759 row.lowest_output_tokens.as_deref().unwrap_or("-")
19760 );
19761 if !row.zero_output_failures.is_empty() {
19762 println!(" zero output: {}", row.zero_output_failures.join(", "));
19763 }
19764 }
19765
19766 for warning in &report.warnings {
19767 println!("warning: {warning}");
19768 }
19769 Ok(())
19770}
19771
19772pub(crate) fn format_compact_count(value: u64) -> String {
19773 if value >= 1_000_000 {
19774 format!("{:.1}M", value as f64 / 1_000_000.0)
19775 } else if value >= 1_000 {
19776 format!("{:.1}K", value as f64 / 1_000.0)
19777 } else {
19778 value.to_string()
19779 }
19780}
19781
19782fn cmd_digest_runner(
19783 kind: &str,
19784 path: &Path,
19785 runner: Option<&str>,
19786 shell_command: &str,
19787 format: OutputFormat,
19788) -> Result<()> {
19789 let digest_kind = DigestRunnerKind::parse(kind)?;
19790 let root = transcript_artifact_root(path)?;
19791 let execution = run_digest_runner_command(shell_command)?;
19792 let output = &execution.output;
19793 let captured = String::from_utf8_lossy(&output.stdout).into_owned();
19794 let exit_code = output.status.code().unwrap_or(-1);
19795 if format.json_output && format.envelope {
19796 let artifact_key = format!(
19797 "{}:{}:{}:{}",
19798 digest_kind.as_str(),
19799 shell_command,
19800 execution.executed_command,
19801 captured
19802 );
19803 let artifact = if captured.trim().is_empty() {
19804 None
19805 } else {
19806 let (suffix, expand) = match digest_kind {
19807 DigestRunnerKind::Test => (
19808 "test.log",
19809 format!(
19810 "tsift test-digest --path {} --input {}{} --json",
19811 shell_quote(root.to_string_lossy().as_ref()),
19812 shell_quote(
19813 root.join(".tsift/artifacts")
19814 .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
19815 .to_string_lossy()
19816 .as_ref()
19817 ),
19818 runner
19819 .map(|value| format!(" --runner {}", shell_quote(value)))
19820 .unwrap_or_default()
19821 ),
19822 ),
19823 DigestRunnerKind::Log => (
19824 "log",
19825 format!(
19826 "tsift log-digest --path {} --input {} --json",
19827 shell_quote(root.to_string_lossy().as_ref()),
19828 shell_quote(
19829 root.join(".tsift/artifacts")
19830 .join(format!("{}.log", stable_handle("tart", &artifact_key)))
19831 .to_string_lossy()
19832 .as_ref()
19833 )
19834 ),
19835 ),
19836 };
19837 Some(persist_transcript_artifact(
19838 &root,
19839 "tart",
19840 suffix,
19841 &artifact_key,
19842 &captured,
19843 expand,
19844 )?)
19845 };
19846 let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
19847
19848 match digest_kind {
19849 DigestRunnerKind::Test => {
19850 let digest_report = test_digest::compute(path, &captured, runner)?;
19851 let report = serde_json::json!({
19852 "kind": digest_kind.as_str(),
19853 "command": shell_command,
19854 "executed_command": execution.executed_command,
19855 "exit_code": exit_code,
19856 "success": output.status.success(),
19857 "filter": filter_report,
19858 "artifact": artifact,
19859 "digest": digest_report,
19860 });
19861 let mut follow_up = artifact
19862 .as_ref()
19863 .map(|entry| vec![entry.expand.clone()])
19864 .unwrap_or_default();
19865 follow_up.push(format!(
19866 "tsift rewrite --run {}",
19867 shell_quote(shell_command)
19868 ));
19869 let summary_text = if output.status.success() && digest_report.failures == 0 {
19870 format!("test run passed for {}", runner.unwrap_or("auto"))
19871 } else {
19872 format!("test run captured {} failure(s)", digest_report.failures)
19873 };
19874 print_json_or_envelope(
19875 &report,
19876 &format,
19877 "digest-runner",
19878 "test-run",
19879 ToolEnvelopeSummary {
19880 text: summary_text,
19881 metrics: vec![
19882 envelope_metric("runner", &digest_report.runner),
19883 envelope_metric("exit_code", exit_code),
19884 envelope_metric("filter", execution.filter_label()),
19885 envelope_metric("failures", digest_report.failures),
19886 envelope_metric("groups", digest_report.grouped_failures),
19887 envelope_metric(
19888 "artifact",
19889 artifact
19890 .as_ref()
19891 .map(|entry| entry.handle.as_str())
19892 .unwrap_or("-"),
19893 ),
19894 ],
19895 },
19896 false,
19897 follow_up,
19898 )?;
19899 }
19900 DigestRunnerKind::Log => {
19901 let digest_report = log_digest::compute(path, &captured)?;
19902 let report = serde_json::json!({
19903 "kind": digest_kind.as_str(),
19904 "command": shell_command,
19905 "executed_command": execution.executed_command,
19906 "exit_code": exit_code,
19907 "success": output.status.success(),
19908 "filter": filter_report,
19909 "artifact": artifact,
19910 "digest": digest_report,
19911 });
19912 let mut follow_up = artifact
19913 .as_ref()
19914 .map(|entry| vec![entry.expand.clone()])
19915 .unwrap_or_default();
19916 follow_up.push(format!(
19917 "tsift rewrite --run {}",
19918 shell_quote(shell_command)
19919 ));
19920 let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
19921 "command finished without log signals".to_string()
19922 } else {
19923 format!(
19924 "command emitted {} log signal group(s)",
19925 digest_report.signal_groups
19926 )
19927 };
19928 print_json_or_envelope(
19929 &report,
19930 &format,
19931 "digest-runner",
19932 "command-run",
19933 ToolEnvelopeSummary {
19934 text: summary_text,
19935 metrics: vec![
19936 envelope_metric("exit_code", exit_code),
19937 envelope_metric("filter", execution.filter_label()),
19938 envelope_metric("signals", digest_report.signal_groups),
19939 envelope_metric("file_refs", digest_report.file_ref_groups),
19940 envelope_metric(
19941 "artifact",
19942 artifact
19943 .as_ref()
19944 .map(|entry| entry.handle.as_str())
19945 .unwrap_or("-"),
19946 ),
19947 ],
19948 },
19949 false,
19950 follow_up,
19951 )?;
19952 }
19953 }
19954
19955 if output.status.success() {
19956 return Ok(());
19957 }
19958 if let Some(code) = output.status.code() {
19959 std::process::exit(code);
19960 }
19961 bail!("digest-wrapped command terminated by signal: {shell_command}");
19962 }
19963
19964 if captured.trim().is_empty() {
19965 let label = match digest_kind {
19966 DigestRunnerKind::Test => "test",
19967 DigestRunnerKind::Log => "log",
19968 };
19969 println!("No {label} output captured.");
19970 } else {
19971 match digest_kind {
19972 DigestRunnerKind::Test => {
19973 render_test_digest_from_input(path, &captured, runner, format)?
19974 }
19975 DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
19976 }
19977 }
19978
19979 if output.status.success() {
19980 return Ok(());
19981 }
19982 if let Some(code) = output.status.code() {
19983 std::process::exit(code);
19984 }
19985 bail!("digest-wrapped command terminated by signal: {shell_command}");
19986}
19987
19988struct DigestRunnerExecution {
19989 output: std::process::Output,
19990 executed_command: String,
19991 filter: Option<DigestRunnerFilter>,
19992}
19993
19994impl DigestRunnerExecution {
19995 fn filter_label(&self) -> &'static str {
19996 self.filter
19997 .as_ref()
19998 .map(|filter| filter.tool)
19999 .unwrap_or("none")
20000 }
20001}
20002
20003struct DigestRunnerFilter {
20004 tool: &'static str,
20005 command: String,
20006}
20007
20008impl DigestRunnerFilter {
20009 fn to_json(&self) -> serde_json::Value {
20010 serde_json::json!({
20011 "tool": self.tool,
20012 "command": self.command,
20013 })
20014 }
20015}
20016
20017fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20018 let filter = rtk_rewrite_for_digest_runner(shell_command);
20019 let executed_command = filter
20020 .as_ref()
20021 .map(|filter| filter.command.as_str())
20022 .unwrap_or(shell_command);
20023 let output = Command::new("sh")
20024 .arg("-lc")
20025 .arg(format!("({executed_command}) 2>&1"))
20026 .stdout(Stdio::piped())
20027 .output()
20028 .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20029
20030 Ok(DigestRunnerExecution {
20031 output,
20032 executed_command: executed_command.to_string(),
20033 filter,
20034 })
20035}
20036
20037fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20038 if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20039 return None;
20040 }
20041 let output = Command::new("rtk")
20042 .arg("rewrite")
20043 .arg(shell_command)
20044 .output()
20045 .ok()?;
20046 if !output.status.success() {
20047 return None;
20048 }
20049 let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20050 if rewritten.is_empty() || rewritten == shell_command {
20051 return None;
20052 }
20053 Some(DigestRunnerFilter {
20054 tool: "rtk",
20055 command: rewritten,
20056 })
20057}
20058
20059fn find_command_on_path(command: &str) -> Option<PathBuf> {
20060 let path_var = std::env::var_os("PATH")?;
20061 std::env::split_paths(&path_var)
20062 .map(|dir| dir.join(command))
20063 .find(|candidate| candidate.is_file())
20064}
20065
20066pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20067 if !db_path.exists() {
20068 bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20069 }
20070 summarize::SummaryDb::open_read_only_resilient(db_path)
20071}
20072
20073fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20074 !matches!(report.index, status::IndexStatus::Fresh { .. })
20075}
20076
20077fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20078 !matches!(report.instructions, init::InstructionStatus::Current { .. })
20079}
20080
20081pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20082 if status_instructions_need_fix(report) {
20083 eprintln!("status fix: refreshing tsift instructions");
20084 init::init(root, false, false)?;
20085 }
20086
20087 let eviction = cycle_packet_cache::cycle_packet_cache_evict(
20088 root,
20089 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
20090 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
20091 );
20092 if eviction.evicted_entries > 0 {
20093 eprintln!(
20094 "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
20095 eviction.evicted_entries,
20096 eviction.evicted_bytes,
20097 eviction.remaining_entries
20098 );
20099 }
20100
20101 if !status_index_needs_fix(report) {
20102 return Ok(());
20103 }
20104
20105 let scopes = config::Config::submodule_dirs(root)?;
20106 if scopes.is_empty() {
20107 eprintln!("status fix: refreshing index");
20108 run_index_update(
20109 &root.join(".tsift/index.db"),
20110 root,
20111 "status --fix refreshing index".to_string(),
20112 root,
20113 None,
20114 false,
20115 false,
20116 )?;
20117 return Ok(());
20118 }
20119
20120 let cfg = config::Config::load(root)?;
20121 for scope in scopes {
20122 if !scope.source_root.exists() {
20123 eprintln!(
20124 "status fix: skipping missing submodule `{}` ({})",
20125 scope.id,
20126 scope.source_root.display()
20127 );
20128 continue;
20129 }
20130 eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20131 run_index_update(
20132 &cfg.db_path_for(root, &scope.id),
20133 &scope.source_root,
20134 format!("status --fix refreshing submodule `{}` index", scope.id),
20135 root,
20136 Some(scope.id.as_str()),
20137 false,
20138 false,
20139 )?;
20140 }
20141
20142 Ok(())
20143}
20144
20145pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20146 match &report.index {
20147 status::IndexStatus::Fresh { missing_scopes, .. }
20148 | status::IndexStatus::Stale { missing_scopes, .. }
20149 | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20150 }
20151}
20152
20153pub(crate) fn autoindex_missing_workspace_scopes(
20154 root: &Path,
20155 report: &status::StatusReport,
20156) -> Result<()> {
20157 let missing_scopes = match &report.index {
20158 status::IndexStatus::Fresh { missing_scopes, .. }
20159 | status::IndexStatus::Stale { missing_scopes, .. }
20160 | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20161 };
20162 if missing_scopes.is_empty() {
20163 return Ok(());
20164 }
20165
20166 let missing_scope_ids = missing_scopes
20167 .iter()
20168 .map(|scope| scope.scope.as_str())
20169 .collect::<std::collections::HashSet<_>>();
20170 let cfg = config::Config::load(root)?;
20171 for scope in config::Config::submodule_dirs(root)? {
20172 if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20173 continue;
20174 }
20175 let db_path = cfg.db_path_for(root, &scope.id);
20176 run_index_update(
20177 &db_path,
20178 &scope.source_root,
20179 format!(
20180 "autoindexing missing submodule `{}` during status",
20181 scope.id
20182 ),
20183 root,
20184 Some(scope.id.as_str()),
20185 false,
20186 false,
20187 )?;
20188 }
20189 Ok(())
20190}
20191
20192pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
20193 for warning in &stats.warnings {
20194 let rel_path = relativize_pathbuf(&warning.path, root);
20195 eprintln!(
20196 "warning: summarize stats {}: {}",
20197 rel_path.display(),
20198 warning.message
20199 );
20200 }
20201}
20202
20203fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
20204 Result::<(), anyhow::Error>::Err(err)
20205 .context(context)
20206 .unwrap_err()
20207}
20208
20209fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
20210 let message = err.to_string();
20211 message.contains("another tsift index writer is already active")
20212 || substrate::error_mentions_locked_db(err)
20213}
20214
20215fn add_write_lock_context(
20216 err: anyhow::Error,
20217 action: String,
20218 root: &std::path::Path,
20219 scope: Option<&str>,
20220) -> anyhow::Error {
20221 if !should_attach_lock_diagnostics(&err) {
20222 return contextualize_error(err, action);
20223 }
20224
20225 let Ok(report) = status::check_locks(root, None, scope) else {
20226 return contextualize_error(err, action);
20227 };
20228
20229 contextualize_error(
20230 err,
20231 format!(
20232 "{}\n\nlock diagnostics:\n{}",
20233 action,
20234 status::format_locks_human(&report, false).trim_end()
20235 ),
20236 )
20237}
20238
20239pub(crate) fn run_index_update(
20240 db_path: &std::path::Path,
20241 source_root: &std::path::Path,
20242 action: String,
20243 root: &std::path::Path,
20244 scope: Option<&str>,
20245 rebuild: bool,
20246 prune: bool,
20247) -> Result<index::IndexSummary> {
20248 let result = (|| {
20249 let db = index::IndexDb::open(db_path)?;
20250 if rebuild {
20251 db.rebuild(source_root)
20252 } else if prune {
20253 db.apply_changes_pruned(source_root)
20254 } else {
20255 db.apply_changes(source_root)
20256 }
20257 })();
20258
20259 let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
20260 emit_index_warnings(&summary, source_root, scope);
20261 Ok(summary)
20262}
20263
20264pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
20265 for change in &mut summary.changes {
20266 change.path = relativize_pathbuf(&change.path, root);
20267 }
20268 for warning in &mut summary.warnings {
20269 warning.path = relativize_pathbuf(&warning.path, root);
20270 }
20271}
20272
20273fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
20274 for warning in &summary.warnings {
20275 let rel_path = relativize_pathbuf(&warning.path, root);
20276 let stage = match warning.stage {
20277 index::IndexWarningStage::ReadSource => "read failed",
20278 index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
20279 index::IndexWarningStage::ExtractCallSites => "call extraction failed",
20280 index::IndexWarningStage::ExtractRoutes => "route extraction failed",
20281 };
20282 let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
20283 let lang_suffix = warning
20284 .language
20285 .as_deref()
20286 .map(|lang| format!(" [{}]", lang))
20287 .unwrap_or_default();
20288 eprintln!(
20289 "warning: {}{}{}: {}: {}",
20290 scope_prefix,
20291 rel_path.display(),
20292 lang_suffix,
20293 stage,
20294 warning.message
20295 );
20296 }
20297}
20298
20299pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
20300 let config_path = root.join(".tsift/config.toml");
20301 if !config_path.exists() {
20302 return summarize::SummarizeConfig::default();
20303 }
20304 #[derive(serde::Deserialize, Default)]
20305 struct RawConfig {
20306 #[serde(default)]
20307 summarize: Option<RawSummarize>,
20308 }
20309 #[derive(serde::Deserialize)]
20310 struct RawSummarize {
20311 model: Option<String>,
20312 max_file_tokens: Option<usize>,
20313 api_key_env: Option<String>,
20314 }
20315 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
20316 let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
20317 let defaults = summarize::SummarizeConfig::default();
20318 match raw.summarize {
20319 Some(s) => summarize::SummarizeConfig {
20320 model: s.model.unwrap_or(defaults.model),
20321 max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
20322 api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
20323 },
20324 None => defaults,
20325 }
20326}
20327
20328#[derive(Debug, Clone, PartialEq, Eq)]
20329struct ExtractSymbolContext {
20330 db_path: PathBuf,
20331 source_root: PathBuf,
20332}
20333
20334pub(crate) fn find_symbols_db_for_file(
20335 root: &Path,
20336 file_path: &Path,
20337) -> Result<Option<ExtractSymbolContext>> {
20338 let cfg = config::Config::load(root)?;
20339 let mut submodules = config::Config::submodule_dirs(root)?;
20340 submodules.sort_by(|left, right| {
20341 right
20342 .source_root
20343 .components()
20344 .count()
20345 .cmp(&left.source_root.components().count())
20346 });
20347
20348 for scope in submodules {
20349 if !file_path.starts_with(&scope.source_root) {
20350 continue;
20351 }
20352 let db_path = cfg.db_path_for(root, &scope.id);
20353 if db_path.exists() {
20354 return Ok(Some(ExtractSymbolContext {
20355 db_path,
20356 source_root: scope.source_root,
20357 }));
20358 }
20359 }
20360
20361 let single = root.join(".tsift/index.db");
20362 if single.exists() && file_path.starts_with(root) {
20363 return Ok(Some(ExtractSymbolContext {
20364 db_path: single,
20365 source_root: root.to_path_buf(),
20366 }));
20367 }
20368
20369 Ok(None)
20370}
20371
20372pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
20373 let canonical = path
20374 .canonicalize()
20375 .with_context(|| format!("canonicalizing {}", path.display()))?;
20376
20377 Ok(if canonical.is_dir() {
20378 canonical
20379 } else {
20380 canonical
20381 .parent()
20382 .map(Path::to_path_buf)
20383 .unwrap_or(canonical)
20384 })
20385}
20386
20387fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
20388 if path.exists() {
20389 return path
20390 .canonicalize()
20391 .with_context(|| format!("canonicalizing extract scope {}", path.display()));
20392 }
20393
20394 Ok(summarize::normalize_lexical_path(path))
20395}
20396
20397pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
20398 let scope = if extract_path.is_absolute() {
20399 extract_path.to_path_buf()
20400 } else {
20401 root.join(extract_path)
20402 };
20403 normalize_extract_scope_path(&scope)
20404}
20405
20406pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
20407 normalize_extract_scope_path(changed_path)
20408 .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
20409 .starts_with(extract_scope)
20410}
20411
20412pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
20413 summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
20414}
20415
20416pub(crate) fn summarize_full_extract_deleted_summary_paths(
20417 summary_db: &summarize::SummaryDb,
20418 root: &Path,
20419 extract_scope: &Path,
20420 files_to_extract: &[PathBuf],
20421) -> Result<BTreeSet<String>> {
20422 let live_paths = files_to_extract
20423 .iter()
20424 .map(|file_path| summarize_relative_file_path(root, file_path))
20425 .collect::<BTreeSet<_>>();
20426 let mut deleted = BTreeSet::new();
20427
20428 for cached_path in summary_db.cached_file_paths()? {
20429 if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
20430 continue;
20431 }
20432 if !live_paths.contains(&cached_path) {
20433 deleted.insert(cached_path);
20434 }
20435 }
20436
20437 Ok(deleted)
20438}
20439
20440#[derive(Debug, Clone)]
20441struct SearchIndexTarget {
20442 label: String,
20443 db_path: PathBuf,
20444 source_root: PathBuf,
20445 scope_name: Option<String>,
20446 reindex_cmd: String,
20447}
20448
20449fn cargo_package_index_target(
20450 root: &Path,
20451 package: multiplicity::CargoPackageInfo,
20452) -> SearchIndexTarget {
20453 SearchIndexTarget {
20454 label: format!("cargo package `{}` index", package.scope_id),
20455 db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
20456 source_root: package.package_root.clone(),
20457 scope_name: Some(package.scope_id.clone()),
20458 reindex_cmd: format!(
20459 "tsift index --submodule {} {}",
20460 package.scope_id,
20461 root.display()
20462 ),
20463 }
20464}
20465
20466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20467enum SearchIndexState {
20468 Missing,
20469 Fresh,
20470 Stale { stale_files: usize },
20471}
20472
20473fn resolve_search_index_targets(
20474 root: &Path,
20475 path_hint: &Path,
20476 scope: Option<&str>,
20477 federated: bool,
20478) -> Result<Vec<SearchIndexTarget>> {
20479 if let Some(scope_name) = scope {
20480 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
20481 let cfg = config::Config::load(root)?;
20482 return Ok(vec![SearchIndexTarget {
20483 label: format!("submodule `{}` index", scope.id),
20484 db_path: cfg.db_path_for(root, &scope.id),
20485 source_root: scope.source_root.clone(),
20486 scope_name: Some(scope.id.clone()),
20487 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20488 }]);
20489 }
20490 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
20491 return Ok(vec![cargo_package_index_target(root, package)]);
20492 }
20493 config::Config::resolve_submodule(root, scope_name)?;
20494 }
20495
20496 if federated {
20497 let cfg = config::Config::load(root)?;
20498 let mut targets = Vec::new();
20499 for scope in config::Config::submodule_dirs(root)? {
20500 if !cfg.federation_for_scope(&scope) {
20501 continue;
20502 }
20503 targets.push(SearchIndexTarget {
20504 label: format!("submodule `{}` index", scope.id),
20505 db_path: cfg.db_path_for(root, &scope.id),
20506 source_root: scope.source_root.clone(),
20507 scope_name: Some(scope.id.clone()),
20508 reindex_cmd: format!("tsift index --workspace {}", root.display()),
20509 });
20510 }
20511 return Ok(targets);
20512 }
20513
20514 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
20515 let cfg = config::Config::load(root)?;
20516 return Ok(vec![SearchIndexTarget {
20517 label: format!("submodule `{}` index", scope.id),
20518 db_path: cfg.db_path_for(root, &scope.id),
20519 source_root: scope.source_root.clone(),
20520 scope_name: Some(scope.id.clone()),
20521 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20522 }]);
20523 }
20524
20525 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
20526 return Ok(vec![cargo_package_index_target(root, package)]);
20527 }
20528
20529 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
20530 let cfg = config::Config::load(root)?;
20531 return Ok(vec![SearchIndexTarget {
20532 label: format!("submodule `{}` index", scope.id),
20533 db_path: cfg.db_path_for(root, &scope.id),
20534 source_root: scope.source_root.clone(),
20535 scope_name: Some(scope.id.clone()),
20536 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20537 }]);
20538 }
20539
20540 let scopes = config::Config::submodule_dirs(root)?;
20541 if !scopes.is_empty() {
20542 let root_db = root.join(".tsift/index.db");
20543 if !root_db.exists() {
20544 let available_scopes = scopes
20545 .iter()
20546 .map(|scope| scope.id.as_str())
20547 .collect::<Vec<_>>()
20548 .join(", ");
20549 let cfg = config::Config::load(root)?;
20550 let indexed_scopes = scopes
20551 .iter()
20552 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
20553 .map(|scope| scope.id.as_str())
20554 .collect::<Vec<_>>();
20555 let indexed_label = if indexed_scopes.is_empty() {
20556 "none".to_string()
20557 } else {
20558 indexed_scopes.join(", ")
20559 };
20560 bail!(
20561 "workspace root {} has no shared root index at {}. Default search requires `--scope <scope>` or `--federated` when the workspace uses scoped `.tsift/indexes/*/index.db` files. Available scopes: {}. Indexed scopes: {}.",
20562 root.display(),
20563 root_db.display(),
20564 available_scopes,
20565 indexed_label,
20566 );
20567 }
20568 }
20569
20570 Ok(vec![SearchIndexTarget {
20571 label: "index".to_string(),
20572 db_path: root.join(".tsift/index.db"),
20573 source_root: root.to_path_buf(),
20574 scope_name: None,
20575 reindex_cmd: format!("tsift index {}", root.display()),
20576 }])
20577}
20578
20579fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
20580 if !target.source_root.exists() || !target.db_path.exists() {
20581 return Ok(SearchIndexState::Missing);
20582 }
20583
20584 let inspection =
20585 index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
20586 let stale_files =
20587 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
20588 if stale_files == 0 {
20589 Ok(SearchIndexState::Fresh)
20590 } else {
20591 Ok(SearchIndexState::Stale { stale_files })
20592 }
20593}
20594
20595#[derive(Debug, Clone, PartialEq, Eq)]
20596struct RebuildSearchTarget {
20597 label: String,
20598 reason: RebuildSearchReason,
20599 reindex_cmd: String,
20600}
20601
20602#[derive(Debug, Clone, PartialEq, Eq)]
20603enum RebuildSearchReason {
20604 Missing,
20605 Stale { stale_files: usize },
20606}
20607
20608#[derive(Debug, Clone, PartialEq, Eq)]
20609struct DegradedSearchTarget {
20610 label: String,
20611 reason: RebuildSearchReason,
20612 reindex_cmd: String,
20613}
20614
20615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20616pub(crate) enum DegradedSearchMode {
20617 ReadOnly,
20618 Exact,
20619}
20620
20621#[derive(Debug)]
20622struct SearchPrecheck {
20623 targets: Vec<SearchIndexTarget>,
20624 degraded_targets: Vec<DegradedSearchTarget>,
20625}
20626
20627fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
20628 err.chain().any(|cause| {
20629 cause
20630 .to_string()
20631 .contains("another tsift index writer is already active")
20632 })
20633}
20634
20635fn infer_agent_doc_task_submodule(
20636 root: &Path,
20637 path_hint: &Path,
20638) -> Result<Option<config::WorkspaceScope>> {
20639 let hinted_path = if path_hint.is_absolute() {
20640 path_hint.to_path_buf()
20641 } else {
20642 root.join(path_hint)
20643 };
20644 let Ok(relative) = hinted_path.strip_prefix(root) else {
20645 return Ok(None);
20646 };
20647 let mut components = relative.components();
20648 let Some(std::path::Component::Normal(first)) = components.next() else {
20649 return Ok(None);
20650 };
20651 if first != "tasks" {
20652 return Ok(None);
20653 }
20654 let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
20655 return Ok(None);
20656 };
20657 config::Config::find_submodule(root, file_stem)
20658}
20659
20660fn degraded_search_target(
20661 target: &SearchIndexTarget,
20662 reason: RebuildSearchReason,
20663) -> DegradedSearchTarget {
20664 DegradedSearchTarget {
20665 label: target.label.clone(),
20666 reason,
20667 reindex_cmd: target.reindex_cmd.clone(),
20668 }
20669}
20670
20671fn apply_search_index_update(
20672 root: &Path,
20673 target: &SearchIndexTarget,
20674) -> Result<index::IndexSummary> {
20675 run_index_update(
20676 &target.db_path,
20677 &target.source_root,
20678 format!("autoindexing {}", target.label),
20679 root,
20680 target.scope_name.as_deref(),
20681 false,
20682 false,
20683 )
20684}
20685
20686fn collect_rebuild_search_targets(
20687 targets: &[SearchIndexTarget],
20688) -> Result<Vec<RebuildSearchTarget>> {
20689 let mut rebuild_targets = Vec::new();
20690 for target in targets {
20691 let reason = match inspect_search_index(target)? {
20692 SearchIndexState::Missing => RebuildSearchReason::Missing,
20693 SearchIndexState::Fresh => continue,
20694 SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
20695 };
20696 rebuild_targets.push(RebuildSearchTarget {
20697 label: target.label.clone(),
20698 reason,
20699 reindex_cmd: target.reindex_cmd.clone(),
20700 });
20701 }
20702 Ok(rebuild_targets)
20703}
20704
20705fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
20706 match target.reason {
20707 RebuildSearchReason::Missing => format!("{} is missing", target.label),
20708 RebuildSearchReason::Stale { stale_files } => {
20709 let file_suffix = if stale_files == 1 { "" } else { "s" };
20710 format!(
20711 "{} is stale ({} file{})",
20712 target.label, stale_files, file_suffix
20713 )
20714 }
20715 }
20716}
20717
20718fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
20719 if rebuild_targets.len() == 1 {
20720 let target = &rebuild_targets[0];
20721 return format!(
20722 "{}. Run `{}` to rebuild before retrying.",
20723 rebuild_search_target_detail(target),
20724 target.reindex_cmd
20725 );
20726 }
20727
20728 let summary: Vec<String> = rebuild_targets
20729 .iter()
20730 .take(3)
20731 .map(rebuild_search_target_detail)
20732 .collect();
20733 let overflow = rebuild_targets.len().saturating_sub(summary.len());
20734 let mut details = summary.join(", ");
20735 if overflow > 0 {
20736 details.push_str(&format!(", +{} more", overflow));
20737 }
20738 let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
20739 format!(
20740 "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
20741 rebuild_targets.len(),
20742 details,
20743 reindex_cmd
20744 )
20745}
20746
20747pub(crate) fn precheck_search_indexes(
20748 root: &Path,
20749 path_hint: &Path,
20750 scope: Option<&str>,
20751 federated: bool,
20752 autoindex: bool,
20753) -> Result<SearchPrecheck> {
20754 let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
20755 let mut stale_targets = Vec::new();
20756 let mut degraded_targets = Vec::new();
20757
20758 for target in &targets {
20759 match inspect_search_index(target)? {
20760 SearchIndexState::Missing => {
20761 if autoindex && let Err(err) = apply_search_index_update(root, target) {
20762 if is_active_writer_lock_error(&err) {
20763 degraded_targets
20764 .push(degraded_search_target(target, RebuildSearchReason::Missing));
20765 } else {
20766 return Err(err);
20767 }
20768 }
20769 }
20770 SearchIndexState::Fresh => {}
20771 SearchIndexState::Stale { stale_files } => {
20772 if autoindex {
20773 if let Err(err) = apply_search_index_update(root, target) {
20774 if is_active_writer_lock_error(&err) {
20775 degraded_targets.push(degraded_search_target(
20776 target,
20777 RebuildSearchReason::Stale { stale_files },
20778 ));
20779 } else {
20780 return Err(err);
20781 }
20782 }
20783 } else {
20784 stale_targets.push(RebuildSearchTarget {
20785 label: target.label.clone(),
20786 reason: RebuildSearchReason::Stale { stale_files },
20787 reindex_cmd: target.reindex_cmd.clone(),
20788 });
20789 }
20790 }
20791 }
20792 }
20793
20794 if stale_targets.is_empty() {
20795 return Ok(SearchPrecheck {
20796 targets,
20797 degraded_targets,
20798 });
20799 }
20800
20801 bail!(
20802 "tsift search aborted: {} \
20803 or re-run without `--no-autoindex`.",
20804 rebuild_search_targets_message(&stale_targets),
20805 );
20806}
20807
20808pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
20809 if targets.is_empty() {
20810 return None;
20811 }
20812
20813 if targets
20814 .iter()
20815 .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
20816 {
20817 Some(DegradedSearchMode::Exact)
20818 } else {
20819 Some(DegradedSearchMode::ReadOnly)
20820 }
20821}
20822
20823fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
20824 if targets.len() == 1 {
20825 let target = &targets[0];
20826 return match target.reason {
20827 RebuildSearchReason::Missing => format!("{} is missing", target.label),
20828 RebuildSearchReason::Stale { stale_files } => {
20829 let file_suffix = if stale_files == 1 { "" } else { "s" };
20830 format!(
20831 "{} is stale ({} file{})",
20832 target.label, stale_files, file_suffix
20833 )
20834 }
20835 };
20836 }
20837
20838 let missing = targets
20839 .iter()
20840 .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
20841 .count();
20842 let stale = targets.len().saturating_sub(missing);
20843 let mut parts = Vec::new();
20844 if stale > 0 {
20845 let suffix = if stale == 1 { "" } else { "es" };
20846 parts.push(format!("{stale} stale index{suffix}"));
20847 }
20848 if missing > 0 {
20849 let suffix = if missing == 1 { "" } else { "es" };
20850 parts.push(format!("{missing} missing index{suffix}"));
20851 }
20852 parts.join(", ")
20853}
20854
20855pub(crate) fn emit_degraded_search_note(
20856 targets: &[DegradedSearchTarget],
20857 mode: DegradedSearchMode,
20858) {
20859 let summary = degraded_search_targets_summary(targets);
20860 let reindex_cmd = &targets[0].reindex_cmd;
20861 match mode {
20862 DegradedSearchMode::ReadOnly => eprintln!(
20863 "note: active tsift writer detected; skipping autoindex because {}. \
20864 Continuing with read-only search and the current index snapshot; symbol hits may lag. \
20865 Retry `{}` after the active writer finishes for fresh index results.",
20866 summary, reindex_cmd
20867 ),
20868 DegradedSearchMode::Exact => eprintln!(
20869 "note: active tsift writer detected; skipping autoindex because {}. \
20870 Continuing with exact live-file search. Retry `{}` after the active writer finishes \
20871 for indexed symbol hits.",
20872 summary, reindex_cmd
20873 ),
20874 }
20875}
20876
20877fn search_timeout_message(
20878 timeout_secs: u64,
20879 strategy: &str,
20880 targets: &[SearchIndexTarget],
20881) -> Result<String> {
20882 let rebuild_targets = collect_rebuild_search_targets(targets)?;
20883 if rebuild_targets.is_empty() {
20884 return Ok(format!(
20885 "tsift search timed out after {}s (strategy: {}). \
20886 The search root looks fresh, so reindexing is unlikely to help. \
20887 Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
20888 or try a different strategy.",
20889 timeout_secs, strategy,
20890 ));
20891 }
20892
20893 Ok(format!(
20894 "tsift search timed out after {}s (strategy: {}). {}",
20895 timeout_secs,
20896 strategy,
20897 rebuild_search_targets_message(&rebuild_targets),
20898 ))
20899}
20900
20901fn is_exact_preferring_query_char(ch: char) -> bool {
20902 matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
20903}
20904
20905fn query_prefers_exact_search(query: &str) -> bool {
20906 let trimmed = query.trim();
20907 !trimmed.is_empty()
20908 && !trimmed.chars().any(char::is_whitespace)
20909 && trimmed.chars().any(|ch| ch.is_alphanumeric())
20910 && trimmed.chars().any(is_exact_preferring_query_char)
20911 && trimmed
20912 .chars()
20913 .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
20914}
20915
20916pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
20917 strategy.unwrap_or_else(|| {
20918 if query_prefers_exact_search(query) {
20919 "exact".to_string()
20920 } else {
20921 "lexical".to_string()
20922 }
20923 })
20924}
20925
20926
20927pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
20928 let mut files = Vec::new();
20929 if path.is_file() {
20930 files.push(path.to_path_buf());
20931 return Ok(files);
20932 }
20933 let walker = ignore::WalkBuilder::new(path)
20934 .hidden(true)
20935 .git_ignore(true)
20936 .build();
20937 for entry in walker {
20938 let entry = entry?;
20939 if entry.file_type().is_some_and(|ft| ft.is_file()) {
20940 let p = entry.path();
20941 if let Some(ext) = p.extension() {
20942 let ext = ext.to_string_lossy();
20943 if matches!(
20944 ext.as_ref(),
20945 "rs" | "py"
20946 | "ts"
20947 | "tsx"
20948 | "js"
20949 | "jsx"
20950 | "kt"
20951 | "kts"
20952 | "zig"
20953 | "sh"
20954 | "bash"
20955 | "zsh"
20956 ) {
20957 files.push(p.to_path_buf());
20958 }
20959 }
20960 }
20961 }
20962 Ok(files)
20963}
20964
20965#[cfg(test)]
20966 mod tests {
20967 use super::*;
20968 use super::semantic_edit::{
20969 EditOp,
20970 apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
20971 markdown_section_spans,
20972 };
20973 use tsift_memory::{MemoryEventKind, MemoryStore};
20974
20975 use std::cell::RefCell;
20976 use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
20977 fn parse_cli<I, T>(itr: I) -> Cli
20978 where
20979 I: IntoIterator<Item = T> + Send + 'static,
20980 T: Into<std::ffi::OsString> + Clone + Send + 'static,
20981 {
20982 std::thread::Builder::new()
20983 .name("cli-parse".to_string())
20984 .stack_size(16 * 1024 * 1024)
20985 .spawn(move || Cli::parse_from(itr))
20986 .unwrap()
20987 .join()
20988 .unwrap()
20989 }
20990
20991 fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
20992 where
20993 I: IntoIterator<Item = T> + Send + 'static,
20994 T: Into<std::ffi::OsString> + Clone + Send + 'static,
20995 {
20996 std::thread::Builder::new()
20997 .name("cli-try-parse".to_string())
20998 .stack_size(16 * 1024 * 1024)
20999 .spawn(move || Cli::try_parse_from(itr))
21000 .unwrap()
21001 .join()
21002 .unwrap()
21003 }
21004
21005 fn build_relative_search_budget_report(
21006 query: &str,
21007 strategy: &str,
21008 root: &Path,
21009 response: &sift::SearchResponse,
21010 symbol_hits: &[index::SymbolHit],
21011 budget: ResponseBudget,
21012 filters: &SearchFacetFilters,
21013 ) -> SearchBudgetReport {
21014 build_search_budget_report(SearchBudgetReportInput {
21015 query,
21016 strategy,
21017 root,
21018 response,
21019 symbol_hits,
21020 absolute: false,
21021 budget,
21022 filters,
21023 })
21024 }
21025
21026 #[derive(Default)]
21027 struct MemoryConvexGraphClient {
21028 nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
21029 edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
21030 }
21031
21032 impl ConvexGraphClient for MemoryConvexGraphClient {
21033 fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
21034 self.nodes
21035 .borrow_mut()
21036 .insert(row.external_id.clone(), row.clone());
21037 Ok(())
21038 }
21039
21040 fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
21041 self.edges
21042 .borrow_mut()
21043 .insert(row.edge_key.clone(), row.clone());
21044 Ok(())
21045 }
21046
21047 fn delete_node_row(&self, external_id: &str) -> Result<usize> {
21048 Ok(usize::from(
21049 self.nodes.borrow_mut().remove(external_id).is_some(),
21050 ))
21051 }
21052
21053 fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
21054 Ok(usize::from(
21055 self.edges.borrow_mut().remove(edge_key).is_some(),
21056 ))
21057 }
21058
21059 fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
21060 Ok(self.nodes.borrow().get(external_id).cloned())
21061 }
21062
21063 fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
21064 Ok(self.nodes.borrow().values().cloned().collect())
21065 }
21066
21067 fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
21068 Ok(self.edges.borrow().values().cloned().collect())
21069 }
21070
21071 fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
21072 Ok(self
21073 .nodes
21074 .borrow()
21075 .values()
21076 .filter(|row| row.kind == kind)
21077 .cloned()
21078 .collect())
21079 }
21080
21081 fn outgoing_edge_rows(
21082 &self,
21083 from_external_id: &str,
21084 kind: Option<&str>,
21085 ) -> Result<Vec<ConvexEdgeRow>> {
21086 Ok(self
21087 .edges
21088 .borrow()
21089 .values()
21090 .filter(|row| row.from_external_id == from_external_id)
21091 .filter(|row| kind.is_none_or(|kind| row.kind == kind))
21092 .cloned()
21093 .collect())
21094 }
21095 }
21096
21097 fn init_git_repo(path: &Path) {
21098 let status = std::process::Command::new("git")
21099 .args(["init"])
21100 .current_dir(path)
21101 .status()
21102 .unwrap();
21103 assert!(status.success(), "git init failed");
21104
21105 let status = std::process::Command::new("git")
21106 .args(["add", "."])
21107 .current_dir(path)
21108 .status()
21109 .unwrap();
21110 assert!(status.success(), "git add failed");
21111
21112 let status = std::process::Command::new("git")
21113 .args([
21114 "-c",
21115 "user.name=tsift-tests",
21116 "-c",
21117 "user.email=tsift-tests@example.com",
21118 "commit",
21119 "--quiet",
21120 "-m",
21121 "init",
21122 ])
21123 .current_dir(path)
21124 .status()
21125 .unwrap();
21126 assert!(status.success(), "git commit failed");
21127 }
21128
21129 fn write_empty_root_index(root: &Path) {
21130 let index_dir = root.join(".tsift");
21131 fs::create_dir_all(&index_dir).unwrap();
21132 fs::write(index_dir.join("index.db"), "").unwrap();
21133 }
21134
21135 fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
21136 if let Some(parent) = path.parent() {
21137 fs::create_dir_all(parent).unwrap();
21138 }
21139 let body = std::iter::repeat_n(line, lines)
21140 .collect::<Vec<_>>()
21141 .join("\n");
21142 fs::write(path, format!("{body}\n")).unwrap();
21143 path.to_path_buf()
21144 }
21145
21146 #[test]
21149 fn token_capped_preview_returns_all_lines_when_under_cap() {
21150 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 1", "}"];
21151 let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
21152 assert!(!result.was_capped);
21153 assert_eq!(result.preview.len(), 3);
21154 assert_eq!(result.capped_end, 3);
21155 }
21156
21157 #[test]
21158 fn token_capped_preview_truncates_when_over_cap() {
21159 let lines: Vec<&str> = (0..200).map(|_| " let x = some_very_long_expression_here();").collect();
21160 let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
21161 assert!(result.was_capped);
21162 assert!(result.preview.len() < 200);
21163 assert!(result.capped_end < 200);
21164 }
21165
21166 #[test]
21167 fn token_capped_preview_keeps_at_least_one_line() {
21168 let long_line: String = "x".repeat(8000);
21169 let lines: Vec<&str> = vec![&long_line];
21170 let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
21171 assert!(!result.was_capped);
21172 assert_eq!(result.preview.len(), 1);
21173 }
21174
21175 #[test]
21176 fn token_capped_preview_cap_at_boundary() {
21177 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21178 let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
21179 assert!(!result.was_capped);
21180 assert_eq!(result.preview.len(), 4);
21181 }
21182
21183 #[test]
21184 fn token_capped_preview_cap_just_over_boundary() {
21185 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21186 let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
21187 assert!(result.was_capped);
21188 assert_eq!(result.preview.len(), 3);
21189 assert_eq!(result.capped_end, 3);
21190 }
21191
21192 #[test]
21193 fn token_capped_preview_empty_lines() {
21194 let lines: Vec<&str> = vec![];
21195 let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
21196 assert!(!result.was_capped);
21197 assert!(result.preview.is_empty());
21198 }
21199
21200 #[test]
21201 fn token_capped_preview_per_line_truncation_applied() {
21202 let long_line = "x".repeat(500);
21203 let lines: Vec<&str> = vec![&long_line, "short"];
21204 let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
21205 assert!(!result.was_capped);
21206 assert_eq!(result.preview.len(), 2);
21207 assert!(result.preview[0].text.len() <= 23);
21208 assert!(result.preview[0].text.ends_with("..."));
21209 }
21210
21211 #[test]
21214 fn route_search_defaults_to_haiku() {
21215 let (tier, model) = classify_task("find all uses of authenticate");
21216 assert_eq!(tier, "haiku");
21217 assert!(
21218 model.contains("haiku"),
21219 "expected haiku model, got {}",
21220 model
21221 );
21222 }
21223
21224 #[test]
21225 fn route_edit_keywords_to_sonnet() {
21226 for kw in &[
21227 "edit the file",
21228 "fix the bug",
21229 "update the config",
21230 "remove dead code",
21231 "create a new module",
21232 ] {
21233 let (tier, _) = classify_task(kw);
21234 assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
21235 }
21236 }
21237
21238 #[test]
21239 fn route_architecture_keywords_to_opus() {
21240 for kw in &[
21241 "design the API",
21242 "architecture review",
21243 "plan the migration",
21244 "analyze the system",
21245 "evaluate trade-offs",
21246 ] {
21247 let (tier, _) = classify_task(kw);
21248 assert_eq!(tier, "opus", "expected opus for {:?}", kw);
21249 }
21250 }
21251
21252 #[test]
21253 fn route_architecture_beats_edit() {
21254 let (tier, _) = classify_task("design and implement the new auth service");
21256 assert_eq!(tier, "opus");
21257 }
21258
21259 #[test]
21260 fn cli_accepts_global_compact_flag() {
21261 let cli = parse_cli(["tsift", "--compact", "status"]);
21262 assert!(cli.compact);
21263 assert!(matches!(cli.command, Some(Commands::Status { .. })));
21264 }
21265
21266 #[test]
21267 fn summarize_diff_scope_matches_relative_directory() {
21268 let root = Path::new("/repo");
21269 let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
21270
21271 assert!(summarize_diff_matches_scope(
21272 Path::new("/repo/src/feature/main.rs"),
21273 &extract_scope
21274 ));
21275 assert!(!summarize_diff_matches_scope(
21276 Path::new("/repo/src/other/main.rs"),
21277 &extract_scope
21278 ));
21279 }
21280
21281 #[test]
21282 fn summarize_diff_scope_matches_relative_file() {
21283 let root = Path::new("/repo");
21284 let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
21285
21286 assert!(summarize_diff_matches_scope(
21287 Path::new("/repo/src/feature/main.rs"),
21288 &extract_scope
21289 ));
21290 assert!(!summarize_diff_matches_scope(
21291 Path::new("/repo/src/feature/lib.rs"),
21292 &extract_scope
21293 ));
21294 }
21295
21296 #[test]
21297 fn summarize_extract_scope_walks_relative_paths_from_root() {
21298 let dir = tempfile::tempdir().unwrap();
21299 let source_dir = dir.path().join("src");
21300 std::fs::create_dir_all(&source_dir).unwrap();
21301 let main_rs = source_dir.join("main.rs");
21302 std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
21303
21304 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
21305 let files = collect_source_files(&extract_scope).unwrap();
21306
21307 assert_eq!(files, vec![main_rs]);
21308 }
21309
21310 #[test]
21311 fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
21312 let dir = tempfile::tempdir().unwrap();
21313 let nested = dir.path().join("src/nested");
21314 std::fs::create_dir_all(&nested).unwrap();
21315 std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
21316 let nested_file = nested.join("main.rs");
21317 std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
21318
21319 let extract_base = resolve_extract_base(&nested).unwrap();
21320 let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
21321 let files = collect_source_files(&extract_scope).unwrap();
21322
21323 assert_eq!(extract_scope, nested);
21324 assert_eq!(files, vec![nested_file]);
21325 }
21326
21327 #[test]
21328 fn summarize_extract_base_uses_parent_of_file_path() {
21329 let dir = tempfile::tempdir().unwrap();
21330 let nested = dir.path().join("src/nested");
21331 std::fs::create_dir_all(&nested).unwrap();
21332 let file_path = nested.join("main.rs");
21333 std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
21334
21335 let extract_base = resolve_extract_base(&file_path).unwrap();
21336
21337 assert_eq!(extract_base, nested);
21338 }
21339
21340 #[test]
21341 fn summarize_extract_scope_normalizes_dotdot_segments() {
21342 let dir = tempfile::tempdir().unwrap();
21343 let source_dir = dir.path().join("src");
21344 std::fs::create_dir_all(&source_dir).unwrap();
21345
21346 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
21347
21348 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21349 assert!(summarize_diff_matches_scope(
21350 &source_dir.join("main.rs"),
21351 &extract_scope
21352 ));
21353 }
21354
21355 #[cfg(unix)]
21356 #[test]
21357 fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
21358 use std::os::unix::fs::symlink;
21359
21360 let dir = tempfile::tempdir().unwrap();
21361 let real_root = dir.path().join("real");
21362 let source_dir = real_root.join("src");
21363 std::fs::create_dir_all(&source_dir).unwrap();
21364 let symlink_scope = dir.path().join("scope-link");
21365 symlink(&source_dir, &symlink_scope).unwrap();
21366
21367 let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
21368
21369 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21370 assert!(summarize_diff_matches_scope(
21371 &source_dir.join("lib.rs"),
21372 &extract_scope
21373 ));
21374 }
21375
21376 #[test]
21377 fn summarize_diff_extract_includes_untracked_files() {
21378 let dir = tempfile::tempdir().unwrap();
21379 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21380 init_git_repo(dir.path());
21381
21382 let source_dir = dir.path().join("src");
21383 std::fs::create_dir_all(&source_dir).unwrap();
21384 let new_file = source_dir.join("new.rs");
21385 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21386
21387 let files = summarize::git_changed_files(dir.path()).unwrap();
21388
21389 assert_eq!(files.existing, vec![new_file]);
21390 assert!(files.deleted.is_empty());
21391 }
21392
21393 #[test]
21394 fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
21395 let dir = tempfile::tempdir().unwrap();
21396 let status = std::process::Command::new("git")
21397 .args(["init"])
21398 .current_dir(dir.path())
21399 .status()
21400 .unwrap();
21401 assert!(status.success(), "git init failed");
21402
21403 let source_dir = dir.path().join("src");
21404 std::fs::create_dir_all(&source_dir).unwrap();
21405 let new_file = source_dir.join("new.rs");
21406 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21407
21408 let files = summarize::git_changed_files(dir.path()).unwrap();
21409
21410 assert_eq!(files.existing, vec![new_file]);
21411 assert!(files.deleted.is_empty());
21412 }
21413
21414 #[test]
21415 fn summarize_diff_extract_tracks_deleted_files() {
21416 let dir = tempfile::tempdir().unwrap();
21417 let source_dir = dir.path().join("src");
21418 std::fs::create_dir_all(&source_dir).unwrap();
21419 let deleted_file = source_dir.join("gone.rs");
21420 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21421 init_git_repo(dir.path());
21422
21423 std::fs::remove_file(&deleted_file).unwrap();
21424
21425 let files = summarize::git_changed_files(dir.path()).unwrap();
21426
21427 assert!(files.existing.is_empty());
21428 assert_eq!(files.deleted, vec![deleted_file]);
21429 }
21430
21431 #[test]
21432 fn summarize_diff_extract_tracks_git_renames() {
21433 let dir = tempfile::tempdir().unwrap();
21434 let source_dir = dir.path().join("src");
21435 std::fs::create_dir_all(&source_dir).unwrap();
21436 let old_file = source_dir.join("old.rs");
21437 let new_file = source_dir.join("new.rs");
21438 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21439 init_git_repo(dir.path());
21440
21441 let status = std::process::Command::new("git")
21442 .args(["mv", "src/old.rs", "src/new.rs"])
21443 .current_dir(dir.path())
21444 .status()
21445 .unwrap();
21446 assert!(status.success(), "git mv failed");
21447
21448 let files = summarize::git_changed_files(dir.path()).unwrap();
21449
21450 assert_eq!(files.existing, vec![new_file]);
21451 assert_eq!(files.deleted, vec![old_file]);
21452 }
21453
21454 #[test]
21455 fn summarize_diff_extract_deletes_removed_summary_rows() {
21456 let dir = tempfile::tempdir().unwrap();
21457 let source_dir = dir.path().join("src");
21458 std::fs::create_dir_all(&source_dir).unwrap();
21459 let deleted_file = source_dir.join("gone.rs");
21460 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21461 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21462 init_git_repo(dir.path());
21463
21464 let summary_db =
21465 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21466 summary_db
21467 .insert(&summarize::Summary {
21468 id: 0,
21469 symbol_name: "stale".to_string(),
21470 file_path: "src/gone.rs".to_string(),
21471 content_hash: "hash1".to_string(),
21472 summary: "stale summary".to_string(),
21473 entities: None,
21474 relationships: None,
21475 concept_labels: None,
21476 extracted_at: "1700000000".to_string(),
21477 model: "test".to_string(),
21478 tokens_input: Some(100),
21479 tokens_output: Some(50),
21480 })
21481 .unwrap();
21482
21483 std::fs::remove_file(&deleted_file).unwrap();
21484
21485 cmd_summarize(
21486 None,
21487 None,
21488 Some(PathBuf::from("src")),
21489 true,
21490 false,
21491 dir.path(),
21492 false,
21493 true,
21494 false,
21495 false,
21496 false,
21497 )
21498 .unwrap();
21499
21500 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21501 }
21502
21503 #[test]
21504 fn summarize_diff_extract_deletes_renamed_summary_rows() {
21505 let dir = tempfile::tempdir().unwrap();
21506 let source_dir = dir.path().join("src");
21507 std::fs::create_dir_all(&source_dir).unwrap();
21508 let old_file = source_dir.join("old.rs");
21509 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21510 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21511 init_git_repo(dir.path());
21512
21513 let summary_db =
21514 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21515 summary_db
21516 .insert(&summarize::Summary {
21517 id: 0,
21518 symbol_name: "stale".to_string(),
21519 file_path: "src/old.rs".to_string(),
21520 content_hash: "hash1".to_string(),
21521 summary: "stale summary".to_string(),
21522 entities: None,
21523 relationships: None,
21524 concept_labels: None,
21525 extracted_at: "1700000000".to_string(),
21526 model: "test".to_string(),
21527 tokens_input: Some(100),
21528 tokens_output: Some(50),
21529 })
21530 .unwrap();
21531
21532 let status = std::process::Command::new("git")
21533 .args(["mv", "src/old.rs", "src/new.rs"])
21534 .current_dir(dir.path())
21535 .status()
21536 .unwrap();
21537 assert!(status.success(), "git mv failed");
21538
21539 cmd_summarize(
21540 None,
21541 None,
21542 Some(PathBuf::from("src")),
21543 true,
21544 false,
21545 dir.path(),
21546 false,
21547 true,
21548 false,
21549 false,
21550 false,
21551 )
21552 .unwrap();
21553
21554 assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
21555 }
21556
21557 #[test]
21558 fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
21559 let dir = tempfile::tempdir().unwrap();
21560 let source_dir = dir.path().join("src");
21561 std::fs::create_dir_all(&source_dir).unwrap();
21562 let deleted_file = source_dir.join("gone.rs");
21563 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21564
21565 let summary_db =
21566 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21567 summary_db
21568 .insert(&summarize::Summary {
21569 id: 0,
21570 symbol_name: "stale".to_string(),
21571 file_path: "src/gone.rs".to_string(),
21572 content_hash: "hash1".to_string(),
21573 summary: "stale summary".to_string(),
21574 entities: None,
21575 relationships: None,
21576 concept_labels: None,
21577 extracted_at: "1700000000".to_string(),
21578 model: "test".to_string(),
21579 tokens_input: Some(100),
21580 tokens_output: Some(50),
21581 })
21582 .unwrap();
21583
21584 std::fs::remove_file(&deleted_file).unwrap();
21585
21586 cmd_summarize(
21587 None,
21588 None,
21589 Some(PathBuf::from("src")),
21590 false,
21591 false,
21592 dir.path(),
21593 false,
21594 true,
21595 false,
21596 false,
21597 false,
21598 )
21599 .unwrap();
21600
21601 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21602 }
21603
21604 #[test]
21605 fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
21606 let dir = tempfile::tempdir().unwrap();
21607 let source_dir = dir.path().join("src");
21608 std::fs::create_dir_all(&source_dir).unwrap();
21609 let file = source_dir.join("lib.rs");
21610 std::fs::write(&file, "fn helper() {}\n").unwrap();
21611
21612 let content = std::fs::read(&file).unwrap();
21613 let summary_db =
21614 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21615 summary_db
21616 .insert(&summarize::Summary {
21617 id: 0,
21618 symbol_name: "lib.rs".to_string(),
21619 file_path: "src/lib.rs".to_string(),
21620 content_hash: summarize::content_hash(&content),
21621 summary: "cached summary".to_string(),
21622 entities: None,
21623 relationships: None,
21624 concept_labels: None,
21625 extracted_at: "1700000000".to_string(),
21626 model: "test".to_string(),
21627 tokens_input: Some(100),
21628 tokens_output: Some(50),
21629 })
21630 .unwrap();
21631 drop(summary_db);
21632
21633 let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
21634 let _lock = hold_writer_lock(&lock_path);
21635
21636 let err = cmd_summarize(
21637 None,
21638 None,
21639 Some(PathBuf::from("src")),
21640 false,
21641 false,
21642 dir.path(),
21643 false,
21644 true,
21645 false,
21646 false,
21647 false,
21648 )
21649 .unwrap_err();
21650 let message = err.to_string();
21651
21652 assert!(message.contains("another tsift summarize extractor is already active"));
21653 assert!(message.contains("tsift summarize --extract"));
21654 }
21655
21656 #[test]
21657 fn summarize_stats_fails_closed_when_cache_missing() {
21658 let dir = tempfile::tempdir().unwrap();
21659 let err = cmd_summarize(
21660 None,
21661 None,
21662 None,
21663 false,
21664 true,
21665 dir.path(),
21666 false,
21667 false,
21668 false,
21669 false,
21670 false,
21671 )
21672 .unwrap_err();
21673
21674 assert!(
21675 err.to_string().contains("no summaries.db found"),
21676 "got: {err}"
21677 );
21678 assert!(!dir.path().join(".tsift/summaries.db").exists());
21679 }
21680
21681 #[test]
21682 fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21683 let dir = tempfile::tempdir().unwrap();
21684 let summary_db =
21685 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21686 summary_db
21687 .insert(&summarize::Summary {
21688 id: 0,
21689 symbol_name: "alpha_helper".to_string(),
21690 file_path: "src/lib.rs".to_string(),
21691 content_hash: "hash1".to_string(),
21692 summary: "cached summary".to_string(),
21693 entities: None,
21694 relationships: None,
21695 concept_labels: None,
21696 extracted_at: "1700000000".to_string(),
21697 model: "claude-haiku-4-5-20251001".to_string(),
21698 tokens_input: Some(100),
21699 tokens_output: Some(40),
21700 })
21701 .unwrap();
21702 drop(summary_db);
21703 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21704
21705 let result = cmd_summarize(
21706 None,
21707 None,
21708 None,
21709 false,
21710 true,
21711 dir.path(),
21712 false,
21713 false,
21714 false,
21715 false,
21716 false,
21717 );
21718
21719 assert!(result.is_ok());
21720 }
21721
21722 #[test]
21723 fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21724 let dir = tempfile::tempdir().unwrap();
21725 let summary_db =
21726 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21727 summary_db
21728 .insert(&summarize::Summary {
21729 id: 0,
21730 symbol_name: "alpha_helper".to_string(),
21731 file_path: "src/lib.rs".to_string(),
21732 content_hash: "hash1".to_string(),
21733 summary: "cached summary".to_string(),
21734 entities: None,
21735 relationships: None,
21736 concept_labels: None,
21737 extracted_at: "1700000000".to_string(),
21738 model: "claude-haiku-4-5-20251001".to_string(),
21739 tokens_input: Some(100),
21740 tokens_output: Some(40),
21741 })
21742 .unwrap();
21743 drop(summary_db);
21744 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21745
21746 let result = cmd_summarize(
21747 Some("alpha_helper".to_string()),
21748 None,
21749 None,
21750 false,
21751 false,
21752 dir.path(),
21753 false,
21754 true,
21755 false,
21756 false,
21757 false,
21758 );
21759
21760 assert!(result.is_ok());
21761 }
21762
21763 #[test]
21764 fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
21765 let dir = tempfile::tempdir().unwrap();
21766 let nested = dir.path().join("src/nested");
21767 std::fs::create_dir_all(&nested).unwrap();
21768
21769 let summary_db =
21770 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21771 summary_db
21772 .insert(&summarize::Summary {
21773 id: 0,
21774 symbol_name: "alpha_helper".to_string(),
21775 file_path: "src/lib.rs".to_string(),
21776 content_hash: "hash1".to_string(),
21777 summary: "cached summary".to_string(),
21778 entities: None,
21779 relationships: None,
21780 concept_labels: None,
21781 extracted_at: "1700000000".to_string(),
21782 model: "claude-haiku-4-5-20251001".to_string(),
21783 tokens_input: Some(100),
21784 tokens_output: Some(40),
21785 })
21786 .unwrap();
21787
21788 let result = cmd_summarize(
21789 Some("alpha_helper".to_string()),
21790 None,
21791 None,
21792 false,
21793 false,
21794 &nested,
21795 false,
21796 true,
21797 false,
21798 false,
21799 false,
21800 );
21801
21802 assert!(result.is_ok());
21803 assert!(!nested.join(".tsift/summaries.db").exists());
21804 }
21805
21806 #[test]
21807 fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
21808 let dir = tempfile::tempdir().unwrap();
21809 std::fs::write(
21810 dir.path().join(".gitmodules"),
21811 r#"[submodule "src/alpha"]
21812 path = src/alpha
21813 url = https://example.com/alpha
21814[submodule "src/beta"]
21815 path = src/beta
21816 url = https://example.com/beta
21817"#,
21818 )
21819 .unwrap();
21820
21821 let alpha_root = dir.path().join("src/alpha");
21822 let beta_root = dir.path().join("src/beta");
21823 std::fs::create_dir_all(alpha_root.join("src")).unwrap();
21824 std::fs::create_dir_all(beta_root.join("src")).unwrap();
21825 std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
21826 std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
21827 std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
21828 let beta_file = beta_root.join("src/lib.rs");
21829 std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
21830 std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
21831 std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
21832
21833 let context = find_symbols_db_for_file(dir.path(), &beta_file)
21834 .unwrap()
21835 .expect("expected matching scoped index");
21836
21837 assert_eq!(
21838 context.db_path,
21839 dir.path().join(".tsift/indexes/beta/index.db")
21840 );
21841 assert_eq!(context.source_root, beta_root);
21842 }
21843
21844 fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
21847 EditOp {
21848 file: PathBuf::from("dummy.txt"),
21849 old: old.to_string(),
21850 new: new.to_string(),
21851 replace_all,
21852 }
21853 }
21854
21855 #[test]
21856 fn edit_replaces_single_occurrence() {
21857 let content = "hello world";
21858 let op = make_op("world", "rust", false);
21859 let (result, count) = apply_edit_op(content, &op).unwrap();
21860 assert_eq!(result, "hello rust");
21861 assert_eq!(count, 1);
21862 }
21863
21864 #[test]
21865 fn edit_replace_all_replaces_every_occurrence() {
21866 let content = "foo foo foo";
21867 let op = make_op("foo", "bar", true);
21868 let (result, count) = apply_edit_op(content, &op).unwrap();
21869 assert_eq!(result, "bar bar bar");
21870 assert_eq!(count, 3);
21871 }
21872
21873 #[test]
21874 fn edit_fails_when_old_not_found() {
21875 let content = "hello world";
21876 let op = make_op("missing", "x", false);
21877 assert!(apply_edit_op(content, &op).is_err());
21878 }
21879
21880 #[test]
21881 fn edit_fails_when_ambiguous_without_replace_all() {
21882 let content = "foo foo";
21883 let op = make_op("foo", "bar", false);
21884 let err = apply_edit_op(content, &op).unwrap_err();
21885 assert!(err.to_string().contains("2 times"), "got: {}", err);
21886 }
21887
21888 #[test]
21889 fn edit_fails_when_old_equals_new() {
21890 let content = "hello";
21891 let op = make_op("hello", "hello", false);
21892 assert!(apply_edit_op(content, &op).is_err());
21893 }
21894
21895 #[test]
21896 fn edit_batch_rolls_back_when_later_swap_fails() {
21897 let dir = tempfile::tempdir().unwrap();
21898 let alpha = dir.path().join("alpha.txt");
21899 let beta = dir.path().join("beta.txt");
21900 fs::write(&alpha, "alpha old\n").unwrap();
21901 fs::write(&beta, "beta old\n").unwrap();
21902
21903 let batch = EditBatch {
21904 edits: vec![
21905 EditOp {
21906 file: alpha.clone(),
21907 old: "old".to_string(),
21908 new: "new".to_string(),
21909 replace_all: false,
21910 },
21911 EditOp {
21912 file: beta.clone(),
21913 old: "old".to_string(),
21914 new: "new".to_string(),
21915 replace_all: false,
21916 },
21917 ],
21918 };
21919
21920 let plan = build_edit_plan(&batch).unwrap();
21921 let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
21922 if commit_index == 1 {
21923 bail!("simulated swap failure");
21924 }
21925 Ok(())
21926 }) {
21927 Ok(_) => panic!("expected simulated swap failure"),
21928 Err(err) => err,
21929 };
21930
21931 assert!(err.to_string().contains("simulated swap failure"));
21932 assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
21933 assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
21934 }
21935
21936 fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
21939 let tmp = tempfile::NamedTempFile::new().unwrap();
21940 let conn = Connection::open(tmp.path()).unwrap();
21941 conn.execute_batch(
21942 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
21943 INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
21944 INSERT INTO users VALUES (2, 'Bob', NULL);
21945 CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
21946 FOREIGN KEY(user_id) REFERENCES users(id));
21947 INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
21948 INSERT INTO posts VALUES (2, 1, 'Second', NULL);
21949 INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
21950 ).unwrap();
21951 (tmp, conn)
21952 }
21953
21954 #[test]
21957 fn rewrite_rg_simple_pattern() {
21958 let result = rewrite_command("rg authenticate");
21959 assert_eq!(
21960 result,
21961 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
21962 );
21963 }
21964
21965 #[test]
21966 fn rewrite_rg_with_path() {
21967 let result = rewrite_command("rg authenticate src/");
21968 assert_eq!(
21969 result,
21970 Some(
21971 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
21972 .to_string()
21973 )
21974 );
21975 }
21976
21977 #[test]
21978 fn rewrite_rg_with_flags_ignored() {
21979 let result = rewrite_command("rg -i authenticate src/");
21980 assert_eq!(
21981 result,
21982 Some(
21983 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
21984 .to_string()
21985 )
21986 );
21987 }
21988
21989 #[test]
21990 fn rewrite_rg_with_type_flag() {
21991 let result = rewrite_command("rg -t rs authenticate");
21993 assert_eq!(
21994 result,
21995 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
21996 );
21997 }
21998
21999 #[test]
22000 fn rewrite_rg_pipe_passthrough() {
22001 let result = rewrite_command("rg authenticate | head -5");
22003 assert_eq!(result, None);
22004 }
22005
22006 #[test]
22007 fn rewrite_rg_files_passthrough() {
22008 let result = rewrite_command("rg --files src/tsift .agent-doc logs");
22009 assert_eq!(result, None);
22010 }
22011
22012 #[test]
22013 fn rewrite_find_passthrough() {
22014 let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
22015 assert_eq!(result, None);
22016 }
22017
22018 #[test]
22019 fn rewrite_grep_recursive() {
22020 let result = rewrite_command("grep -r authenticate src/");
22021 assert_eq!(
22022 result,
22023 Some(
22024 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22025 .to_string()
22026 )
22027 );
22028 }
22029
22030 #[test]
22031 fn rewrite_grep_non_recursive_passthrough() {
22032 let result = rewrite_command("grep authenticate file.txt");
22033 assert_eq!(result, None);
22034 }
22035
22036 #[test]
22037 fn rewrite_tsift_passthrough() {
22038 let result = rewrite_command("tsift search \"foo\"");
22039 assert_eq!(result, Some("tsift search \"foo\"".to_string()));
22040 }
22041
22042 #[test]
22043 fn rewrite_run_tsift_search_disables_timeout_by_default() {
22044 let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
22045 assert_eq!(
22046 result,
22047 "tsift search hookcaps --exact --path /tmp/x --timeout 0"
22048 );
22049 }
22050
22051 #[test]
22052 fn rewrite_run_preserves_explicit_search_timeout() {
22053 let result = effective_rewrite_run_command(
22054 "tsift search hookcaps --exact --path /tmp/x --timeout 5",
22055 );
22056 assert_eq!(
22057 result,
22058 "tsift search hookcaps --exact --path /tmp/x --timeout 5"
22059 );
22060 }
22061
22062 #[test]
22063 fn rewrite_unrelated_passthrough() {
22064 let result = rewrite_command("echo cargo build");
22065 assert_eq!(result, None);
22066 }
22067
22068 #[test]
22069 fn rewrite_rg_quoted_pattern() {
22070 let result = rewrite_command("rg \"fn main\"");
22071 assert_eq!(
22072 result,
22073 Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
22074 );
22075 }
22076
22077 #[test]
22078 fn rewrite_git_diff_to_diff_digest() {
22079 let result = rewrite_command("git diff");
22080 assert_eq!(result, Some("tsift diff-digest .".to_string()));
22081 }
22082
22083 #[test]
22084 fn rewrite_git_diff_cached_to_diff_digest() {
22085 let result = rewrite_command("git diff --cached");
22086 assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
22087 }
22088
22089 #[test]
22090 fn rewrite_git_diff_with_path_to_diff_digest() {
22091 let result = rewrite_command("git diff -- src/");
22092 assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
22093 }
22094
22095 #[test]
22096 fn rewrite_git_diff_with_revision_passthrough() {
22097 let result = rewrite_command("git diff HEAD~1");
22098 assert_eq!(result, None);
22099 }
22100
22101 #[test]
22102 fn rewrite_git_show_to_revision_diff_digest() {
22103 let result = rewrite_command("git show HEAD~1");
22104 assert_eq!(
22105 result,
22106 Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
22107 );
22108 }
22109
22110 #[test]
22111 fn rewrite_git_log_patch_history_to_revision_diff_digest() {
22112 let result = rewrite_command("git log -p -1 HEAD~2");
22113 assert_eq!(
22114 result,
22115 Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
22116 );
22117 }
22118
22119 #[test]
22120 fn rewrite_cat_long_agent_doc_session_to_session_digest() {
22121 let dir = tempfile::tempdir().unwrap();
22122 let session = dir.path().join("tsift.md");
22123 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22124 for index in 0..90 {
22125 body.push_str(&format!("❯ prompt {index}?\n"));
22126 }
22127 fs::write(&session, body).unwrap();
22128
22129 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22130 assert_eq!(
22131 result,
22132 Some(format!(
22133 "tsift session-digest --path {} --input {} --source markdown",
22134 shell_quote(&resolve_digest_context_path(&session)),
22135 shell_quote(session.to_str().unwrap())
22136 ))
22137 );
22138 }
22139
22140 #[test]
22141 fn rewrite_head_long_claude_jsonl_to_session_digest() {
22142 let dir = tempfile::tempdir().unwrap();
22143 let session = dir.path().join("session.jsonl");
22144 let line =
22145 r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
22146 let body = std::iter::repeat_n(line, 120)
22147 .collect::<Vec<_>>()
22148 .join("\n");
22149 fs::write(&session, format!("{body}\n")).unwrap();
22150
22151 let result = rewrite_command(&format!(
22152 "head -n 120 {}",
22153 shell_quote(session.to_str().unwrap())
22154 ));
22155 assert_eq!(
22156 result,
22157 Some(format!(
22158 "tsift session-digest --path {} --input {} --source claude-jsonl",
22159 shell_quote(&resolve_digest_context_path(&session)),
22160 shell_quote(session.to_str().unwrap())
22161 ))
22162 );
22163 }
22164
22165 #[test]
22166 fn rewrite_head_long_codex_jsonl_to_session_digest() {
22167 let dir = tempfile::tempdir().unwrap();
22168 let session = dir.path().join("codex.jsonl");
22169 let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
22170 let body = std::iter::repeat_n(line, 120)
22171 .collect::<Vec<_>>()
22172 .join("\n");
22173 fs::write(&session, format!("{body}\n")).unwrap();
22174
22175 let result = rewrite_command(&format!(
22176 "head -n 120 {}",
22177 shell_quote(session.to_str().unwrap())
22178 ));
22179 assert_eq!(
22180 result,
22181 Some(format!(
22182 "tsift session-digest --path {} --input {} --source codex-jsonl",
22183 shell_quote(&resolve_digest_context_path(&session)),
22184 shell_quote(session.to_str().unwrap())
22185 ))
22186 );
22187 }
22188
22189 #[test]
22190 fn rewrite_small_transcript_window_passthrough() {
22191 let dir = tempfile::tempdir().unwrap();
22192 let session = dir.path().join("session.jsonl");
22193 let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
22194 let body = std::iter::repeat_n(line, 120)
22195 .collect::<Vec<_>>()
22196 .join("\n");
22197 fs::write(&session, format!("{body}\n")).unwrap();
22198
22199 let result = rewrite_command(&format!(
22200 "tail -n 20 {}",
22201 shell_quote(session.to_str().unwrap())
22202 ));
22203 assert_eq!(result, None);
22204 }
22205
22206 #[test]
22207 fn rewrite_sed_large_agent_doc_range_to_session_digest() {
22208 let dir = tempfile::tempdir().unwrap();
22209 let session = dir.path().join("tsift.md");
22210 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22211 for index in 0..120 {
22212 body.push_str(&format!("### Re: topic {index}\n"));
22213 }
22214 fs::write(&session, body).unwrap();
22215
22216 let result = rewrite_command(&format!(
22217 "sed -n '1,120p' {}",
22218 shell_quote(session.to_str().unwrap())
22219 ));
22220 assert_eq!(
22221 result,
22222 Some(format!(
22223 "tsift session-digest --path {} --input {} --source markdown",
22224 shell_quote(&resolve_digest_context_path(&session)),
22225 shell_quote(session.to_str().unwrap())
22226 ))
22227 );
22228 }
22229
22230 #[test]
22231 fn rewrite_cat_large_agent_doc_log_to_session_digest() {
22232 let dir = tempfile::tempdir().unwrap();
22233 let session = dir.path().join("tsift.log");
22234 let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
22235 let body = std::iter::repeat_n(line, 120)
22236 .collect::<Vec<_>>()
22237 .join("\n");
22238 fs::write(&session, format!("{body}\n")).unwrap();
22239
22240 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22241 assert_eq!(
22242 result,
22243 Some(format!(
22244 "tsift session-digest --path {} --input {} --source agent-doc-log",
22245 shell_quote(&resolve_digest_context_path(&session)),
22246 shell_quote(session.to_str().unwrap())
22247 ))
22248 );
22249 }
22250
22251 #[test]
22252 fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
22253 let dir = tempfile::tempdir().unwrap();
22254 fs::write(
22255 dir.path().join(".gitmodules"),
22256 r#"[submodule "src/tsift"]
22257 path = src/tsift
22258 url = https://example.com/tsift
22259"#,
22260 )
22261 .unwrap();
22262 let submodule = dir.path().join("src/tsift");
22263 fs::create_dir_all(submodule.join("tasks")).unwrap();
22264 fs::write(
22265 submodule.join(".git"),
22266 "gitdir: ../../.git/modules/src/tsift\n",
22267 )
22268 .unwrap();
22269 let session = submodule.join("tasks/plan.md");
22270 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22271 for index in 0..90 {
22272 body.push_str(&format!("❯ prompt {index}?\n"));
22273 }
22274 fs::write(&session, body).unwrap();
22275
22276 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22277
22278 assert_eq!(
22279 result,
22280 Some(format!(
22281 "tsift session-digest --path {} --input {} --source markdown",
22282 shell_quote(submodule.to_str().unwrap()),
22283 shell_quote(session.to_str().unwrap())
22284 ))
22285 );
22286 }
22287
22288 #[test]
22289 fn rewrite_regular_markdown_read_passthrough() {
22290 let dir = tempfile::tempdir().unwrap();
22291 let readme = dir.path().join("README.md");
22292 let body = std::iter::repeat_n("plain markdown", 120)
22293 .collect::<Vec<_>>()
22294 .join("\n");
22295 fs::write(&readme, format!("{body}\n")).unwrap();
22296
22297 let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
22298 assert_eq!(result, None);
22299 }
22300
22301 #[test]
22302 fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
22303 let dir = tempfile::tempdir().unwrap();
22304 write_empty_root_index(dir.path());
22305 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22306
22307 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22308
22309 assert_eq!(
22310 result,
22311 Some(format!(
22312 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
22313 shell_quote(&dir.path().to_string_lossy())
22314 ))
22315 );
22316 }
22317
22318 #[test]
22319 fn rewrite_head_small_source_window_passthrough() {
22320 let dir = tempfile::tempdir().unwrap();
22321 write_empty_root_index(dir.path());
22322 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22323
22324 let result = rewrite_command(&format!(
22325 "head -n 20 {}",
22326 shell_quote(source.to_str().unwrap())
22327 ));
22328
22329 assert_eq!(result, None);
22330 }
22331
22332 #[test]
22333 fn rewrite_sed_large_source_range_to_source_read() {
22334 let dir = tempfile::tempdir().unwrap();
22335 write_empty_root_index(dir.path());
22336 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22337
22338 let result = rewrite_command(&format!(
22339 "sed -n '40,160p' {}",
22340 shell_quote(source.to_str().unwrap())
22341 ));
22342
22343 assert_eq!(
22344 result,
22345 Some(format!(
22346 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
22347 shell_quote(&dir.path().to_string_lossy())
22348 ))
22349 );
22350 }
22351
22352 #[test]
22353 fn rewrite_tail_large_source_window_preserves_tail_anchor() {
22354 let dir = tempfile::tempdir().unwrap();
22355 write_empty_root_index(dir.path());
22356 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22357
22358 let result = rewrite_command(&format!(
22359 "tail -n 120 {}",
22360 shell_quote(source.to_str().unwrap())
22361 ));
22362
22363 assert_eq!(
22364 result,
22365 Some(format!(
22366 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
22367 shell_quote(&dir.path().to_string_lossy())
22368 ))
22369 );
22370 }
22371
22372 #[test]
22373 fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
22374 let dir = tempfile::tempdir().unwrap();
22375 write_empty_root_index(dir.path());
22376 let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
22377
22378 let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
22379
22380 assert_eq!(result, None);
22381 }
22382
22383 #[test]
22384 fn rewrite_large_source_read_passthrough_without_index() {
22385 let dir = tempfile::tempdir().unwrap();
22386 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22387
22388 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22389
22390 assert_eq!(result, None);
22391 }
22392
22393 #[test]
22394 fn rewrite_cargo_test_to_digest_runner() {
22395 let result = rewrite_command("cargo test --lib");
22396 assert_eq!(
22397 result,
22398 Some(
22399 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
22400 )
22401 );
22402 }
22403
22404 #[test]
22405 fn rewrite_pytest_to_digest_runner() {
22406 let result = rewrite_command("pytest -q tests/test_cli.py");
22407 assert_eq!(
22408 result,
22409 Some(
22410 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
22411 )
22412 );
22413 }
22414
22415 #[test]
22416 fn rewrite_python_m_pytest_to_digest_runner() {
22417 let result = rewrite_command("python -m pytest tests/test_cli.py");
22418 assert_eq!(
22419 result,
22420 Some(
22421 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
22422 )
22423 );
22424 }
22425
22426 #[test]
22427 fn rewrite_cargo_build_to_log_digest_runner() {
22428 let result = rewrite_command("cargo build --release");
22429 assert_eq!(
22430 result,
22431 Some(
22432 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
22433 )
22434 );
22435 }
22436
22437 #[test]
22438 fn rewrite_cargo_install_to_log_digest_runner() {
22439 let result = rewrite_command("cargo install --path . --force");
22440 assert_eq!(
22441 result,
22442 Some(
22443 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
22444 )
22445 );
22446 }
22447
22448 #[test]
22449 fn rewrite_metacharacter_command_passthrough() {
22450 let result = rewrite_command("cargo test | head");
22451 assert_eq!(result, None);
22452 }
22453
22454 #[test]
22455 fn rewrite_output_cap_detects_search_even_with_global_flag() {
22456 let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
22457 assert_eq!(cap.max_lines, 50);
22458 assert_eq!(cap.strip_prefix, Some("Strategy:"));
22459 }
22460
22461 #[test]
22462 fn rewrite_output_cap_skips_structured_output() {
22463 assert!(rewrite_output_cap("tsift search foo --json").is_none());
22464 assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
22465 assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
22466 }
22467
22468 #[test]
22469 fn rewrite_output_format_forwards_envelope_to_digest_runner() {
22470 let command = rewrite_command("cargo test --lib").expect("rewrite");
22471 let forwarded = apply_rewrite_output_format(
22472 &command,
22473 OutputFormat {
22474 json_output: true,
22475 compact: false,
22476 pretty: false,
22477 terse: false,
22478 ultra_terse: false,
22479 schema: false,
22480 envelope: true,
22481 },
22482 );
22483 assert_eq!(
22484 forwarded,
22485 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
22486 );
22487 }
22488
22489 #[test]
22490 fn rewrite_output_format_forwards_json_when_requested() {
22491 let command = rewrite_command("cargo build --release").expect("rewrite");
22492 let forwarded = apply_rewrite_output_format(
22493 &command,
22494 OutputFormat {
22495 json_output: true,
22496 compact: false,
22497 pretty: true,
22498 terse: false,
22499 ultra_terse: false,
22500 schema: false,
22501 envelope: false,
22502 },
22503 );
22504 assert_eq!(
22505 forwarded,
22506 "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
22507 );
22508 }
22509
22510 #[test]
22511 fn output_cap_strips_search_header_and_truncates() {
22512 let capped = apply_output_cap(
22513 b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
22514 OutputCap {
22515 max_lines: 2,
22516 strip_prefix: Some("Strategy:"),
22517 },
22518 );
22519 assert_eq!(
22520 capped,
22521 "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
22522 );
22523 }
22524
22525 #[test]
22526 fn sql_schema_overview_lists_tables() {
22527 let (_tmp, conn) = setup_test_db();
22528 let tables = schema_overview(&conn).unwrap();
22529 let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
22530 assert_eq!(names, &["posts", "users"]);
22531 }
22532
22533 #[test]
22534 fn sql_schema_overview_row_counts() {
22535 let (_tmp, conn) = setup_test_db();
22536 let tables = schema_overview(&conn).unwrap();
22537 let users = tables.iter().find(|t| t.name == "users").unwrap();
22538 let posts = tables.iter().find(|t| t.name == "posts").unwrap();
22539 assert_eq!(users.row_count, 2);
22540 assert_eq!(posts.row_count, 3);
22541 }
22542
22543 #[test]
22544 fn sql_table_columns_metadata() {
22545 let (_tmp, conn) = setup_test_db();
22546 let cols = table_columns(&conn, "users").unwrap();
22547 assert_eq!(cols.len(), 3);
22548 assert_eq!(cols[0].name, "id");
22549 assert!(cols[0].pk);
22550 assert_eq!(cols[1].name, "name");
22551 assert!(cols[1].notnull);
22552 assert_eq!(cols[2].name, "email");
22553 assert!(!cols[2].notnull);
22554 }
22555
22556 #[test]
22557 fn sql_execute_query_returns_rows() {
22558 let (_tmp, conn) = setup_test_db();
22559 let (columns, rows) =
22560 execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
22561 assert_eq!(columns, &["name", "email"]);
22562 assert_eq!(rows.len(), 2);
22563 assert_eq!(rows[0][0], serde_json::json!("Alice"));
22564 assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
22565 assert_eq!(rows[1][1], serde_json::Value::Null);
22566 }
22567
22568 #[test]
22569 fn sql_execute_query_aggregate() {
22570 let (_tmp, conn) = setup_test_db();
22571 let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
22572 assert_eq!(columns, &["cnt"]);
22573 assert_eq!(rows[0][0], serde_json::json!(3));
22574 }
22575
22576 #[test]
22577 fn sql_execute_query_join() {
22578 let (_tmp, conn) = setup_test_db();
22579 let (_cols, rows) = execute_query(
22580 &conn,
22581 "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
22582 )
22583 .unwrap();
22584 assert_eq!(rows.len(), 3);
22585 assert_eq!(rows[0][0], serde_json::json!("Alice"));
22586 assert_eq!(rows[2][0], serde_json::json!("Bob"));
22587 }
22588
22589 #[test]
22590 fn sql_open_db_read_only() {
22591 let (tmp, _conn) = setup_test_db();
22592 drop(_conn);
22593 let ro_conn = open_db(tmp.path()).unwrap();
22594 let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
22595 assert!(result.is_err(), "read-only connection should reject writes");
22596 }
22597
22598 #[test]
22599 fn sql_empty_table_schema() {
22600 let tmp = tempfile::NamedTempFile::new().unwrap();
22601 let conn = Connection::open(tmp.path()).unwrap();
22602 conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
22603 .unwrap();
22604 let tables = schema_overview(&conn).unwrap();
22605 assert_eq!(tables[0].row_count, 0);
22606 assert_eq!(tables[0].columns.len(), 2);
22607 }
22608
22609 fn setup_graph_index() -> tempfile::TempDir {
22612 let dir = tempfile::tempdir().unwrap();
22613 std::fs::write(
22614 dir.path().join("main.rs"),
22615 "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
22616 )
22617 .unwrap();
22618 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22619 db.apply_changes(dir.path()).unwrap();
22620 dir
22621 }
22622
22623 fn setup_traversal_project() -> tempfile::TempDir {
22624 let dir = setup_graph_index();
22625 let task_dir = dir.path().join("tasks/software");
22626 std::fs::create_dir_all(&task_dir).unwrap();
22627 std::fs::write(
22628 task_dir.join("tsift.md"),
22629 r#"---
22630agent_doc_session: tsift-v0.1
22631agent_doc_format: template
22632---
22633
22634## Exchange
22635
22636<!-- agent:exchange patch=append -->
22637❯ do [#kgnv]
22638Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
22639<!-- /agent:exchange -->
22640
22641<!-- agent:queue -->
22642dispatch #spec-test-build-install-commit-push
22643- do [#kgnv]
22644<!-- /agent:queue -->
22645
22646## Backlog
22647
22648<!-- agent:backlog -->
22649- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
22650<!-- /agent:backlog -->
22651"#,
22652 )
22653 .unwrap();
22654 dir
22655 }
22656
22657 fn resolve_ast_span_node<'a>(
22658 graph: &'a TraversalGraphBuild,
22659 label: &str,
22660 symbol_kind: &str,
22661 ) -> &'a TraversalNode {
22662 graph
22663 .nodes
22664 .values()
22665 .find(|node| {
22666 node.kind == "ast_span"
22667 && node.label == label
22668 && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
22669 })
22670 .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
22671 }
22672
22673 fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
22674 let dir = tempfile::tempdir().unwrap();
22675 std::fs::write(
22676 dir.path().join("rust.rs"),
22677 r#"mod fixture_nav_rust_mod {
22678 pub fn fixture_nav_rust_helper() {}
22679 pub fn fixture_nav_rust_entry() {
22680 fixture_nav_rust_helper();
22681 }
22682}
22683"#,
22684 )
22685 .unwrap();
22686 std::fs::write(
22687 dir.path().join("python.py"),
22688 r#"def fixture_nav_python_helper():
22689 return 1
22690
22691def fixture_nav_python_entry():
22692 return fixture_nav_python_helper()
22693"#,
22694 )
22695 .unwrap();
22696 std::fs::write(
22697 dir.path().join("typescript.ts"),
22698 r#"export function fixture_nav_typescript_entry(): number {
22699 return fixtureNavTsHelper();
22700}
22701
22702function fixtureNavTsHelper(): number {
22703 return 1;
22704}
22705"#,
22706 )
22707 .unwrap();
22708 std::fs::write(
22709 dir.path().join("javascript.js"),
22710 r#"function fixture_nav_javascript_entry() {
22711 return fixtureNavJsHelper();
22712}
22713
22714function fixtureNavJsHelper() {
22715 return 1;
22716}
22717"#,
22718 )
22719 .unwrap();
22720 std::fs::write(
22721 dir.path().join("kotlin.kt"),
22722 r#"fun fixture_nav_kotlin_entry(): Int {
22723 return fixtureNavKotlinHelper()
22724}
22725
22726fun fixtureNavKotlinHelper(): Int = 1
22727"#,
22728 )
22729 .unwrap();
22730 std::fs::write(
22731 dir.path().join("zig.zig"),
22732 r#"pub fn fixture_nav_zig_entry() i32 {
22733 return fixtureNavZigHelper();
22734}
22735
22736fn fixtureNavZigHelper() i32 {
22737 return 1;
22738}
22739"#,
22740 )
22741 .unwrap();
22742 std::fs::write(
22743 dir.path().join("bash.sh"),
22744 r#"#!/usr/bin/env bash
22745fixture_nav_bash_entry() {
22746 fixture_nav_bash_helper
22747}
22748
22749fixture_nav_bash_helper() {
22750 echo ok
22751}
22752
22753alias fixture_nav_bash_alias='echo alias'
22754"#,
22755 )
22756 .unwrap();
22757 std::fs::write(
22758 dir.path().join("README.md"),
22759 r#"# Fixture Guide
22760
22761## Fixture Section
22762
22763- Fixture step
22764 - Nested fixture step
22765
22766```python
22767def fixture_nav_markdown_embedded():
22768 return 1
22769```
22770"#,
22771 )
22772 .unwrap();
22773
22774 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22775 db.apply_changes(dir.path()).unwrap();
22776 dir
22777 }
22778
22779 fn assert_cli_expand_command_parses(command: &str) {
22780 let args = shell_split(command)
22781 .into_iter()
22782 .map(str::to_string)
22783 .collect::<Vec<_>>();
22784 assert!(
22785 try_parse_cli(args).is_ok(),
22786 "expand command should parse as a tsift CLI command: {command}"
22787 );
22788 }
22789
22790 fn setup_multiplicity_project() -> tempfile::TempDir {
22791 let dir = tempfile::tempdir().unwrap();
22792 std::fs::write(
22793 dir.path().join("Cargo.toml"),
22794 r#"[workspace]
22795members = ["crates/core-lib", "crates/cli-app"]
22796"#,
22797 )
22798 .unwrap();
22799 std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
22800 std::fs::write(
22801 dir.path().join("crates/core-lib/Cargo.toml"),
22802 r#"[package]
22803name = "core-lib"
22804
22805[lib]
22806name = "core_lib"
22807
22808[features]
22809default = []
22810"#,
22811 )
22812 .unwrap();
22813 std::fs::write(
22814 dir.path().join("crates/core-lib/src/lib.rs"),
22815 "pub fn run() {}\n",
22816 )
22817 .unwrap();
22818 std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
22819 std::fs::write(
22820 dir.path().join("crates/cli-app/Cargo.toml"),
22821 r#"[package]
22822name = "cli-app"
22823
22824[[bin]]
22825name = "cli-app"
22826
22827[dependencies]
22828core-lib = { path = "../core-lib" }
22829"#,
22830 )
22831 .unwrap();
22832 std::fs::write(
22833 dir.path().join("crates/cli-app/src/main.rs"),
22834 "use core_lib::run;\nfn main() { run(); }\n",
22835 )
22836 .unwrap();
22837 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22838 db.apply_changes(dir.path()).unwrap();
22839
22840 let task_dir = dir.path().join("tasks/software");
22841 std::fs::create_dir_all(&task_dir).unwrap();
22842 std::fs::write(
22843 task_dir.join("tsift.md"),
22844 r#"---
22845agent_doc_session: tsift-multiplicity
22846agent_doc_format: template
22847---
22848
22849## Backlog
22850
22851<!-- agent:backlog -->
22852- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
22853<!-- /agent:backlog -->
22854"#,
22855 )
22856 .unwrap();
22857 init_git_repo(dir.path());
22858 dir
22859 }
22860
22861 fn setup_dependency_dag_project() -> tempfile::TempDir {
22862 let dir = tempfile::tempdir().unwrap();
22863 std::fs::write(
22864 dir.path().join("main.rs"),
22865 "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
22866 )
22867 .unwrap();
22868 std::fs::write(
22869 dir.path().join("Cargo.toml"),
22870 "[package]\nname = \"dag-fixture\"\n",
22871 )
22872 .unwrap();
22873 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22874 db.apply_changes(dir.path()).unwrap();
22875
22876 let task_dir = dir.path().join("tasks/software");
22877 std::fs::create_dir_all(&task_dir).unwrap();
22878 std::fs::write(
22879 task_dir.join("tsift.md"),
22880 r#"---
22881agent_doc_session: tsift-dag
22882agent_doc_format: template
22883---
22884
22885## Exchange
22886
22887<!-- agent:exchange patch=append -->
22888Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
22889<!-- /agent:exchange -->
22890
22891## Backlog
22892
22893<!-- agent:backlog -->
22894- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
22895- [ ] [#alpha] Update shared_helper in main.rs after #prep.
22896- [ ] [#beta] Refactor shared_helper tests in main.rs.
22897- [ ] [#gamma] Follow-up review for graph navigation.
22898<!-- /agent:backlog -->
22899"#,
22900 )
22901 .unwrap();
22902 dir
22903 }
22904
22905 fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
22906 let dir = setup_graph_index();
22907 let task_dir = dir.path().join("tasks/software");
22908 std::fs::create_dir_all(&task_dir).unwrap();
22909 std::fs::write(
22910 task_dir.join("tsift.md"),
22911 r#"---
22912agent_doc_session: tsift-dag-cycle
22913agent_doc_format: template
22914---
22915
22916## Backlog
22917
22918<!-- agent:backlog -->
22919- [ ] [#left] Left side depends on #right.
22920- [ ] [#right] Right side depends on #left.
22921<!-- /agent:backlog -->
22922"#,
22923 )
22924 .unwrap();
22925 dir
22926 }
22927
22928 fn seed_traversal_semantic_summaries(dir: &Path) {
22929 let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
22930 summary_db
22931 .insert(&summarize::Summary {
22932 id: 0,
22933 symbol_name: "helper".to_string(),
22934 file_path: "main.rs".to_string(),
22935 content_hash: "hash-main".to_string(),
22936 summary: "helper builds graph navigation handles for traversal.".to_string(),
22937 entities: Some(vec![
22938 summarize::Entity {
22939 name: "helper".to_string(),
22940 kind: "function".to_string(),
22941 description: "Builds graph navigation handles.".to_string(),
22942 },
22943 summarize::Entity {
22944 name: "TraversalGraph".to_string(),
22945 kind: "type".to_string(),
22946 description: "Carries GraphStore-backed traversal rows.".to_string(),
22947 },
22948 ]),
22949 relationships: Some(vec![summarize::Relationship {
22950 from: "helper".to_string(),
22951 to: "TraversalGraph".to_string(),
22952 kind: "uses".to_string(),
22953 }]),
22954 concept_labels: Some(vec![
22955 "graph navigation".to_string(),
22956 "semantic extraction".to_string(),
22957 ]),
22958 extracted_at: "1700000000".to_string(),
22959 model: "test-model".to_string(),
22960 tokens_input: Some(10),
22961 tokens_output: Some(5),
22962 })
22963 .unwrap();
22964 }
22965
22966 fn seed_tsift_memory_graph_db(dir: &Path) {
22967 let db = dir.join(".tsift").join("memory.db");
22968 let store = MemoryStore::open_or_create(&db).unwrap();
22969 let project = dir.to_string_lossy().to_string();
22970 let observation = MemoryEvent::new(
22971 MemoryEventKind::ImportedObservation,
22972 "claude-mem:observations:1",
22973 [
22974 "Graph memory adapter",
22975 "read-only projection",
22976 "graph-db should retrieve tsift memory observations",
22977 "Project memory is queried from .tsift/memory.db",
22978 "graph memory, tsift memory, semantic query",
22979 ]
22980 .join("\n\n"),
22981 )
22982 .with_session_id("claude-session-a")
22983 .with_observed_at_unix(1_700_000_000)
22984 .with_import("claude-mem", "observations:1")
22985 .with_metadata("project", project.clone())
22986 .with_metadata("observation_type", "fact")
22987 .with_metadata("prompt_number", "7")
22988 .with_metadata("discovery_tokens", "42")
22989 .with_metadata("content_hash", "hash-observation-1");
22990 store.insert_event(&observation).unwrap();
22991
22992 let summary = MemoryEvent::new(
22993 MemoryEventKind::ImportedSessionSummary,
22994 "claude-mem:session_summaries:2",
22995 [
22996 "Query old memory from graph-db",
22997 "Read-only tsift memory SQLite projection",
22998 "Semantic graph rows can point at existing memory",
22999 "Projected source and session nodes",
23000 "Keep capture ownership inside tsift-memory",
23001 "summary note",
23002 ]
23003 .join("\n\n"),
23004 )
23005 .with_session_id("claude-session-a")
23006 .with_observed_at_unix(1_700_000_010)
23007 .with_import("claude-mem", "session_summaries:2")
23008 .with_metadata("project", project)
23009 .with_metadata("prompt_number", "8")
23010 .with_metadata("discovery_tokens", "36");
23011 store.insert_event(&summary).unwrap();
23012
23013 let prompt = MemoryEvent::new(
23014 MemoryEventKind::ImportedUserPrompt,
23015 "claude-mem:user_prompts:3",
23016 "How can graph-db query tsift memory semantic history?",
23017 )
23018 .with_session_id("claude-session-a")
23019 .with_observed_at_unix(1_700_000_020)
23020 .with_import("claude-mem", "user_prompts:3")
23021 .with_metadata("prompt_number", "9");
23022 store.insert_event(&prompt).unwrap();
23023 }
23024
23025 #[test]
23026 fn graph_callers_query() {
23027 let dir = setup_graph_index();
23028 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23029 let callers = db.callers_of("helper").unwrap();
23030 assert_eq!(callers.len(), 1);
23031 assert_eq!(callers[0].caller_name, "main");
23032 }
23033
23034 #[test]
23035 fn graph_callees_query() {
23036 let dir = setup_graph_index();
23037 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23038 let callees = db.callees_of("main").unwrap();
23039 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
23040 assert!(names.contains(&"helper"));
23041 assert!(names.contains(&"new"));
23042 }
23043
23044 #[test]
23045 fn graph_no_callers_returns_empty() {
23046 let dir = setup_graph_index();
23047 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23048 let callers = db.callers_of("nonexistent").unwrap();
23049 assert!(callers.is_empty());
23050 }
23051
23052 #[test]
23053 fn graph_cmd_autoindexes_missing_index_by_default() {
23054 let dir = tempfile::tempdir().unwrap();
23055 std::fs::write(
23056 dir.path().join("main.rs"),
23057 "fn helper() {}\nfn main() { helper(); }\n",
23058 )
23059 .unwrap();
23060 let result = cmd_graph(
23061 "helper",
23062 dir.path(),
23063 true,
23064 false,
23065 None,
23066 20,
23067 false,
23068 true,
23069 false,
23070 false,
23071 false,
23072 false,
23073 false,
23074 TagpathSearchOpts::default(),
23075 );
23076
23077 assert!(result.is_ok());
23078 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
23079 let summary = db.compute_changes(dir.path()).unwrap();
23080 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
23081 }
23082
23083 #[test]
23084 fn traversal_graph_has_stable_typed_handles() {
23085 let dir = setup_traversal_project();
23086 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23087 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23088
23089 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
23090 let symbol = resolve_traversal_node(&graph, "helper").unwrap();
23091 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23092 let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
23093
23094 assert!(file.handle.starts_with("gfil-"));
23095 assert!(symbol.handle.starts_with("gsym-"));
23096 assert!(backlog.handle.starts_with("gbak-"));
23097 assert!(session.handle.starts_with("gses-"));
23098
23099 assert_eq!(
23100 symbol.handle,
23101 resolve_traversal_node(&graph_again, "helper")
23102 .unwrap()
23103 .handle
23104 );
23105 assert_eq!(
23106 backlog.handle,
23107 resolve_traversal_node(&graph_again, "#kgnv")
23108 .unwrap()
23109 .handle
23110 );
23111 }
23112
23113 #[test]
23114 fn traversal_graph_links_backlog_items_to_code_tokens() {
23115 let dir = setup_traversal_project();
23116 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23117 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23118 let helper = resolve_traversal_node(&graph, "helper").unwrap();
23119
23120 assert!(graph.edges.iter().any(|edge| {
23121 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23122 }));
23123 }
23124
23125 #[test]
23126 fn session_hinted_traversal_skips_global_call_edges() {
23127 let dir = setup_traversal_project();
23128 let session = dir.path().join("tasks/software/tsift.md");
23129 let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
23130 let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
23131 let helper = resolve_traversal_node(&bounded, "helper").unwrap();
23132
23133 assert!(bounded.edges.iter().any(|edge| {
23134 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23135 }));
23136 assert!(
23137 !bounded.edges.iter().any(|edge| edge.relation == "calls"),
23138 "session-hinted graph-db projections should not materialize unrelated global call edges"
23139 );
23140
23141 let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
23142 assert!(
23143 full.edges.iter().any(|edge| edge.relation == "calls"),
23144 "root/full projections still carry the complete indexed call graph"
23145 );
23146 }
23147
23148 #[test]
23149 fn agent_doc_task_path_infers_matching_workspace_scope() {
23150 let dir = tempfile::tempdir().unwrap();
23151 std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
23152 std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
23153 std::fs::write(
23154 dir.path().join(".gitmodules"),
23155 "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
23156 )
23157 .unwrap();
23158 let task = dir.path().join("tasks/software/tsift.md");
23159 std::fs::write(&task, "# tsift\n").unwrap();
23160
23161 let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
23162 let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
23163 let cfg = config::Config::load(dir.path()).unwrap();
23164
23165 assert_eq!(targets.len(), 1);
23166 assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
23167 assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
23168 assert!(
23169 targets[0]
23170 .db_path
23171 .ends_with(".tsift/indexes/tsift/index.db")
23172 );
23173 assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
23174 }
23175
23176 #[test]
23177 fn cargo_package_scope_selector_indexes_package_db() {
23178 let dir = setup_multiplicity_project();
23179 let targets =
23180 resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
23181
23182 assert_eq!(targets.len(), 1);
23183 assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
23184 assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
23185 assert!(
23186 targets[0]
23187 .db_path
23188 .ends_with(".tsift/indexes/cargo/core-lib/index.db")
23189 );
23190
23191 cmd_index(
23192 dir.path(),
23193 false,
23194 false,
23195 false,
23196 false,
23197 true,
23198 false,
23199 Some("core_lib"),
23200 false,
23201 true,
23202 false,
23203 false,
23204 false,
23205 false,
23206 )
23207 .unwrap();
23208 assert!(targets[0].db_path.exists());
23209 }
23210
23211 #[test]
23212 fn path_inference_prefers_nested_cargo_package_without_submodule() {
23213 let dir = setup_multiplicity_project();
23214 let source = dir.path().join("crates/cli-app/src/main.rs");
23215 let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
23216
23217 assert_eq!(targets.len(), 1);
23218 assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
23219 assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
23220 }
23221
23222 #[test]
23223 fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
23224 let dir = setup_multiplicity_project();
23225 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23226 let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
23227 let core = resolve_traversal_node(&graph, "core-lib").unwrap();
23228 let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
23229 let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
23230
23231 assert_eq!(workspace.kind, "cargo_workspace");
23232 assert_eq!(core.kind, "cargo_package");
23233 assert_eq!(
23234 core.properties.get("features"),
23235 Some(&"default".to_string())
23236 );
23237 assert!(graph.edges.iter().any(|edge| {
23238 edge.from == workspace.handle
23239 && edge.to == core.handle
23240 && edge.relation == "contains_package"
23241 }));
23242 assert!(graph.edges.iter().any(|edge| {
23243 edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
23244 }));
23245 assert!(graph.edges.iter().any(|edge| {
23246 edge.from == cli.handle
23247 && edge.to == core.handle
23248 && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
23249 }));
23250 }
23251
23252 #[test]
23253 fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
23254 let dir = setup_multiplicity_project();
23255 let session = dir.path().join("tasks/software/tsift.md");
23256 let report =
23257 build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
23258 .unwrap();
23259
23260 assert!(report.per_target_fail_closed.is_empty());
23261 let candidate = report
23262 .candidates
23263 .iter()
23264 .find(|candidate| candidate.target == "corepkg")
23265 .unwrap();
23266 assert!(
23267 candidate
23268 .owned_files
23269 .iter()
23270 .any(|file| file == "crates/core-lib/Cargo.toml"),
23271 "{:?}",
23272 candidate.owned_files
23273 );
23274 }
23275
23276 #[test]
23277 fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
23278 let dir = setup_traversal_project();
23279 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23280 let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
23281 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23282
23283 assert_eq!(job.kind, "job_packet");
23284 assert!(job.handle.starts_with("gjob-"));
23285 assert!(graph.edges.iter().any(|edge| {
23286 edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
23287 }));
23288
23289 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23290 let jobs = store.nodes_by_kind("job_packet").unwrap();
23291 assert!(
23292 jobs.iter()
23293 .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23294 "expected queued job packet in graph store, got {jobs:?}"
23295 );
23296 }
23297
23298 #[test]
23299 fn traversal_graph_includes_routes_and_handler_edges() {
23300 let dir = tempfile::tempdir().unwrap();
23301 std::fs::write(
23302 dir.path().join("api.py"),
23303 r#"@router.get("/items")
23304def list_items():
23305 return []
23306"#,
23307 )
23308 .unwrap();
23309 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23310 db.apply_changes(dir.path()).unwrap();
23311
23312 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23313 let route = resolve_traversal_node(&graph, "/items").unwrap();
23314 let handler = resolve_traversal_node(&graph, "list_items").unwrap();
23315
23316 assert_eq!(route.kind, "route");
23317 assert!(graph.edges.iter().any(|edge| {
23318 edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
23319 }));
23320 }
23321
23322 #[test]
23323 fn traversal_graph_projects_rust_ast_navigation_edges() {
23324 let dir = tempfile::tempdir().unwrap();
23325 std::fs::write(
23326 dir.path().join("main.rs"),
23327 r#"mod api {
23328 pub fn helper() {}
23329 pub fn handler() { helper(); }
23330}
23331
23332fn main() { api::handler(); }
23333"#,
23334 )
23335 .unwrap();
23336 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23337 db.apply_changes(dir.path()).unwrap();
23338
23339 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23340 let api = resolve_ast_span_node(&graph, "api", "mod");
23341 let helper = resolve_ast_span_node(&graph, "helper", "function");
23342 let handler = resolve_ast_span_node(&graph, "handler", "function");
23343
23344 assert_eq!(helper.kind, "ast_span");
23345 assert!(helper.handle.starts_with("span-"));
23346 assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
23347 assert!(graph.edges.iter().any(|edge| {
23348 edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
23349 }));
23350 assert!(graph.edges.iter().any(|edge| {
23351 edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
23352 }));
23353 assert!(graph.edges.iter().any(|edge| {
23354 edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
23355 }));
23356 assert!(graph.edges.iter().any(|edge| {
23357 edge.from == helper.handle
23358 && edge.to == handler.handle
23359 && edge.relation == "next_sibling"
23360 }));
23361 assert!(graph.edges.iter().any(|edge| {
23362 edge.from == handler.handle
23363 && edge.to == helper.handle
23364 && edge.relation == "previous_sibling"
23365 }));
23366 assert!(graph.edges.iter().any(|edge| {
23367 edge.from == helper.handle
23368 && edge.to == api.handle
23369 && edge.relation == "enclosing_module"
23370 }));
23371 assert!(graph.edges.iter().any(|edge| {
23372 edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
23373 }));
23374
23375 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23376 let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
23377 assert!(
23378 ast_nodes.iter().any(|node| node.id == helper.handle
23379 && node.properties.get("symbol_kind") == Some(&"function".to_string())),
23380 "expected helper AST span in graph store, got {ast_nodes:?}"
23381 );
23382 assert!(
23383 store
23384 .outgoing_edges(&helper.handle, Some("parent"))
23385 .unwrap()
23386 .iter()
23387 .any(|edge| edge.to_id == api.handle),
23388 "expected persisted AST parent edge"
23389 );
23390 }
23391
23392 #[test]
23393 fn traversal_graph_projects_markdown_section_block_edges() {
23394 let dir = tempfile::tempdir().unwrap();
23395 std::fs::write(
23396 dir.path().join("README.md"),
23397 "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
23398 )
23399 .unwrap();
23400 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23401 db.apply_changes(dir.path()).unwrap();
23402
23403 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23404 let guide = resolve_ast_span_node(&graph, "Guide", "heading");
23405 let code = resolve_ast_span_node(&graph, "rust", "code_block");
23406 let embedded = resolve_ast_span_node(&graph, "demo", "function");
23407 let list_item = graph
23408 .nodes
23409 .values()
23410 .find(|node| {
23411 node.kind == "ast_span"
23412 && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
23413 && node.properties.get("section_handle") == Some(&guide.handle)
23414 })
23415 .expect("missing Markdown list item AST span");
23416
23417 assert_eq!(
23418 code.properties.get("markdown_block_kind"),
23419 Some(&"fenced_code_block".to_string())
23420 );
23421 assert_eq!(
23422 guide.properties.get("heading_level"),
23423 Some(&"1".to_string())
23424 );
23425 assert_eq!(
23426 embedded.properties.get("embedded"),
23427 Some(&"true".to_string())
23428 );
23429 assert_eq!(
23430 embedded.properties.get("language"),
23431 Some(&"rust".to_string())
23432 );
23433 assert_eq!(
23434 embedded.properties.get("markdown_block_handle"),
23435 Some(&code.handle)
23436 );
23437 assert!(graph.edges.iter().any(|edge| {
23438 edge.from == guide.handle
23439 && edge.to == code.handle
23440 && edge.relation == "contains_markdown_block"
23441 }));
23442 assert!(graph.edges.iter().any(|edge| {
23443 edge.from == code.handle
23444 && edge.to == guide.handle
23445 && edge.relation == "enclosing_section"
23446 }));
23447 assert!(graph.edges.iter().any(|edge| {
23448 edge.from == guide.handle
23449 && edge.to == list_item.handle
23450 && edge.relation == "contains_markdown_block"
23451 }));
23452 assert!(graph.edges.iter().any(|edge| {
23453 edge.from == code.handle
23454 && edge.to == embedded.handle
23455 && edge.relation == "contains_embedded_symbol"
23456 }));
23457 assert!(graph.edges.iter().any(|edge| {
23458 edge.from == embedded.handle
23459 && edge.to == code.handle
23460 && edge.relation == "embedded_in_fence"
23461 }));
23462 assert!(graph.edges.iter().any(|edge| {
23463 edge.from == guide.handle
23464 && edge.to == embedded.handle
23465 && edge.relation == "contains_embedded_code"
23466 }));
23467
23468 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23469 assert!(
23470 store
23471 .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
23472 .unwrap()
23473 .iter()
23474 .any(|edge| edge.to_id == code.handle),
23475 "expected persisted Markdown section/block edge"
23476 );
23477 assert!(
23478 store
23479 .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
23480 .unwrap()
23481 .iter()
23482 .any(|edge| edge.to_id == embedded.handle),
23483 "expected persisted Markdown fence/embedded symbol edge"
23484 );
23485 }
23486
23487 #[test]
23488 fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
23489 let dir = setup_multilingual_ast_navigation_project();
23490 let db =
23491 index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
23492 let symbols = db.all_symbols().unwrap();
23493 let expected_symbols = [
23494 ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
23495 (
23496 "python",
23497 "fixture_nav_python_entry",
23498 "function",
23499 "python.py",
23500 ),
23501 (
23502 "typescript",
23503 "fixture_nav_typescript_entry",
23504 "function",
23505 "typescript.ts",
23506 ),
23507 (
23508 "javascript",
23509 "fixture_nav_javascript_entry",
23510 "function",
23511 "javascript.js",
23512 ),
23513 (
23514 "kotlin",
23515 "fixture_nav_kotlin_entry",
23516 "function",
23517 "kotlin.kt",
23518 ),
23519 ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
23520 ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
23521 ("markdown", "Fixture Section", "heading", "README.md"),
23522 ("markdown", "Fixture step", "list_item", "README.md"),
23523 ("markdown", "python", "code_block", "README.md"),
23524 ];
23525
23526 for (language, name, kind, file) in expected_symbols {
23527 let symbol = symbols
23528 .iter()
23529 .find(|symbol| {
23530 symbol.language == language
23531 && symbol.name == name
23532 && symbol.kind == kind
23533 && symbol.file.ends_with(file)
23534 })
23535 .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
23536 assert!(
23537 symbol.start_byte.is_some() && symbol.end_byte.is_some(),
23538 "{language} {name} should carry AST byte spans"
23539 );
23540 }
23541
23542 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23543 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23544 let expected_ast_nodes = [
23545 ("fixture_nav_rust_entry", "function", "rust"),
23546 ("fixture_nav_python_entry", "function", "python"),
23547 ("fixture_nav_typescript_entry", "function", "typescript"),
23548 ("fixture_nav_javascript_entry", "function", "javascript"),
23549 ("fixture_nav_kotlin_entry", "function", "kotlin"),
23550 ("fixture_nav_zig_entry", "function", "zig"),
23551 ("fixture_nav_bash_entry", "function", "bash"),
23552 ("Fixture Section", "heading", "markdown"),
23553 ("Fixture step", "list_item", "markdown"),
23554 ("python", "code_block", "markdown"),
23555 ("fixture_nav_markdown_embedded", "function", "python"),
23556 ];
23557
23558 for (name, kind, language) in expected_ast_nodes {
23559 let node = resolve_ast_span_node(&graph, name, kind);
23560 let repeated = resolve_ast_span_node(&graph_again, name, kind);
23561 assert!(
23562 node.handle.starts_with("span-"),
23563 "{name} handle: {}",
23564 node.handle
23565 );
23566 assert_eq!(
23567 node.handle, repeated.handle,
23568 "{language} {name} handle drifted"
23569 );
23570 assert_eq!(
23571 node.properties.get("language"),
23572 Some(&language.to_string()),
23573 "{name} should keep its language label"
23574 );
23575 }
23576
23577 let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
23578 let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
23579 let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
23580 assert!(graph.edges.iter().any(|edge| {
23581 edge.from == markdown_section.handle
23582 && edge.to == markdown_code.handle
23583 && edge.relation == "contains_markdown_block"
23584 }));
23585 assert!(graph.edges.iter().any(|edge| {
23586 edge.from == markdown_code.handle
23587 && edge.to == embedded.handle
23588 && edge.relation == "contains_embedded_symbol"
23589 }));
23590 assert!(
23591 graph.nodes.len() <= 80,
23592 "multilingual AST fixture should stay bounded, got {} nodes",
23593 graph.nodes.len()
23594 );
23595 assert!(
23596 graph.edges.len() <= 180,
23597 "multilingual AST fixture should stay bounded, got {} edges",
23598 graph.edges.len()
23599 );
23600
23601 let response = empty_search_response(dir.path(), "lexical");
23602 let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
23603 let report = build_relative_search_budget_report(
23604 "fixture_nav_python_entry",
23605 "lexical",
23606 dir.path(),
23607 &response,
23608 &symbol_hits,
23609 ResponseBudget::new(Some(8), Some(120)),
23610 &SearchFacetFilters::default(),
23611 );
23612 let report_again = build_relative_search_budget_report(
23613 "fixture_nav_python_entry",
23614 "lexical",
23615 dir.path(),
23616 &response,
23617 &symbol_hits,
23618 ResponseBudget::new(Some(8), Some(120)),
23619 &SearchFacetFilters::default(),
23620 );
23621
23622 let top = report
23623 .ranked
23624 .first()
23625 .expect("ranked preview should not be empty");
23626 assert_eq!(top.source, "symbol_span");
23627 assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
23628 assert!(top.handle.starts_with("srnk-"));
23629 assert_eq!(top.handle, report_again.ranked[0].handle);
23630 assert!(
23631 top.reasons.iter().any(|reason| reason == "ast_span"),
23632 "expected AST span ranking reason, got {:?}",
23633 top.reasons
23634 );
23635 assert!(report.ranked.len() <= 8);
23636 assert!(report.symbols.len() <= 8);
23637
23638 let symbol = report
23639 .symbols
23640 .iter()
23641 .find(|symbol| symbol.name == "fixture_nav_python_entry")
23642 .expect("missing search preview symbol");
23643 assert_cli_expand_command_parses(&symbol.expand);
23644 let ast = symbol
23645 .ast
23646 .as_ref()
23647 .expect("search symbol should expose AST");
23648 assert_cli_expand_command_parses(&ast.expand.source_window);
23649 assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
23650 assert_cli_expand_command_parses(&ast.expand.symbol_read);
23651
23652 let markdown_hits = db.symbol_search("python", 20).unwrap();
23653 let markdown_report = build_relative_search_budget_report(
23654 "python",
23655 "lexical",
23656 dir.path(),
23657 &response,
23658 &markdown_hits,
23659 ResponseBudget::new(Some(8), Some(120)),
23660 &SearchFacetFilters::default(),
23661 );
23662 let markdown_symbol = markdown_report
23663 .symbols
23664 .iter()
23665 .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
23666 .expect("missing Markdown code-block symbol");
23667 let markdown_ast = markdown_symbol
23668 .ast
23669 .as_ref()
23670 .expect("Markdown code block should expose AST");
23671 assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
23672 assert_eq!(
23673 markdown_ast
23674 .span
23675 .markdown
23676 .as_ref()
23677 .unwrap()
23678 .embedded_symbols[0]
23679 .name,
23680 "fixture_nav_markdown_embedded"
23681 );
23682 }
23683
23684 #[test]
23685 fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
23686 let edges = vec![
23687 TraversalEdge {
23688 from: "origin".to_string(),
23689 to: "aaa_low".to_string(),
23690 relation: "unknown".to_string(),
23691 label: None,
23692 weight: 1,
23693 },
23694 TraversalEdge {
23695 from: "origin".to_string(),
23696 to: "zzz_high".to_string(),
23697 relation: "mentions".to_string(),
23698 label: None,
23699 weight: 1,
23700 },
23701 ];
23702
23703 let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
23704
23705 assert!(handles.contains("origin"));
23706 assert!(handles.contains("zzz_high"), "{handles:?}");
23707 assert!(!handles.contains("aaa_low"), "{handles:?}");
23708 }
23709
23710 #[test]
23711 fn traversal_materializes_provider_neutral_sqlite_graph() {
23712 let dir = setup_traversal_project();
23713 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23714 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23715
23716 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23717 let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
23718 assert!(
23719 backlog_nodes.iter().any(|node| node.id == backlog.handle
23720 && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23721 "expected materialized backlog node, got {backlog_nodes:?}"
23722 );
23723 assert!(
23724 store
23725 .all_nodes()
23726 .unwrap()
23727 .iter()
23728 .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
23729 && node.properties.get("projection_version")
23730 == Some(&GRAPH_PROJECTION_VERSION.to_string())),
23731 "expected projection metadata node"
23732 );
23733 let source_handles = store.nodes_by_kind("source_handle").unwrap();
23734 assert!(
23735 source_handles
23736 .iter()
23737 .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
23738 "expected bounded source_handle rows, got {source_handles:?}"
23739 );
23740 let worker_context = store.nodes_by_kind("worker_context").unwrap();
23741 assert!(
23742 worker_context
23743 .iter()
23744 .any(|node| node.properties.get("target")
23745 == Some(&"tasks/software/tsift.md".to_string())),
23746 "expected bounded worker_context rows, got {worker_context:?}"
23747 );
23748 let worker_results = store.nodes_by_kind("worker_result").unwrap();
23749 assert!(
23750 worker_results.iter().any(|node| {
23751 node.properties.get("ref_id") == Some(&"kgnv".to_string())
23752 && node.properties.get("status") == Some(&"completed".to_string())
23753 && node.properties.get("touched_files") == Some(&"main.rs".to_string())
23754 && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
23755 }),
23756 "expected worker_result rows, got {worker_results:?}"
23757 );
23758 }
23759
23760 #[test]
23761 fn traversal_projection_materializes_cached_semantic_rows() {
23762 let dir = setup_traversal_project();
23763 seed_traversal_semantic_summaries(dir.path());
23764 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23765 let helper = resolve_traversal_node(&graph, "helper").unwrap();
23766 let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
23767 let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
23768
23769 assert_eq!(concept.kind, "semantic_concept");
23770 assert_eq!(entity.kind, "semantic_entity");
23771 assert!(concept.handle.starts_with("gcon-"));
23772 assert!(entity.handle.starts_with("gent-"));
23773
23774 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23775 assert!(
23776 store
23777 .nodes_by_kind("semantic_concept")
23778 .unwrap()
23779 .iter()
23780 .any(|node| node.label == "semantic extraction"
23781 && node.properties.contains_key("embedding")),
23782 "expected persisted concept embeddings"
23783 );
23784 assert!(
23785 store
23786 .outgoing_edges(&helper.handle, Some("mentions_concept"))
23787 .unwrap()
23788 .iter()
23789 .any(|edge| edge.to_id == concept.handle),
23790 "expected helper symbol to link to cached summary concept"
23791 );
23792 assert!(
23793 store
23794 .outgoing_edges(
23795 &semantic_entity_handle("helper", "function"),
23796 Some("semantic_relation")
23797 )
23798 .unwrap()
23799 .iter()
23800 .any(|edge| edge.to_id == entity.handle
23801 && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
23802 "expected LLM relationship rows projected into GraphStore"
23803 );
23804 }
23805
23806 #[test]
23807 fn traversal_projection_materializes_tsift_memory_rows() {
23808 let dir = setup_traversal_project();
23809 seed_tsift_memory_graph_db(dir.path());
23810 let memory_db = dir.path().join(".tsift").join("memory.db");
23811 let store = MemoryStore::open_or_create(&memory_db).unwrap();
23812 for summary in ["first closeout", "second closeout"] {
23813 let event = MemoryEvent::new(
23814 MemoryEventKind::ResponseSummary,
23815 "tasks/software/tsift.md",
23816 summary,
23817 )
23818 .with_session_id("tasks/software/tsift.md")
23819 .with_observed_at_unix(1_700_000_100);
23820 store.insert_event(&event).unwrap();
23821 }
23822 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23823 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23824
23825 let native_sources = store
23826 .nodes_by_kind("source_handle")
23827 .unwrap()
23828 .into_iter()
23829 .filter(|node| {
23830 node.properties.get("provider") == Some(&"tsift-memory".to_string())
23831 && node.properties.get("source_ref")
23832 == Some(&"tasks/software/tsift.md".to_string())
23833 })
23834 .collect::<Vec<_>>();
23835 assert_eq!(
23836 native_sources.len(),
23837 2,
23838 "same-source native memory events must get distinct source handles"
23839 );
23840
23841 let source = store
23842 .nodes_by_kind("source_handle")
23843 .unwrap()
23844 .into_iter()
23845 .find(|node| {
23846 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23847 })
23848 .expect("expected tsift-memory source handle");
23849 let session = store
23850 .nodes_by_kind("memory_session")
23851 .unwrap()
23852 .into_iter()
23853 .find(|node| {
23854 node.properties.get("provider") == Some(&"tsift-memory".to_string())
23855 && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
23856 })
23857 .expect("expected tsift-memory session node");
23858 let event = store
23859 .nodes_by_kind("memory_event")
23860 .unwrap()
23861 .into_iter()
23862 .find(|node| {
23863 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23864 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
23865 && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
23866 })
23867 .expect("expected tsift-memory event node");
23868 let concept = store
23869 .nodes_by_kind("semantic_concept")
23870 .unwrap()
23871 .into_iter()
23872 .find(|node| {
23873 node.properties.get("provider") == Some(&"tsift-memory".to_string())
23874 && node.label.contains("Graph memory adapter")
23875 && node.properties.contains_key("embedding")
23876 })
23877 .expect("expected tsift-memory semantic concept");
23878
23879 assert!(
23880 store
23881 .outgoing_edges(&session.id, Some("records_memory_source"))
23882 .unwrap()
23883 .iter()
23884 .any(|edge| edge.to_id == source.id),
23885 "expected session to link to source handle"
23886 );
23887 assert!(
23888 store
23889 .outgoing_edges(&session.id, Some("records_memory_event"))
23890 .unwrap()
23891 .iter()
23892 .any(|edge| edge.to_id == event.id),
23893 "expected session to link to memory event"
23894 );
23895 assert!(
23896 store
23897 .outgoing_edges(&event.id, Some("projects_source"))
23898 .unwrap()
23899 .iter()
23900 .any(|edge| edge.to_id == source.id),
23901 "expected memory event to project source handle"
23902 );
23903 assert!(
23904 store
23905 .outgoing_edges(&source.id, Some("mentions_concept"))
23906 .unwrap()
23907 .iter()
23908 .any(|edge| edge.to_id == concept.id),
23909 "expected source handle to seed semantic concept"
23910 );
23911
23912 let related = semantic_related_report_from_store(
23913 dir.path(),
23914 None,
23915 "tsift memory graph adapter",
23916 5,
23917 SemanticRelatedKind::Concept,
23918 &store,
23919 )
23920 .unwrap();
23921 assert!(
23922 related
23923 .items
23924 .iter()
23925 .any(|item| item.handle == concept.id && item.score > 0.0),
23926 "expected semantic query to retrieve tsift-memory concept, got {:?}",
23927 related.items
23928 );
23929
23930 let graph_related = graph_db_report_from_store(
23931 dir.path(),
23932 None,
23933 "sqlite",
23934 GraphDbQuery::Related {
23935 query: "tsift memory graph adapter".to_string(),
23936 kind: SemanticRelatedKind::Concept,
23937 depth: 1,
23938 seed_limit: 5,
23939 limit: 20,
23940 },
23941 &store,
23942 sqlite_graph_freshness(&store, "root").unwrap(),
23943 Vec::new(),
23944 )
23945 .unwrap();
23946 assert_eq!(
23947 graph_related
23948 .readiness
23949 .as_ref()
23950 .map(|readiness| readiness.status.as_str()),
23951 Some("ready"),
23952 "tsift-memory semantic rows should satisfy graph-db related readiness"
23953 );
23954 assert!(
23955 graph_related.nodes.iter().any(|node| {
23956 node.kind == "semantic_concept"
23957 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
23958 }),
23959 "expected related graph output to include tsift-memory semantic rows"
23960 );
23961 }
23962
23963 #[test]
23964 fn semantic_related_query_uses_persisted_graph_embeddings() {
23965 let dir = setup_traversal_project();
23966 seed_traversal_semantic_summaries(dir.path());
23967 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23968 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23969
23970 let report = semantic_related_report_from_store(
23971 dir.path(),
23972 None,
23973 "graph navigation",
23974 5,
23975 SemanticRelatedKind::Concept,
23976 &store,
23977 )
23978 .unwrap();
23979
23980 assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
23981 assert!(
23982 report
23983 .items
23984 .iter()
23985 .any(|item| item.label == "graph navigation"
23986 && item.kind == "semantic_concept"
23987 && item.score > 0.9),
23988 "expected nearest concept match from graph embeddings, got {:?}",
23989 report.items
23990 );
23991 }
23992
23993 #[test]
23994 fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
23995 let dir = setup_traversal_project();
23996 seed_traversal_semantic_summaries(dir.path());
23997 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23998 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23999
24000 let report = graph_db_report_from_store(
24001 dir.path(),
24002 None,
24003 "sqlite",
24004 GraphDbQuery::Related {
24005 query: "graph navigation".to_string(),
24006 kind: SemanticRelatedKind::All,
24007 depth: 1,
24008 seed_limit: 2,
24009 limit: 20,
24010 },
24011 &store,
24012 sqlite_graph_freshness(&store, "root").unwrap(),
24013 Vec::new(),
24014 )
24015 .unwrap();
24016
24017 let knowledge = report.knowledge_retrieval.as_ref().unwrap();
24018 assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
24019 assert_eq!(knowledge.seed_kind, "all");
24020 assert_eq!(knowledge.depth, 1);
24021 assert_eq!(
24022 report
24023 .readiness
24024 .as_ref()
24025 .map(|readiness| readiness.status.as_str()),
24026 Some("ready")
24027 );
24028 assert!(
24029 knowledge
24030 .diagnostics
24031 .iter()
24032 .any(|diagnostic| diagnostic.contains("incident"))
24033 );
24034 assert!(
24035 report
24036 .semantic_related
24037 .iter()
24038 .any(|item| item.label == "graph navigation"
24039 && item.kind == "semantic_concept"
24040 && item.score > 0.9),
24041 "expected natural-language query to seed the graph navigation concept, got {:?}",
24042 report.semantic_related
24043 );
24044 assert!(
24045 report
24046 .nodes
24047 .iter()
24048 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
24049 );
24050 assert!(
24051 report
24052 .nodes
24053 .iter()
24054 .any(|node| node.kind == "symbol" && node.label == "helper"),
24055 "incident expansion from semantic seed should recover source symbols, got {:?}",
24056 report
24057 .nodes
24058 .iter()
24059 .map(|node| (&node.kind, &node.label))
24060 .collect::<Vec<_>>()
24061 );
24062 assert!(
24063 report
24064 .edges
24065 .iter()
24066 .any(|edge| edge.kind == "mentions_concept")
24067 );
24068 assert!(
24069 report.output_budget.as_ref().is_some_and(|budget| budget
24070 .diagnostics
24071 .iter()
24072 .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
24073 "expected related output budget diagnostics, got {:?}",
24074 report.output_budget
24075 );
24076 }
24077
24078 #[test]
24079 fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
24080 let dir = setup_graph_index();
24081 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24082 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24083
24084 let report = graph_db_report_from_store(
24085 dir.path(),
24086 None,
24087 "sqlite",
24088 GraphDbQuery::Related {
24089 query: "graph navigation".to_string(),
24090 kind: SemanticRelatedKind::All,
24091 depth: 1,
24092 seed_limit: 2,
24093 limit: 20,
24094 },
24095 &store,
24096 sqlite_graph_freshness(&store, "root").unwrap(),
24097 Vec::new(),
24098 )
24099 .unwrap();
24100
24101 let readiness = report.readiness.as_ref().unwrap();
24102 assert_eq!(readiness.status, "blocked");
24103 assert_eq!(readiness.reason, "summary_cache_empty");
24104 assert!(readiness.fail_closed);
24105 assert_eq!(
24106 readiness.next_commands,
24107 vec![
24108 "tsift summarize --extract .".to_string(),
24109 graph_db_refresh_command(dir.path(), None)
24110 ]
24111 );
24112 assert!(
24113 report
24114 .knowledge_retrieval
24115 .as_ref()
24116 .unwrap()
24117 .diagnostics
24118 .iter()
24119 .any(|diagnostic| diagnostic.contains("summary cache empty")
24120 && diagnostic.contains("graph-db materialized code/session rows")),
24121 "expected related diagnostics to carry readiness gate, got {:?}",
24122 report.knowledge_retrieval.as_ref().unwrap().diagnostics
24123 );
24124 }
24125
24126 #[test]
24127 fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
24128 let mut nodes = vec![
24129 SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
24130 SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
24131 ];
24132 let mut edges = vec![SubstrateGraphEdge::new(
24133 "zzz_high",
24134 "seed",
24135 "mentions_concept",
24136 )];
24137 for idx in 0..24 {
24138 let id = format!("aaa_low_{idx:02}");
24139 nodes.push(SubstrateGraphNode::new(
24140 id.clone(),
24141 "note",
24142 format!("low {idx}"),
24143 ));
24144 edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
24145 }
24146 let mut store = SqliteGraphStore::in_memory().unwrap();
24147 store
24148 .replace_projection(&GraphProjection { nodes, edges })
24149 .unwrap();
24150
24151 let subgraph =
24152 graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
24153
24154 assert_eq!(subgraph.nodes.len(), 3);
24155 assert_eq!(subgraph.nodes[0].id, "seed");
24156 assert_eq!(
24157 subgraph.nodes[1].id, "zzz_high",
24158 "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
24159 subgraph.nodes
24160 );
24161 assert!(subgraph.truncated);
24162 assert!(
24163 subgraph
24164 .diagnostics
24165 .iter()
24166 .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
24167 "{:?}",
24168 subgraph.diagnostics
24169 );
24170 assert!(
24171 subgraph
24172 .diagnostics
24173 .iter()
24174 .any(|diagnostic| diagnostic.contains("skipped")),
24175 "{:?}",
24176 subgraph.diagnostics
24177 );
24178 }
24179
24180 #[test]
24181 fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
24182 let dir = setup_traversal_project();
24183 seed_traversal_semantic_summaries(dir.path());
24184 init_git_repo(dir.path());
24185 let session = dir.path().join("tasks/software/tsift.md");
24186 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24187 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24188 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24189 let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24190 root: dir.path(),
24191 scope: None,
24192 backend: "sqlite",
24193 target: "kgnv",
24194 depth: 4,
24195 limit: 8,
24196 cursor: None,
24197 store: &store,
24198 freshness,
24199 warnings: Vec::new(),
24200 })
24201 .unwrap();
24202 assert!(
24203 evidence
24204 .semantic_related
24205 .iter()
24206 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
24207 "expected semantic evidence rows, got {:?}",
24208 evidence
24209 .semantic_related
24210 .iter()
24211 .map(|node| (&node.kind, &node.label))
24212 .collect::<Vec<_>>()
24213 );
24214 assert!(
24215 evidence
24216 .output_budget
24217 .as_ref()
24218 .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
24219 diagnostic.contains("semantic_match")
24220 && diagnostic.contains("source_handle_coverage")
24221 })),
24222 "expected evidence output budget diagnostics, got {:?}",
24223 evidence.output_budget
24224 );
24225
24226 let cached_diff = diff_digest::compute(
24227 dir.path(),
24228 diff_digest::DiffDigestOptions {
24229 cached: true,
24230 revision: None,
24231 max_parsed_files: None,
24232 },
24233 )
24234 .unwrap();
24235 let impact_report = impact::compute(
24236 dir.path(),
24237 impact::ImpactOptions {
24238 cached: true,
24239 revision: None,
24240 scope: None,
24241 limit: 10,
24242 },
24243 )
24244 .unwrap();
24245 let graph_nodes = store.all_nodes().unwrap();
24246 let graph_index = conflict_matrix_graph_index(&graph_nodes);
24247 let semantic_candidate = conflict_matrix_candidate_from_evidence(
24248 dir.path(),
24249 &evidence,
24250 &graph_index,
24251 &cached_diff,
24252 &impact_report,
24253 );
24254 assert!(semantic_candidate.semantic_dispatch_score > 0);
24255 assert!(
24256 semantic_candidate
24257 .semantic_dispatch_reasons
24258 .iter()
24259 .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
24260 "expected semantic ranking explanations, got {:?}",
24261 semantic_candidate.semantic_dispatch_reasons
24262 );
24263 assert!(
24264 semantic_candidate
24265 .semantic_related
24266 .iter()
24267 .any(|item| item.label == "graph navigation")
24268 );
24269
24270 let mut plain_candidate = semantic_candidate.clone();
24271 plain_candidate.target = "plain".to_string();
24272 plain_candidate.semantic_related.clear();
24273 plain_candidate.semantic_dispatch_score = 0;
24274 plain_candidate.semantic_dispatch_reasons.clear();
24275 let mut ranked = [plain_candidate, semantic_candidate];
24276 ranked.sort_by(|left, right| {
24277 left.risk
24278 .cmp(&right.risk)
24279 .then_with(|| left.risk_score.cmp(&right.risk_score))
24280 .then_with(|| {
24281 right
24282 .semantic_dispatch_score
24283 .cmp(&left.semantic_dispatch_score)
24284 })
24285 .then_with(|| left.target.cmp(&right.target))
24286 });
24287 assert_eq!(ranked[0].target, "kgnv");
24288 }
24289
24290 #[test]
24291 fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
24292 let dir = setup_dependency_dag_project();
24293 let session = dir.path().join("tasks/software/tsift.md");
24294 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24295
24296 assert_eq!(report.contract_version, "dependency-dag-v1");
24297 assert_eq!(
24298 report.targets,
24299 vec![
24300 "prep".to_string(),
24301 "alpha".to_string(),
24302 "beta".to_string(),
24303 "gamma".to_string()
24304 ]
24305 );
24306 assert!(report.edges.iter().any(|edge| {
24307 edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
24308 }));
24309 assert!(report.edges.iter().any(|edge| {
24310 edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
24311 }));
24312 assert!(report.edges.iter().any(|edge| {
24313 edge.from == "alpha"
24314 && edge.to == "beta"
24315 && edge.kind == "shared_resource"
24316 && edge.shared_files.contains(&"main.rs".to_string())
24317 && edge.shared_symbols.contains(&"shared_helper".to_string())
24318 }));
24319 assert!(
24320 !report.cycle_diagnostics.has_cycles,
24321 "{:?}",
24322 report.cycle_diagnostics
24323 );
24324 assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
24325 assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
24326 assert!(
24327 report.replay_commands[0].contains("dependency-dag"),
24328 "{:?}",
24329 report.replay_commands
24330 );
24331
24332 cmd_dependency_dag(
24333 &session,
24334 None,
24335 &["alpha".to_string(), "beta".to_string()],
24336 4,
24337 12,
24338 OutputFormat {
24339 json_output: true,
24340 compact: false,
24341 pretty: false,
24342 terse: false,
24343 ultra_terse: false,
24344 schema: false,
24345 envelope: false,
24346 },
24347 )
24348 .unwrap();
24349 }
24350
24351 #[test]
24352 fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
24353 let dir = setup_dependency_dag_cycle_project();
24354 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24355
24356 assert!(report.cycle_diagnostics.has_cycles);
24357 assert_eq!(
24358 report.cycle_diagnostics.blocked_nodes,
24359 vec!["left".to_string(), "right".to_string()]
24360 );
24361 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24362 edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
24363 }));
24364 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24365 edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
24366 }));
24367 }
24368
24369 #[test]
24370 fn traversal_projection_queries_match_sqlite_and_convex_stores() {
24371 let dir = setup_traversal_project();
24372 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24373 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
24374
24375 let mut sqlite = SqliteGraphStore::in_memory().unwrap();
24376 sqlite.replace_projection(&projection).unwrap();
24377 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24378 projection.upsert_into(&convex).unwrap();
24379
24380 let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
24381 let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
24382 assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
24383 assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
24384
24385 let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
24386 let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
24387 assert!(convex_graph.edges.iter().any(|edge| {
24388 edge.from == sqlite_backlog.handle
24389 && edge.to == convex_helper.handle
24390 && edge.relation == "mentions"
24391 }));
24392 }
24393
24394 #[test]
24395 fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
24396 let dir = setup_traversal_project();
24397 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24398 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24399 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24400 assert_eq!(freshness.status, "current");
24401
24402 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24403 let report = graph_db_report_from_store(
24404 dir.path(),
24405 None,
24406 "sqlite",
24407 GraphDbQuery::Neighborhood {
24408 id: backlog.handle.clone(),
24409 depth: 1,
24410 edge_kind: Some("mentions".to_string()),
24411 cursor: None,
24412 limit: None,
24413 property_filters: Vec::new(),
24414 },
24415 &store,
24416 freshness,
24417 Vec::new(),
24418 )
24419 .unwrap();
24420 assert!(
24421 report
24422 .edges
24423 .iter()
24424 .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
24425 "expected backlog mention edge, got {:?}",
24426 report.edges
24427 );
24428 assert!(
24429 report.ranked_neighbors.iter().any(|neighbor| {
24430 neighbor.depth == Some(1)
24431 && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
24432 && neighbor.node_id != backlog.handle
24433 && neighbor.handle_coverage_pct >= 95.0
24434 && neighbor.duplicate_name_precision >= 0.99
24435 }),
24436 "expected ranked neighborhood neighbors with quality scores, got {:?}",
24437 report.ranked_neighbors
24438 );
24439 assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
24440 let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
24441 assert!(!ranking_gate.ranked_output_default);
24442 assert_eq!(ranking_gate.default_order, "stable_node_id");
24443 assert!(
24444 ranking_gate
24445 .diagnostics
24446 .iter()
24447 .any(|diagnostic| diagnostic.contains("score-capped")),
24448 "{ranking_gate:?}"
24449 );
24450 assert!(
24451 ranking_gate
24452 .required_metrics
24453 .iter()
24454 .any(|metric| metric == "handle_coverage_pct")
24455 );
24456 assert!(
24457 ranking_gate
24458 .required_metrics
24459 .iter()
24460 .any(|metric| metric == "duplicate_name_precision")
24461 );
24462 assert!(
24463 report
24464 .page
24465 .as_ref()
24466 .unwrap()
24467 .diagnostics
24468 .iter()
24469 .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
24470 "expected SQLite neighborhood query plan diagnostics, got {:?}",
24471 report.page.as_ref().unwrap().diagnostics
24472 );
24473 let edges_report = graph_db_report_from_store(
24474 dir.path(),
24475 None,
24476 "sqlite",
24477 GraphDbQuery::Edges {
24478 edge_kind: Some("mentions".to_string()),
24479 cursor: None,
24480 limit: Some(2),
24481 property_filters: Vec::new(),
24482 },
24483 &store,
24484 sqlite_graph_freshness(&store, "root").unwrap(),
24485 Vec::new(),
24486 )
24487 .unwrap();
24488 let edge_id = edges_report
24489 .edges
24490 .first()
24491 .map(|edge| edge.id.clone())
24492 .expect("expected at least one paged mentions edge");
24493 assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
24494 assert_eq!(
24495 edges_report.page.as_ref().unwrap().returned_edges,
24496 edges_report.edges.len()
24497 );
24498
24499 let edge_report = graph_db_report_from_store(
24500 dir.path(),
24501 None,
24502 "sqlite",
24503 GraphDbQuery::Edge {
24504 id: edge_id.clone(),
24505 },
24506 &store,
24507 sqlite_graph_freshness(&store, "root").unwrap(),
24508 Vec::new(),
24509 )
24510 .unwrap();
24511 assert_eq!(
24512 edge_report.edge.as_ref().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
24513 Some(edge_id.clone())
24514 );
24515
24516 let incident_report = graph_db_report_from_store(
24517 dir.path(),
24518 None,
24519 "sqlite",
24520 GraphDbQuery::Incident {
24521 id: backlog.handle.clone(),
24522 edge_kind: Some("mentions".to_string()),
24523 cursor: None,
24524 limit: Some(1),
24525 property_filters: Vec::new(),
24526 },
24527 &store,
24528 sqlite_graph_freshness(&store, "root").unwrap(),
24529 Vec::new(),
24530 )
24531 .unwrap();
24532 assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
24533 assert!(
24534 incident_report
24535 .edges
24536 .iter()
24537 .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
24538 "{:?}",
24539 incident_report.edges
24540 );
24541
24542 let schema_report = graph_db_report_from_store(
24543 dir.path(),
24544 None,
24545 "sqlite",
24546 GraphDbQuery::Schema,
24547 &store,
24548 sqlite_graph_freshness(&store, "root").unwrap(),
24549 Vec::new(),
24550 )
24551 .unwrap();
24552 assert!(
24553 schema_report
24554 .schema
24555 .unwrap()
24556 .operations
24557 .iter()
24558 .any(|operation| operation.command.starts_with("neighborhood"))
24559 );
24560 }
24561
24562 #[test]
24563 fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
24564 let mut nodes = vec![SubstrateGraphNode::new(
24565 "origin",
24566 "backlog",
24567 "#budgeted-neighborhood",
24568 )];
24569 let mut edges = Vec::new();
24570 for idx in 0..32 {
24571 let id = format!("src-{idx:02}");
24572 nodes.push(
24573 SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
24574 .with_property("source_ref", format!("fixture:{idx}"))
24575 .with_property("detail", "x".repeat(600)),
24576 );
24577 edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
24578 }
24579 let store = SqliteGraphStore::in_memory().unwrap();
24580 GraphProjection { nodes, edges }
24581 .upsert_into(&store)
24582 .unwrap();
24583
24584 let report = graph_db_report_from_store(
24585 Path::new("."),
24586 None,
24587 "fixture",
24588 GraphDbQuery::Neighborhood {
24589 id: "origin".to_string(),
24590 depth: 1,
24591 edge_kind: None,
24592 cursor: None,
24593 limit: None,
24594 property_filters: Vec::new(),
24595 },
24596 &store,
24597 current_graph_db_freshness(),
24598 Vec::new(),
24599 )
24600 .unwrap();
24601 let budget = report.output_budget.as_ref().unwrap();
24602 assert!(budget.selected_nodes < budget.candidate_nodes);
24603 assert!(
24604 budget.dropped_by_budget.iter().any(|drop| {
24605 drop.item == "node"
24606 && drop.kind == "source_handle"
24607 && drop.reason == "per_kind_quota"
24608 }),
24609 "expected source_handle budget drops, got {:?}",
24610 budget.dropped_by_budget
24611 );
24612 assert!(report.page.as_ref().unwrap().truncated);
24613 assert!(
24614 report
24615 .page
24616 .as_ref()
24617 .unwrap()
24618 .diagnostics
24619 .iter()
24620 .any(|diagnostic| diagnostic.contains("budget ranking signals")),
24621 "{:?}",
24622 report.page
24623 );
24624 }
24625
24626 #[test]
24627 fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
24628 let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
24629 let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
24630 for idx in 0..8 {
24631 let id = format!("far-{idx:02}");
24632 nodes.push(SubstrateGraphNode::new(
24633 id.clone(),
24634 "note",
24635 format!("aaa deeper row {idx}"),
24636 ));
24637 depth_by_id.insert(id, 6);
24638 }
24639
24640 let origin_ids = vec!["target".to_string()];
24641 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
24642 &origin_ids,
24643 &BTreeMap::new(),
24644 nodes,
24645 Vec::new(),
24646 Some(3),
24647 Some(&depth_by_id),
24648 None,
24649 );
24650
24651 assert!(
24652 budgeted.nodes.iter().any(|node| node.id == "near"),
24653 "expected the shallow evidence row to outrank deeper rows, got {:?}",
24654 budgeted
24655 .nodes
24656 .iter()
24657 .map(|node| (&node.id, &node.label))
24658 .collect::<Vec<_>>()
24659 );
24660 assert!(
24661 budgeted.report.dropped_by_budget.iter().any(|drop| {
24662 drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
24663 }),
24664 "expected node quota drops, got {:?}",
24665 budgeted.report.dropped_by_budget
24666 );
24667 assert!(
24668 budgeted
24669 .report
24670 .diagnostics
24671 .iter()
24672 .any(|diagnostic| diagnostic.contains("depth")),
24673 "{:?}",
24674 budgeted.report.diagnostics
24675 );
24676 }
24677
24678 #[test]
24679 fn evidence_pagination_returns_next_cursor_when_truncated() {
24680 let mut nodes = vec![SubstrateGraphNode::new(
24681 "target".to_string(),
24682 "backlog_item",
24683 "target item".to_string(),
24684 )];
24685 let mut depth_by_id = BTreeMap::new();
24686 depth_by_id.insert("target".to_string(), 0);
24687 for idx in 0..20 {
24688 let id = format!("ev-{idx}");
24689 nodes.push(SubstrateGraphNode::new(
24690 id.clone(),
24691 "source_handle",
24692 format!("evidence row {idx}"),
24693 ).with_property("detail", "x".repeat(400)));
24694 depth_by_id.insert(id, 1);
24695 }
24696 let origin_ids = vec!["target".to_string()];
24697 let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
24698 &origin_ids,
24699 &BTreeMap::new(),
24700 nodes.clone(),
24701 Vec::new(),
24702 Some(3),
24703 Some(&depth_by_id),
24704 None,
24705 );
24706 assert!(
24707 first_page.truncated,
24708 "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
24709 first_page.nodes.len(),
24710 first_page.report.candidate_nodes
24711 );
24712 assert!(
24713 first_page.next_cursor.is_some(),
24714 "expected next_cursor when truncated"
24715 );
24716 let cursor = first_page.next_cursor.unwrap();
24717 assert!(
24718 !cursor.is_empty(),
24719 "cursor should be a non-empty node id"
24720 );
24721 let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
24722 let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
24723 &origin_ids,
24724 &BTreeMap::new(),
24725 nodes.clone(),
24726 Vec::new(),
24727 Some(3),
24728 Some(&depth_by_id),
24729 Some(&cursor),
24730 );
24731 let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
24732 let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
24733 assert!(
24734 overlap.is_empty(),
24735 "pages should not overlap, but found shared ids: {overlap:?}"
24736 );
24737 assert!(
24738 second_page.report.diagnostics.iter().any(|d| d.contains("cursor skipped")),
24739 "expected cursor skip diagnostic, got {:?}",
24740 second_page.report.diagnostics
24741 );
24742 }
24743
24744 #[test]
24745 fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
24746 let mut nodes = vec![SubstrateGraphNode::new(
24747 "target".to_string(),
24748 "backlog_item",
24749 "target item".to_string(),
24750 )];
24751 let mut depth_by_id = BTreeMap::new();
24752 depth_by_id.insert("target".to_string(), 0);
24753 for idx in 0..3 {
24754 let id = format!("ev-{idx}");
24755 nodes.push(SubstrateGraphNode::new(
24756 id.clone(),
24757 "source_handle",
24758 format!("evidence row {idx}"),
24759 ));
24760 depth_by_id.insert(id, 1);
24761 }
24762 let origin_ids = vec!["target".to_string()];
24763 let result = graph_db_apply_output_budget_with_depths_and_cursor(
24764 &origin_ids,
24765 &BTreeMap::new(),
24766 nodes,
24767 Vec::new(),
24768 None,
24769 Some(&depth_by_id),
24770 None,
24771 );
24772 assert!(
24773 !result.truncated,
24774 "expected no truncation with small candidate set and default budget"
24775 );
24776 assert!(
24777 result.next_cursor.is_none(),
24778 "expected no next_cursor when not truncated"
24779 );
24780 }
24781
24782 #[test]
24783 fn evidence_pagination_invalid_cursor_returns_first_page() {
24784 let mut nodes = vec![SubstrateGraphNode::new(
24785 "target".to_string(),
24786 "backlog_item",
24787 "target item".to_string(),
24788 )];
24789 let mut depth_by_id = BTreeMap::new();
24790 depth_by_id.insert("target".to_string(), 0);
24791 for idx in 0..5 {
24792 let id = format!("ev-{idx}");
24793 nodes.push(SubstrateGraphNode::new(
24794 id.clone(),
24795 "source_handle",
24796 format!("evidence row {idx}"),
24797 ));
24798 depth_by_id.insert(id, 1);
24799 }
24800 let origin_ids = vec!["target".to_string()];
24801 let result = graph_db_apply_output_budget_with_depths_and_cursor(
24802 &origin_ids,
24803 &BTreeMap::new(),
24804 nodes.clone(),
24805 Vec::new(),
24806 None,
24807 Some(&depth_by_id),
24808 Some("nonexistent-id"),
24809 );
24810 assert!(
24811 result.report.diagnostics.iter().any(|d| d.contains("cursor skipped 0")),
24812 "invalid cursor should skip 0 candidates, got {:?}",
24813 result.report.diagnostics
24814 );
24815 }
24816
24817 #[test]
24818 fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
24819 let dir = setup_traversal_project();
24820 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24821 let graph_db = dir.path().join(".tsift/graph.db");
24822 let _lock = hold_rollback_journal_lock(&graph_db);
24823
24824 let report =
24825 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24826 .unwrap();
24827
24828 assert_eq!(report.status, "current");
24829 assert_eq!(
24830 report.recovery,
24831 Some(index::ReadOnlyRecovery::SnapshotFallback)
24832 );
24833 assert!(
24834 report
24835 .warnings
24836 .iter()
24837 .any(|warning| warning.contains("rollback-journal lock")),
24838 "expected rollback-journal recovery warning, got {:?}",
24839 report.warnings
24840 );
24841 }
24842
24843 #[test]
24844 fn graph_db_status_copies_wal_sidecars_when_locked() {
24845 let dir = setup_traversal_project();
24846 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24847 let graph_db = dir.path().join(".tsift/graph.db");
24848 let _lock = hold_wal_database_lock(&graph_db);
24849
24850 let report =
24851 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24852 .unwrap();
24853
24854 assert_eq!(report.status, "current");
24855 assert_eq!(
24856 report.recovery,
24857 Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
24858 );
24859 assert!(
24860 report
24861 .warnings
24862 .iter()
24863 .any(|warning| warning.contains("WAL-aware snapshot fallback")),
24864 "expected WAL recovery warning, got {:?}",
24865 report.warnings
24866 );
24867 }
24868
24869 #[test]
24870 fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
24871 let dir = setup_traversal_project();
24872 let session = dir.path().join("tasks/software/tsift.md");
24873 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24874 let graph_db = dir.path().join(".tsift/graph.db");
24875 let _lock = hold_rollback_journal_lock(&graph_db);
24876
24877 let result = cmd_graph_db(
24878 &session,
24879 None,
24880 GraphDbBackend::Sqlite,
24881 None,
24882 GraphDbQuery::Evidence {
24883 target: "kgnv".to_string(),
24884 depth: 3,
24885 limit: 8,
24886 cursor: None,
24887 },
24888 OutputFormat {
24889 json_output: false,
24890 compact: true,
24891 pretty: false,
24892 terse: false,
24893 ultra_terse: false,
24894 schema: false,
24895 envelope: false,
24896 },
24897 );
24898
24899 assert!(result.is_ok());
24900 }
24901
24902 fn current_graph_db_freshness() -> GraphDbFreshnessReport {
24903 GraphDbFreshnessReport {
24904 status: "current".to_string(),
24905 fail_closed: false,
24906 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
24907 content_hash: Some("fixture".to_string()),
24908 source_watermark: None,
24909 diagnostics: Vec::new(),
24910 }
24911 }
24912
24913 #[test]
24914 fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
24915 let dir = setup_traversal_project();
24916 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24917 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24918 let stale = GraphDbFreshnessReport {
24919 status: "stale".to_string(),
24920 fail_closed: true,
24921 projection_version: Some("old-v0".to_string()),
24922 content_hash: None,
24923 source_watermark: None,
24924 diagnostics: vec!["projection content hash is missing".to_string()],
24925 };
24926
24927 let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24928 root: dir.path(),
24929 scope: None,
24930 backend: "sqlite",
24931 target: "kgnv",
24932 depth: 3,
24933 limit: 8,
24934 cursor: None,
24935 store: &store,
24936 freshness: stale,
24937 warnings: Vec::new(),
24938 }) {
24939 Ok(_) => panic!("stale graph freshness should fail closed"),
24940 Err(err) => err,
24941 };
24942 let message = err.to_string();
24943 assert!(message.contains("failed closed"), "{message}");
24944 assert!(message.contains("graph-db --path"), "{message}");
24945 assert!(message.contains("refresh --json"), "{message}");
24946 }
24947
24948 fn paged_graph_ids(
24949 store: &impl GraphStore,
24950 cursor: Option<&str>,
24951 ) -> (Vec<String>, GraphDbPageReport) {
24952 let report = graph_db_report_from_store(
24953 Path::new("."),
24954 None,
24955 "fixture",
24956 GraphDbQuery::Kind {
24957 kind: "backlog".to_string(),
24958 cursor: cursor.map(str::to_string),
24959 limit: Some(2),
24960 property_filters: vec!["phase=open".to_string()],
24961 },
24962 store,
24963 current_graph_db_freshness(),
24964 Vec::new(),
24965 )
24966 .unwrap();
24967 (
24968 report.nodes.iter().map(|node| node.id.clone()).collect(),
24969 report.page.unwrap(),
24970 )
24971 }
24972
24973 #[test]
24974 fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
24975 let nodes = (0..5)
24976 .map(|idx| {
24977 let phase = if idx == 1 { "closed" } else { "open" };
24978 SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
24979 .with_property("phase", phase)
24980 })
24981 .collect::<Vec<_>>();
24982 let projection = GraphProjection {
24983 nodes,
24984 edges: Vec::new(),
24985 };
24986 let sqlite = SqliteGraphStore::in_memory().unwrap();
24987 projection.upsert_into(&sqlite).unwrap();
24988 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24989 projection.upsert_into(&convex).unwrap();
24990
24991 let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
24992 let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
24993 assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
24994 assert_eq!(sqlite_first_ids, convex_first_ids);
24995 assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
24996 assert!(sqlite_first_page.truncated);
24997 assert_eq!(
24998 sqlite_first_page.returned_nodes,
24999 convex_first_page.returned_nodes
25000 );
25001 assert_eq!(
25002 sqlite_first_page.property_filters,
25003 convex_first_page.property_filters
25004 );
25005 assert!(
25006 sqlite_first_page
25007 .diagnostics
25008 .iter()
25009 .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
25010 "expected SQLite kind query plan diagnostics, got {:?}",
25011 sqlite_first_page.diagnostics
25012 );
25013
25014 let cursor = sqlite_first_page.next_cursor.as_deref();
25015 let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
25016 let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
25017 assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
25018 assert_eq!(sqlite_next_ids, convex_next_ids);
25019 assert_eq!(sqlite_next_page.next_cursor, None);
25020 assert!(!sqlite_next_page.truncated);
25021 assert_eq!(
25022 sqlite_next_page.returned_nodes,
25023 convex_next_page.returned_nodes
25024 );
25025 assert_eq!(
25026 sqlite_next_page.property_filters,
25027 convex_next_page.property_filters
25028 );
25029 }
25030
25031 #[test]
25032 fn traversal_shortest_path_crosses_artifacts_and_symbols() {
25033 let dir = setup_traversal_project();
25034 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25035 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25036 let main = resolve_traversal_node(&graph, "main").unwrap();
25037
25038 let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
25039 assert_eq!(path.first(), Some(&backlog.handle));
25040 assert_eq!(path.last(), Some(&main.handle));
25041 assert!(
25042 path.len() >= 3,
25043 "expected backlog -> symbol -> main, got {path:?}"
25044 );
25045 }
25046
25047 #[test]
25048 fn traversal_report_recommends_next_bugfix_nodes() {
25049 let dir = setup_traversal_project();
25050 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25051 let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
25052
25053 assert_eq!(report.mode, "neighborhood");
25054 assert!(
25055 report
25056 .recommendations
25057 .iter()
25058 .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
25059 "expected helper recommendation, got {:?}",
25060 report.recommendations
25061 );
25062 assert!(
25063 !report.exploration.source_windows.is_empty(),
25064 "expected exploration source windows"
25065 );
25066 assert!(
25067 report
25068 .exploration
25069 .no_reread_guidance
25070 .contains("avoid whole-file reads")
25071 );
25072 }
25073
25074 #[test]
25075 fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
25076 let dir = setup_traversal_project();
25077 std::thread::sleep(std::time::Duration::from_millis(50));
25078 std::fs::write(
25079 dir.path().join("main.rs"),
25080 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25081 )
25082 .unwrap();
25083
25084 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25085
25086 assert!(
25087 graph
25088 .warnings
25089 .iter()
25090 .any(|warning| warning.contains("index refreshed")
25091 && warning.contains("graph traversal packet")),
25092 "expected refresh diagnostic, got {:?}",
25093 graph.warnings
25094 );
25095 assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
25096
25097 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
25098 let summary = db.compute_changes(dir.path()).unwrap();
25099 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
25100 }
25101
25102 #[test]
25103 fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
25104 let dir = setup_traversal_project();
25105 let db_path = dir.path().join(".tsift/index.db");
25106 let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
25107 std::thread::sleep(std::time::Duration::from_millis(50));
25108 std::fs::write(
25109 dir.path().join("main.rs"),
25110 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25111 )
25112 .unwrap();
25113
25114 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25115 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
25116
25117 assert!(
25118 graph
25119 .warnings
25120 .iter()
25121 .any(|warning| warning.contains("falling back to raw source file nodes")),
25122 "expected raw-source fallback diagnostic, got {:?}",
25123 graph.warnings
25124 );
25125 assert!(
25126 file.detail
25127 .as_deref()
25128 .is_some_and(|detail| detail.contains("raw source fallback")),
25129 "expected raw-source detail, got {:?}",
25130 file.detail
25131 );
25132 assert!(
25133 file.expand.contains("source-read"),
25134 "expected source-read fallback command, got {}",
25135 file.expand
25136 );
25137 assert!(
25138 resolve_traversal_node(&graph, "helper").is_none(),
25139 "stale symbol evidence should be skipped when refresh is blocked"
25140 );
25141 }
25142
25143 #[test]
25144 fn traversal_cmd_supports_json_and_html_outputs() {
25145 let dir = setup_traversal_project();
25146 cmd_traverse(
25147 Some("#kgnv"),
25148 Some("main"),
25149 dir.path(),
25150 None,
25151 1,
25152 50,
25153 TraverseFormat::Json,
25154 false,
25155 false,
25156 false,
25157 None,
25158 )
25159 .unwrap();
25160 cmd_traverse(
25161 None,
25162 None,
25163 dir.path(),
25164 None,
25165 1,
25166 50,
25167 TraverseFormat::Html,
25168 false,
25169 false,
25170 false,
25171 None,
25172 )
25173 .unwrap();
25174 }
25175
25176 #[test]
25177 fn traversal_html_renders_inline_graph_visualization() {
25178 let dir = setup_traversal_project();
25179 seed_traversal_semantic_summaries(dir.path());
25180 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25181 let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
25182 let html = traversal_report_html(&report).unwrap();
25183
25184 assert!(html.contains("id=\"graph-canvas\""));
25185 assert!(html.contains("semantic_concept"));
25186 assert!(html.contains("graph navigation"));
25187 assert!(html.contains("JSON.parse"));
25188 }
25189
25190 #[test]
25191 fn compact_helpers_trim_scores_and_snippets() {
25192 assert_eq!(format_score(0.12345, true), "0.12");
25193 assert_eq!(format_score(0.12345, false), "0.1235");
25194 let snippet = compact_snippet(" first line with useful context\nsecond");
25195 assert_eq!(snippet.as_deref(), Some("first line with useful context"));
25196 }
25197
25198 #[test]
25199 fn compact_members_caps_list() {
25200 let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
25201 .iter()
25202 .map(|n| graph::CommunityMember::new(*n))
25203 .collect();
25204 assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
25205 }
25206
25207 #[test]
25208 fn abbreviate_kind_maps_common_kinds() {
25209 assert_eq!(abbreviate_kind("function"), "fn");
25210 assert_eq!(abbreviate_kind("method"), "meth");
25211 assert_eq!(abbreviate_kind("class"), "cls");
25212 assert_eq!(abbreviate_kind("interface"), "iface");
25213 assert_eq!(abbreviate_kind("type_alias"), "type");
25214 assert_eq!(abbreviate_kind("data_class"), "data_cls");
25215 assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
25216 assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
25217 assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
25218 assert_eq!(abbreviate_kind("object"), "obj");
25219 assert_eq!(abbreviate_kind("heading"), "h");
25220 assert_eq!(abbreviate_kind("code_block"), "code");
25221 assert_eq!(abbreviate_kind("struct"), "struct");
25223 assert_eq!(abbreviate_kind("trait"), "trait");
25224 assert_eq!(abbreviate_kind("enum"), "enum");
25225 assert_eq!(abbreviate_kind("const"), "const");
25226 assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
25227 }
25228
25229 #[test]
25230 fn abbreviate_match_type_maps_search_types() {
25231 assert_eq!(abbreviate_match_type("exact_name"), "exact");
25232 assert_eq!(abbreviate_match_type("partial_tags"), "partial");
25233 assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
25234 assert_eq!(abbreviate_match_type("other_type"), "other_type");
25235 }
25236
25237 #[test]
25238 fn explain_compact_groups_edges_by_file() {
25239 let edges = vec![
25240 index::StoredEdge {
25241 caller_file: "src/main.rs".to_string(),
25242 caller_name: "main".to_string(),
25243 caller_line: 1,
25244 callee_name: "helper".to_string(),
25245 call_site_line: 2,
25246 tagpath_handle: None,
25247 },
25248 index::StoredEdge {
25249 caller_file: "src/main.rs".to_string(),
25250 caller_name: "main".to_string(),
25251 caller_line: 1,
25252 callee_name: "render".to_string(),
25253 call_site_line: 3,
25254 tagpath_handle: None,
25255 },
25256 ];
25257 let lines = format_edge_groups(&edges, false);
25258 assert_eq!(lines, vec![" src/main.rs (2): helper, render"]);
25259 }
25260
25261 #[test]
25262 fn search_hit_groups_preserve_file_counts_and_samples() {
25263 let dir = tempfile::tempdir().unwrap();
25264 let root = dir.path();
25265 let main_rs = root.join("src/main.rs");
25266 fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
25267 fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
25268 let freshness = exact_search_file_timestamp(&main_rs);
25269 let hits = vec![
25270 sift::SearchHit {
25271 artifact_id: "a".to_string(),
25272 artifact_kind: sift::ContextArtifactKind::File,
25273 path: main_rs.display().to_string(),
25274 rank: 1,
25275 score: 10.0,
25276 confidence: sift::ScoreConfidence::High,
25277 location: Some("line 3".to_string()),
25278 snippet: "claudescore-3 anchor".to_string(),
25279 provenance: sift::ArtifactProvenance {
25280 adapter: sift::AcquisitionAdapterKind::FileSystem,
25281 source: "ripgrep -F".to_string(),
25282 synthetic: false,
25283 },
25284 freshness: freshness.clone(),
25285 budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
25286 },
25287 sift::SearchHit {
25288 artifact_id: "b".to_string(),
25289 artifact_kind: sift::ContextArtifactKind::File,
25290 path: main_rs.display().to_string(),
25291 rank: 2,
25292 score: 9.0,
25293 confidence: sift::ScoreConfidence::High,
25294 location: Some("line 7".to_string()),
25295 snippet: "claudescore-3 follow-up".to_string(),
25296 provenance: sift::ArtifactProvenance {
25297 adapter: sift::AcquisitionAdapterKind::FileSystem,
25298 source: "ripgrep -F".to_string(),
25299 synthetic: false,
25300 },
25301 freshness: freshness.clone(),
25302 budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
25303 },
25304 sift::SearchHit {
25305 artifact_id: "c".to_string(),
25306 artifact_kind: sift::ContextArtifactKind::File,
25307 path: main_rs.display().to_string(),
25308 rank: 3,
25309 score: 8.0,
25310 confidence: sift::ScoreConfidence::High,
25311 location: Some("line 9".to_string()),
25312 snippet: "claudescore-3 tail".to_string(),
25313 provenance: sift::ArtifactProvenance {
25314 adapter: sift::AcquisitionAdapterKind::FileSystem,
25315 source: "ripgrep -F".to_string(),
25316 synthetic: false,
25317 },
25318 freshness,
25319 budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
25320 },
25321 ];
25322
25323 let groups = group_search_hits(&hits, root, false);
25324 assert_eq!(groups.len(), 1);
25325 assert_eq!(groups[0].path, "src/main.rs");
25326 assert_eq!(groups[0].hits, 3);
25327 assert_eq!(
25328 groups[0].samples,
25329 vec![
25330 "line 3: claudescore-3 anchor".to_string(),
25331 "line 7: claudescore-3 follow-up".to_string()
25332 ]
25333 );
25334 assert!(should_collapse_search_hits(&hits, root, false));
25335 }
25336
25337 #[test]
25338 fn dense_edge_groups_trigger_collapse() {
25339 let edges = vec![
25340 index::StoredEdge {
25341 caller_file: "src/main.rs".to_string(),
25342 caller_name: "main".to_string(),
25343 caller_line: 1,
25344 callee_name: "helper".to_string(),
25345 call_site_line: 2,
25346 tagpath_handle: None,
25347 },
25348 index::StoredEdge {
25349 caller_file: "src/main.rs".to_string(),
25350 caller_name: "beta".to_string(),
25351 caller_line: 5,
25352 callee_name: "helper".to_string(),
25353 call_site_line: 6,
25354 tagpath_handle: None,
25355 },
25356 index::StoredEdge {
25357 caller_file: "src/main.rs".to_string(),
25358 caller_name: "gamma".to_string(),
25359 caller_line: 9,
25360 callee_name: "helper".to_string(),
25361 call_site_line: 10,
25362 tagpath_handle: None,
25363 },
25364 ];
25365 assert!(should_collapse_edge_groups(&edges));
25366 }
25367
25368 fn setup_workspace() -> tempfile::TempDir {
25371 let dir = tempfile::tempdir().unwrap();
25372 let root = dir.path();
25373 std::fs::write(
25374 root.join(".gitmodules"),
25375 r#"[submodule "src/alpha"]
25376 path = src/alpha
25377 url = https://example.com/alpha
25378[submodule "src/beta"]
25379 path = src/beta
25380 url = https://example.com/beta
25381"#,
25382 )
25383 .unwrap();
25384 let alpha = root.join("src/alpha");
25385 let beta = root.join("src/beta");
25386 std::fs::create_dir_all(&alpha).unwrap();
25387 std::fs::create_dir_all(&beta).unwrap();
25388 std::fs::write(
25389 alpha.join("lib.rs"),
25390 "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
25391 )
25392 .unwrap();
25393 std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
25394 dir
25395 }
25396
25397 fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
25398 let dir = tempfile::tempdir().unwrap();
25399 let root = dir.path();
25400 std::fs::write(
25401 root.join(".gitmodules"),
25402 r#"[submodule "pkg/app/foo"]
25403 path = pkg/app/foo
25404 url = https://example.com/pkg-app-foo
25405[submodule "vendor/foo"]
25406 path = vendor/foo
25407 url = https://example.com/vendor-foo
25408"#,
25409 )
25410 .unwrap();
25411 let pkg_foo = root.join("pkg/app/foo");
25412 let vendor_foo = root.join("vendor/foo");
25413 std::fs::create_dir_all(&pkg_foo).unwrap();
25414 std::fs::create_dir_all(&vendor_foo).unwrap();
25415 std::fs::write(
25416 pkg_foo.join("lib.rs"),
25417 "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
25418 )
25419 .unwrap();
25420 std::fs::write(
25421 vendor_foo.join("lib.rs"),
25422 "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
25423 )
25424 .unwrap();
25425 dir
25426 }
25427
25428 #[test]
25429 fn workspace_index_creates_per_submodule_dbs() {
25430 let dir = setup_workspace();
25431 cmd_index(
25432 dir.path(),
25433 false,
25434 false,
25435 false,
25436 false,
25437 false,
25438 true,
25439 None,
25440 false,
25441 false,
25442 false,
25443 false,
25444 false,
25445 false,
25446 )
25447 .unwrap();
25448 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25449 assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
25450 }
25451
25452 #[test]
25453 fn workspace_index_single_submodule() {
25454 let dir = setup_workspace();
25455 cmd_index(
25456 dir.path(),
25457 false,
25458 false,
25459 false,
25460 false,
25461 false,
25462 false,
25463 Some("alpha"),
25464 false,
25465 false,
25466 false,
25467 false,
25468 false,
25469 false,
25470 )
25471 .unwrap();
25472 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25473 assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
25474 }
25475
25476 #[test]
25477 fn workspace_index_single_submodule_errors_on_unknown_scope() {
25478 let dir = setup_workspace();
25479
25480 let err = cmd_index(
25481 dir.path(),
25482 false,
25483 false,
25484 false,
25485 false,
25486 false,
25487 false,
25488 Some("missing"),
25489 false,
25490 false,
25491 false,
25492 false,
25493 false,
25494 false,
25495 )
25496 .unwrap_err();
25497
25498 let msg = err.to_string();
25499 assert!(msg.contains("unknown scope `missing`"));
25500 assert!(msg.contains("Available scopes: alpha, beta"));
25501 assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
25502 }
25503
25504 #[test]
25505 fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
25506 let dir = setup_workspace_with_duplicate_leaf_names();
25507 cmd_index(
25508 dir.path(),
25509 false,
25510 false,
25511 false,
25512 false,
25513 false,
25514 true,
25515 None,
25516 false,
25517 false,
25518 false,
25519 false,
25520 false,
25521 false,
25522 )
25523 .unwrap();
25524
25525 assert!(
25526 dir.path()
25527 .join(".tsift/indexes/pkg/app/foo/index.db")
25528 .exists()
25529 );
25530 assert!(
25531 dir.path()
25532 .join(".tsift/indexes/vendor/foo/index.db")
25533 .exists()
25534 );
25535 }
25536
25537 #[test]
25538 fn federated_search_across_submodules() {
25539 let dir = setup_workspace();
25540 cmd_index(
25541 dir.path(),
25542 false,
25543 false,
25544 false,
25545 false,
25546 false,
25547 true,
25548 None,
25549 false,
25550 false,
25551 false,
25552 false,
25553 false,
25554 false,
25555 )
25556 .unwrap();
25557 let (hits, _diag) = federated_symbol_search(
25558 dir.path(),
25559 "alpha_helper",
25560 10,
25561 &TagpathSearchOpts {
25562 no_tagpath: true,
25563 strict: false,
25564 },
25565 )
25566 .unwrap();
25567 assert!(
25568 !hits.is_empty(),
25569 "should find alpha_helper via federated search"
25570 );
25571 }
25572
25573 #[test]
25574 fn federated_search_respects_isolation() {
25575 let dir = setup_workspace();
25576 let tsift_dir = dir.path().join(".tsift");
25577 std::fs::create_dir_all(&tsift_dir).unwrap();
25578 std::fs::write(
25579 tsift_dir.join("config.toml"),
25580 r#"
25581[overrides.alpha]
25582tier = "isolated"
25583"#,
25584 )
25585 .unwrap();
25586 cmd_index(
25587 dir.path(),
25588 false,
25589 false,
25590 false,
25591 false,
25592 false,
25593 true,
25594 None,
25595 false,
25596 false,
25597 false,
25598 false,
25599 false,
25600 false,
25601 )
25602 .unwrap();
25603 let (hits, _diag) = federated_symbol_search(
25604 dir.path(),
25605 "alpha_helper",
25606 10,
25607 &TagpathSearchOpts {
25608 no_tagpath: true,
25609 strict: false,
25610 },
25611 )
25612 .unwrap();
25613 assert!(
25614 hits.is_empty(),
25615 "isolated submodule should not appear in federated search"
25616 );
25617 }
25618
25619 #[test]
25620 fn federated_lexical_search_respects_isolation() {
25621 let dir = setup_workspace();
25622 let tsift_dir = dir.path().join(".tsift");
25623 std::fs::create_dir_all(&tsift_dir).unwrap();
25624 std::fs::write(
25625 tsift_dir.join("config.toml"),
25626 r#"
25627[overrides.alpha]
25628tier = "isolated"
25629"#,
25630 )
25631 .unwrap();
25632 cmd_index(
25633 dir.path(),
25634 false,
25635 false,
25636 false,
25637 false,
25638 false,
25639 true,
25640 None,
25641 false,
25642 false,
25643 false,
25644 false,
25645 false,
25646 false,
25647 )
25648 .unwrap();
25649
25650 let response = federated_sift_search(
25651 dir.path(),
25652 &dir.path().join(".tsift/search-cache"),
25653 "fn",
25654 10,
25655 0,
25656 "lexical",
25657 )
25658 .unwrap();
25659
25660 assert!(
25661 !response.hits.is_empty(),
25662 "shared scopes should still contribute lexical hits"
25663 );
25664 assert!(
25665 response
25666 .hits
25667 .iter()
25668 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25669 "isolated scope should not leak lexical hits: {:?}",
25670 response.hits
25671 );
25672 }
25673
25674 #[test]
25675 fn federated_lexical_search_respects_private_tier() {
25676 let dir = setup_workspace();
25677 let tsift_dir = dir.path().join(".tsift");
25678 std::fs::create_dir_all(&tsift_dir).unwrap();
25679 std::fs::write(
25680 tsift_dir.join("config.toml"),
25681 r#"
25682[overrides.alpha]
25683tier = "private"
25684"#,
25685 )
25686 .unwrap();
25687 cmd_index(
25688 dir.path(),
25689 false,
25690 false,
25691 false,
25692 false,
25693 false,
25694 true,
25695 None,
25696 false,
25697 false,
25698 false,
25699 false,
25700 false,
25701 false,
25702 )
25703 .unwrap();
25704
25705 let response = federated_sift_search(
25706 dir.path(),
25707 &dir.path().join(".tsift/search-cache"),
25708 "fn",
25709 10,
25710 0,
25711 "lexical",
25712 )
25713 .unwrap();
25714
25715 assert!(
25716 !response.hits.is_empty(),
25717 "shared scopes should still contribute lexical hits"
25718 );
25719 assert!(
25720 response
25721 .hits
25722 .iter()
25723 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25724 "private scope should not leak lexical hits: {:?}",
25725 response.hits
25726 );
25727 }
25728
25729 #[test]
25730 fn scoped_search_finds_submodule_symbols() {
25731 let dir = setup_workspace();
25732 cmd_index(
25733 dir.path(),
25734 false,
25735 false,
25736 false,
25737 false,
25738 false,
25739 true,
25740 None,
25741 false,
25742 false,
25743 false,
25744 false,
25745 false,
25746 false,
25747 )
25748 .unwrap();
25749 let cfg = config::Config::load(dir.path()).unwrap();
25750 let db_path = cfg.db_path_for(dir.path(), "alpha");
25751 let db = index::IndexDb::open(&db_path).unwrap();
25752 let hits = db.symbol_search("alpha_main", 10).unwrap();
25753 assert!(!hits.is_empty());
25754 assert_eq!(hits[0].name, "alpha_main");
25755 }
25756
25757 #[test]
25758 fn scoped_search_cmd_errors_on_unknown_scope() {
25759 let dir = setup_workspace();
25760
25761 let err = cmd_search(
25762 "alpha_main".to_string(),
25763 Some(dir.path().to_path_buf()),
25764 5,
25765 Some("lexical".to_string()),
25766 Some("missing".to_string()),
25767 false,
25768 false,
25769 false,
25770 0,
25771 false,
25772 false,
25773 false,
25774 false,
25775 false,
25776 false,
25777 false,
25778 )
25779 .unwrap_err();
25780
25781 let msg = err.to_string();
25782 assert!(msg.contains("unknown scope `missing`"));
25783 assert!(msg.contains("Available scopes: alpha, beta"));
25784 }
25785
25786 #[test]
25787 fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
25788 let dir = setup_workspace_with_duplicate_leaf_names();
25789 cmd_index(
25790 dir.path(),
25791 false,
25792 false,
25793 false,
25794 false,
25795 false,
25796 true,
25797 None,
25798 false,
25799 false,
25800 false,
25801 false,
25802 false,
25803 false,
25804 )
25805 .unwrap();
25806
25807 let err = cmd_search(
25808 "vendor_only".to_string(),
25809 Some(dir.path().to_path_buf()),
25810 5,
25811 Some("lexical".to_string()),
25812 Some("foo".to_string()),
25813 false,
25814 false,
25815 false,
25816 0,
25817 false,
25818 false,
25819 false,
25820 false,
25821 false,
25822 false,
25823 false,
25824 )
25825 .unwrap_err();
25826
25827 let msg = err.to_string();
25828 assert!(msg.contains("ambiguous scope `foo`"));
25829 assert!(msg.contains("pkg/app/foo"));
25830 assert!(msg.contains("vendor/foo"));
25831 }
25832
25833 #[test]
25834 fn scoped_graph_query() {
25835 let dir = setup_workspace();
25836 cmd_index(
25837 dir.path(),
25838 false,
25839 false,
25840 false,
25841 false,
25842 false,
25843 true,
25844 None,
25845 false,
25846 false,
25847 false,
25848 false,
25849 false,
25850 false,
25851 )
25852 .unwrap();
25853 let cfg = config::Config::load(dir.path()).unwrap();
25854 let db_path = cfg.db_path_for(dir.path(), "alpha");
25855 let db = index::IndexDb::open(&db_path).unwrap();
25856 let callees = db.callees_of("alpha_main").unwrap();
25857 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
25858 assert!(names.contains(&"alpha_helper"));
25859 }
25860
25861 fn assert_workspace_query_requires_scope(err: anyhow::Error) {
25862 let msg = err.to_string();
25863 assert!(msg.contains("require `--scope <scope>`"), "{msg}");
25864 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
25865 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
25866 assert!(
25867 !msg.contains("no index found at"),
25868 "workspace query should fail with scope guidance, got: {msg}"
25869 );
25870 }
25871
25872 fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
25873 let msg = err.to_string();
25874 assert!(
25875 msg.contains("requires `--scope <scope>` or `--federated`"),
25876 "{msg}"
25877 );
25878 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
25879 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
25880 assert!(
25881 !msg.contains("autoindexing index"),
25882 "workspace search should fail before creating a shared root index: {msg}"
25883 );
25884 }
25885
25886 #[test]
25887 fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
25888 let dir = setup_workspace();
25889 cmd_index(
25890 dir.path(),
25891 false,
25892 false,
25893 false,
25894 false,
25895 false,
25896 true,
25897 None,
25898 false,
25899 false,
25900 false,
25901 false,
25902 false,
25903 false,
25904 )
25905 .unwrap();
25906
25907 let err = cmd_graph(
25908 "alpha_main",
25909 dir.path(),
25910 false,
25911 false,
25912 None,
25913 20,
25914 false,
25915 false,
25916 false,
25917 false,
25918 false,
25919 false,
25920 false,
25921 TagpathSearchOpts::default(),
25922 )
25923 .unwrap_err();
25924
25925 assert_workspace_query_requires_scope(err);
25926 }
25927
25928 #[test]
25929 fn graph_cmd_infers_scope_from_nested_workspace_path() {
25930 let dir = setup_workspace();
25931 cmd_index(
25932 dir.path(),
25933 false,
25934 false,
25935 false,
25936 false,
25937 false,
25938 true,
25939 None,
25940 false,
25941 false,
25942 false,
25943 false,
25944 false,
25945 false,
25946 )
25947 .unwrap();
25948 let nested = dir.path().join("src/alpha/nested");
25949 std::fs::create_dir_all(&nested).unwrap();
25950
25951 let result = cmd_graph(
25952 "alpha_main",
25953 &nested,
25954 false,
25955 false,
25956 None,
25957 20,
25958 false,
25959 false,
25960 false,
25961 false,
25962 false,
25963 false,
25964 false,
25965 TagpathSearchOpts::default(),
25966 );
25967
25968 assert!(result.is_ok());
25969 }
25970
25971 #[test]
25972 fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
25973 let dir = setup_workspace();
25974 cmd_index(
25975 dir.path(),
25976 false,
25977 false,
25978 false,
25979 false,
25980 false,
25981 true,
25982 None,
25983 false,
25984 false,
25985 false,
25986 false,
25987 false,
25988 false,
25989 )
25990 .unwrap();
25991
25992 let err = cmd_communities(
25993 dir.path(),
25994 None,
25995 1,
25996 10,
25997 false,
25998 false,
25999 false,
26000 false,
26001 false,
26002 false,
26003 TagpathSearchOpts::default(),
26004 )
26005 .unwrap_err();
26006
26007 assert_workspace_query_requires_scope(err);
26008 }
26009
26010 #[test]
26011 fn communities_cmd_infers_scope_from_nested_workspace_path() {
26012 let dir = setup_workspace();
26013 cmd_index(
26014 dir.path(),
26015 false,
26016 false,
26017 false,
26018 false,
26019 false,
26020 true,
26021 None,
26022 false,
26023 false,
26024 false,
26025 false,
26026 false,
26027 false,
26028 )
26029 .unwrap();
26030 let nested = dir.path().join("src/alpha/nested");
26031 std::fs::create_dir_all(&nested).unwrap();
26032
26033 let result = cmd_communities(
26034 &nested,
26035 None,
26036 1,
26037 10,
26038 false,
26039 false,
26040 false,
26041 false,
26042 false,
26043 false,
26044 TagpathSearchOpts::default(),
26045 );
26046
26047 assert!(result.is_ok());
26048 }
26049
26050 #[test]
26051 fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
26052 let dir = setup_workspace();
26053 cmd_index(
26054 dir.path(),
26055 false,
26056 false,
26057 false,
26058 false,
26059 false,
26060 true,
26061 None,
26062 false,
26063 false,
26064 false,
26065 false,
26066 false,
26067 false,
26068 )
26069 .unwrap();
26070
26071 let err = cmd_path(
26072 "alpha_main",
26073 "alpha_helper",
26074 dir.path(),
26075 None,
26076 false,
26077 false,
26078 false,
26079 false,
26080 false,
26081 TagpathSearchOpts::default(),
26082 )
26083 .unwrap_err();
26084
26085 assert_workspace_query_requires_scope(err);
26086 }
26087
26088 #[test]
26089 fn path_cmd_infers_scope_from_nested_workspace_path() {
26090 let dir = setup_workspace();
26091 cmd_index(
26092 dir.path(),
26093 false,
26094 false,
26095 false,
26096 false,
26097 false,
26098 true,
26099 None,
26100 false,
26101 false,
26102 false,
26103 false,
26104 false,
26105 false,
26106 )
26107 .unwrap();
26108 let nested = dir.path().join("src/alpha/nested");
26109 std::fs::create_dir_all(&nested).unwrap();
26110
26111 let result = cmd_path(
26112 "alpha_main",
26113 "alpha_helper",
26114 &nested,
26115 None,
26116 false,
26117 false,
26118 false,
26119 false,
26120 false,
26121 TagpathSearchOpts::default(),
26122 );
26123
26124 assert!(result.is_ok());
26125 }
26126
26127 #[test]
26128 fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26129 let dir = setup_graph_index();
26130 let db_path = dir.path().join(".tsift/index.db");
26131 let _lock = hold_rollback_journal_lock(&db_path);
26132
26133 let result = cmd_path(
26134 "main",
26135 "helper",
26136 dir.path(),
26137 None,
26138 false,
26139 false,
26140 false,
26141 false,
26142 false,
26143 TagpathSearchOpts::default(),
26144 );
26145
26146 assert!(result.is_ok());
26147 }
26148
26149 #[test]
26150 fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
26151 let dir = setup_workspace();
26152 cmd_index(
26153 dir.path(),
26154 false,
26155 false,
26156 false,
26157 false,
26158 false,
26159 true,
26160 None,
26161 false,
26162 false,
26163 false,
26164 false,
26165 false,
26166 false,
26167 )
26168 .unwrap();
26169
26170 let err = cmd_explain(
26171 "alpha_main",
26172 dir.path(),
26173 None,
26174 15,
26175 false,
26176 false,
26177 false,
26178 false,
26179 false,
26180 false,
26181 false,
26182 false,
26183 )
26184 .unwrap_err();
26185
26186 assert_workspace_query_requires_scope(err);
26187 }
26188
26189 #[test]
26190 fn explain_cmd_infers_scope_from_nested_workspace_path() {
26191 let dir = setup_workspace();
26192 cmd_index(
26193 dir.path(),
26194 false,
26195 false,
26196 false,
26197 false,
26198 false,
26199 true,
26200 None,
26201 false,
26202 false,
26203 false,
26204 false,
26205 false,
26206 false,
26207 )
26208 .unwrap();
26209 let nested = dir.path().join("src/alpha/nested");
26210 std::fs::create_dir_all(&nested).unwrap();
26211
26212 let result = cmd_explain(
26213 "alpha_main",
26214 &nested,
26215 None,
26216 15,
26217 false,
26218 false,
26219 false,
26220 false,
26221 false,
26222 false,
26223 false,
26224 false,
26225 );
26226
26227 assert!(result.is_ok());
26228 }
26229
26230 #[test]
26231 fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26232 let dir = setup_graph_index();
26233 let db_path = dir.path().join(".tsift/index.db");
26234 let _lock = hold_rollback_journal_lock(&db_path);
26235
26236 let result = cmd_explain(
26237 "main",
26238 dir.path(),
26239 None,
26240 15,
26241 false,
26242 false,
26243 false,
26244 false,
26245 false,
26246 false,
26247 false,
26248 false,
26249 );
26250
26251 assert!(result.is_ok());
26252 }
26253
26254 #[test]
26257 fn community_detection_groups_related() {
26258 let dir = setup_graph_index();
26259 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26260 let edges = db.all_edges().unwrap();
26261 let result = graph::detect_communities(&edges);
26262 assert!(result.node_count > 0);
26263 assert!(!result.communities.is_empty());
26264 }
26265
26266 #[test]
26267 fn community_cmd_autoindexes_missing_index_by_default() {
26268 let dir = tempfile::tempdir().unwrap();
26269 let result = cmd_communities(
26270 dir.path(),
26271 None,
26272 2,
26273 10,
26274 false,
26275 false,
26276 false,
26277 false,
26278 false,
26279 false,
26280 TagpathSearchOpts::default(),
26281 );
26282
26283 assert!(result.is_ok());
26284 assert!(dir.path().join(".tsift/index.db").exists());
26285 }
26286
26287 #[test]
26290 fn path_finds_connected_symbols() {
26291 let dir = setup_graph_index();
26292 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26293 let edges = db.all_edges().unwrap();
26294 let result = graph::shortest_path(&edges, "main", "helper");
26295 assert!(result.is_some());
26296 let path = result.unwrap();
26297 assert_eq!(path.hops, 1);
26298 }
26299
26300 #[test]
26301 fn path_returns_none_for_unknown() {
26302 let dir = setup_graph_index();
26303 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26304 let edges = db.all_edges().unwrap();
26305 assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
26306 }
26307
26308 #[test]
26309 fn path_cmd_autoindexes_missing_index_by_default() {
26310 let dir = tempfile::tempdir().unwrap();
26311 let result = cmd_path(
26312 "a",
26313 "b",
26314 dir.path(),
26315 None,
26316 false,
26317 false,
26318 false,
26319 false,
26320 false,
26321 TagpathSearchOpts::default(),
26322 );
26323
26324 assert!(result.is_ok());
26325 assert!(dir.path().join(".tsift/index.db").exists());
26326 }
26327
26328 #[test]
26331 fn explain_shows_symbol_info() {
26332 let dir = setup_graph_index();
26333 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26334 let symbols = db.symbol_info("main").unwrap();
26335 assert!(!symbols.is_empty());
26336 assert_eq!(symbols[0].name, "main");
26337 assert_eq!(symbols[0].kind, "function");
26338 }
26339
26340 #[test]
26341 fn explain_cmd_autoindexes_missing_index_by_default() {
26342 let dir = tempfile::tempdir().unwrap();
26343 let result = cmd_explain(
26344 "main",
26345 dir.path(),
26346 None,
26347 15,
26348 false,
26349 false,
26350 false,
26351 false,
26352 false,
26353 false,
26354 false,
26355 false,
26356 );
26357
26358 assert!(result.is_ok());
26359 assert!(dir.path().join(".tsift/index.db").exists());
26360 }
26361
26362 fn hold_write_lock(db_path: &std::path::Path) -> Connection {
26363 let conn = Connection::open(db_path).unwrap();
26364 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
26365 conn
26366 }
26367
26368 fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
26369 use fs4::fs_std::FileExt;
26370 use std::io::Write;
26371
26372 let mut file = std::fs::OpenOptions::new()
26373 .read(true)
26374 .write(true)
26375 .create(true)
26376 .truncate(false)
26377 .open(lock_path)
26378 .unwrap();
26379 assert!(file.try_lock_exclusive().unwrap());
26380 writeln!(file, "{}", std::process::id()).unwrap();
26381 file
26382 }
26383
26384 fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
26385 let conn = Connection::open(db_path).unwrap();
26386 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26387 .unwrap();
26388 std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
26389 conn
26390 }
26391
26392 fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
26393 let conn = Connection::open(db_path).unwrap();
26394 conn.execute_batch(
26395 "PRAGMA journal_mode=WAL;
26396 PRAGMA wal_autocheckpoint=0;
26397 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
26398 INSERT INTO wal_lock_probe DEFAULT VALUES;
26399 PRAGMA locking_mode=EXCLUSIVE;
26400 BEGIN EXCLUSIVE;",
26401 )
26402 .unwrap();
26403 assert!(substrate::wal_sidecar_path(db_path).exists());
26404 conn
26405 }
26406
26407 #[test]
26408 fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
26409 let dir = setup_graph_index();
26410 let db_path = dir.path().join(".tsift/index.db");
26411 let _lock = hold_wal_database_lock(&db_path);
26412
26413 let err = cmd_index(
26414 dir.path(),
26415 false,
26416 false,
26417 false,
26418 false,
26419 false,
26420 false,
26421 None,
26422 false,
26423 false,
26424 false,
26425 false,
26426 false,
26427 false,
26428 )
26429 .unwrap_err();
26430
26431 let msg = err.to_string();
26432 assert!(msg.contains("indexing"));
26433 assert!(msg.contains("lock diagnostics:"));
26434 assert!(msg.contains("lock: absent"));
26435 assert!(msg.contains("wal: present") || msg.contains("shm: present"));
26436 assert!(msg.contains("wedged writer holding live WAL sidecars"));
26437 assert!(msg.contains("snapshot fallback"));
26438 }
26439
26440 #[test]
26441 fn search_cmd_succeeds_while_writer_lock_is_held() {
26442 let dir = setup_graph_index();
26443 let db_path = dir.path().join(".tsift/index.db");
26444 let _lock = hold_write_lock(&db_path);
26445
26446 let result = cmd_search(
26447 "main".to_string(),
26448 Some(dir.path().to_path_buf()),
26449 5,
26450 Some("lexical".to_string()),
26451 None,
26452 false,
26453 false,
26454 false,
26455 0,
26456 true,
26457 false,
26458 false,
26459 false,
26460 false,
26461 false,
26462 false,
26463 );
26464
26465 assert!(result.is_ok());
26466 }
26467
26468 #[test]
26469 fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
26470 let dir = setup_graph_index();
26471 let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
26472
26473 let result = cmd_search(
26474 "main".to_string(),
26475 Some(dir.path().to_path_buf()),
26476 5,
26477 Some("lexical".to_string()),
26478 None,
26479 false,
26480 false,
26481 false,
26482 0,
26483 true,
26484 false,
26485 false,
26486 false,
26487 false,
26488 false,
26489 false,
26490 );
26491
26492 assert!(result.is_ok());
26493 }
26494
26495 #[test]
26496 fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
26497 let dir = setup_graph_index();
26498 let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
26499
26500 let result = cmd_search(
26501 "main".to_string(),
26502 Some(dir.path().to_path_buf()),
26503 5,
26504 Some("lexical".to_string()),
26505 None,
26506 false,
26507 false,
26508 false,
26509 0,
26510 true,
26511 false,
26512 false,
26513 false,
26514 false,
26515 false,
26516 false,
26517 );
26518
26519 assert!(result.is_ok());
26520 }
26521
26522 #[test]
26523 fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
26524 let dir = setup_graph_index();
26525 std::thread::sleep(std::time::Duration::from_millis(50));
26526 std::fs::write(
26527 dir.path().join("main.rs"),
26528 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26529 )
26530 .unwrap();
26531
26532 let err = cmd_search(
26533 "helper".to_string(),
26534 Some(dir.path().to_path_buf()),
26535 5,
26536 Some("lexical".to_string()),
26537 None,
26538 false,
26539 false,
26540 false,
26541 0,
26542 false,
26543 false,
26544 false,
26545 false,
26546 false,
26547 false,
26548 false,
26549 )
26550 .unwrap_err();
26551
26552 assert!(err.to_string().contains("search aborted"));
26553 assert!(err.to_string().contains("index is stale"));
26554 assert!(err.to_string().contains("--no-autoindex"));
26555 }
26556
26557 #[test]
26558 fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
26559 let dir = setup_graph_index();
26560 std::thread::sleep(std::time::Duration::from_millis(50));
26561 std::fs::write(
26562 dir.path().join("main.rs"),
26563 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26564 )
26565 .unwrap();
26566 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26567
26568 let err = cmd_search(
26569 "helper".to_string(),
26570 Some(dir.path().to_path_buf()),
26571 5,
26572 Some("lexical".to_string()),
26573 None,
26574 false,
26575 false,
26576 false,
26577 0,
26578 false,
26579 false,
26580 false,
26581 false,
26582 false,
26583 false,
26584 false,
26585 )
26586 .unwrap_err();
26587
26588 assert!(err.to_string().contains("search aborted"));
26589 assert!(err.to_string().contains("index is stale"));
26590 assert!(!err.to_string().contains("database is locked"));
26591 }
26592
26593 #[test]
26594 fn search_cmd_autoindexes_stale_index_by_default() {
26595 let dir = setup_graph_index();
26596 std::thread::sleep(std::time::Duration::from_millis(50));
26597 std::fs::write(
26598 dir.path().join("main.rs"),
26599 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26600 )
26601 .unwrap();
26602
26603 let result = cmd_search(
26604 "helper".to_string(),
26605 Some(dir.path().to_path_buf()),
26606 5,
26607 Some("lexical".to_string()),
26608 None,
26609 false,
26610 false,
26611 true,
26612 0,
26613 false,
26614 false,
26615 false,
26616 false,
26617 false,
26618 false,
26619 false,
26620 );
26621
26622 assert!(result.is_ok());
26623
26624 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26625 let summary = db.compute_changes(dir.path()).unwrap();
26626 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26627 }
26628
26629 #[test]
26630 fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
26631 let dir = setup_graph_index();
26632 std::thread::sleep(std::time::Duration::from_millis(50));
26633 std::fs::write(
26634 dir.path().join("main.rs"),
26635 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26636 )
26637 .unwrap();
26638 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
26639
26640 let result = cmd_search(
26641 "helper".to_string(),
26642 Some(dir.path().to_path_buf()),
26643 5,
26644 Some("lexical".to_string()),
26645 None,
26646 false,
26647 false,
26648 true,
26649 0,
26650 false,
26651 false,
26652 false,
26653 false,
26654 false,
26655 false,
26656 false,
26657 );
26658
26659 assert!(result.is_ok());
26660
26661 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26662 let summary = db.compute_changes(dir.path()).unwrap();
26663 assert_eq!(summary.modified, 1);
26664 }
26665
26666 #[test]
26667 fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
26668 let dir = setup_graph_index();
26669 std::thread::sleep(std::time::Duration::from_millis(50));
26670 std::fs::write(
26671 dir.path().join("main.rs"),
26672 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26673 )
26674 .unwrap();
26675 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26676
26677 let err = cmd_search(
26678 "helper".to_string(),
26679 Some(dir.path().to_path_buf()),
26680 5,
26681 Some("lexical".to_string()),
26682 None,
26683 false,
26684 false,
26685 true,
26686 0,
26687 false,
26688 false,
26689 false,
26690 false,
26691 false,
26692 false,
26693 false,
26694 )
26695 .unwrap_err();
26696
26697 let msg = err.to_string();
26698 assert!(msg.contains("autoindexing index"));
26699 assert!(msg.contains("lock diagnostics:"));
26700 assert!(msg.contains("journal: present"));
26701 assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
26702 }
26703
26704 #[test]
26705 fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
26706 let dir = setup_graph_index();
26707 let nested = dir.path().join("src/nested");
26708 std::fs::create_dir_all(&nested).unwrap();
26709
26710 let result = cmd_search(
26711 "helper".to_string(),
26712 Some(nested.clone()),
26713 5,
26714 Some("lexical".to_string()),
26715 None,
26716 false,
26717 false,
26718 true,
26719 0,
26720 false,
26721 false,
26722 false,
26723 false,
26724 false,
26725 false,
26726 false,
26727 );
26728
26729 assert!(result.is_ok());
26730 assert!(!nested.join(".tsift/index.db").exists());
26731 }
26732
26733 #[test]
26734 fn exact_search_returns_literal_matches() {
26735 let dir = tempfile::tempdir().unwrap();
26736 std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
26737
26738 let response = run_exact_search_with_timeout(dir.path(), "claudescore-3", 5, 0).unwrap();
26739
26740 assert_eq!(response.strategy, "exact");
26741 assert_eq!(response.hits.len(), 1);
26742 assert!(response.hits[0].path.ends_with("notes.txt"));
26743 assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
26744 assert!(response.hits[0].snippet.contains("claudescore-3"));
26745 }
26746
26747 #[test]
26748 fn exact_search_skips_stale_index_precheck() {
26749 let dir = setup_graph_index();
26750 std::thread::sleep(std::time::Duration::from_millis(50));
26751 std::fs::write(
26752 dir.path().join("main.rs"),
26753 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
26754 )
26755 .unwrap();
26756
26757 let result = cmd_search(
26758 "println!(\"updated\")".to_string(),
26759 Some(dir.path().to_path_buf()),
26760 5,
26761 Some("exact".to_string()),
26762 None,
26763 false,
26764 false,
26765 false,
26766 0,
26767 false,
26768 false,
26769 false,
26770 false,
26771 false,
26772 false,
26773 false,
26774 );
26775
26776 assert!(result.is_ok());
26777 }
26778
26779 #[test]
26780 fn workspace_exact_search_does_not_require_shared_root_index() {
26781 let dir = setup_workspace();
26782 cmd_index(
26783 dir.path(),
26784 false,
26785 false,
26786 false,
26787 false,
26788 false,
26789 true,
26790 None,
26791 false,
26792 false,
26793 false,
26794 false,
26795 false,
26796 false,
26797 )
26798 .unwrap();
26799
26800 let result = cmd_search(
26801 "alpha_helper".to_string(),
26802 Some(dir.path().to_path_buf()),
26803 5,
26804 Some("exact".to_string()),
26805 None,
26806 false,
26807 false,
26808 false,
26809 0,
26810 false,
26811 false,
26812 false,
26813 false,
26814 false,
26815 false,
26816 false,
26817 );
26818
26819 assert!(result.is_ok());
26820 assert!(!dir.path().join(".tsift/index.db").exists());
26821 }
26822
26823 #[test]
26824 fn identifier_like_query_prefers_exact_search() {
26825 assert!(query_prefers_exact_search("claudescore-3"));
26826 assert!(query_prefers_exact_search("alpha_helper"));
26827 assert!(query_prefers_exact_search("src/main.rs"));
26828 assert!(query_prefers_exact_search("crate::module"));
26829 assert!(!query_prefers_exact_search("authenticate"));
26830 assert!(!query_prefers_exact_search("fn main"));
26831 assert!(!query_prefers_exact_search("."));
26832 }
26833
26834 #[test]
26835 fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
26836 assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
26837 assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
26838 assert_eq!(
26839 resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
26840 "hybrid"
26841 );
26842 }
26843
26844 #[test]
26845 fn workspace_identifier_like_search_auto_uses_exact_backend() {
26846 let dir = setup_workspace();
26847 cmd_index(
26848 dir.path(),
26849 false,
26850 false,
26851 false,
26852 false,
26853 false,
26854 true,
26855 None,
26856 false,
26857 false,
26858 false,
26859 false,
26860 false,
26861 false,
26862 )
26863 .unwrap();
26864
26865 let result = cmd_search(
26866 "alpha_helper".to_string(),
26867 Some(dir.path().to_path_buf()),
26868 5,
26869 None,
26870 None,
26871 false,
26872 false,
26873 false,
26874 0,
26875 false,
26876 false,
26877 false,
26878 false,
26879 false,
26880 false,
26881 false,
26882 );
26883
26884 assert!(result.is_ok());
26885 assert!(!dir.path().join(".tsift/index.db").exists());
26886 }
26887
26888 #[test]
26889 fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
26890 let dir = setup_graph_index();
26891 let nested = dir.path().join("src/nested");
26892 std::fs::create_dir_all(&nested).unwrap();
26893 std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
26894
26895 let result = cmd_index(
26896 &nested, false, false, false, false, false, false, None, false, false, false, false,
26897 false, false,
26898 );
26899
26900 assert!(result.is_ok());
26901 assert!(dir.path().join(".tsift/index.db").exists());
26902 assert!(!nested.join(".tsift/index.db").exists());
26903 }
26904
26905 #[test]
26906 fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
26907 let dir = setup_workspace();
26908 let nested = dir.path().join("docs/nested");
26909 std::fs::create_dir_all(&nested).unwrap();
26910
26911 let result = cmd_index(
26912 &nested, false, false, false, false, false, true, None, false, false, false, false,
26913 false, false,
26914 );
26915
26916 let cfg = config::Config::load(dir.path()).unwrap();
26917
26918 assert!(result.is_ok());
26919 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
26920 assert!(cfg.db_path_for(dir.path(), "beta").exists());
26921 }
26922
26923 #[test]
26924 fn status_cmd_autoindexes_missing_workspace_scopes() {
26925 let dir = setup_workspace();
26926 let cfg = config::Config::load(dir.path()).unwrap();
26927 let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
26928 let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
26929 let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
26930 alpha_db.apply_changes(&alpha.source_root).unwrap();
26931
26932 let beta_db_path = cfg.db_path_for(dir.path(), "beta");
26933 assert!(!beta_db_path.exists());
26934
26935 cmd_status(
26936 dir.path(),
26937 StatusCommandOptions {
26938 fix: false,
26939 no_fix: false,
26940 json_output: true,
26941 compact: false,
26942 pretty: false,
26943 terse: false,
26944 schema: false,
26945 },
26946 )
26947 .unwrap();
26948
26949 assert!(beta_db_path.exists());
26950 let report = status::check_status(dir.path()).unwrap();
26951 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
26952 }
26953
26954 #[test]
26955 fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
26956 let dir = setup_workspace();
26957 let cfg = config::Config::load(dir.path()).unwrap();
26958
26959 cmd_status(
26960 dir.path(),
26961 StatusCommandOptions {
26962 fix: false,
26963 no_fix: false,
26964 json_output: true,
26965 compact: false,
26966 pretty: false,
26967 terse: false,
26968 schema: false,
26969 },
26970 )
26971 .unwrap();
26972
26973 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
26974 assert!(cfg.db_path_for(dir.path(), "beta").exists());
26975 let report = status::check_status(dir.path()).unwrap();
26976 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
26977 }
26978
26979 #[test]
26980 fn status_cmd_fix_refreshes_stale_index() {
26981 let dir = setup_graph_index();
26982 std::thread::sleep(std::time::Duration::from_millis(50));
26983 std::fs::write(
26984 dir.path().join("main.rs"),
26985 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
26986 )
26987 .unwrap();
26988
26989 let report = status::check_status(dir.path()).unwrap();
26990 assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
26991
26992 cmd_status(
26993 dir.path(),
26994 StatusCommandOptions {
26995 fix: false,
26996 no_fix: false,
26997 json_output: true,
26998 compact: false,
26999 pretty: false,
27000 terse: false,
27001 schema: false,
27002 },
27003 )
27004 .unwrap();
27005
27006 let report = status::check_status(dir.path()).unwrap();
27007 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27008 }
27009
27010 #[test]
27011 fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
27012 let dir = setup_graph_index();
27013 let db_path = dir.path().join(".tsift/index.db");
27014 let _lock = hold_wal_database_lock(&db_path);
27015
27016 cmd_status(
27017 dir.path(),
27018 StatusCommandOptions {
27019 fix: false,
27020 no_fix: false,
27021 json_output: true,
27022 compact: false,
27023 pretty: false,
27024 terse: false,
27025 schema: false,
27026 },
27027 )
27028 .unwrap();
27029
27030 let report = status::check_status(dir.path()).unwrap();
27031 assert!(matches!(
27032 report.index,
27033 status::IndexStatus::Fresh {
27034 recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
27035 ..
27036 }
27037 ));
27038 let locks = status::check_locks(dir.path(), None, None).unwrap();
27039 assert!(matches!(
27040 locks.writer_lock,
27041 status::WriterLockStatus::Absent { .. }
27042 ));
27043 assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
27044 assert!(
27045 locks
27046 .recommended_action
27047 .contains("wedged writer holding live WAL sidecars")
27048 );
27049 }
27050
27051 #[test]
27052 fn locks_report_uses_ancestor_project_root_for_nested_paths() {
27053 let dir = setup_graph_index();
27054 let nested = dir.path().join("src/nested");
27055 std::fs::create_dir_all(&nested).unwrap();
27056
27057 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27058 let report = status::check_locks(&root, Some(&nested), None).unwrap();
27059
27060 assert_eq!(report.source_root, dir.path());
27061 assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
27062 }
27063
27064 #[test]
27065 fn workspace_locks_report_infers_scope_from_nested_path() {
27066 let dir = setup_workspace();
27067 cmd_index(
27068 dir.path(),
27069 false,
27070 false,
27071 false,
27072 false,
27073 false,
27074 true,
27075 None,
27076 false,
27077 false,
27078 false,
27079 false,
27080 false,
27081 false,
27082 )
27083 .unwrap();
27084 let nested = dir.path().join("src/alpha/nested");
27085 std::fs::create_dir_all(&nested).unwrap();
27086
27087 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27088 let report = status::check_locks(&root, Some(&nested), None).unwrap();
27089 let cfg = config::Config::load(dir.path()).unwrap();
27090
27091 assert_eq!(report.label, "submodule `alpha` index");
27092 assert_eq!(report.source_root, dir.path().join("src/alpha"));
27093 assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
27094 assert_eq!(
27095 report.reindex_command,
27096 format!("tsift index --submodule alpha {}", dir.path().display())
27097 );
27098 }
27099
27100 #[test]
27101 fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
27102 let dir = setup_workspace();
27103 cmd_index(
27104 dir.path(),
27105 false,
27106 false,
27107 false,
27108 false,
27109 false,
27110 true,
27111 None,
27112 false,
27113 false,
27114 false,
27115 false,
27116 false,
27117 false,
27118 )
27119 .unwrap();
27120
27121 let alpha = dir.path().join("src/alpha/lib.rs");
27122 std::thread::sleep(std::time::Duration::from_millis(50));
27123 std::fs::write(
27124 &alpha,
27125 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27126 )
27127 .unwrap();
27128
27129 let result = cmd_search(
27130 "alpha_helper".to_string(),
27131 Some(dir.path().to_path_buf()),
27132 5,
27133 Some("lexical".to_string()),
27134 Some("alpha".to_string()),
27135 false,
27136 false,
27137 true,
27138 0,
27139 false,
27140 false,
27141 false,
27142 false,
27143 false,
27144 false,
27145 false,
27146 );
27147
27148 assert!(result.is_ok());
27149
27150 let cfg = config::Config::load(dir.path()).unwrap();
27151 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27152 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27153 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27154 }
27155
27156 #[test]
27157 fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27158 let dir = setup_workspace();
27159 cmd_index(
27160 dir.path(),
27161 false,
27162 false,
27163 false,
27164 false,
27165 false,
27166 true,
27167 None,
27168 false,
27169 false,
27170 false,
27171 false,
27172 false,
27173 false,
27174 )
27175 .unwrap();
27176
27177 let alpha = dir.path().join("src/alpha/lib.rs");
27178 std::thread::sleep(std::time::Duration::from_millis(50));
27179 std::fs::write(
27180 &alpha,
27181 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27182 )
27183 .unwrap();
27184
27185 let cfg = config::Config::load(dir.path()).unwrap();
27186 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27187
27188 let err = cmd_search(
27189 "alpha_helper".to_string(),
27190 Some(dir.path().to_path_buf()),
27191 5,
27192 Some("lexical".to_string()),
27193 Some("alpha".to_string()),
27194 false,
27195 false,
27196 false,
27197 0,
27198 false,
27199 false,
27200 false,
27201 false,
27202 false,
27203 false,
27204 false,
27205 )
27206 .unwrap_err();
27207
27208 assert!(err.to_string().contains("search aborted"));
27209 assert!(err.to_string().contains("submodule `alpha` index"));
27210 assert!(!err.to_string().contains("database is locked"));
27211 }
27212
27213 #[test]
27214 fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
27215 let dir = setup_workspace();
27216 cmd_index(
27217 dir.path(),
27218 false,
27219 false,
27220 false,
27221 false,
27222 false,
27223 true,
27224 None,
27225 false,
27226 false,
27227 false,
27228 false,
27229 false,
27230 false,
27231 )
27232 .unwrap();
27233
27234 let alpha = dir.path().join("src/alpha/lib.rs");
27235 std::thread::sleep(std::time::Duration::from_millis(50));
27236 std::fs::write(
27237 &alpha,
27238 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27239 )
27240 .unwrap();
27241
27242 let result = cmd_search(
27243 "alpha_helper".to_string(),
27244 Some(dir.path().to_path_buf()),
27245 5,
27246 Some("lexical".to_string()),
27247 None,
27248 true,
27249 false,
27250 true,
27251 0,
27252 false,
27253 false,
27254 false,
27255 false,
27256 false,
27257 false,
27258 false,
27259 );
27260
27261 assert!(result.is_ok());
27262
27263 let cfg = config::Config::load(dir.path()).unwrap();
27264 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27265 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27266 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27267 }
27268
27269 #[test]
27270 fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27271 let dir = setup_workspace();
27272 cmd_index(
27273 dir.path(),
27274 false,
27275 false,
27276 false,
27277 false,
27278 false,
27279 true,
27280 None,
27281 false,
27282 false,
27283 false,
27284 false,
27285 false,
27286 false,
27287 )
27288 .unwrap();
27289
27290 let alpha = dir.path().join("src/alpha/lib.rs");
27291 std::thread::sleep(std::time::Duration::from_millis(50));
27292 std::fs::write(
27293 &alpha,
27294 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27295 )
27296 .unwrap();
27297
27298 let cfg = config::Config::load(dir.path()).unwrap();
27299 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27300
27301 let err = cmd_search(
27302 "alpha_helper".to_string(),
27303 Some(dir.path().to_path_buf()),
27304 5,
27305 Some("lexical".to_string()),
27306 None,
27307 true,
27308 false,
27309 false,
27310 30,
27311 false,
27312 false,
27313 false,
27314 false,
27315 false,
27316 false,
27317 false,
27318 )
27319 .unwrap_err();
27320
27321 assert!(err.to_string().contains("stale"));
27322 assert!(err.to_string().contains("submodule `alpha` index"));
27323 assert!(!err.to_string().contains("database is locked"));
27324 }
27325
27326 #[test]
27327 fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
27328 let dir = setup_workspace();
27329 cmd_index(
27330 dir.path(),
27331 false,
27332 false,
27333 false,
27334 false,
27335 false,
27336 true,
27337 None,
27338 false,
27339 false,
27340 false,
27341 false,
27342 false,
27343 false,
27344 )
27345 .unwrap();
27346
27347 let err = cmd_search(
27348 "alpha_helper".to_string(),
27349 Some(dir.path().to_path_buf()),
27350 5,
27351 Some("lexical".to_string()),
27352 None,
27353 false,
27354 false,
27355 true,
27356 0,
27357 false,
27358 false,
27359 false,
27360 false,
27361 false,
27362 false,
27363 false,
27364 )
27365 .unwrap_err();
27366
27367 assert_workspace_search_requires_explicit_target(err);
27368 assert!(!dir.path().join(".tsift/index.db").exists());
27369 }
27370
27371 #[test]
27372 fn workspace_search_cmd_infers_scope_from_nested_path() {
27373 let dir = setup_workspace();
27374 cmd_index(
27375 dir.path(),
27376 false,
27377 false,
27378 false,
27379 false,
27380 false,
27381 true,
27382 None,
27383 false,
27384 false,
27385 false,
27386 false,
27387 false,
27388 false,
27389 )
27390 .unwrap();
27391 let nested = dir.path().join("src/alpha/nested");
27392 std::fs::create_dir_all(&nested).unwrap();
27393
27394 let result = cmd_search(
27395 "alpha_helper".to_string(),
27396 Some(nested),
27397 5,
27398 Some("lexical".to_string()),
27399 None,
27400 false,
27401 false,
27402 false,
27403 0,
27404 false,
27405 false,
27406 false,
27407 false,
27408 false,
27409 false,
27410 false,
27411 );
27412
27413 assert!(result.is_ok());
27414 }
27415
27416 #[test]
27417 fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
27418 let dir = setup_workspace_with_duplicate_leaf_names();
27419 cmd_index(
27420 dir.path(),
27421 false,
27422 false,
27423 false,
27424 false,
27425 false,
27426 true,
27427 None,
27428 false,
27429 false,
27430 false,
27431 false,
27432 false,
27433 false,
27434 )
27435 .unwrap();
27436 let nested = dir.path().join("vendor/foo/nested");
27437 std::fs::create_dir_all(&nested).unwrap();
27438
27439 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27440 let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
27441 let cfg = config::Config::load(dir.path()).unwrap();
27442
27443 assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
27444 }
27445
27446 #[test]
27447 fn graph_cmd_succeeds_while_writer_lock_is_held() {
27448 let dir = setup_graph_index();
27449 let db_path = dir.path().join(".tsift/index.db");
27450 let _lock = hold_write_lock(&db_path);
27451
27452 let result = cmd_graph(
27453 "main",
27454 dir.path(),
27455 false,
27456 false,
27457 None,
27458 20,
27459 false,
27460 true,
27461 false,
27462 false,
27463 false,
27464 false,
27465 false,
27466 TagpathSearchOpts::default(),
27467 );
27468
27469 assert!(result.is_ok());
27470 }
27471
27472 #[test]
27473 fn graph_cmd_autoindexes_stale_index_by_default() {
27474 let dir = setup_graph_index();
27475 std::thread::sleep(std::time::Duration::from_millis(50));
27476 std::fs::write(
27477 dir.path().join("main.rs"),
27478 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27479 )
27480 .unwrap();
27481
27482 let result = cmd_graph(
27483 "helper",
27484 dir.path(),
27485 true,
27486 false,
27487 None,
27488 20,
27489 false,
27490 true,
27491 false,
27492 false,
27493 false,
27494 false,
27495 false,
27496 TagpathSearchOpts::default(),
27497 );
27498
27499 assert!(result.is_ok());
27500 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27501 let summary = db.compute_changes(dir.path()).unwrap();
27502 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27503 }
27504
27505 #[test]
27506 fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27507 let dir = setup_graph_index();
27508 let db_path = dir.path().join(".tsift/index.db");
27509 let _lock = hold_rollback_journal_lock(&db_path);
27510
27511 let result = cmd_graph(
27512 "main",
27513 dir.path(),
27514 false,
27515 false,
27516 None,
27517 20,
27518 false,
27519 true,
27520 false,
27521 false,
27522 false,
27523 false,
27524 false,
27525 TagpathSearchOpts::default(),
27526 );
27527
27528 assert!(result.is_ok());
27529 }
27530
27531 #[test]
27532 fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
27533 let dir = setup_graph_index();
27534 let nested = dir.path().join("src/nested");
27535 std::fs::create_dir_all(&nested).unwrap();
27536
27537 let result = cmd_graph(
27538 "helper",
27539 &nested,
27540 true,
27541 false,
27542 None,
27543 20,
27544 false,
27545 false,
27546 false,
27547 false,
27548 false,
27549 false,
27550 false,
27551 TagpathSearchOpts::default(),
27552 );
27553
27554 assert!(result.is_ok());
27555 }
27556
27557 #[test]
27558 fn communities_cmd_succeeds_while_writer_lock_is_held() {
27559 let dir = setup_graph_index();
27560 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27561
27562 let result = cmd_communities(
27563 dir.path(),
27564 None,
27565 1,
27566 10,
27567 false,
27568 false,
27569 false,
27570 false,
27571 false,
27572 false,
27573 TagpathSearchOpts::default(),
27574 );
27575
27576 assert!(result.is_ok());
27577 }
27578
27579 #[test]
27580 fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27581 let dir = setup_graph_index();
27582 let db_path = dir.path().join(".tsift/index.db");
27583 let _lock = hold_rollback_journal_lock(&db_path);
27584
27585 let result = cmd_communities(
27586 dir.path(),
27587 None,
27588 1,
27589 10,
27590 false,
27591 false,
27592 false,
27593 false,
27594 false,
27595 false,
27596 TagpathSearchOpts::default(),
27597 );
27598
27599 assert!(result.is_ok());
27600 }
27601
27602 #[test]
27603 fn lint_finds_entities_from_project_root_index_db() {
27604 let dir = tempfile::tempdir().unwrap();
27605 std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
27606 std::fs::write(
27607 dir.path().join("README.md"),
27608 "alpha_helper should be backticked.\n",
27609 )
27610 .unwrap();
27611 cmd_index(
27612 dir.path(),
27613 false,
27614 false,
27615 false,
27616 false,
27617 false,
27618 false,
27619 None,
27620 false,
27621 false,
27622 false,
27623 false,
27624 false,
27625 false,
27626 )
27627 .unwrap();
27628
27629 let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
27630 .unwrap()
27631 .unwrap();
27632 let entities = lint::collect_entities_from_index_path(&root).unwrap();
27633 let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
27634
27635 assert!(
27636 result
27637 .annotations
27638 .iter()
27639 .any(|ann| ann.text == "alpha_helper")
27640 );
27641 }
27642
27643 #[test]
27646 fn search_direct_runs_ok() {
27647 let dir = tempfile::tempdir().unwrap();
27648 let search_dir = dir.path().to_path_buf();
27649 let cache_dir = search_dir.join(".tsift/search-cache");
27650 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27651 let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical");
27652 assert!(result.is_ok(), "direct search should succeed");
27653 assert!(
27654 cache_dir.exists(),
27655 "search should create the configured cache dir"
27656 );
27657 }
27658
27659 #[test]
27660 fn search_timeout_zero_disables_timeout() {
27661 let dir = tempfile::tempdir().unwrap();
27662 let search_dir = dir.path().to_path_buf();
27663 let cache_dir = search_dir.join(".tsift/search-cache");
27664 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27665 let result = run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[]);
27666 assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
27667 assert!(
27668 cache_dir.exists(),
27669 "timeout=0 should keep using the stable search cache dir"
27670 );
27671 }
27672
27673 #[test]
27674 fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
27675 let dir = tempfile::tempdir().unwrap();
27676 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
27677 cmd_index(
27678 dir.path(),
27679 false,
27680 false,
27681 false,
27682 false,
27683 false,
27684 false,
27685 None,
27686 false,
27687 false,
27688 false,
27689 false,
27690 false,
27691 false,
27692 )
27693 .unwrap();
27694 let db_path = dir.path().join(".tsift/index.db");
27695 std::fs::remove_file(&db_path).unwrap();
27696 let search_target = SearchIndexTarget {
27697 label: "index".to_string(),
27698 db_path,
27699 source_root: dir.path().to_path_buf(),
27700 scope_name: None,
27701 reindex_cmd: format!("tsift index {}", dir.path().display()),
27702 };
27703
27704 let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
27705
27706 assert!(message.contains("timed out after 1s"));
27707 assert!(message.contains("index is missing"));
27708 assert!(message.contains("Run `tsift index"));
27709 assert!(!message.contains("search root looks fresh"));
27710 }
27711
27712 #[test]
27713 fn search_worker_output_path_uses_json_suffix() {
27714 let path = next_search_worker_output_path();
27715 assert!(path.extension().is_some_and(|ext| ext == "json"));
27716 }
27717
27718 #[test]
27721 fn index_quiet_suppresses_file_list() {
27722 let dir = setup_graph_index();
27723 let result = cmd_index(
27724 dir.path(),
27725 false,
27726 true,
27727 false,
27728 false,
27729 true,
27730 false,
27731 None,
27732 false,
27733 false,
27734 false,
27735 false,
27736 false,
27737 false,
27738 );
27739 assert!(result.is_ok());
27740 }
27741
27742 #[test]
27743 fn index_exit_code_implies_quiet() {
27744 let dir = setup_graph_index();
27745 let result = cmd_index(
27746 dir.path(),
27747 false,
27748 true,
27749 false,
27750 false,
27751 false,
27752 false,
27753 None,
27754 false,
27755 false,
27756 false,
27757 false,
27758 false,
27759 false,
27760 );
27761 assert!(result.is_ok());
27762 }
27763
27764 #[test]
27765 fn index_quiet_json_omits_changes() {
27766 let dir = setup_graph_index();
27767 let result = cmd_index(
27768 dir.path(),
27769 false,
27770 true,
27771 false,
27772 false,
27773 true,
27774 false,
27775 None,
27776 true,
27777 false,
27778 false,
27779 false,
27780 false,
27781 false,
27782 );
27783 assert!(result.is_ok());
27784 }
27785
27786 #[test]
27787 fn cli_workflow_defaults_to_search_topic() {
27788 let cli = parse_cli(["tsift", "workflow"]);
27789 match cli.command {
27790 Some(Commands::Workflow { topic, json }) => {
27791 assert_eq!(topic, "search");
27792 assert!(!json);
27793 }
27794 _ => panic!("expected Workflow command"),
27795 }
27796 }
27797
27798 #[test]
27799 fn search_workflow_recipe_preserves_handles_across_expansions() {
27800 let recipe = workflow::search_workflow_recipe();
27801 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
27802 assert_eq!(
27803 step_names,
27804 vec![
27805 "exact-anchor",
27806 "semantic-search",
27807 "explain-symbol",
27808 "summarize-selection",
27809 "digest-expansion"
27810 ]
27811 );
27812 assert!(
27813 recipe
27814 .handle_contract
27815 .iter()
27816 .any(|item| item.contains("originating command"))
27817 );
27818 assert!(
27819 recipe.steps[1]
27820 .preserves
27821 .iter()
27822 .any(|item| item.contains("sfam-*"))
27823 );
27824 assert!(
27825 recipe.steps[2]
27826 .preserves
27827 .iter()
27828 .any(|item| item.contains("ecall-*"))
27829 );
27830 assert!(
27831 recipe.steps[4]
27832 .preserves
27833 .iter()
27834 .any(|item| item.contains("artifact handles"))
27835 );
27836 }
27837
27838 #[test]
27841 fn to_json_compact_default() {
27842 let val = serde_json::json!({"a": 1, "b": [2, 3]});
27843 let compact = to_json(&val, false, false).unwrap();
27844 assert!(!compact.contains('\n'));
27845 assert!(
27846 compact.contains("\"a\":1")
27847 || compact.contains("\"a\": 1")
27848 || compact.contains("\"a\":")
27849 );
27850 }
27851
27852 #[test]
27853 fn to_json_pretty_indents() {
27854 let val = serde_json::json!({"a": 1, "b": [2, 3]});
27855 let pretty = to_json(&val, true, false).unwrap();
27856 assert!(pretty.contains('\n'));
27857 assert!(pretty.contains(" "));
27858 }
27859
27860 #[test]
27861 fn to_json_compact_is_shorter() {
27862 let val =
27863 serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
27864 let compact = to_json(&val, false, false).unwrap();
27865 let pretty = to_json(&val, true, false).unwrap();
27866 assert!(compact.len() < pretty.len());
27867 }
27868
27869 #[test]
27870 fn terse_renames_keys() {
27871 let val =
27872 serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
27873 let result = to_json(&val, false, true).unwrap();
27874 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27875 assert!(parsed["_s"].is_object());
27876 let d = &parsed["d"];
27877 assert_eq!(d["cf"], "a.rs");
27878 assert_eq!(d["cn"], "main");
27879 assert_eq!(d["csl"], 10);
27880 }
27881
27882 #[test]
27883 fn terse_schema_only_includes_used_keys() {
27884 let val = serde_json::json!({"name": "test", "score": 0.5});
27885 let result = to_json(&val, false, true).unwrap();
27886 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27887 let schema = parsed["_s"].as_object().unwrap();
27888 assert_eq!(schema["n"], "name");
27889 assert_eq!(schema["sc"], "score");
27890 assert!(!schema.contains_key("cf"));
27891 }
27892
27893 #[test]
27894 fn terse_nested_arrays() {
27895 let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
27896 let result = to_json(&val, false, true).unwrap();
27897 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27898 let d = &parsed["d"];
27899 assert_eq!(d["crs"][0]["cn"], "a");
27900 assert_eq!(d["crs"][0]["cf"], "b.rs");
27901 }
27902
27903 #[test]
27904 fn terse_preserves_unknown_keys() {
27905 let val = serde_json::json!({"custom_field": "value", "name": "test"});
27906 let result = to_json(&val, false, true).unwrap();
27907 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27908 let d = &parsed["d"];
27909 assert_eq!(d["custom_field"], "value");
27910 assert_eq!(d["n"], "test");
27911 }
27912
27913 #[test]
27916 fn ultra_terse_strips_properties_from_graph_nodes() {
27917 let val = serde_json::json!({
27918 "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
27919 });
27920 let result = to_json_schema(&val, false, true, true, false).unwrap();
27921 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27922 let node = &parsed["d"]["nodes"][0];
27923 assert_eq!(node["id"], "fn:main");
27924 assert_eq!(node["k"], "fn");
27925 assert_eq!(node["n"], "main");
27926 assert!(node.get("properties").is_none());
27927 }
27928
27929 #[test]
27930 fn ultra_terse_strips_properties_from_graph_edges() {
27931 let val = serde_json::json!({
27932 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
27933 });
27934 let result = to_json_schema(&val, false, true, true, false).unwrap();
27935 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27936 let edge = &parsed["d"]["edges"][0];
27937 assert_eq!(edge["from_id"], "a");
27938 assert_eq!(edge["to_id"], "b");
27939 assert_eq!(edge["k"], "c");
27940 assert!(edge.get("properties").is_none());
27941 }
27942
27943 #[test]
27944 fn ultra_terse_abbreviates_edge_kinds() {
27945 let val = serde_json::json!({
27946 "edges": [
27947 {"from_id": "a", "to_id": "b", "kind": "defines"},
27948 {"from_id": "a", "to_id": "c", "kind": "contains"},
27949 {"from_id": "a", "to_id": "d", "kind": "imports"},
27950 {"from_id": "a", "to_id": "e", "kind": "mentions"},
27951 {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
27952 {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
27953 {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
27954 {"from_id": "a", "to_id": "i", "kind": "uses"},
27955 {"from_id": "a", "to_id": "j", "kind": "parent"},
27956 {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
27957 ]
27958 });
27959 let result = to_json_schema(&val, false, true, true, false).unwrap();
27960 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27961 let edges = &parsed["d"]["edges"].as_array().unwrap();
27962 assert_eq!(edges[0]["k"], "d");
27963 assert_eq!(edges[1]["k"], "ct");
27964 assert_eq!(edges[2]["k"], "i");
27965 assert_eq!(edges[3]["k"], "m");
27966 assert_eq!(edges[4]["k"], "sr");
27967 assert_eq!(edges[5]["k"], "bt");
27968 assert_eq!(edges[6]["k"], "sctx");
27969 assert_eq!(edges[7]["k"], "u");
27970 assert_eq!(edges[8]["k"], "p");
27971 assert_eq!(edges[9]["k"], "unknown_edge");
27972 }
27973
27974 #[test]
27975 fn ultra_terse_strips_provenance_freshness_from_edges() {
27976 let val = serde_json::json!({
27977 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
27978 });
27979 let result = to_json_schema(&val, false, true, true, false).unwrap();
27980 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27981 let edge = &parsed["d"]["edges"][0];
27982 assert!(edge.get("provenance").is_none());
27983 assert!(edge.get("freshness").is_none());
27984 assert_eq!(edge["k"], "c");
27985 }
27986
27987 #[test]
27988 fn ultra_terse_truncates_snippets() {
27989 let long_snippet = "x".repeat(120);
27990 let val = serde_json::json!({"snippet": long_snippet});
27991 let result = to_json_schema(&val, false, true, true, false).unwrap();
27992 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27993 let snipped = parsed["d"]["sn"].as_str().unwrap();
27994 assert_eq!(snipped.len(), 80);
27995 assert!(snipped.ends_with("..."));
27996 }
27997
27998 #[test]
27999 fn ultra_terse_truncates_abbreviated_snippet_key() {
28000 let long_snippet = "y".repeat(100);
28001 let val = serde_json::json!({"snippet": long_snippet});
28002 let result = to_json_schema(&val, false, true, true, false).unwrap();
28003 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28004 let snipped = parsed["d"]["sn"].as_str().unwrap();
28005 assert_eq!(snipped.len(), 80);
28006 assert!(snipped.ends_with("..."));
28007 }
28008
28009 #[test]
28010 fn ultra_terse_compacts_coverage_snapshot() {
28011 let val = serde_json::json!({
28012 "mode": "incremental",
28013 "total_sector_count": 10,
28014 "dirty_sector_count": 2,
28015 "active_rebuild": Some("rebuild-1"),
28016 "completed_dirty_sector_count": 1,
28017 "mounted_sector_count": 8,
28018 "rebuilding_sector_count": 1,
28019 "resumed_sector_count": 3,
28020 "reused_sector_count": 5
28021 });
28022 let result = to_json_schema(&val, false, true, true, false).unwrap();
28023 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28024 let d = &parsed["d"];
28025 assert_eq!(d["mode"], "incremental");
28026 assert_eq!(d["total_sector_count"], 10);
28027 assert_eq!(d["dirty_sector_count"], 2);
28028 assert!(d.get("active_rebuild").is_none());
28029 assert!(d.get("completed_dirty_sector_count").is_none());
28030 assert!(d.get("mounted_sector_count").is_none());
28031 assert!(d.get("rebuilding_sector_count").is_none());
28032 assert!(d.get("resumed_sector_count").is_none());
28033 assert!(d.get("reused_sector_count").is_none());
28034 }
28035
28036 #[test]
28037 fn ultra_terse_short_snippet_unchanged() {
28038 let val = serde_json::json!({"snippet": "short text"});
28039 let result = to_json_schema(&val, false, true, true, false).unwrap();
28040 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28041 assert_eq!(parsed["d"]["sn"], "short text");
28042 }
28043
28044 #[test]
28045 fn ultra_terse_non_graph_object_properties_preserved() {
28046 let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
28047 let result = to_json_schema(&val, false, true, true, false).unwrap();
28048 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28049 assert!(parsed["d"]["config"]["properties"].is_object());
28050 }
28051
28052 #[test]
28055 fn schema_converts_homogeneous_arrays() {
28056 let val = serde_json::json!({"symbols": [
28057 {"name": "foo", "kind": "fn", "line": 10},
28058 {"name": "bar", "kind": "fn", "line": 20}
28059 ]});
28060 let result = to_json_schema(&val, false, false, false, true).unwrap();
28061 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28062 let syms = &parsed["symbols"];
28063 let columns = syms["_c"]
28064 .as_array()
28065 .unwrap()
28066 .iter()
28067 .map(|value| value.as_str().unwrap())
28068 .collect::<Vec<_>>();
28069 let row0 = syms["_r"][0].as_array().unwrap();
28070 let row1 = syms["_r"][1].as_array().unwrap();
28071 let name_index = columns.iter().position(|column| *column == "name").unwrap();
28072 let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
28073 let line_index = columns.iter().position(|column| *column == "line").unwrap();
28074 assert_eq!(row0[name_index], "foo");
28075 assert_eq!(row0[kind_index], "fn");
28076 assert_eq!(row0[line_index], 10);
28077 assert_eq!(row1[name_index], "bar");
28078 assert_eq!(row1[kind_index], "fn");
28079 assert_eq!(row1[line_index], 20);
28080 }
28081
28082 #[test]
28083 fn schema_skips_short_arrays() {
28084 let val = serde_json::json!({"items": [{"name": "only"}]});
28085 let result = to_json_schema(&val, false, false, false, true).unwrap();
28086 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28087 assert!(parsed["items"].is_array());
28088 assert_eq!(parsed["items"][0]["name"], "only");
28089 }
28090
28091 #[test]
28092 fn schema_skips_heterogeneous_arrays() {
28093 let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
28094 let result = to_json_schema(&val, false, false, false, true).unwrap();
28095 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28096 assert!(parsed["items"].is_array());
28097 assert_eq!(parsed["items"][0]["a"], 1);
28098 }
28099
28100 #[test]
28101 fn schema_with_terse_combines() {
28102 let val = serde_json::json!({"callers": [
28103 {"caller_name": "a", "caller_file": "x.rs"},
28104 {"caller_name": "b", "caller_file": "y.rs"}
28105 ]});
28106 let result = to_json_schema(&val, false, true, false, true).unwrap();
28107 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28108 assert!(parsed["_s"].is_object());
28109 let d = &parsed["d"];
28110 let crs = &d["crs"];
28111 assert!(crs["_c"].is_array());
28112 assert!(crs["_r"].is_array());
28113 let columns = crs["_c"]
28114 .as_array()
28115 .unwrap()
28116 .iter()
28117 .map(|value| value.as_str().unwrap())
28118 .collect::<Vec<_>>();
28119 let row = crs["_r"][0].as_array().unwrap();
28120 let name_index = columns.iter().position(|column| *column == "cn").unwrap();
28121 let file_index = columns.iter().position(|column| *column == "cf").unwrap();
28122 assert_eq!(row[name_index], "a");
28123 assert_eq!(row[file_index], "x.rs");
28124 }
28125
28126 #[test]
28127 fn schema_preserves_non_object_arrays() {
28128 let val = serde_json::json!({"tags": ["a", "b", "c"]});
28129 let result = to_json_schema(&val, false, false, false, true).unwrap();
28130 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28131 assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
28132 }
28133
28134 #[test]
28135 fn cli_accepts_global_schema_flag() {
28136 let cli = parse_cli(["tsift", "--schema", "search", "test"]);
28137 assert!(cli.schema);
28138 assert!(matches!(cli.command, Some(Commands::Search { .. })));
28139 }
28140
28141 #[test]
28142 fn cli_accepts_global_envelope_flag() {
28143 let cli = parse_cli([
28144 "tsift",
28145 "--envelope",
28146 "context-pack",
28147 "tasks/software/tsift.md",
28148 ]);
28149 assert!(cli.envelope);
28150 assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
28151 }
28152
28153 #[test]
28154 fn cli_accepts_locks_command() {
28155 let cli = parse_cli(["tsift", "locks"]);
28156 assert!(matches!(cli.command, Some(Commands::Locks { .. })));
28157 }
28158
28159 #[test]
28160 fn cli_parses_memory_budget_guard_command() {
28161 let cli = parse_cli([
28162 "tsift",
28163 "memory",
28164 "budget-guard",
28165 "--file",
28166 "tool.log",
28167 "--budget-tokens",
28168 "1000",
28169 "--json",
28170 ]);
28171 match cli.command {
28172 Some(Commands::Memory {
28173 command:
28174 crate::cli::MemoryCommand::BudgetGuard {
28175 file,
28176 budget_tokens,
28177 json,
28178 ..
28179 },
28180 }) => {
28181 assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
28182 assert_eq!(budget_tokens, 1000);
28183 assert!(json);
28184 }
28185 _ => panic!("expected memory budget-guard command"),
28186 }
28187 }
28188
28189 #[test]
28190 fn cli_parses_memory_capture_agent_doc_closeout_command() {
28191 let cli = parse_cli([
28192 "tsift",
28193 "memory",
28194 "capture-agent-doc-closeout",
28195 ".",
28196 "--session-path",
28197 "tasks/software/tsift.md",
28198 "--prompt-target",
28199 "do [#tsiftmemhooks]",
28200 "--response-summary",
28201 "wired closeout capture",
28202 "--commit-hash",
28203 "abc123",
28204 "--session-check-status",
28205 "clean",
28206 "--json",
28207 ]);
28208 match cli.command {
28209 Some(Commands::Memory {
28210 command:
28211 crate::cli::MemoryCommand::CaptureAgentDocCloseout {
28212 path,
28213 session_path,
28214 prompt_target,
28215 response_summary,
28216 commit_hash,
28217 session_check_status,
28218 json,
28219 },
28220 }) => {
28221 assert_eq!(path, std::path::PathBuf::from("."));
28222 assert_eq!(
28223 session_path,
28224 std::path::PathBuf::from("tasks/software/tsift.md")
28225 );
28226 assert_eq!(prompt_target, "do [#tsiftmemhooks]");
28227 assert_eq!(response_summary, "wired closeout capture");
28228 assert_eq!(commit_hash.as_deref(), Some("abc123"));
28229 assert_eq!(session_check_status, "clean");
28230 assert!(json);
28231 }
28232 _ => panic!("expected memory capture-agent-doc-closeout command"),
28233 }
28234 }
28235
28236 #[test]
28237 fn cli_locks_accepts_scope_flag() {
28238 let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
28239 match cli.command {
28240 Some(Commands::Locks { scope, .. }) => {
28241 assert_eq!(scope.as_deref(), Some("alpha"));
28242 }
28243 _ => panic!("expected Locks command"),
28244 }
28245 }
28246
28247 #[test]
28248 fn cli_search_accepts_autoindex_flag() {
28249 let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
28250 match cli.command {
28251 Some(Commands::Search {
28252 autoindex,
28253 no_autoindex,
28254 ..
28255 }) => {
28256 assert!(autoindex);
28257 assert!(!no_autoindex);
28258 }
28259 _ => panic!("expected Search command"),
28260 }
28261 }
28262
28263 #[test]
28264 fn cli_search_accepts_exact_flag() {
28265 let cli = parse_cli(["tsift", "search", "test", "--exact"]);
28266 match cli.command {
28267 Some(Commands::Search {
28268 exact, strategy, ..
28269 }) => {
28270 assert!(exact);
28271 assert!(strategy.is_none());
28272 }
28273 _ => panic!("expected Search command"),
28274 }
28275 }
28276
28277 #[test]
28278 fn cli_parses_diff_digest_command() {
28279 let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
28280 match cli.command {
28281 Some(Commands::DiffDigest {
28282 json,
28283 path,
28284 cached,
28285 revision,
28286 max_parsed_files,
28287 }) => {
28288 assert!(json);
28289 assert_eq!(path, PathBuf::from("."));
28290 assert!(!cached);
28291 assert!(revision.is_none());
28292 assert_eq!(max_parsed_files, 25);
28293 }
28294 _ => panic!("expected DiffDigest command"),
28295 }
28296 }
28297
28298 #[test]
28299 fn cli_rejects_conflicting_diff_digest_modes() {
28300 match try_parse_cli([
28301 "tsift",
28302 "diff-digest",
28303 "--cached",
28304 "--revision",
28305 "HEAD",
28306 ".",
28307 ]) {
28308 Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
28309 Err(err) => {
28310 assert!(err.to_string().contains("--cached"));
28311 assert!(err.to_string().contains("--revision"));
28312 }
28313 }
28314 }
28315
28316 #[test]
28317 fn cli_parses_test_digest_command() {
28318 let cli = parse_cli([
28319 "tsift",
28320 "test-digest",
28321 "--path",
28322 ".",
28323 "--input",
28324 "target/test.log",
28325 "--runner",
28326 "cargo",
28327 "--json",
28328 ]);
28329 match cli.command {
28330 Some(Commands::TestDigest {
28331 json,
28332 path,
28333 input,
28334 runner,
28335 }) => {
28336 assert!(json);
28337 assert_eq!(path, PathBuf::from("."));
28338 assert_eq!(input, Some(PathBuf::from("target/test.log")));
28339 assert_eq!(runner.as_deref(), Some("cargo"));
28340 }
28341 _ => panic!("expected TestDigest command"),
28342 }
28343 }
28344
28345 #[test]
28346 fn cli_parses_log_digest_command() {
28347 let cli = parse_cli([
28348 "tsift",
28349 "log-digest",
28350 "--path",
28351 ".",
28352 "--input",
28353 "target/build.log",
28354 "--json",
28355 ]);
28356 match cli.command {
28357 Some(Commands::LogDigest { json, path, input }) => {
28358 assert!(json);
28359 assert_eq!(path, PathBuf::from("."));
28360 assert_eq!(input, Some(PathBuf::from("target/build.log")));
28361 }
28362 _ => panic!("expected LogDigest command"),
28363 }
28364 }
28365
28366 #[test]
28367 fn cli_parses_metric_digest_command() {
28368 let cli = parse_cli([
28369 "tsift",
28370 "metric-digest",
28371 "--input",
28372 "target/runs.json",
28373 "--baseline",
28374 "target/prior.json",
28375 "--metric",
28376 "session_mae",
28377 "--lower-is-better",
28378 "session_mae",
28379 "--history",
28380 "4",
28381 "--top",
28382 "2",
28383 "--json",
28384 ]);
28385 match cli.command {
28386 Some(Commands::MetricDigest {
28387 input,
28388 baseline,
28389 metrics,
28390 lower_is_better,
28391 history,
28392 top,
28393 json,
28394 ..
28395 }) => {
28396 assert!(json);
28397 assert_eq!(input, Some(PathBuf::from("target/runs.json")));
28398 assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
28399 assert_eq!(metrics, vec!["session_mae"]);
28400 assert_eq!(lower_is_better, vec!["session_mae"]);
28401 assert_eq!(history, 4);
28402 assert_eq!(top, 2);
28403 }
28404 _ => panic!("expected MetricDigest command"),
28405 }
28406 }
28407
28408 #[test]
28409 fn cli_parses_dci_benchmark_command() {
28410 let cli = parse_cli([
28411 "tsift",
28412 "dci-benchmark",
28413 "--fixture",
28414 "fixtures/dci-search-benchmark.json",
28415 "--json",
28416 ]);
28417 match cli.command {
28418 Some(Commands::DciBenchmark { fixture, json }) => {
28419 assert!(json);
28420 assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
28421 }
28422 _ => panic!("expected DciBenchmark command"),
28423 }
28424 }
28425
28426 #[test]
28427 fn cli_parses_session_digest_command() {
28428 let cli = parse_cli([
28429 "tsift",
28430 "session-digest",
28431 "--path",
28432 ".",
28433 "--input",
28434 "target/session.md",
28435 "--source",
28436 "markdown",
28437 "--json",
28438 ]);
28439 match cli.command {
28440 Some(Commands::SessionDigest {
28441 json,
28442 path,
28443 input,
28444 source,
28445 }) => {
28446 assert!(json);
28447 assert_eq!(path, PathBuf::from("."));
28448 assert_eq!(input, Some(PathBuf::from("target/session.md")));
28449 assert_eq!(source.as_deref(), Some("markdown"));
28450 }
28451 _ => panic!("expected SessionDigest command"),
28452 }
28453 }
28454
28455 #[test]
28456 fn cli_parses_session_cost_command() {
28457 let cli = parse_cli([
28458 "tsift",
28459 "session-cost",
28460 "--input",
28461 "target/session.jsonl",
28462 "--source",
28463 "codex-jsonl",
28464 "--json",
28465 ]);
28466 match cli.command {
28467 Some(Commands::SessionCost {
28468 json,
28469 input,
28470 source,
28471 }) => {
28472 assert!(json);
28473 assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
28474 assert_eq!(source.as_deref(), Some("codex-jsonl"));
28475 }
28476 _ => panic!("expected SessionCost command"),
28477 }
28478 }
28479
28480 #[test]
28481 fn cli_parses_session_review_command() {
28482 let cli = parse_cli([
28483 "tsift",
28484 "session-review",
28485 "tasks/software/tsift.md",
28486 "--next-context",
28487 "--json",
28488 ]);
28489 match cli.command {
28490 Some(Commands::SessionReview {
28491 json,
28492 next_context,
28493 path,
28494 ..
28495 }) => {
28496 assert!(json);
28497 assert!(next_context);
28498 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28499 }
28500 _ => panic!("expected SessionReview command"),
28501 }
28502 }
28503
28504 #[test]
28505 fn cli_search_accepts_budget_flags() {
28506 let cli = parse_cli([
28507 "tsift",
28508 "search",
28509 "alpha_helper",
28510 "--max-items",
28511 "3",
28512 "--max-bytes",
28513 "96",
28514 ]);
28515 match cli.command {
28516 Some(Commands::Search {
28517 max_items,
28518 max_bytes,
28519 ..
28520 }) => {
28521 assert_eq!(max_items, Some(3));
28522 assert_eq!(max_bytes, Some(96));
28523 }
28524 _ => panic!("expected Search command"),
28525 }
28526 }
28527
28528 #[test]
28529 fn cli_search_accepts_budget_preset() {
28530 let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
28531 match cli.command {
28532 Some(Commands::Search { budget, .. }) => {
28533 assert_eq!(budget, Some(ResponseBudgetPreset::Small));
28534 }
28535 _ => panic!("expected Search command"),
28536 }
28537 }
28538
28539 #[test]
28540 fn cli_search_accepts_ast_facet_filters() {
28541 let cli = parse_cli([
28542 "tsift",
28543 "search",
28544 "setup",
28545 "--lang",
28546 "markdown",
28547 "--kind",
28548 "list_item",
28549 "--node-kind",
28550 "list_item",
28551 "--section",
28552 "Install",
28553 "--parent",
28554 "Run setup.",
28555 "--child",
28556 "Confirm setup.",
28557 "--fence-language",
28558 "rust",
28559 "--list-depth",
28560 "1",
28561 "--heading-level",
28562 "2",
28563 ]);
28564 match cli.command {
28565 Some(Commands::Search {
28566 lang,
28567 kind,
28568 node_kind,
28569 section,
28570 parent,
28571 child,
28572 fence_language,
28573 list_depth,
28574 heading_level,
28575 ..
28576 }) => {
28577 assert_eq!(lang, vec!["markdown"]);
28578 assert_eq!(kind, vec!["list_item"]);
28579 assert_eq!(node_kind, vec!["list_item"]);
28580 assert_eq!(section, vec!["Install"]);
28581 assert_eq!(parent, vec!["Run setup."]);
28582 assert_eq!(child, vec!["Confirm setup."]);
28583 assert_eq!(fence_language, vec!["rust"]);
28584 assert_eq!(list_depth, vec![1]);
28585 assert_eq!(heading_level, vec![2]);
28586 }
28587 _ => panic!("expected Search command"),
28588 }
28589 }
28590
28591 #[test]
28592 fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
28593 let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
28594 assert_eq!(small.preview_items(), 3);
28595 assert_eq!(small.preview_bytes(), 120);
28596 assert_eq!(small.follow_up_items(), 4);
28597
28598 let overridden =
28599 ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
28600 assert_eq!(overridden.preview_items(), 7);
28601 assert_eq!(overridden.preview_bytes(), 120);
28602 assert_eq!(overridden.follow_up_items(), 7);
28603
28604 let envelope_default = ResponseBudget::from_cli(None, None, None, true);
28605 assert!(envelope_default.is_active());
28606 }
28607
28608 #[test]
28609 fn cli_explain_accepts_budget_flags() {
28610 let cli = parse_cli([
28611 "tsift",
28612 "explain",
28613 "alpha_helper",
28614 "--max-items",
28615 "2",
28616 "--max-bytes",
28617 "80",
28618 ]);
28619 match cli.command {
28620 Some(Commands::Explain {
28621 max_items,
28622 max_bytes,
28623 ..
28624 }) => {
28625 assert_eq!(max_items, Some(2));
28626 assert_eq!(max_bytes, Some(80));
28627 }
28628 _ => panic!("expected Explain command"),
28629 }
28630 }
28631
28632 #[test]
28633 fn cli_session_review_accepts_budget_flags() {
28634 let cli = parse_cli([
28635 "tsift",
28636 "session-review",
28637 "tasks/software/tsift.md",
28638 "--max-items",
28639 "4",
28640 "--max-bytes",
28641 "120",
28642 ]);
28643 match cli.command {
28644 Some(Commands::SessionReview {
28645 max_items,
28646 max_bytes,
28647 ..
28648 }) => {
28649 assert_eq!(max_items, Some(4));
28650 assert_eq!(max_bytes, Some(120));
28651 }
28652 _ => panic!("expected SessionReview command"),
28653 }
28654 }
28655
28656 #[test]
28657 fn cli_parses_context_pack_command() {
28658 let cli = parse_cli([
28659 "tsift",
28660 "context-pack",
28661 "tasks/software/tsift.md",
28662 "--test-input",
28663 "target/test.log",
28664 "--runner",
28665 "cargo",
28666 "--log-input",
28667 "target/build.log",
28668 "--max-items",
28669 "3",
28670 "--max-bytes",
28671 "96",
28672 "--json",
28673 ]);
28674 match cli.command {
28675 Some(Commands::ContextPack {
28676 path,
28677 test_input,
28678 runner,
28679 log_input,
28680 json,
28681 max_items,
28682 max_bytes,
28683 budget,
28684 convex_snapshot,
28685 }) => {
28686 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28687 assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
28688 assert_eq!(runner.as_deref(), Some("cargo"));
28689 assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
28690 assert!(json);
28691 assert_eq!(max_items, Some(3));
28692 assert_eq!(max_bytes, Some(96));
28693 assert!(budget.is_none());
28694 assert!(convex_snapshot.is_none());
28695 }
28696 _ => panic!("expected ContextPack command"),
28697 }
28698 }
28699
28700 #[test]
28701 fn cli_parses_token_savings_command() {
28702 let cli = parse_cli([
28703 "tsift",
28704 "token-savings",
28705 "--fixture",
28706 "fixtures/tsift-token-savings.json",
28707 "--fail-under",
28708 "--json",
28709 ]);
28710 match cli.command {
28711 Some(Commands::TokenSavings {
28712 fixture,
28713 fail_under,
28714 json,
28715 }) => {
28716 assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
28717 assert!(fail_under);
28718 assert!(json);
28719 }
28720 _ => panic!("expected TokenSavings command"),
28721 }
28722 }
28723
28724 #[test]
28725 fn token_savings_report_records_fixture_thresholds() {
28726 let raw_symbols = [
28727 "validate_user",
28728 "validateUser",
28729 "ValidateUser",
28730 "validate-user",
28731 "VALIDATE_USER",
28732 "Validate_User",
28733 "raw_symbol",
28734 "rawSymbol",
28735 "RawSymbol",
28736 "raw-symbol",
28737 "RAW_SYMBOL",
28738 "Raw_Symbol",
28739 ]
28740 .iter()
28741 .enumerate()
28742 .map(|(idx, identifier)| TokenSavingsRawSymbol {
28743 identifier: (*identifier).to_string(),
28744 file: format!("src/example_{idx}.rs"),
28745 line: (idx + 1) as u64,
28746 context: "function".to_string(),
28747 })
28748 .collect();
28749 let fixture = TokenSavingsFixture {
28750 schema_version: 1,
28751 description: "fixture".to_string(),
28752 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28753 cases: vec![TokenSavingsFixtureCase {
28754 name: "search-preview".to_string(),
28755 surface: "search".to_string(),
28756 minimum_savings_percent: 40.0,
28757 raw_symbols,
28758 tagpath_families: vec![
28759 TokenSavingsFamily {
28760 canonical: "validate_user".to_string(),
28761 count: 6,
28762 aliases: BTreeMap::new(),
28763 },
28764 TokenSavingsFamily {
28765 canonical: "raw_symbol".to_string(),
28766 count: 6,
28767 aliases: BTreeMap::new(),
28768 },
28769 ],
28770 context_pack_inputs: None,
28771 session_review_inputs: None,
28772 source_read_inputs: None,
28773 markdown_projection_inputs: None,
28774 }],
28775 };
28776
28777 let report = build_token_savings_report(&fixture).unwrap();
28778
28779 assert!(report.pass);
28780 assert_eq!(report.cases[0].raw_symbol_count, 12);
28781 assert_eq!(report.cases[0].family_count, 2);
28782 assert_eq!(report.cases[0].status, "pass");
28783 assert!(report.cases[0].byte_delta > 0);
28784 assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
28785 assert!(report.cases[0].savings_percent >= 40.0);
28786 }
28787
28788 #[test]
28789 fn token_savings_source_read_inputs_preserve_required_anchors() {
28790 let fixture = TokenSavingsFixture {
28791 schema_version: 1,
28792 description: "fixture".to_string(),
28793 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28794 cases: vec![TokenSavingsFixtureCase {
28795 name: "source-read".to_string(),
28796 surface: "source-read".to_string(),
28797 minimum_savings_percent: 40.0,
28798 raw_symbols: Vec::new(),
28799 tagpath_families: Vec::new(),
28800 context_pack_inputs: None,
28801 session_review_inputs: None,
28802 source_read_inputs: Some(TokenSavingsSourceReadInputs {
28803 reads: vec![TokenSavingsSourceReadInput {
28804 command: "sed -n '40,160p' src/main.rs".to_string(),
28805 file: "src/main.rs".to_string(),
28806 raw_start: 40,
28807 raw_lines: 121,
28808 raw_excerpt: "line 40\n".repeat(121),
28809 envelope_start: 40,
28810 envelope_lines: 121,
28811 required_line_anchors: vec![40, 120, 160],
28812 }],
28813 }),
28814 markdown_projection_inputs: None,
28815 }],
28816 };
28817
28818 let report = build_token_savings_report(&fixture).unwrap();
28819
28820 assert!(report.pass);
28821 assert_eq!(report.cases[0].surface, "source-read");
28822 assert!(report.cases[0].savings_percent >= 40.0);
28823 }
28824
28825 #[test]
28826 fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
28827 let fixture = TokenSavingsFixture {
28828 schema_version: 1,
28829 description: "fixture".to_string(),
28830 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28831 cases: vec![TokenSavingsFixtureCase {
28832 name: "source-read".to_string(),
28833 surface: "source-read".to_string(),
28834 minimum_savings_percent: 40.0,
28835 raw_symbols: Vec::new(),
28836 tagpath_families: Vec::new(),
28837 context_pack_inputs: None,
28838 session_review_inputs: None,
28839 source_read_inputs: Some(TokenSavingsSourceReadInputs {
28840 reads: vec![TokenSavingsSourceReadInput {
28841 command: "cat src/main.rs".to_string(),
28842 file: "src/main.rs".to_string(),
28843 raw_start: 1,
28844 raw_lines: 200,
28845 raw_excerpt: "line\n".repeat(200),
28846 envelope_start: 1,
28847 envelope_lines: 80,
28848 required_line_anchors: vec![120],
28849 }],
28850 }),
28851 markdown_projection_inputs: None,
28852 }],
28853 };
28854
28855 let err = match build_token_savings_report(&fixture) {
28856 Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
28857 Err(err) => err,
28858 };
28859
28860 assert!(err.to_string().contains("hides required line anchor 120"));
28861 }
28862
28863 #[test]
28864 fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
28865 let fixture = TokenSavingsFixture {
28866 schema_version: 1,
28867 description: "fixture".to_string(),
28868 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28869 cases: vec![TokenSavingsFixtureCase {
28870 name: "markdown-projection".to_string(),
28871 surface: "context-pack".to_string(),
28872 minimum_savings_percent: 40.0,
28873 raw_symbols: Vec::new(),
28874 tagpath_families: Vec::new(),
28875 context_pack_inputs: None,
28876 session_review_inputs: None,
28877 source_read_inputs: None,
28878 markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
28879 documents: vec![TokenSavingsMarkdownProjectionInput {
28880 command: "context-pack markdown body".to_string(),
28881 file: "tasks/software/tsift.md".to_string(),
28882 raw_markdown: "# Heading\n\n".repeat(120),
28883 outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
28884 selected_nodes: vec!["mdast-selected".to_string()],
28885 expand:
28886 "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
28887 .to_string(),
28888 }],
28889 }),
28890 }],
28891 };
28892
28893 let report = build_token_savings_report(&fixture).unwrap();
28894
28895 assert!(report.pass);
28896 assert_eq!(report.cases[0].surface, "context-pack");
28897 assert!(report.cases[0].savings_percent >= 40.0);
28898 }
28899
28900 #[test]
28901 fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
28902 let mut content = String::from("# Cache Root\n\n");
28903 for idx in 0..96 {
28904 content.push_str(&format!(
28905 "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
28906 ));
28907 }
28908
28909 let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
28910 assert!(!first.cache_hit);
28911 assert!(first.nodes.len() > 200);
28912
28913 let sections = markdown_section_spans(&content).unwrap();
28914 let list_items = markdown_block_spans(&content, "list_item").unwrap();
28915 let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
28916 let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
28917
28918 assert!(second.cache_hit);
28919 assert_eq!(second.nodes.len(), first.nodes.len());
28920 assert_eq!(sections.len(), 97);
28921 assert_eq!(list_items.len(), 96);
28922 assert_eq!(code_blocks.len(), 96);
28923 let first_code = first
28924 .nodes
28925 .iter()
28926 .find(|node| node.kind == "code_block")
28927 .expect("expected a Markdown code block");
28928 let first_code_node = markdown_ast_node(
28929 Path::new("/repo"),
28930 "semantic-edit",
28931 first_code,
28932 content.as_bytes(),
28933 &first.nodes,
28934 8,
28935 );
28936 assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
28937 assert_eq!(
28938 first_code_node.metadata.embedded_symbols[0].name,
28939 "sample_0"
28940 );
28941 assert_eq!(
28942 first_code_node.metadata.embedded_symbols[0].language,
28943 "rust"
28944 );
28945 }
28946
28947 #[test]
28948 fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
28949 let response = empty_search_response(Path::new("/repo"), "lexical");
28950 let symbol_hits = vec![index::SymbolHit {
28951 name: "alpha_helper_with_a_long_name".to_string(),
28952 kind: "function".to_string(),
28953 language: "rust".to_string(),
28954 file: "/repo/src/lib.rs".to_string(),
28955 line: 12,
28956 end_line: None,
28957 node_kind: None,
28958 start_byte: None,
28959 end_byte: None,
28960 body_start_byte: None,
28961 body_end_byte: None,
28962 tags: None,
28963 score: 0.98,
28964 match_type: "exact_name".to_string(),
28965 tagpath_handle: None,
28966 }];
28967
28968 let report = build_relative_search_budget_report(
28969 "alpha_helper_with_a_long_name",
28970 "lexical",
28971 Path::new("/repo"),
28972 &response,
28973 &symbol_hits,
28974 ResponseBudget::new(Some(1), Some(12)),
28975 &SearchFacetFilters::default(),
28976 );
28977
28978 assert_eq!(report.symbols.len(), 1);
28979 assert!(report.symbols[0].handle.starts_with("sfam-"));
28980 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
28981 assert_eq!(report.symbols[0].name, "alpha_hel...");
28982 assert_eq!(report.symbols[0].file, "src/lib.rs");
28983 assert!(report.symbols[0].expand.contains("tsift search"));
28984 }
28985
28986 #[test]
28987 fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
28988 let dir = tempfile::tempdir().unwrap();
28989 let src_dir = dir.path().join("src");
28990 fs::create_dir_all(&src_dir).unwrap();
28991 let source = "fn alpha_helper() {\n beta();\n}\n";
28992 let file = src_dir.join("lib.rs");
28993 fs::write(&file, source).unwrap();
28994 let body_start = source.find("{\n").unwrap() + 1;
28995 let body_end = source.rfind("\n}").unwrap() + 1;
28996
28997 let response = empty_search_response(dir.path(), "lexical");
28998 let symbol_hits = vec![index::SymbolHit {
28999 name: "alpha_helper".to_string(),
29000 kind: "function".to_string(),
29001 language: "rust".to_string(),
29002 file: file.to_string_lossy().to_string(),
29003 line: 0,
29004 end_line: Some(2),
29005 node_kind: Some("function_item".to_string()),
29006 start_byte: Some(0),
29007 end_byte: Some(i64::try_from(source.len()).unwrap()),
29008 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29009 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29010 tags: Some("alpha,helper".to_string()),
29011 score: 0.98,
29012 match_type: "exact_name".to_string(),
29013 tagpath_handle: None,
29014 }];
29015
29016 let report = build_relative_search_budget_report(
29017 "alpha helper",
29018 "lexical",
29019 dir.path(),
29020 &response,
29021 &symbol_hits,
29022 ResponseBudget::new(Some(5), Some(96)),
29023 &SearchFacetFilters::default(),
29024 );
29025
29026 let symbol = &report.symbols[0];
29027 assert_eq!(symbol.language, "rust");
29028 assert_eq!(symbol.end_line, Some(2));
29029 let ast = symbol
29030 .ast
29031 .as_ref()
29032 .expect("search symbol preview should expose an AST span artifact");
29033 assert_eq!(ast.artifact_kind, "ast_span");
29034 assert!(ast.span.handle.starts_with("span-"));
29035 assert_eq!(ast.span.node_kind, "function_item");
29036 assert_eq!(ast.span.start_byte, 0);
29037 assert_eq!(ast.span.end_byte, source.len());
29038 assert_eq!(ast.span.body_start_byte, Some(body_start));
29039 assert_eq!(ast.span.body_end_byte, Some(body_end));
29040 assert!(ast.expand.source_window.contains("source-read"));
29041 assert!(
29042 ast.expand
29043 .source_body
29044 .as_ref()
29045 .unwrap()
29046 .contains("source-read")
29047 );
29048 assert!(ast.expand.symbol_read.contains("symbol-read"));
29049 assert!(ast.expand.markdown_ast.is_none());
29050 }
29051
29052 #[test]
29053 fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
29054 let dir = tempfile::tempdir().unwrap();
29055 let source = "# Guide\n\n## Install\n\n- Run setup.\n";
29056 let file = dir.path().join("README.md");
29057 fs::write(&file, source).unwrap();
29058 let heading_start = source.find("## Install").unwrap();
29059 let heading_end = source.len();
29060
29061 let response = empty_search_response(dir.path(), "lexical");
29062 let symbol_hits = vec![index::SymbolHit {
29063 name: "Install".to_string(),
29064 kind: "heading".to_string(),
29065 language: "markdown".to_string(),
29066 file: file.to_string_lossy().to_string(),
29067 line: 2,
29068 end_line: Some(4),
29069 node_kind: Some("atx_heading".to_string()),
29070 start_byte: Some(i64::try_from(heading_start).unwrap()),
29071 end_byte: Some(i64::try_from(heading_end).unwrap()),
29072 body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
29073 body_end_byte: Some(i64::try_from(heading_end).unwrap()),
29074 tags: Some("install".to_string()),
29075 score: 1.0,
29076 match_type: "exact_name".to_string(),
29077 tagpath_handle: None,
29078 }];
29079
29080 let report = build_relative_search_budget_report(
29081 "Install",
29082 "lexical",
29083 dir.path(),
29084 &response,
29085 &symbol_hits,
29086 ResponseBudget::new(Some(5), Some(96)),
29087 &SearchFacetFilters::default(),
29088 );
29089
29090 let ast = report.symbols[0]
29091 .ast
29092 .as_ref()
29093 .expect("Markdown search symbol should expose an AST span artifact");
29094 assert_eq!(ast.span.node_kind, "atx_heading");
29095 assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
29096 let markdown_ast = ast
29097 .expand
29098 .markdown_ast
29099 .as_ref()
29100 .expect("Markdown symbols should include markdown-ast expansion");
29101 assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
29102 assert!(markdown_ast.contains("--node"), "{markdown_ast}");
29103 assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
29104 assert!(ast.expand.source_window.contains("source-read"));
29105 assert!(ast.expand.symbol_read.contains("symbol-read"));
29106 }
29107
29108 #[test]
29109 fn search_budget_report_exposes_markdown_embedded_code_symbols() {
29110 let dir = tempfile::tempdir().unwrap();
29111 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29112 let file = dir.path().join("README.md");
29113 fs::write(&file, source).unwrap();
29114 let fence_start = source.find("```rust").unwrap();
29115 let body_start = source.find("fn sample").unwrap();
29116 let body_end = body_start + "fn sample() {}\n".len();
29117
29118 let response = empty_search_response(dir.path(), "lexical");
29119 let symbol_hits = vec![index::SymbolHit {
29120 name: "rust".to_string(),
29121 kind: "code_block".to_string(),
29122 language: "markdown".to_string(),
29123 file: file.to_string_lossy().to_string(),
29124 line: 2,
29125 end_line: Some(4),
29126 node_kind: Some("fenced_code_block".to_string()),
29127 start_byte: Some(i64::try_from(fence_start).unwrap()),
29128 end_byte: Some(i64::try_from(source.len()).unwrap()),
29129 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29130 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29131 tags: Some("rust".to_string()),
29132 score: 1.0,
29133 match_type: "exact_name".to_string(),
29134 tagpath_handle: None,
29135 }];
29136
29137 let report = build_relative_search_budget_report(
29138 "rust",
29139 "lexical",
29140 dir.path(),
29141 &response,
29142 &symbol_hits,
29143 ResponseBudget::new(Some(5), Some(96)),
29144 &SearchFacetFilters::default(),
29145 );
29146
29147 let embedded = &report.symbols[0]
29148 .ast
29149 .as_ref()
29150 .unwrap()
29151 .span
29152 .markdown
29153 .as_ref()
29154 .unwrap()
29155 .embedded_symbols;
29156 assert_eq!(embedded.len(), 1);
29157 assert_eq!(embedded[0].name, "sample");
29158 assert_eq!(embedded[0].kind, "function");
29159 assert_eq!(embedded[0].language, "rust");
29160 assert_eq!(embedded[0].node_kind, "function_item");
29161 assert!(embedded[0].handle.starts_with("span-"));
29162 assert_eq!(embedded[0].start_byte, body_start);
29163 assert_eq!(embedded[0].start_line, 4);
29164 }
29165
29166 fn test_lexical_search_hit(
29167 path: &Path,
29168 rank: usize,
29169 score: f64,
29170 snippet: &str,
29171 ) -> sift::SearchHit {
29172 sift::SearchHit {
29173 artifact_id: format!("hit-{rank}"),
29174 artifact_kind: sift::ContextArtifactKind::File,
29175 budget: sift::ArtifactBudget::from_text(snippet, 1),
29176 confidence: sift::ScoreConfidence::High,
29177 freshness: sift::ArtifactFreshness {
29178 modified_unix_secs: None,
29179 observed_unix_secs: 0,
29180 },
29181 location: Some("line 1".to_string()),
29182 path: path.to_string_lossy().to_string(),
29183 provenance: sift::ArtifactProvenance {
29184 adapter: sift::AcquisitionAdapterKind::FileSystem,
29185 source: "test lexical hit".to_string(),
29186 synthetic: false,
29187 },
29188 rank,
29189 score,
29190 snippet: snippet.to_string(),
29191 }
29192 }
29193
29194 fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
29195 summarize::Summary {
29196 id: 0,
29197 symbol_name: symbol_name.to_string(),
29198 file_path: file_path.to_string(),
29199 content_hash: "hash".to_string(),
29200 summary: summary.to_string(),
29201 entities: None,
29202 relationships: None,
29203 concept_labels: None,
29204 extracted_at: "2026-06-02T00:00:00Z".to_string(),
29205 model: "test".to_string(),
29206 tokens_input: None,
29207 tokens_output: None,
29208 }
29209 }
29210
29211 #[test]
29212 fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
29213 let dir = tempfile::tempdir().unwrap();
29214 let src_dir = dir.path().join("src");
29215 fs::create_dir_all(&src_dir).unwrap();
29216 let source = "fn alpha_helper() {}\n";
29217 let file = src_dir.join("lib.rs");
29218 let broad_file = dir.path().join("README.md");
29219 fs::write(&file, source).unwrap();
29220 fs::write(
29221 &broad_file,
29222 "alpha helper alpha helper alpha helper in prose\n",
29223 )
29224 .unwrap();
29225
29226 let mut response = empty_search_response(dir.path(), "lexical");
29227 response.hits.push(test_lexical_search_hit(
29228 &broad_file,
29229 1,
29230 240.0,
29231 "alpha helper alpha helper alpha helper in prose",
29232 ));
29233 let symbol_hits = vec![index::SymbolHit {
29234 name: "alpha_helper".to_string(),
29235 kind: "function".to_string(),
29236 language: "rust".to_string(),
29237 file: file.to_string_lossy().to_string(),
29238 line: 0,
29239 end_line: Some(0),
29240 node_kind: Some("function_item".to_string()),
29241 start_byte: Some(0),
29242 end_byte: Some(i64::try_from(source.len()).unwrap()),
29243 body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29244 body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29245 tags: Some("alpha,helper".to_string()),
29246 score: 0.8,
29247 match_type: "all_tags".to_string(),
29248 tagpath_handle: None,
29249 }];
29250
29251 let report = build_relative_search_budget_report(
29252 "alpha helper",
29253 "lexical",
29254 dir.path(),
29255 &response,
29256 &symbol_hits,
29257 ResponseBudget::new(Some(5), Some(128)),
29258 &SearchFacetFilters::default(),
29259 );
29260
29261 assert_eq!(report.ranked[0].source, "symbol_span");
29262 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
29263 assert!(report.ranked[0].score > report.ranked[1].score);
29264 assert_eq!(report.ranked[1].source, "lexical_file");
29265 }
29266
29267 #[test]
29268 fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
29269 let dir = tempfile::tempdir().unwrap();
29270 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29271 let file = dir.path().join("README.md");
29272 fs::write(&file, source).unwrap();
29273 let summary_db =
29274 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
29275 summary_db
29276 .insert(&test_summary(
29277 "rust",
29278 "README.md",
29279 "Rust fence contains a sample function.",
29280 ))
29281 .unwrap();
29282
29283 let fence_start = source.find("```rust").unwrap();
29284 let body_start = source.find("fn sample").unwrap();
29285 let body_end = body_start + "fn sample() {}\n".len();
29286 let response = empty_search_response(dir.path(), "lexical");
29287 let symbol_hits = vec![index::SymbolHit {
29288 name: "rust".to_string(),
29289 kind: "code_block".to_string(),
29290 language: "markdown".to_string(),
29291 file: file.to_string_lossy().to_string(),
29292 line: 2,
29293 end_line: Some(4),
29294 node_kind: Some("fenced_code_block".to_string()),
29295 start_byte: Some(i64::try_from(fence_start).unwrap()),
29296 end_byte: Some(i64::try_from(source.len()).unwrap()),
29297 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29298 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29299 tags: Some("rust".to_string()),
29300 score: 1.0,
29301 match_type: "exact_name".to_string(),
29302 tagpath_handle: None,
29303 }];
29304
29305 let report = build_relative_search_budget_report(
29306 "rust",
29307 "lexical",
29308 dir.path(),
29309 &response,
29310 &symbol_hits,
29311 ResponseBudget::new(Some(5), Some(128)),
29312 &SearchFacetFilters::default(),
29313 );
29314
29315 let symbol = &report.symbols[0];
29316 assert_eq!(symbol.summary_refs, 1);
29317 assert_eq!(symbol.graph_neighbors, 1);
29318 assert!(
29319 report.ranked[0]
29320 .reasons
29321 .iter()
29322 .any(|reason| reason == "summary_refs:1")
29323 );
29324 assert!(
29325 report.ranked[0]
29326 .reasons
29327 .iter()
29328 .any(|reason| reason == "graph_neighbors:1")
29329 );
29330 }
29331
29332 fn markdown_search_facet_fixture() -> tempfile::TempDir {
29333 let dir = tempfile::tempdir().unwrap();
29334 let source = r#"# Guide
29335
29336## Install
29337
29338- Run setup.
29339 - Confirm setup.
29340
29341```rust
29342fn sample() {}
29343```
29344"#;
29345 fs::write(dir.path().join("README.md"), source).unwrap();
29346 let index_dir = dir.path().join(".tsift");
29347 fs::create_dir_all(&index_dir).unwrap();
29348 run_index_update(
29349 &index_dir.join("index.db"),
29350 dir.path(),
29351 "indexing markdown search facet fixture".to_string(),
29352 dir.path(),
29353 None,
29354 false,
29355 false,
29356 )
29357 .unwrap();
29358 dir
29359 }
29360
29361 fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
29362 let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
29363 db.symbol_search(query, 20).unwrap()
29364 }
29365
29366 #[test]
29367 fn search_facet_filters_match_scalar_symbol_fields() {
29368 let dir = tempfile::tempdir().unwrap();
29369 let hits = vec![
29370 index::SymbolHit {
29371 name: "alpha_helper".to_string(),
29372 kind: "function".to_string(),
29373 language: "rust".to_string(),
29374 file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
29375 line: 0,
29376 end_line: None,
29377 node_kind: Some("function_item".to_string()),
29378 start_byte: None,
29379 end_byte: None,
29380 body_start_byte: None,
29381 body_end_byte: None,
29382 tags: None,
29383 score: 1.0,
29384 match_type: "exact_name".to_string(),
29385 tagpath_handle: None,
29386 },
29387 index::SymbolHit {
29388 name: "Install".to_string(),
29389 kind: "heading".to_string(),
29390 language: "markdown".to_string(),
29391 file: dir.path().join("README.md").to_string_lossy().to_string(),
29392 line: 0,
29393 end_line: None,
29394 node_kind: Some("atx_heading".to_string()),
29395 start_byte: None,
29396 end_byte: None,
29397 body_start_byte: None,
29398 body_end_byte: None,
29399 tags: None,
29400 score: 0.9,
29401 match_type: "exact_name".to_string(),
29402 tagpath_handle: None,
29403 },
29404 ];
29405
29406 let filtered = apply_search_facet_filters(
29407 dir.path(),
29408 hits,
29409 &SearchFacetFilters {
29410 languages: vec!["rust".to_string()],
29411 kinds: vec!["function".to_string()],
29412 node_kinds: vec!["function_item".to_string()],
29413 ..SearchFacetFilters::default()
29414 },
29415 );
29416
29417 assert_eq!(filtered.len(), 1);
29418 assert_eq!(filtered[0].name, "alpha_helper");
29419 }
29420
29421 #[test]
29422 fn search_facet_filters_match_markdown_sections_and_block_metadata() {
29423 let dir = markdown_search_facet_fixture();
29424
29425 let nested_list = apply_search_facet_filters(
29426 dir.path(),
29427 markdown_search_facet_hits(dir.path(), "setup"),
29428 &SearchFacetFilters {
29429 sections: vec!["Install".to_string()],
29430 parents: vec!["Run setup.".to_string()],
29431 list_depths: vec![1],
29432 ..SearchFacetFilters::default()
29433 },
29434 );
29435 assert_eq!(nested_list.len(), 1);
29436 assert_eq!(nested_list[0].name, "Confirm setup.");
29437
29438 let parent_list = apply_search_facet_filters(
29439 dir.path(),
29440 markdown_search_facet_hits(dir.path(), "setup"),
29441 &SearchFacetFilters {
29442 children: vec!["Confirm setup.".to_string()],
29443 ..SearchFacetFilters::default()
29444 },
29445 );
29446 assert_eq!(parent_list.len(), 1);
29447 assert_eq!(parent_list[0].name, "Run setup.");
29448
29449 let heading = apply_search_facet_filters(
29450 dir.path(),
29451 markdown_search_facet_hits(dir.path(), "Install"),
29452 &SearchFacetFilters {
29453 heading_levels: vec![2],
29454 node_kinds: vec!["atx_heading".to_string()],
29455 ..SearchFacetFilters::default()
29456 },
29457 );
29458 assert_eq!(heading.len(), 1);
29459 assert_eq!(heading[0].name, "Install");
29460
29461 let fence = apply_search_facet_filters(
29462 dir.path(),
29463 markdown_search_facet_hits(dir.path(), "rust"),
29464 &SearchFacetFilters {
29465 fence_languages: vec!["rust".to_string()],
29466 kinds: vec!["code_block".to_string()],
29467 ..SearchFacetFilters::default()
29468 },
29469 );
29470 assert_eq!(fence.len(), 1);
29471 assert_eq!(fence[0].kind, "code_block");
29472
29473 let embedded_child = apply_search_facet_filters(
29474 dir.path(),
29475 markdown_search_facet_hits(dir.path(), "rust"),
29476 &SearchFacetFilters {
29477 children: vec!["sample".to_string()],
29478 kinds: vec!["code_block".to_string()],
29479 ..SearchFacetFilters::default()
29480 },
29481 );
29482 assert_eq!(embedded_child.len(), 1);
29483 assert_eq!(embedded_child[0].name, "rust");
29484 }
29485
29486 #[test]
29487 fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
29488 let response = empty_search_response(Path::new("/repo"), "lexical");
29489 let symbol_hits = vec![
29490 index::SymbolHit {
29491 name: "alpha_helper".to_string(),
29492 kind: "function".to_string(),
29493 language: "rust".to_string(),
29494 file: "/repo/src/lib.rs".to_string(),
29495 line: 12,
29496 end_line: None,
29497 node_kind: None,
29498 start_byte: None,
29499 end_byte: None,
29500 body_start_byte: None,
29501 body_end_byte: None,
29502 tags: Some("alpha,helper".to_string()),
29503 score: 0.98,
29504 match_type: "exact_name".to_string(),
29505 tagpath_handle: None,
29506 },
29507 index::SymbolHit {
29508 name: "alphaHelper".to_string(),
29509 kind: "method".to_string(),
29510 language: "rust".to_string(),
29511 file: "/repo/src/main.rs".to_string(),
29512 line: 34,
29513 end_line: None,
29514 node_kind: None,
29515 start_byte: None,
29516 end_byte: None,
29517 body_start_byte: None,
29518 body_end_byte: None,
29519 tags: Some("alpha,helper".to_string()),
29520 score: 0.93,
29521 match_type: "tag_overlap".to_string(),
29522 tagpath_handle: None,
29523 },
29524 index::SymbolHit {
29525 name: "alpha_helper".to_string(),
29526 kind: "function".to_string(),
29527 language: "rust".to_string(),
29528 file: "/repo/src/worker.rs".to_string(),
29529 line: 56,
29530 end_line: None,
29531 node_kind: None,
29532 start_byte: None,
29533 end_byte: None,
29534 body_start_byte: None,
29535 body_end_byte: None,
29536 tags: Some("alpha,helper".to_string()),
29537 score: 0.91,
29538 match_type: "tag_overlap".to_string(),
29539 tagpath_handle: None,
29540 },
29541 ];
29542
29543 let report = build_relative_search_budget_report(
29544 "alpha helper",
29545 "lexical",
29546 Path::new("/repo"),
29547 &response,
29548 &symbol_hits,
29549 ResponseBudget::new(Some(5), Some(48)),
29550 &SearchFacetFilters::default(),
29551 );
29552
29553 assert_eq!(report.symbol_total, 1);
29554 assert_eq!(report.raw_symbol_total, 3);
29555 assert_eq!(report.symbols.len(), 1);
29556 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
29557 assert_eq!(report.symbols[0].match_count, 3);
29558 assert_eq!(report.symbols[0].surface_count, 2);
29559 assert_eq!(report.symbols[0].file_count, 3);
29560 assert_eq!(
29561 report.symbols[0].surface_examples,
29562 vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
29563 );
29564 assert!(report.symbols[0].name.contains("(+1 variant)"));
29565 assert!(report.symbols[0].file.contains("(+2 files)"));
29566 assert!(report.symbols[0].expand.contains("tsift search"));
29567 assert!(report.symbols[0].expand.contains("alpha helper"));
29568 }
29569
29570 #[test]
29571 fn search_budget_report_carries_active_filters() {
29572 let response = empty_search_response(Path::new("/repo"), "lexical");
29573 let symbol_hits = vec![index::SymbolHit {
29574 name: "alpha_helper".to_string(),
29575 kind: "function".to_string(),
29576 language: "rust".to_string(),
29577 file: "/repo/src/lib.rs".to_string(),
29578 line: 12,
29579 end_line: None,
29580 node_kind: Some("function_item".to_string()),
29581 start_byte: None,
29582 end_byte: None,
29583 body_start_byte: None,
29584 body_end_byte: None,
29585 tags: Some("alpha,helper".to_string()),
29586 score: 0.98,
29587 match_type: "exact_name".to_string(),
29588 tagpath_handle: None,
29589 }];
29590 let filters = SearchFacetFilters {
29591 languages: vec!["rust".to_string()],
29592 kinds: vec!["function".to_string()],
29593 node_kinds: vec!["function_item".to_string()],
29594 ..SearchFacetFilters::default()
29595 };
29596
29597 let report = build_relative_search_budget_report(
29598 "alpha helper",
29599 "lexical",
29600 Path::new("/repo"),
29601 &response,
29602 &symbol_hits,
29603 ResponseBudget::new(Some(5), Some(48)),
29604 &filters,
29605 );
29606
29607 assert_eq!(report.filters, filters);
29608 assert_eq!(
29609 search_facet_filters_summary(&report.filters),
29610 "lang=rust kind=function node-kind=function_item"
29611 );
29612 }
29613
29614 #[test]
29615 fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
29616 let mut response = empty_search_response(Path::new("/repo"), "lexical");
29617 response.indexed_artifacts = 450;
29618 let symbol_hits = vec![
29619 index::SymbolHit {
29620 name: "alpha_helper".to_string(),
29621 kind: "function".to_string(),
29622 language: "rust".to_string(),
29623 file: "/repo/src/lib.rs".to_string(),
29624 line: 12,
29625 end_line: None,
29626 node_kind: None,
29627 start_byte: None,
29628 end_byte: None,
29629 body_start_byte: None,
29630 body_end_byte: None,
29631 tags: Some("alpha,helper".to_string()),
29632 score: 0.98,
29633 match_type: "exact_name".to_string(),
29634 tagpath_handle: None,
29635 },
29636 index::SymbolHit {
29637 name: "beta_helper".to_string(),
29638 kind: "function".to_string(),
29639 language: "rust".to_string(),
29640 file: "/repo/src/beta.rs".to_string(),
29641 line: 21,
29642 end_line: None,
29643 node_kind: None,
29644 start_byte: None,
29645 end_byte: None,
29646 body_start_byte: None,
29647 body_end_byte: None,
29648 tags: Some("beta,helper".to_string()),
29649 score: 0.92,
29650 match_type: "tag_overlap".to_string(),
29651 tagpath_handle: None,
29652 },
29653 ];
29654
29655 let report = build_relative_search_budget_report(
29656 "helper",
29657 "lexical",
29658 Path::new("/repo"),
29659 &response,
29660 &symbol_hits,
29661 ResponseBudget::new(Some(1), Some(64)),
29662 &SearchFacetFilters::default(),
29663 );
29664
29665 let guard = report
29666 .scale_guard
29667 .as_ref()
29668 .expect("broad previews should emit a scale guard");
29669 assert_eq!(guard.level, "high-hit");
29670 assert_eq!(guard.signals.indexed_artifacts, 450);
29671 assert_eq!(guard.signals.raw_symbol_matches, 2);
29672 assert!(
29673 guard
29674 .narrow_commands
29675 .iter()
29676 .any(|command| command.contains("--exact"))
29677 );
29678 assert!(
29679 guard
29680 .narrow_commands
29681 .iter()
29682 .any(|command| command.contains("alpha helper"))
29683 );
29684 assert!(
29685 guard
29686 .narrow_commands
29687 .last()
29688 .unwrap()
29689 .contains("workflow search")
29690 );
29691 }
29692
29693 #[test]
29694 fn explain_budget_report_limits_edges_and_members() {
29695 let symbols = vec![index::StoredSymbol {
29696 name: "alpha_helper".to_string(),
29697 kind: "function".to_string(),
29698 language: "rust".to_string(),
29699 signature: None,
29700 file: "src/lib.rs".to_string(),
29701 line: 10,
29702 end_line: None,
29703 node_kind: None,
29704 start_byte: None,
29705 end_byte: None,
29706 body_start_byte: None,
29707 body_end_byte: None,
29708 parent_module: None,
29709 visibility: None,
29710 tags: None,
29711 tagpath_handle: None,
29712 }];
29713 let callers = vec![
29714 index::StoredEdge {
29715 caller_file: "src/main.rs".to_string(),
29716 caller_name: "main".to_string(),
29717 caller_line: 1,
29718 callee_name: "alpha_helper".to_string(),
29719 call_site_line: 3,
29720 tagpath_handle: None,
29721 },
29722 index::StoredEdge {
29723 caller_file: "src/worker.rs".to_string(),
29724 caller_name: "worker".to_string(),
29725 caller_line: 5,
29726 callee_name: "alpha_helper".to_string(),
29727 call_site_line: 8,
29728 tagpath_handle: None,
29729 },
29730 ];
29731 let community = graph::Community {
29732 id: 1,
29733 members: vec![
29734 graph::CommunityMember::new("alpha_helper"),
29735 graph::CommunityMember::new("main"),
29736 graph::CommunityMember::new("worker"),
29737 ],
29738 modularity_contribution: 0.5,
29739 };
29740
29741 let report = build_explain_budget_report(
29742 "alpha_helper",
29743 Path::new("/repo"),
29744 &symbols,
29745 &callers,
29746 2,
29747 false,
29748 &[],
29749 0,
29750 false,
29751 Some(&community),
29752 ResponseBudget::new(Some(1), Some(24)),
29753 );
29754
29755 assert_eq!(report.definitions.len(), 1);
29756 assert_eq!(report.callers.len(), 1);
29757 assert!(report.truncated);
29758 assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
29759 assert_eq!(
29760 report.definitions[0].tag_alias.as_deref(),
29761 Some("alpha/helper")
29762 );
29763 assert!(report.callers[0].handle.starts_with("ecall-"));
29764 assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
29765 }
29766
29767 #[test]
29768 fn session_review_next_context_budget_limits_lists() {
29769 let report = session_review::SessionReviewReport {
29770 root: "/repo".to_string(),
29771 target: "tasks/software/tsift.md".to_string(),
29772 target_kind: "file".to_string(),
29773 sessions_considered: 1,
29774 sessions_matched: 1,
29775 claude_sessions: 1,
29776 codex_sessions: 0,
29777 agent_doc_logs: 0,
29778 prompt_target_count: 2,
29779 command_groups: 0,
29780 file_groups: 2,
29781 symbol_groups: 1,
29782 failure_groups: 1,
29783 runtime_event_groups: 0,
29784 restart_churn_groups: 0,
29785 closeout_groups: 0,
29786 usage_samples: 1,
29787 prompt_tokens: 120,
29788 cached_input_tokens: 80,
29789 cache_creation_input_tokens: 0,
29790 output_tokens: 40,
29791 reasoning_output_tokens: 0,
29792 total_tokens: 240,
29793 cached_input_ratio: Some(40.0),
29794 largest_turn_total_tokens: 240,
29795 aggregate_cost: session_review::SessionReviewCostSummary {
29796 scope: "bounded_matched_sessions".to_string(),
29797 sessions: 1,
29798 usage_samples: 1,
29799 prompt_tokens: 120,
29800 cached_input_tokens: 80,
29801 cache_creation_input_tokens: 0,
29802 output_tokens: 40,
29803 reasoning_output_tokens: 0,
29804 total_tokens: 240,
29805 cached_input_ratio: Some(40.0),
29806 largest_turn_total_tokens: 240,
29807 },
29808 latest_session_cost: Some(session_review::SessionReviewCostSummary {
29809 scope: "latest_matched_session".to_string(),
29810 sessions: 1,
29811 usage_samples: 1,
29812 prompt_tokens: 120,
29813 cached_input_tokens: 80,
29814 cache_creation_input_tokens: 0,
29815 output_tokens: 40,
29816 reasoning_output_tokens: 0,
29817 total_tokens: 240,
29818 cached_input_ratio: Some(66.67),
29819 largest_turn_total_tokens: 240,
29820 }),
29821 guardrails: vec![
29822 session_cost::SessionCostGuardrail {
29823 kind: "cache_resend".to_string(),
29824 severity: "warn".to_string(),
29825 message: "cached input ratio was high".to_string(),
29826 guidance: "compact or restart the session".to_string(),
29827 },
29828 session_cost::SessionCostGuardrail {
29829 kind: "prompt_budget".to_string(),
29830 severity: "warn".to_string(),
29831 message: "largest prompt turn reached 999999 tokens".to_string(),
29832 guidance: "compact the session before another large turn".to_string(),
29833 },
29834 session_cost::SessionCostGuardrail {
29835 kind: "restart_loop".to_string(),
29836 severity: "warn".to_string(),
29837 message: "restart churn detected".to_string(),
29838 guidance: "restart cleanly".to_string(),
29839 },
29840 session_cost::SessionCostGuardrail {
29841 kind: "noop_closeout".to_string(),
29842 severity: "warn".to_string(),
29843 message: "commit_already_current appeared 8 times".to_string(),
29844 guidance: "avoid reopening without new edits".to_string(),
29845 },
29846 ],
29847 loop_clusters: vec![],
29848 file_read_diagnostics: vec![],
29849 prompt_targets: vec![
29850 session_review::SessionReviewPromptTarget {
29851 text: "do one".to_string(),
29852 occurrences: 1,
29853 },
29854 session_review::SessionReviewPromptTarget {
29855 text: "do two".to_string(),
29856 occurrences: 1,
29857 },
29858 ],
29859 commands: vec![],
29860 touched_files: vec![],
29861 touched_symbols: vec![],
29862 failures: vec![],
29863 runtime_events: vec![],
29864 restart_churn: vec![],
29865 closeout: vec![],
29866 largest_turns: vec![],
29867 sessions: vec![session_review::SessionReviewSession {
29868 source: "claude_jsonl".to_string(),
29869 path: "/tmp/session.jsonl".to_string(),
29870 matched_by: vec!["path".to_string()],
29871 modified_unix_secs: None,
29872 prompt_target_count: 2,
29873 command_groups: 0,
29874 file_groups: 2,
29875 symbol_groups: 1,
29876 failure_groups: 1,
29877 runtime_event_groups: 0,
29878 restart_churn_groups: 0,
29879 closeout_groups: 0,
29880 usage_samples: 1,
29881 prompt_tokens: 120,
29882 cached_input_tokens: 80,
29883 cache_creation_input_tokens: 0,
29884 output_tokens: 40,
29885 reasoning_output_tokens: 0,
29886 total_tokens: 240,
29887 largest_turn_total_tokens: 240,
29888 }],
29889 next_context: session_review::SessionReviewNextContext {
29890 target: "tasks/software/tsift.md".to_string(),
29891 active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
29892 last_verification: session_review::SessionReviewVerificationState {
29893 status: "green".to_string(),
29894 detail: "cargo test".to_string(),
29895 },
29896 touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
29897 touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
29898 unresolved_failures: vec![session_review::SessionReviewFailure {
29899 kind: "timeout".to_string(),
29900 message: "search timed out".to_string(),
29901 occurrences: 1,
29902 command: None,
29903 session_path: None,
29904 }],
29905 next_digest_commands: vec![
29906 "tsift session-review --next-context tasks/software/tsift.md".to_string(),
29907 "tsift diff-digest .".to_string(),
29908 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
29909 "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
29910 ],
29911 },
29912 warnings: vec![],
29913 };
29914
29915 let budget_report = build_session_review_next_context_budget_report(
29916 &report,
29917 ResponseBudget::new(Some(1), Some(12)),
29918 None,
29919 );
29920
29921 assert!(budget_report.truncated);
29922 assert_eq!(budget_report.prompt_targets, vec!["do one"]);
29923 assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
29924 assert!(
29925 budget_report.touched_symbol_refs[0]
29926 .handle
29927 .starts_with("ncsym-")
29928 );
29929 assert_eq!(
29930 budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
29931 Some("alpha/helper")
29932 );
29933 assert!(
29934 budget_report.unresolved_failures[0]
29935 .handle
29936 .starts_with("snf-")
29937 );
29938 assert_eq!(budget_report.next_digest_commands.len(), 4);
29939 assert_eq!(
29940 budget_report.next_digest_commands[2],
29941 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
29942 );
29943 assert_eq!(budget_report.next_token_actions.len(), 1);
29944 assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
29945
29946 let full_action_report = build_session_review_next_context_budget_report(
29947 &report,
29948 ResponseBudget::new(Some(4), Some(120)),
29949 None,
29950 );
29951 assert_eq!(
29952 full_action_report
29953 .next_token_actions
29954 .iter()
29955 .map(|action| action.kind.as_str())
29956 .collect::<Vec<_>>(),
29957 vec![
29958 "prompt_budget",
29959 "cache_resend",
29960 "restart_loop",
29961 "noop_closeout"
29962 ]
29963 );
29964 assert_eq!(
29965 full_action_report.next_token_actions[0]
29966 .compact_command
29967 .as_deref(),
29968 Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
29969 );
29970 assert_eq!(
29971 full_action_report.next_token_actions[0]
29972 .restart_command
29973 .as_deref(),
29974 Some("agent-doc start \"tasks/software/tsift.md\"")
29975 );
29976 assert!(
29977 full_action_report.next_token_actions[0]
29978 .digest_commands
29979 .iter()
29980 .any(|command| command
29981 == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
29982 );
29983 }
29984
29985 #[test]
29986 fn context_pack_diff_preview_limits_files_and_symbols() {
29987 let report = diff_digest::DiffDigestReport {
29988 root: "/repo".to_string(),
29989 mode: diff_digest::DiffDigestMode::WorkingTree,
29990 revision: None,
29991 files_changed: 2,
29992 files_with_current_summaries: 1,
29993 symbols_touched: 3,
29994 call_edges_added: 1,
29995 call_edges_removed: 0,
29996 files: vec![
29997 diff_digest::DiffDigestFile {
29998 path: "src/lib.rs".to_string(),
29999 status: diff_digest::DiffDigestFileStatus::Modified,
30000 touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
30001 summary_state: diff_digest::DiffDigestSummaryState::Current,
30002 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30003 symbol: "alpha_helper".to_string(),
30004 summary: "alpha helper handles the main alpha workflow".to_string(),
30005 }],
30006 added_call_edges: vec!["alpha->beta".to_string()],
30007 removed_call_edges: vec![],
30008 warnings: vec!["stale parse".to_string()],
30009 },
30010 diff_digest::DiffDigestFile {
30011 path: "src/main.rs".to_string(),
30012 status: diff_digest::DiffDigestFileStatus::Added,
30013 touched_symbols: vec!["main".to_string()],
30014 summary_state: diff_digest::DiffDigestSummaryState::Missing,
30015 current_summaries: vec![],
30016 added_call_edges: vec![],
30017 removed_call_edges: vec![],
30018 warnings: vec![],
30019 },
30020 ],
30021 };
30022
30023 let preview =
30024 build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
30025
30026 assert!(preview.truncated);
30027 assert_eq!(preview.files.len(), 1);
30028 assert_eq!(preview.files[0].path, "src/lib.rs");
30029 assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
30030 assert!(
30031 preview.files[0].touched_symbol_refs[0]
30032 .handle
30033 .starts_with("cdsym-")
30034 );
30035 assert_eq!(
30036 preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
30037 Some("alpha/he...")
30038 );
30039 assert!(
30040 preview.files[0].summary_refs[0]
30041 .handle
30042 .starts_with("cdsum-")
30043 );
30044 assert_eq!(
30045 preview.files[0].summary_refs[0].tag_alias.as_deref(),
30046 Some("alpha/he...")
30047 );
30048 assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
30049 assert_eq!(
30050 preview.files[0].summary_refs[0].expand,
30051 "tsift summarize --file \"src/lib.rs\""
30052 );
30053 assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
30054 }
30055
30056 #[test]
30057 fn context_pack_status_reminders_include_stale_index_state() {
30058 let dir = setup_graph_index();
30059 std::thread::sleep(std::time::Duration::from_millis(50));
30060 std::fs::write(
30061 dir.path().join("main.rs"),
30062 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
30063 )
30064 .unwrap();
30065
30066 let reminders = context_pack_status_reminders(dir.path());
30067
30068 assert_eq!(reminders.len(), 1);
30069 assert!(reminders[0].contains("index stale"));
30070 assert!(reminders[0].contains("tsift index ."));
30071 }
30072
30073 #[test]
30080 fn build_context_pack_reuses_inspect_within_scope() {
30081 let dir = setup_graph_index();
30082 init_git_repo(dir.path());
30083 let _guard = index::InspectScopeGuard::new();
30084 let _ = build_context_pack_report(
30085 dir.path(),
30086 None,
30087 None,
30088 None,
30089 ResponseBudget::new(Some(2), Some(96)),
30090 )
30091 .unwrap();
30092 let (hits, misses) = index::inspect_scope_stats();
30093 assert!(
30094 hits >= 1,
30095 "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
30096 );
30097 assert!(
30098 misses >= 1,
30099 "expected at least one initial inspect miss (hits={hits}, misses={misses})"
30100 );
30101 }
30102
30103 #[test]
30108 fn inspect_read_only_outside_scope_does_not_cache() {
30109 let dir = setup_graph_index();
30110 let db_path = dir.path().join(".tsift/index.db");
30111 let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30112 let (hits, misses) = index::inspect_scope_stats();
30113 assert_eq!(
30114 (hits, misses),
30115 (0, 0),
30116 "no scope guard => no hits/misses recorded"
30117 );
30118 let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30119 let (hits, _) = index::inspect_scope_stats();
30120 assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
30121 }
30122
30123 #[test]
30124 fn context_pack_refreshes_stale_index_before_handoff() {
30125 let dir = setup_graph_index();
30126 init_git_repo(dir.path());
30127 std::thread::sleep(std::time::Duration::from_millis(50));
30128 std::fs::write(
30129 dir.path().join("main.rs"),
30130 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
30131 )
30132 .unwrap();
30133
30134 let report = build_context_pack_report(
30135 dir.path(),
30136 None,
30137 None,
30138 None,
30139 ResponseBudget::new(Some(2), Some(96)),
30140 )
30141 .unwrap();
30142
30143 assert!(
30144 report
30145 .status_reminders
30146 .iter()
30147 .any(|reminder| reminder.contains("index refreshed")
30148 && reminder.contains("context-pack handoff")),
30149 "expected context-pack refresh diagnostic, got {:?}",
30150 report.status_reminders
30151 );
30152 assert!(
30153 !report
30154 .status_reminders
30155 .iter()
30156 .any(|reminder| reminder.contains("index stale")),
30157 "stale reminder should be gone after refresh: {:?}",
30158 report.status_reminders
30159 );
30160
30161 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
30162 let summary = db.compute_changes(dir.path()).unwrap();
30163 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
30164 }
30165
30166 #[test]
30167 fn context_pack_materializes_source_handles_into_graph_store() {
30168 let dir = tempfile::tempdir().unwrap();
30169 let packet = ExplorationPacket {
30170 budget: exploration_budget_for_counts(2, 1),
30171 relationship_map: vec![ExplorationRelation {
30172 from: "file:main.rs".to_string(),
30173 relation: "touches_symbol".to_string(),
30174 to: "symbol:helper".to_string(),
30175 label: Some("modified diff".to_string()),
30176 }],
30177 source_windows: vec![ExplorationSourceWindow {
30178 handle: "xwin-test".to_string(),
30179 file: "main.rs".to_string(),
30180 start: 1,
30181 end: 32,
30182 reason: "changed file".to_string(),
30183 expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
30184 }],
30185 worker_context: vec![ExplorationWorkerContext {
30186 handle: "xwrk-test".to_string(),
30187 target: "tasks/software/tsift.md".to_string(),
30188 summary: "do #kgnv".to_string(),
30189 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
30190 .to_string(),
30191 }],
30192 no_reread_guidance: "use windows".to_string(),
30193 };
30194
30195 let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
30196 assert_eq!(packet.source_windows[0].handle, "xwin-test");
30197
30198 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
30199 let source_handles = store.nodes_by_kind("source_handle").unwrap();
30200 assert_eq!(source_handles.len(), 1);
30201 assert_eq!(
30202 source_handles[0].properties.get("file"),
30203 Some(&"main.rs".to_string())
30204 );
30205 assert_eq!(
30206 store
30207 .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
30208 .unwrap()
30209 .len(),
30210 1
30211 );
30212 let worker_context = store.nodes_by_kind("worker_context").unwrap();
30213 assert_eq!(worker_context.len(), 1);
30214 assert_eq!(
30215 store
30216 .outgoing_edges("xwrk-test", Some("scopes_source"))
30217 .unwrap()
30218 .len(),
30219 1
30220 );
30221 }
30222
30223 #[test]
30224 fn context_pack_records_graph_orchestration_observability() {
30225 let dir = setup_traversal_project();
30226 init_git_repo(dir.path());
30227 let session = dir.path().join("tasks/software/tsift.md");
30228 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
30229
30230 let report = build_context_pack_report(
30231 &session,
30232 None,
30233 None,
30234 None,
30235 ResponseBudget::new(Some(4), Some(160)),
30236 )
30237 .unwrap();
30238
30239 assert_eq!(
30240 report.graph_orchestration.contract_version,
30241 CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
30242 );
30243 assert_eq!(
30244 report
30245 .graph_orchestration
30246 .projection_freshness
30247 .status
30248 .as_str(),
30249 "current"
30250 );
30251 assert!(!report.graph_orchestration.projection_hashes.is_empty());
30252 assert_eq!(report.graph_orchestration.readiness.status, "blocked");
30253 assert_eq!(
30254 report.graph_orchestration.readiness.reason,
30255 "summary_cache_empty"
30256 );
30257 assert!(report.graph_orchestration.readiness.fail_closed);
30258 assert!(
30259 report
30260 .graph_orchestration
30261 .readiness
30262 .next_commands
30263 .iter()
30264 .any(|command| command == "tsift summarize --extract ."),
30265 "{:?}",
30266 report.graph_orchestration.readiness.next_commands
30267 );
30268 assert!(
30269 report
30270 .graph_orchestration
30271 .evidence_packet_ids
30272 .iter()
30273 .all(|id| !id.starts_with("gevd-")),
30274 "evidence packet ids should be empty when readiness is blocked: {:?}",
30275 report.graph_orchestration.evidence_packet_ids
30276 );
30277 assert!(
30278 report
30279 .graph_orchestration
30280 .conflict_matrix_decisions
30281 .iter()
30282 .any(|decision| decision.contains("readiness blocked")),
30283 "conflict-matrix decisions should reference readiness block: {:?}",
30284 report.graph_orchestration.conflict_matrix_decisions
30285 );
30286 assert!(
30287 !report
30288 .graph_orchestration
30289 .follow_up_commands
30290 .iter()
30291 .any(|command| command.contains("conflict-matrix")),
30292 "conflict-matrix command should not appear when readiness is blocked: {:?}",
30293 report.graph_orchestration.follow_up_commands
30294 );
30295 assert!(
30296 report
30297 .graph_orchestration
30298 .follow_up_commands
30299 .iter()
30300 .any(|command| command == "tsift summarize --extract ."),
30301 "{:?}",
30302 report.graph_orchestration.follow_up_commands
30303 );
30304 assert!(
30305 !report
30306 .graph_orchestration
30307 .worker_ownership_blocks
30308 .is_empty()
30309 );
30310 }
30311
30312 #[test]
30313 fn convex_sync_report_chunks_upserts_and_tombstones() {
30314 let dir = setup_traversal_project();
30315 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
30316 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
30317 let mut snapshot = projection.to_convex_rows();
30318 snapshot.nodes.push(ConvexNodeRow {
30319 external_id: "stale-node".to_string(),
30320 kind: "backlog".to_string(),
30321 label: "stale".to_string(),
30322 properties: BTreeMap::new(),
30323 provenance: Vec::new(),
30324 freshness: None,
30325 });
30326 snapshot.edges.clear();
30327 snapshot.edges.push(ConvexEdgeRow {
30328 edge_key: "stale-edge".to_string(),
30329 from_external_id: "stale-node".to_string(),
30330 to_external_id: "stale-node".to_string(),
30331 kind: "mentions".to_string(),
30332 properties: BTreeMap::new(),
30333 provenance: Vec::new(),
30334 freshness: None,
30335 });
30336 let snapshot_path = dir.path().join("convex-snapshot.json");
30337 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30338
30339 let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
30340
30341 assert_eq!(report.freshness.status, "stale");
30342 assert!(report.freshness.fail_closed);
30343 assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
30344 assert!(
30345 report.edge_upserts.len() > 1,
30346 "snapshot without edges should upsert local edges"
30347 );
30348 assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
30349 assert_eq!(
30350 report.chunks.first().map(|chunk| chunk.operation.as_str()),
30351 Some("delete_edges"),
30352 "edge tombstones should be planned before node tombstones"
30353 );
30354 assert!(
30355 report
30356 .chunks
30357 .iter()
30358 .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
30359 "expected chunked edge upserts, got {:?}",
30360 report.chunks
30361 );
30362 }
30363
30364 #[test]
30365 fn convex_snapshot_validation_fails_closed_when_stale() {
30366 let dir = setup_traversal_project();
30367 build_traversal_graph(dir.path(), dir.path(), None).unwrap();
30368 let snapshot = ConvexProjectionRows::default();
30369 let snapshot_path = dir.path().join("empty-convex-snapshot.json");
30370 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30371
30372 let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
30373 assert!(
30374 err.to_string()
30375 .contains("Convex graph projection is not current"),
30376 "{err}"
30377 );
30378 }
30379
30380 #[test]
30381 fn convex_sync_report_marks_live_apply_mode_without_network() {
30382 let dir = setup_traversal_project();
30383 let report =
30384 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30385
30386 assert!(!report.dry_run);
30387 assert!(
30388 !report
30389 .diagnostics
30390 .iter()
30391 .any(|diagnostic| diagnostic.contains("dry-run only")),
30392 "apply-mode report should not claim dry-run diagnostics"
30393 );
30394 assert!(
30395 report
30396 .chunks
30397 .iter()
30398 .any(|chunk| chunk.operation == "upsert_nodes"),
30399 "live apply mode should still expose chunked idempotent operations"
30400 );
30401 }
30402
30403 #[test]
30404 fn convex_sync_apply_round_trips_with_http_backend() {
30405 use std::net::TcpListener;
30406 use std::sync::{Arc, Mutex};
30407
30408 let dir = setup_traversal_project();
30409 let report =
30410 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30411 let expected_chunks = report.chunks.len();
30412 assert!(expected_chunks > 0);
30413
30414 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
30415 let endpoint = format!("http://{}", listener.local_addr().unwrap());
30416 let operations = Arc::new(Mutex::new(Vec::<String>::new()));
30417 let server_operations = Arc::clone(&operations);
30418 let server = std::thread::spawn(move || {
30419 for _ in 0..expected_chunks {
30420 let (mut stream, _) = listener.accept().unwrap();
30421 let mut reader = BufReader::new(stream.try_clone().unwrap());
30422 let mut request_line = String::new();
30423 reader.read_line(&mut request_line).unwrap();
30424 assert!(request_line.starts_with("POST "));
30425
30426 let mut content_length = 0usize;
30427 loop {
30428 let mut line = String::new();
30429 reader.read_line(&mut line).unwrap();
30430 if line == "\r\n" {
30431 break;
30432 }
30433 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
30434 content_length = value.trim().parse().unwrap();
30435 }
30436 }
30437
30438 let mut body = vec![0u8; content_length];
30439 reader.read_exact(&mut body).unwrap();
30440 let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
30441 server_operations
30442 .lock()
30443 .unwrap()
30444 .push(request["operation"].as_str().unwrap().to_string());
30445
30446 let response = br#"{"status":"ok","message":"accepted"}"#;
30447 write!(
30448 stream,
30449 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
30450 response.len()
30451 )
30452 .unwrap();
30453 stream.write_all(response).unwrap();
30454 }
30455 });
30456
30457 cmd_convex_sync(
30458 ConvexSyncOptions {
30459 path: dir.path(),
30460 scope: None,
30461 snapshot: None,
30462 chunk_size: 100,
30463 remote_snapshot: false,
30464 apply: true,
30465 endpoint: Some(&endpoint),
30466 auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
30467 },
30468 OutputFormat {
30469 json_output: false,
30470 compact: true,
30471 pretty: false,
30472 terse: false,
30473 ultra_terse: false,
30474 schema: false,
30475 envelope: false,
30476 },
30477 )
30478 .unwrap();
30479 server.join().unwrap();
30480
30481 let operations = operations.lock().unwrap().clone();
30482 assert!(operations.contains(&"upsert_nodes".to_string()));
30483 assert!(operations.contains(&"upsert_edges".to_string()));
30484 }
30485
30486 #[test]
30487 fn context_pack_diff_preview_attaches_tag_ontology_refs() {
30488 let root = tempfile::tempdir().unwrap();
30489 fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
30490 fs::write(
30491 root.path().join(".naming/tags/alpha.md"),
30492 "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
30493 )
30494 .unwrap();
30495 let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
30496 let report = diff_digest::DiffDigestReport {
30497 root: root.path().display().to_string(),
30498 mode: diff_digest::DiffDigestMode::WorkingTree,
30499 revision: None,
30500 files_changed: 1,
30501 files_with_current_summaries: 1,
30502 symbols_touched: 1,
30503 call_edges_added: 0,
30504 call_edges_removed: 0,
30505 files: vec![diff_digest::DiffDigestFile {
30506 path: "src/lib.rs".to_string(),
30507 status: diff_digest::DiffDigestFileStatus::Modified,
30508 touched_symbols: vec!["alpha_helper".to_string()],
30509 summary_state: diff_digest::DiffDigestSummaryState::Current,
30510 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30511 symbol: "alpha_helper".to_string(),
30512 summary: "alpha helper summary".to_string(),
30513 }],
30514 added_call_edges: vec![],
30515 removed_call_edges: vec![],
30516 warnings: vec![],
30517 }],
30518 };
30519
30520 let preview = build_context_pack_diff_preview(
30521 &report,
30522 ResponseBudget::new(Some(1), Some(80)),
30523 Some(&ontology),
30524 );
30525
30526 let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
30527 assert!(symbol_ref.handle.starts_with("tont-"));
30528 assert_eq!(symbol_ref.tag, "alpha");
30529 assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
30530 assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
30531 assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
30532 assert_eq!(
30533 preview.files[0].summary_refs[0].ontology_refs[0].path,
30534 ".naming/tags/alpha.md"
30535 );
30536 }
30537
30538 #[test]
30539 fn context_pack_test_preview_limits_failure_groups() {
30540 let report = test_digest::TestDigestReport {
30541 root: "/repo".to_string(),
30542 runner: "cargo".to_string(),
30543 failures: 2,
30544 grouped_failures: 2,
30545 counts: test_digest::TestDigestCounts {
30546 passed: Some(8),
30547 failed: Some(2),
30548 skipped: Some(1),
30549 },
30550 failure_groups: vec![
30551 test_digest::TestDigestFailure {
30552 tests: vec!["suite::alpha_failure".to_string()],
30553 message: "assertion failed".to_string(),
30554 path: Some("src/lib.rs".to_string()),
30555 line: Some(42),
30556 column: None,
30557 occurrences: 1,
30558 summary_state: test_digest::TestDigestSummaryState::Current,
30559 current_summaries: vec![test_digest::TestDigestSummarySnippet {
30560 symbol: "alpha_failure".to_string(),
30561 summary: "failure summary for alpha test".to_string(),
30562 }],
30563 },
30564 test_digest::TestDigestFailure {
30565 tests: vec!["suite::beta_failure".to_string()],
30566 message: "panic".to_string(),
30567 path: Some("src/main.rs".to_string()),
30568 line: Some(7),
30569 column: None,
30570 occurrences: 1,
30571 summary_state: test_digest::TestDigestSummaryState::Missing,
30572 current_summaries: vec![],
30573 },
30574 ],
30575 warnings: vec!["warning text".to_string()],
30576 };
30577
30578 let preview =
30579 build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30580
30581 assert!(preview.truncated);
30582 assert_eq!(preview.failure_groups.len(), 1);
30583 assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
30584 assert_eq!(preview.failure_groups[0].message, "assertion f...");
30585 assert!(
30586 preview.failure_groups[0].summary_refs[0]
30587 .handle
30588 .starts_with("ctsum-")
30589 );
30590 assert_eq!(
30591 preview.failure_groups[0].summary_refs[0].expand,
30592 "tsift summarize --file \"src/lib.rs\""
30593 );
30594 assert_eq!(preview.warnings, vec!["warning text"]);
30595 }
30596
30597 #[test]
30598 fn context_pack_log_preview_limits_signals_and_refs() {
30599 let report = log_digest::LogDigestReport {
30600 root: "/repo".to_string(),
30601 total_lines: 12,
30602 non_empty_lines: 10,
30603 signal_groups: 2,
30604 repeated_line_groups: 2,
30605 repeated_line_occurrences: 3,
30606 file_ref_groups: 2,
30607 symbol_ref_groups: 2,
30608 stack_groups: 1,
30609 signals: vec![
30610 log_digest::LogDigestSignal {
30611 severity: "error".to_string(),
30612 message: "src/lib.rs:42 boom".to_string(),
30613 path: Some("src/lib.rs".to_string()),
30614 line: Some(42),
30615 column: None,
30616 occurrences: 2,
30617 summary_state: log_digest::LogDigestSummaryState::Current,
30618 current_summaries: vec![log_digest::LogDigestSummarySnippet {
30619 symbol: "alpha_helper".to_string(),
30620 summary: "alpha helper cached log summary".to_string(),
30621 }],
30622 },
30623 log_digest::LogDigestSignal {
30624 severity: "warn".to_string(),
30625 message: "slow path".to_string(),
30626 path: None,
30627 line: None,
30628 column: None,
30629 occurrences: 1,
30630 summary_state: log_digest::LogDigestSummaryState::Unavailable,
30631 current_summaries: vec![],
30632 },
30633 ],
30634 repeated_lines: vec![
30635 log_digest::LogDigestRepeatedLine {
30636 line: "retrying work item alpha".to_string(),
30637 occurrences: 3,
30638 },
30639 log_digest::LogDigestRepeatedLine {
30640 line: "retrying work item beta".to_string(),
30641 occurrences: 2,
30642 },
30643 ],
30644 file_refs: vec![
30645 log_digest::LogDigestFileRef {
30646 path: "src/lib.rs".to_string(),
30647 line: Some(42),
30648 column: None,
30649 occurrences: 2,
30650 summary_state: log_digest::LogDigestSummaryState::Current,
30651 current_summaries: vec![log_digest::LogDigestSummarySnippet {
30652 symbol: "alpha_helper".to_string(),
30653 summary: "alpha helper cached file summary".to_string(),
30654 }],
30655 },
30656 log_digest::LogDigestFileRef {
30657 path: "src/main.rs".to_string(),
30658 line: Some(7),
30659 column: None,
30660 occurrences: 1,
30661 summary_state: log_digest::LogDigestSummaryState::Missing,
30662 current_summaries: vec![],
30663 },
30664 ],
30665 symbol_refs: vec![
30666 log_digest::LogDigestSymbolRef {
30667 symbol: "alpha_helper".to_string(),
30668 occurrences: 2,
30669 summary_state: log_digest::LogDigestSummaryState::Current,
30670 current_summaries: vec![log_digest::LogDigestSummarySnippet {
30671 symbol: "alpha_helper".to_string(),
30672 summary: "alpha helper cached symbol summary".to_string(),
30673 }],
30674 },
30675 log_digest::LogDigestSymbolRef {
30676 symbol: "beta_helper".to_string(),
30677 occurrences: 1,
30678 summary_state: log_digest::LogDigestSummaryState::Missing,
30679 current_summaries: vec![],
30680 },
30681 ],
30682 stack_traces: vec![log_digest::LogDigestStackGroup {
30683 frames: vec!["frame one".to_string()],
30684 occurrences: 1,
30685 }],
30686 warnings: vec!["warning text".to_string()],
30687 };
30688
30689 let preview =
30690 build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30691
30692 assert!(preview.truncated);
30693 assert_eq!(preview.signals.len(), 1);
30694 assert_eq!(preview.signals[0].message, "src/lib.rs:...");
30695 assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
30696 assert_eq!(preview.file_refs.len(), 1);
30697 assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
30698 assert!(
30699 preview.signals[0].summary_refs[0]
30700 .handle
30701 .starts_with("clsum-")
30702 );
30703 assert!(
30704 preview.file_refs[0].summary_refs[0]
30705 .handle
30706 .starts_with("clfsum-")
30707 );
30708 assert!(
30709 preview.symbol_refs[0].summary_refs[0]
30710 .handle
30711 .starts_with("clssum-")
30712 );
30713 assert_eq!(
30714 preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
30715 Some("alpha/helper")
30716 );
30717 assert_eq!(
30718 preview.symbol_refs[0].summary_refs[0].expand,
30719 "tsift summarize \"alpha_helper\""
30720 );
30721 assert_eq!(preview.warnings, vec!["warning text"]);
30722 }
30723
30724 #[test]
30725 fn cli_search_rejects_exact_with_strategy_flag() {
30726 let cli = try_parse_cli([
30727 "tsift",
30728 "search",
30729 "test",
30730 "--exact",
30731 "--strategy",
30732 "lexical",
30733 ]);
30734 assert!(cli.is_err());
30735 }
30736
30737 #[test]
30738 fn cli_search_autoindexes_by_default() {
30739 let cli = parse_cli(["tsift", "search", "test"]);
30740 match cli.command {
30741 Some(Commands::Search {
30742 autoindex,
30743 no_autoindex,
30744 ..
30745 }) => {
30746 assert!(!autoindex);
30747 assert!(!no_autoindex);
30748 assert!(autoindex || !no_autoindex);
30749 }
30750 _ => panic!("expected Search command"),
30751 }
30752 }
30753
30754 #[test]
30755 fn cli_search_accepts_no_autoindex_flag() {
30756 let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
30757 match cli.command {
30758 Some(Commands::Search {
30759 autoindex,
30760 no_autoindex,
30761 ..
30762 }) => {
30763 assert!(!autoindex);
30764 assert!(no_autoindex);
30765 }
30766 _ => panic!("expected Search command"),
30767 }
30768 }
30769
30770 #[test]
30771 fn cli_search_rejects_conflicting_autoindex_flags() {
30772 let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
30773 assert!(cli.is_err());
30774 }
30775
30776 #[test]
30779 fn cli_accepts_global_absolute_flag() {
30780 let cli = parse_cli(["tsift", "--absolute", "status"]);
30781 assert!(cli.absolute);
30782 assert!(matches!(cli.command, Some(Commands::Status { .. })));
30783 }
30784
30785 #[test]
30786 fn cli_accepts_global_tabular_flag() {
30787 let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
30788 assert!(cli.tabular);
30789 assert!(matches!(cli.command, Some(Commands::Search { .. })));
30790 }
30791
30792 #[test]
30793 fn cli_tabular_with_graph() {
30794 let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
30795 assert!(cli.tabular);
30796 assert!(matches!(cli.command, Some(Commands::Graph { .. })));
30797 }
30798
30799 #[test]
30800 fn cli_tabular_with_communities() {
30801 let cli = parse_cli(["tsift", "--tabular", "communities"]);
30802 assert!(cli.tabular);
30803 assert!(matches!(cli.command, Some(Commands::Communities { .. })));
30804 }
30805
30806 #[test]
30807 fn cli_tabular_with_explain() {
30808 let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
30809 assert!(cli.tabular);
30810 assert!(matches!(cli.command, Some(Commands::Explain { .. })));
30811 }
30812
30813 #[test]
30814 fn cli_traverse_accepts_path_target_and_html_format() {
30815 let cli = parse_cli([
30816 "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
30817 ]);
30818 match cli.command {
30819 Some(Commands::Traverse {
30820 node,
30821 to,
30822 path,
30823 format,
30824 ..
30825 }) => {
30826 assert_eq!(node.as_deref(), Some("#kgnv"));
30827 assert_eq!(to.as_deref(), Some("main"));
30828 assert_eq!(path, PathBuf::from("."));
30829 assert_eq!(format, TraverseFormat::Html);
30830 }
30831 _ => panic!("expected Traverse command"),
30832 }
30833 }
30834
30835 #[test]
30836 fn cli_parses_semantic_related_command() {
30837 let cli = parse_cli([
30838 "tsift",
30839 "semantic",
30840 "graph navigation",
30841 "--path",
30842 ".",
30843 "--kind",
30844 "all",
30845 "--limit",
30846 "3",
30847 "--json",
30848 ]);
30849 match cli.command {
30850 Some(Commands::Semantic {
30851 query,
30852 path,
30853 kind,
30854 limit,
30855 json,
30856 ..
30857 }) => {
30858 assert_eq!(query, "graph navigation");
30859 assert_eq!(path, PathBuf::from("."));
30860 assert_eq!(kind, SemanticRelatedKind::All);
30861 assert_eq!(limit, 3);
30862 assert!(json);
30863 }
30864 _ => panic!("expected Semantic command"),
30865 }
30866 }
30867
30868 #[test]
30869 fn cli_parses_convex_sync_command() {
30870 let cli = parse_cli([
30871 "tsift",
30872 "convex-sync",
30873 ".",
30874 "--snapshot",
30875 "rows.json",
30876 "--chunk-size",
30877 "25",
30878 "--json",
30879 ]);
30880 match cli.command {
30881 Some(Commands::ConvexSync {
30882 path,
30883 snapshot,
30884 chunk_size,
30885 json,
30886 ..
30887 }) => {
30888 assert_eq!(path, PathBuf::from("."));
30889 assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
30890 assert_eq!(chunk_size, 25);
30891 assert!(json);
30892 }
30893 _ => panic!("expected ConvexSync command"),
30894 }
30895 }
30896
30897 #[test]
30898 fn cli_parses_convex_sync_live_flags() {
30899 let cli = parse_cli([
30900 "tsift",
30901 "convex-sync",
30902 ".",
30903 "--remote-snapshot",
30904 "--apply",
30905 "--endpoint",
30906 "https://example.test/convex-graph",
30907 "--auth-token-env",
30908 "TSIFT_TEST_TOKEN",
30909 ]);
30910 match cli.command {
30911 Some(Commands::ConvexSync {
30912 remote_snapshot,
30913 apply,
30914 endpoint,
30915 auth_token_env,
30916 ..
30917 }) => {
30918 assert!(remote_snapshot);
30919 assert!(apply);
30920 assert_eq!(
30921 endpoint.as_deref(),
30922 Some("https://example.test/convex-graph")
30923 );
30924 assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
30925 }
30926 _ => panic!("expected ConvexSync command"),
30927 }
30928 }
30929
30930 #[test]
30931 fn cli_parses_graph_db_query() {
30932 let cli = parse_cli([
30933 "tsift",
30934 "graph-db",
30935 "--backend",
30936 "convex-snapshot",
30937 "--convex-snapshot",
30938 "rows.json",
30939 "--json",
30940 "neighborhood",
30941 "gbak-kgnv",
30942 "--depth",
30943 "2",
30944 "--edge-kind",
30945 "mentions",
30946 "--property",
30947 "path=tasks/software/tsift.md",
30948 "--cursor",
30949 "gbak-old",
30950 "--limit",
30951 "10",
30952 ]);
30953 match cli.command {
30954 Some(Commands::GraphDb {
30955 backend,
30956 convex_snapshot,
30957 json,
30958 query,
30959 ..
30960 }) => {
30961 assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
30962 assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
30963 assert!(json);
30964 match query {
30965 GraphDbQuery::Neighborhood {
30966 id,
30967 depth,
30968 edge_kind,
30969 cursor,
30970 limit,
30971 property_filters,
30972 } => {
30973 assert_eq!(id, "gbak-kgnv");
30974 assert_eq!(depth, 2);
30975 assert_eq!(edge_kind.as_deref(), Some("mentions"));
30976 assert_eq!(cursor.as_deref(), Some("gbak-old"));
30977 assert_eq!(limit, Some(10));
30978 assert_eq!(
30979 property_filters,
30980 vec!["path=tasks/software/tsift.md".to_string()]
30981 );
30982 }
30983 _ => panic!("expected graph-db neighborhood query"),
30984 }
30985 }
30986 _ => panic!("expected GraphDb command"),
30987 }
30988 }
30989
30990 #[test]
30991 fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
30992 let cli = parse_cli([
30993 "tsift",
30994 "graph-db",
30995 "--json",
30996 "backend-eval",
30997 "--candidate",
30998 "surrealdb",
30999 "--target",
31000 "gval",
31001 "--full-projection",
31002 ]);
31003 match cli.command {
31004 Some(Commands::GraphDb { json, query, .. }) => {
31005 assert!(json);
31006 match query {
31007 GraphDbQuery::BackendEval {
31008 candidates,
31009 targets,
31010 full_projection,
31011 } => {
31012 assert_eq!(candidates, vec!["surrealdb".to_string()]);
31013 assert_eq!(targets, vec!["gval".to_string()]);
31014 assert!(full_projection);
31015 }
31016 _ => panic!("expected graph-db backend-eval query"),
31017 }
31018 }
31019 _ => panic!("expected GraphDb command"),
31020 }
31021 }
31022
31023 #[test]
31024 fn cli_parses_graph_db_tokensave_backend() {
31025 let cli = parse_cli([
31026 "tsift",
31027 "graph-db",
31028 "--backend",
31029 "tokensave",
31030 "--json",
31031 "node",
31032 "fn:main",
31033 ]);
31034 match cli.command {
31035 Some(Commands::GraphDb {
31036 backend,
31037 json,
31038 query,
31039 ..
31040 }) => {
31041 assert_eq!(backend, GraphDbBackend::Tokensave);
31042 assert!(json);
31043 match query {
31044 GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
31045 _ => panic!("expected graph-db node query"),
31046 }
31047 }
31048 _ => panic!("expected GraphDb command"),
31049 }
31050 }
31051
31052 #[test]
31053 fn cli_parses_analyze_command() {
31054 let cli = parse_cli([
31055 "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
31056 "--limit", "7", "--json",
31057 ]);
31058 match cli.command {
31059 Some(Commands::Analyze {
31060 path,
31061 scope,
31062 entry_points,
31063 limit,
31064 json,
31065 }) => {
31066 assert_eq!(path, PathBuf::from("."));
31067 assert_eq!(scope.as_deref(), Some("core"));
31068 assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
31069 assert_eq!(limit, 7);
31070 assert!(json);
31071 }
31072 _ => panic!("expected Analyze command"),
31073 }
31074 }
31075
31076 #[test]
31077 fn cli_parses_graph_db_related_query() {
31078 let cli = parse_cli([
31079 "tsift",
31080 "graph-db",
31081 "--json",
31082 "related",
31083 "voice avatar memory retrieval",
31084 "--kind",
31085 "all",
31086 "--depth",
31087 "3",
31088 "--seed-limit",
31089 "4",
31090 "--limit",
31091 "12",
31092 ]);
31093 match cli.command {
31094 Some(Commands::GraphDb { json, query, .. }) => {
31095 assert!(json);
31096 match query {
31097 GraphDbQuery::Related {
31098 query,
31099 kind,
31100 depth,
31101 seed_limit,
31102 limit,
31103 } => {
31104 assert_eq!(query, "voice avatar memory retrieval");
31105 assert_eq!(kind, SemanticRelatedKind::All);
31106 assert_eq!(depth, 3);
31107 assert_eq!(seed_limit, 4);
31108 assert_eq!(limit, 12);
31109 }
31110 _ => panic!("expected graph-db related query"),
31111 }
31112 }
31113 _ => panic!("expected GraphDb command"),
31114 }
31115 }
31116
31117 #[test]
31118 fn cli_parses_graph_db_compact_query() {
31119 let cli = parse_cli([
31120 "tsift",
31121 "graph-db",
31122 "--path",
31123 ".",
31124 "compact",
31125 "--apply",
31126 "--prune-tombstones",
31127 "--confirmed-convex-reconciled",
31128 ]);
31129 match cli.command {
31130 Some(Commands::GraphDb { query, .. }) => match query {
31131 GraphDbQuery::Compact {
31132 apply,
31133 prune_tombstones,
31134 confirmed_convex_reconciled,
31135 } => {
31136 assert!(apply);
31137 assert!(prune_tombstones);
31138 assert!(confirmed_convex_reconciled);
31139 }
31140 _ => panic!("expected graph-db compact query"),
31141 },
31142 _ => panic!("expected GraphDb command"),
31143 }
31144 }
31145
31146 #[test]
31147 fn cli_parses_impact_command() {
31148 let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
31149 match cli.command {
31150 Some(Commands::Impact {
31151 path,
31152 cached,
31153 limit,
31154 ..
31155 }) => {
31156 assert_eq!(path, PathBuf::from("."));
31157 assert!(cached);
31158 assert_eq!(limit, 5);
31159 }
31160 _ => panic!("expected Impact command"),
31161 }
31162 }
31163
31164 #[test]
31165 fn cli_parses_conflict_matrix_command() {
31166 let cli = parse_cli([
31167 "tsift",
31168 "conflict-matrix",
31169 "--path",
31170 "tasks/software/tsift.md",
31171 "--depth",
31172 "4",
31173 "--limit",
31174 "12",
31175 "--impact-limit",
31176 "6",
31177 "--json",
31178 "pwcm",
31179 "#g6kf",
31180 ]);
31181 match cli.command {
31182 Some(Commands::ConflictMatrix {
31183 targets,
31184 path,
31185 depth,
31186 limit,
31187 impact_limit,
31188 json,
31189 ..
31190 }) => {
31191 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31192 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31193 assert_eq!(depth, 4);
31194 assert_eq!(limit, 12);
31195 assert_eq!(impact_limit, 6);
31196 assert!(json);
31197 }
31198 _ => panic!("expected ConflictMatrix command"),
31199 }
31200 }
31201
31202 #[test]
31203 fn cli_parses_dispatch_trace_command() {
31204 let cli = parse_cli([
31205 "tsift",
31206 "dispatch-trace",
31207 "--path",
31208 "tasks/software/tsift.md",
31209 "--format",
31210 "html",
31211 "--depth",
31212 "4",
31213 "pwcm",
31214 "#g6kf",
31215 ]);
31216 match cli.command {
31217 Some(Commands::DispatchTrace {
31218 targets,
31219 path,
31220 format,
31221 depth,
31222 ..
31223 }) => {
31224 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31225 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31226 assert_eq!(format, DispatchTraceFormat::Html);
31227 assert_eq!(depth, 4);
31228 }
31229 _ => panic!("expected DispatchTrace command"),
31230 }
31231 }
31232
31233 #[test]
31234 fn cli_parses_dependency_dag_command() {
31235 let cli = parse_cli([
31236 "tsift",
31237 "dependency-dag",
31238 "--path",
31239 "tasks/software/tsift.md",
31240 "--depth",
31241 "5",
31242 "--limit",
31243 "20",
31244 "--json",
31245 "alpha",
31246 "#beta",
31247 ]);
31248 match cli.command {
31249 Some(Commands::DependencyDag {
31250 targets,
31251 path,
31252 depth,
31253 limit,
31254 json,
31255 ..
31256 }) => {
31257 assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
31258 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31259 assert_eq!(depth, 5);
31260 assert_eq!(limit, 20);
31261 assert!(json);
31262 }
31263 _ => panic!("expected DependencyDag command"),
31264 }
31265 }
31266
31267 #[test]
31268 fn relativize_strips_root_prefix() {
31269 let root = std::path::Path::new("/home/user/project");
31270 assert_eq!(
31271 relativize("/home/user/project/src/main.rs", root),
31272 "src/main.rs"
31273 );
31274 }
31275
31276 #[test]
31277 fn relativize_leaves_non_matching_path() {
31278 let root = std::path::Path::new("/home/user/project");
31279 assert_eq!(
31280 relativize("/other/path/file.rs", root),
31281 "/other/path/file.rs"
31282 );
31283 }
31284
31285 #[test]
31286 fn relativize_leaves_already_relative() {
31287 let root = std::path::Path::new("/home/user/project");
31288 assert_eq!(relativize("src/main.rs", root), "src/main.rs");
31289 }
31290
31291 #[test]
31292 fn relativize_pathbuf_strips_prefix() {
31293 let root = std::path::Path::new("/home/user/project");
31294 let path = std::path::Path::new("/home/user/project/src/lib.rs");
31295 assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
31296 }
31297
31298 #[test]
31299 fn relativize_edges_strips_caller_file() {
31300 let root = std::path::Path::new("/tmp/proj");
31301 let mut edges = vec![index::StoredEdge {
31302 caller_file: "/tmp/proj/src/main.rs".to_string(),
31303 caller_name: "main".to_string(),
31304 caller_line: 1,
31305 callee_name: "helper".to_string(),
31306 call_site_line: 5,
31307 tagpath_handle: None,
31308 }];
31309 relativize_edges(&mut edges, root);
31310 assert_eq!(edges[0].caller_file, "src/main.rs");
31311 }
31312
31313 #[test]
31314 fn relativize_json_paths_strips_known_keys() {
31315 let root = std::path::Path::new("/tmp/proj");
31316 let mut val = serde_json::json!({
31317 "file": "/tmp/proj/src/main.rs",
31318 "path": "/tmp/proj/test.rs",
31319 "name": "/tmp/proj/not-a-path",
31320 "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
31321 });
31322 relativize_json_paths(&mut val, root);
31323 assert_eq!(val["file"], "src/main.rs");
31324 assert_eq!(val["path"], "test.rs");
31325 assert_eq!(val["name"], "/tmp/proj/not-a-path");
31326 assert_eq!(val["hits"][0]["path"], "nested.rs");
31327 }
31328
31329 #[test]
31332 fn cli_graph_accepts_limit_flag() {
31333 let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
31334 match cli.command {
31335 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
31336 _ => panic!("expected Graph command"),
31337 }
31338 }
31339
31340 #[test]
31341 fn cli_graph_default_limit_is_20() {
31342 let cli = parse_cli(["tsift", "graph", "main"]);
31343 match cli.command {
31344 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
31345 _ => panic!("expected Graph command"),
31346 }
31347 }
31348
31349 #[test]
31350 fn cli_communities_accepts_limit_flag() {
31351 let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
31352 match cli.command {
31353 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
31354 _ => panic!("expected Communities command"),
31355 }
31356 }
31357
31358 #[test]
31359 fn cli_communities_default_limit_is_10() {
31360 let cli = parse_cli(["tsift", "communities"]);
31361 match cli.command {
31362 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
31363 _ => panic!("expected Communities command"),
31364 }
31365 }
31366
31367 #[test]
31368 fn cli_explain_accepts_limit_flag() {
31369 let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
31370 match cli.command {
31371 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
31372 _ => panic!("expected Explain command"),
31373 }
31374 }
31375
31376 #[test]
31377 fn cli_explain_default_limit_is_15() {
31378 let cli = parse_cli(["tsift", "explain", "main"]);
31379 match cli.command {
31380 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
31381 _ => panic!("expected Explain command"),
31382 }
31383 }
31384
31385 #[test]
31386 fn cli_limit_zero_means_unlimited() {
31387 let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
31388 match cli.command {
31389 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
31390 _ => panic!("expected Graph command"),
31391 }
31392 }
31393
31394 #[test]
31395 fn graph_cmd_limit_runs_ok() {
31396 let dir = setup_graph_index();
31397 let result = cmd_graph(
31398 "main",
31399 dir.path(),
31400 false,
31401 false,
31402 None,
31403 1,
31404 false,
31405 false,
31406 false,
31407 false,
31408 false,
31409 false,
31410 false,
31411 TagpathSearchOpts::default(),
31412 );
31413 assert!(result.is_ok());
31414 }
31415
31416 #[test]
31417 fn graph_cmd_unlimited_runs_ok() {
31418 let dir = setup_graph_index();
31419 let result = cmd_graph(
31420 "main",
31421 dir.path(),
31422 false,
31423 false,
31424 None,
31425 0,
31426 false,
31427 false,
31428 false,
31429 false,
31430 false,
31431 false,
31432 false,
31433 TagpathSearchOpts::default(),
31434 );
31435 assert!(result.is_ok());
31436 }
31437
31438 #[test]
31439 fn graph_cmd_tabular_runs_ok() {
31440 let dir = setup_graph_index();
31441 let result = cmd_graph(
31442 "main",
31443 dir.path(),
31444 false,
31445 false,
31446 None,
31447 20,
31448 false,
31449 false,
31450 false,
31451 false,
31452 false,
31453 true,
31454 false,
31455 TagpathSearchOpts::default(),
31456 );
31457 assert!(result.is_ok());
31458 }
31459
31460 #[test]
31461 fn communities_cmd_tabular_runs_ok() {
31462 let dir = setup_graph_index();
31463 let result = cmd_communities(
31464 dir.path(),
31465 None,
31466 1,
31467 10,
31468 false,
31469 false,
31470 false,
31471 false,
31472 true,
31473 false,
31474 TagpathSearchOpts::default(),
31475 );
31476 assert!(result.is_ok());
31477 }
31478
31479 #[test]
31480 fn explain_cmd_tabular_runs_ok() {
31481 let dir = setup_graph_index();
31482 let result = cmd_explain(
31483 "main",
31484 dir.path(),
31485 None,
31486 15,
31487 false,
31488 false,
31489 false,
31490 false,
31491 false,
31492 true,
31493 false,
31494 false,
31495 );
31496 assert!(result.is_ok());
31497 }
31498
31499 #[test]
31500 fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
31501 let cases = [
31506 ".agent-doc",
31507 ".agent-doc/snapshots/abc.md",
31508 ".agent-doc/baselines/abc.md",
31509 ".agent-doc/archives/2026.md",
31510 ".agent-doc/runtime/run.jsonl",
31511 "src/foo/.agent-doc",
31512 "src/foo/.agent-doc/snapshots/x.md",
31513 "./.agent-doc/snapshots/x.md",
31514 ];
31515 for path in cases {
31516 assert!(
31517 traversal_relative_path_is_generated_artifact(path),
31518 "expected `{path}` to be excluded from source watermark"
31519 );
31520 }
31521 for path in [
31523 "src/main.rs",
31524 "tests/perf_gate.rs",
31525 "fixtures/x.json",
31526 "agent-doc/src/lib.rs", "src/.agent-doc-helper.rs",
31528 ] {
31529 assert!(
31530 !traversal_relative_path_is_generated_artifact(path),
31531 "expected `{path}` to be included in source watermark"
31532 );
31533 }
31534 }
31535
31536 #[test]
31537 fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
31538 let cases = [
31546 ".tsift",
31547 ".tsift/index.db",
31548 ".tsift/indexes/foo/index.db",
31549 ".tsift/conflict-matrix-cache/inputs/abc.json",
31550 ".tsift/summaries.db",
31551 "src/foo/.tsift",
31552 "src/foo/.tsift/graph.db",
31553 "./.tsift/index.db",
31554 "target",
31555 "target/debug/build/x",
31556 "target/release/tsift",
31557 "src/foo/target/debug/x",
31558 "./target/release/x",
31559 ];
31560 for path in cases {
31561 assert!(
31562 traversal_relative_path_is_generated_artifact(path),
31563 "expected `{path}` to be excluded from source watermark"
31564 );
31565 }
31566 for path in [
31568 "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
31569 "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
31570 "src/tsift-extras/lib.rs",
31571 "tsift/README.md",
31572 "src/targeting.rs",
31573 "src/.tsiftrc",
31574 "src/agent-doc-helper.rs",
31575 ] {
31576 assert!(
31577 !traversal_relative_path_is_generated_artifact(path),
31578 "expected `{path}` to be included in source watermark"
31579 );
31580 }
31581 }
31582
31583 #[test]
31584 fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
31585 let dir = tempfile::tempdir().unwrap();
31594 let root = dir.path();
31595 std::fs::create_dir_all(root.join("src")).unwrap();
31596 std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
31597 let hint = root.join("README.md");
31598 std::fs::write(&hint, "# stable\n").unwrap();
31599 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31601 std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
31602 std::fs::create_dir_all(root.join("target/debug")).unwrap();
31603 std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
31604
31605 let first = traversal_source_watermark(root, &hint, None, true)
31606 .expect("first watermark call must succeed")
31607 .expect("first watermark must produce a hash for hinted markdown");
31608 let second = traversal_source_watermark(root, &hint, None, true)
31609 .expect("second watermark call must succeed")
31610 .expect("second watermark must produce a hash for hinted markdown");
31611 assert_eq!(
31612 first, second,
31613 "watermark must be identical across back-to-back invocations on a quiescent root"
31614 );
31615
31616 std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
31618 std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
31619 let third = traversal_source_watermark(root, &hint, None, true)
31620 .expect("third watermark call must succeed")
31621 .expect("third watermark must produce a hash for hinted markdown");
31622 assert_eq!(
31623 first, third,
31624 "watermark must ignore mutations under .tsift/ and target/"
31625 );
31626
31627 std::thread::sleep(std::time::Duration::from_millis(20));
31632 std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
31633 let fourth = traversal_source_watermark(root, &hint, None, true)
31634 .expect("fourth watermark call must succeed")
31635 .expect("fourth watermark must produce a hash for hinted markdown");
31636 assert_ne!(
31637 first, fourth,
31638 "watermark must invalidate when the hinted markdown file changes"
31639 );
31640 }
31641
31642 #[test]
31643 fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
31644 let dir = tempfile::tempdir().unwrap();
31648 let root = dir.path();
31649 std::fs::write(root.join("README.md"), "# stable\n").unwrap();
31650 let summaries_db_path = root.join(".tsift/summaries.db");
31651 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31652 let mut summary = summarize::Summary {
31653 id: 0,
31654 symbol_name: "main".to_string(),
31655 file_path: "src/main.rs".to_string(),
31656 content_hash: "hash-main".to_string(),
31657 summary: "main wires the CLI".to_string(),
31658 entities: Some(vec![summarize::Entity {
31659 name: "Cli".to_string(),
31660 kind: "type".to_string(),
31661 description: "Command-line interface".to_string(),
31662 }]),
31663 relationships: None,
31664 concept_labels: Some(vec!["cli".to_string()]),
31665 extracted_at: "1700000000".to_string(),
31666 model: "test-model".to_string(),
31667 tokens_input: Some(10),
31668 tokens_output: Some(5),
31669 };
31670 summary_db.insert(&summary).unwrap();
31671 drop(summary_db);
31672
31673 let hint = root.join("README.md");
31674 let first = traversal_source_watermark(root, &hint, None, true)
31675 .expect("first watermark call must succeed")
31676 .expect("first watermark must produce a hash");
31677
31678 std::thread::sleep(std::time::Duration::from_millis(20));
31679 let conn = Connection::open(&summaries_db_path).unwrap();
31680 conn.pragma_update(None, "user_version", 1).unwrap();
31681 conn.pragma_update(None, "user_version", 0).unwrap();
31682 drop(conn);
31683
31684 let second = traversal_source_watermark(root, &hint, None, true)
31685 .expect("second watermark call must succeed")
31686 .expect("second watermark must produce a hash");
31687 assert_eq!(
31688 first, second,
31689 "metadata-only summaries.db churn must not invalidate the source watermark"
31690 );
31691
31692 summary.entities = Some(vec![summarize::Entity {
31693 name: "GraphCache".to_string(),
31694 kind: "type".to_string(),
31695 description: "Stable full-projection cache input".to_string(),
31696 }]);
31697 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31698 summary_db.delete_by_file("src/main.rs").unwrap();
31699 summary_db.insert(&summary).unwrap();
31700 drop(summary_db);
31701
31702 let third = traversal_source_watermark(root, &hint, None, true)
31703 .expect("third watermark call must succeed")
31704 .expect("third watermark must produce a hash");
31705 assert_ne!(
31706 first, third,
31707 "semantic summary row changes must invalidate the source watermark"
31708 );
31709 }
31710
31711 #[test]
31712 fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
31713 let dir = tempfile::tempdir().unwrap();
31717 let root = dir.path();
31718 std::fs::create_dir_all(root.join("src")).unwrap();
31719 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31720 let source = root.join("src/lib.rs");
31721 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
31722 std::fs::write(&source, source_body).unwrap();
31723 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31724 db.rebuild(root).unwrap();
31725 drop(db);
31726
31727 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31728 .unwrap()
31729 .value;
31730 std::thread::sleep(std::time::Duration::from_millis(20));
31731 std::fs::write(&source, source_body).unwrap();
31732 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31733 db.apply_changes(root).unwrap();
31734 drop(db);
31735
31736 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31737 .unwrap()
31738 .value;
31739 assert_eq!(
31740 first, second,
31741 "mtime-only source index churn must not invalidate the full-projection cache"
31742 );
31743 }
31744
31745 #[test]
31746 fn full_projection_source_watermark_ignores_session_markdown_churn() {
31747 let dir = tempfile::tempdir().unwrap();
31752 let root = dir.path();
31753 std::fs::create_dir_all(root.join("src")).unwrap();
31754 std::fs::create_dir_all(root.join("tasks/software")).unwrap();
31755 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31756 std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
31757 let task_doc = root.join("tasks/software/tsift.md");
31758 std::fs::write(
31759 &task_doc,
31760 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
31761 )
31762 .unwrap();
31763 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31764 db.rebuild(root).unwrap();
31765 drop(db);
31766
31767 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31768 .unwrap()
31769 .value;
31770 std::fs::write(
31771 &task_doc,
31772 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
31773 )
31774 .unwrap();
31775 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31776 .unwrap()
31777 .value;
31778 assert_eq!(
31779 first, second,
31780 "session markdown churn must not invalidate the full-projection code/summary cache"
31781 );
31782 }
31783
31784 #[test]
31785 fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
31786 let dir = tempfile::tempdir().unwrap();
31790 let root = dir.path();
31791 std::fs::create_dir_all(root.join("src")).unwrap();
31792 std::fs::create_dir_all(root.join(".tsift")).unwrap();
31793 let source = root.join("src/lib.rs");
31794 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
31795 std::fs::write(&source, source_body).unwrap();
31796 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31797 db.rebuild(root).unwrap();
31798 drop(db);
31799
31800 let (_projection, _warnings, _phases, first_stats) =
31801 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
31802 assert!(
31803 !first_stats.hit,
31804 "the first full-projection run should populate the cache"
31805 );
31806
31807 std::thread::sleep(std::time::Duration::from_millis(20));
31808 std::fs::write(&source, source_body).unwrap();
31809 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31810 db.apply_changes(root).unwrap();
31811 drop(db);
31812
31813 let (_projection, _warnings, phases, second_stats) =
31814 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
31815 assert!(second_stats.hit, "mtime-only churn should still cache-hit");
31816 let source_graph_build = phases
31817 .iter()
31818 .find(|phase| phase.name == "full_projection.source_graph_build")
31819 .expect("cache hit must report source_graph_build");
31820 let projection_rows = phases
31821 .iter()
31822 .find(|phase| phase.name == "full_projection.projection_rows")
31823 .expect("cache hit must report projection_rows");
31824 assert_eq!(source_graph_build.duration_micros, 0);
31825 assert_eq!(projection_rows.duration_micros, 0);
31826 }
31827
31828 #[test]
31829 fn build_token_capped_preview_within_cap() {
31830 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 2", "}"];
31831 let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
31832 assert!(!capped.was_capped);
31833 assert_eq!(capped.preview.len(), 3);
31834 assert_eq!(capped.capped_end, 3);
31835 }
31836
31837 #[test]
31838 fn build_token_capped_preview_truncates_long_body() {
31839 let owned: Vec<String> = (0..200).map(|i| format!(" let line_{i} = {i};")).collect();
31840 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
31841 let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
31842 assert!(capped.was_capped);
31843 assert!(capped.preview.len() < 200);
31844 assert!(capped.capped_end < 200);
31845 assert!(!capped.preview.is_empty());
31846 }
31847
31848 #[test]
31849 fn build_token_capped_preview_respects_start_offset() {
31850 let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
31851 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
31852 let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
31853 assert!(capped.was_capped);
31854 assert!(capped.capped_end >= 50);
31855 assert!(capped.capped_end < 100);
31856 assert_eq!(capped.preview[0].line, 50);
31857 }
31858
31859 #[test]
31860 fn response_budget_body_token_cap_defaults() {
31861 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
31862 assert_eq!(budget.body_token_cap(), 1500);
31863
31864 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
31865 assert_eq!(budget.body_token_cap(), 500);
31866
31867 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
31868 assert_eq!(budget.body_token_cap(), 3000);
31869 }
31870
31871 #[test]
31872 fn build_token_capped_preview_empty_input() {
31873 let lines: Vec<&str> = vec![];
31874 let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
31875 assert!(!capped.was_capped);
31876 assert!(capped.preview.is_empty());
31877 }
31878
31879 #[test]
31880 fn build_token_capped_preview_single_long_line_fits() {
31881 let lines: Vec<&str> = vec!["short"];
31882 let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
31883 assert!(!capped.was_capped);
31884 assert_eq!(capped.preview.len(), 1);
31885 assert_eq!(capped.capped_end, 1);
31886 }
31887
31888 #[test]
31889 fn edge_index_replaces_from_id_to_id_with_positions() {
31890 let input = serde_json::json!({
31891 "nodes": [
31892 {"id": "symbol:src/lib.rs:foo"},
31893 {"id": "symbol:src/lib.rs:bar"},
31894 {"id": "symbol:src/lib.rs:baz"}
31895 ],
31896 "edges": [
31897 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
31898 {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
31899 ]
31900 });
31901 let result = edge_index_transform(input);
31902 let edges = result.get("edges").unwrap().as_array().unwrap();
31903 assert_eq!(edges.len(), 2);
31904 assert_eq!(edges[0]["from"], 0);
31905 assert_eq!(edges[0]["to"], 1);
31906 assert_eq!(edges[1]["from"], 1);
31907 assert_eq!(edges[1]["to"], 2);
31908 assert!(edges[0].get("from_id").is_none());
31909 assert!(edges[0].get("to_id").is_none());
31910 }
31911
31912 #[test]
31913 fn edge_index_preserves_unresolved_ids_as_strings() {
31914 let input = serde_json::json!({
31915 "nodes": [{"id": "symbol:src/lib.rs:foo"}],
31916 "edges": [
31917 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
31918 ]
31919 });
31920 let result = edge_index_transform(input);
31921 let edge = &result["edges"][0];
31922 assert_eq!(edge["from"], 0);
31923 assert_eq!(edge["to_id"], "symbol:other.rs:missing");
31924 }
31925
31926 #[test]
31927 fn edge_index_noop_without_nodes_and_edges() {
31928 let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
31929 let result = edge_index_transform(input);
31930 assert_eq!(result["report"]["entries"][0]["from_id"], "a");
31931 }
31932}
31933
31934#[derive(Serialize)]
31937struct TableInfo {
31938 name: String,
31939 columns: Vec<ColumnInfo>,
31940 row_count: i64,
31941}
31942
31943#[derive(Serialize)]
31944struct ColumnInfo {
31945 name: String,
31946 #[serde(rename = "type")]
31947 col_type: String,
31948 notnull: bool,
31949 pk: bool,
31950 #[serde(skip_serializing_if = "Option::is_none")]
31951 default_value: Option<String>,
31952}
31953
31954pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
31956 let conn = Connection::open_with_flags(
31957 path,
31958 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
31959 )
31960 .with_context(|| format!("opening database: {}", path.display()))?;
31961 Ok(conn)
31962}
31963
31964pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
31966 let mut stmt = conn.prepare(
31967 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
31968 )?;
31969 let table_names: Vec<String> = stmt
31970 .query_map([], |row| row.get(0))?
31971 .collect::<std::result::Result<Vec<_>, _>>()?;
31972
31973 let mut tables = Vec::new();
31974 for tbl in table_names {
31975 let columns = table_columns(conn, &tbl)?;
31976 let row_count: i64 =
31977 conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
31978 row.get(0)
31979 })?;
31980 tables.push(TableInfo {
31981 name: tbl,
31982 columns,
31983 row_count,
31984 });
31985 }
31986 Ok(tables)
31987}
31988
31989pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
31991 let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
31992 let cols = stmt
31993 .query_map([], |row| {
31994 Ok(ColumnInfo {
31995 name: row.get(1)?,
31996 col_type: row.get::<_, String>(2).unwrap_or_default(),
31997 notnull: row.get::<_, bool>(3).unwrap_or(false),
31998 pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
31999 default_value: row.get(4)?,
32000 })
32001 })?
32002 .collect::<std::result::Result<Vec<_>, _>>()?;
32003 Ok(cols)
32004}
32005
32006pub(crate) fn execute_query(
32008 conn: &Connection,
32009 sql: &str,
32010) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
32011 let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
32012 let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
32013 let col_count = col_names.len();
32014
32015 let mut rows = Vec::new();
32016 let mut query_rows = stmt.query([])?;
32017 while let Some(row) = query_rows.next()? {
32018 let mut vals = Vec::with_capacity(col_count);
32019 for i in 0..col_count {
32020 let val = match row.get_ref(i)? {
32021 rusqlite::types::ValueRef::Null => serde_json::Value::Null,
32022 rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
32023 rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
32024 rusqlite::types::ValueRef::Text(s) => {
32025 serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
32026 }
32027 rusqlite::types::ValueRef::Blob(b) => {
32028 serde_json::Value::String(format!("<blob {} bytes>", b.len()))
32029 }
32030 };
32031 vals.push(val);
32032 }
32033 rows.push(vals);
32034 }
32035 Ok((col_names, rows))
32036}
32037
32038
32039#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32040enum DigestRunnerKind {
32041 Test,
32042 Log,
32043}
32044
32045impl DigestRunnerKind {
32046 fn parse(raw: &str) -> Result<Self> {
32047 match raw.trim().to_ascii_lowercase().as_str() {
32048 "test" => Ok(Self::Test),
32049 "log" => Ok(Self::Log),
32050 other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
32051 }
32052 }
32053
32054 fn as_str(self) -> &'static str {
32055 match self {
32056 Self::Test => "test",
32057 Self::Log => "log",
32058 }
32059 }
32060}
32061
32062pub(crate) fn shell_split(s: &str) -> Vec<&str> {
32064 let mut parts = Vec::new();
32065 let mut i = 0;
32066 let bytes = s.as_bytes();
32067 while i < bytes.len() {
32068 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
32070 i += 1;
32071 }
32072 if i >= bytes.len() {
32073 break;
32074 }
32075 let start = i;
32076 if bytes[i] == b'"' || bytes[i] == b'\'' {
32077 let quote = bytes[i];
32078 i += 1;
32079 while i < bytes.len() && bytes[i] != quote {
32080 i += 1;
32081 }
32082 if i < bytes.len() {
32083 i += 1; }
32085 } else {
32086 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
32087 i += 1;
32088 }
32089 }
32090 parts.push(&s[start..i]);
32091 }
32092 parts
32093}
32094
32095pub(crate) fn shell_quote(s: &str) -> String {
32097 let unquoted =
32099 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
32100 &s[1..s.len() - 1]
32101 } else {
32102 s
32103 };
32104
32105 if unquoted
32106 .chars()
32107 .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
32108 {
32109 format!("\"{}\"", unquoted)
32110 } else {
32111 format!(
32112 "\"{}\"",
32113 unquoted.replace('\\', "\\\\").replace('"', "\\\"")
32114 )
32115 }
32116}
32117
32118fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
32119 sift::SearchCoverageSnapshot {
32120 mode: sift::SearchCoverageMode::Sealed,
32121 total_sector_count: 0,
32122 mounted_sector_count: 0,
32123 reused_sector_count: 0,
32124 dirty_sector_count: 0,
32125 completed_dirty_sector_count: 0,
32126 rebuilding_sector_count: 0,
32127 resumed_sector_count: 0,
32128 active_rebuild: None,
32129 }
32130}
32131
32132fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
32133 let total_sector_count = responses
32134 .iter()
32135 .map(|response| response.coverage.total_sector_count)
32136 .sum();
32137 let mounted_sector_count = responses
32138 .iter()
32139 .map(|response| response.coverage.mounted_sector_count)
32140 .sum();
32141 let reused_sector_count = responses
32142 .iter()
32143 .map(|response| response.coverage.reused_sector_count)
32144 .sum();
32145 let dirty_sector_count = responses
32146 .iter()
32147 .map(|response| response.coverage.dirty_sector_count)
32148 .sum();
32149 let completed_dirty_sector_count = responses
32150 .iter()
32151 .map(|response| response.coverage.completed_dirty_sector_count)
32152 .sum();
32153 let rebuilding_sector_count = responses
32154 .iter()
32155 .map(|response| response.coverage.rebuilding_sector_count)
32156 .sum();
32157 let resumed_sector_count = responses
32158 .iter()
32159 .map(|response| response.coverage.resumed_sector_count)
32160 .sum();
32161
32162 let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
32163 sift::SearchCoverageMode::Sealed
32164 } else if completed_dirty_sector_count > 0
32165 || rebuilding_sector_count > 0
32166 || resumed_sector_count > 0
32167 {
32168 sift::SearchCoverageMode::Converging
32169 } else {
32170 sift::SearchCoverageMode::Frontier
32171 };
32172
32173 sift::SearchCoverageSnapshot {
32174 mode,
32175 total_sector_count,
32176 mounted_sector_count,
32177 reused_sector_count,
32178 dirty_sector_count,
32179 completed_dirty_sector_count,
32180 rebuilding_sector_count,
32181 resumed_sector_count,
32182 active_rebuild: responses
32183 .iter()
32184 .find_map(|response| response.coverage.active_rebuild.clone()),
32185 }
32186}
32187
32188fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
32189 sift::SearchResponse {
32190 strategy: strategy.to_string(),
32191 root: root.display().to_string(),
32192 indexed_artifacts: 0,
32193 skipped_artifacts: 0,
32194 coverage: empty_search_coverage(),
32195 hits: Vec::new(),
32196 }
32197}
32198
32199fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
32200 for hit in &mut response.hits {
32201 let path = Path::new(&hit.path);
32202 if path.is_relative() {
32203 hit.path = search_root.join(path).display().to_string();
32204 }
32205 }
32206}
32207
32208fn merge_search_responses(
32209 root: &Path,
32210 strategy: &str,
32211 limit: usize,
32212 responses: Vec<sift::SearchResponse>,
32213) -> sift::SearchResponse {
32214 let indexed_artifacts = responses
32215 .iter()
32216 .map(|response| response.indexed_artifacts)
32217 .sum();
32218 let skipped_artifacts = responses
32219 .iter()
32220 .map(|response| response.skipped_artifacts)
32221 .sum();
32222 let coverage = if responses.is_empty() {
32223 empty_search_coverage()
32224 } else {
32225 aggregate_search_coverage(&responses)
32226 };
32227 let mut hits: Vec<sift::SearchHit> = responses
32228 .into_iter()
32229 .flat_map(|response| response.hits)
32230 .collect();
32231 hits.sort_by(|left, right| {
32232 right
32233 .score
32234 .partial_cmp(&left.score)
32235 .unwrap_or(Ordering::Equal)
32236 .then_with(|| left.path.cmp(&right.path))
32237 .then_with(|| left.location.cmp(&right.location))
32238 });
32239 hits.truncate(limit);
32240 for (rank, hit) in hits.iter_mut().enumerate() {
32241 hit.rank = rank + 1;
32242 }
32243
32244 sift::SearchResponse {
32245 strategy: strategy.to_string(),
32246 root: root.display().to_string(),
32247 indexed_artifacts,
32248 skipped_artifacts,
32249 coverage,
32250 hits,
32251 }
32252}
32253
32254pub(crate) fn federated_sift_search(
32255 root: &Path,
32256 cache_dir: &Path,
32257 query: &str,
32258 limit: usize,
32259 timeout_secs: u64,
32260 strategy: &str,
32261) -> Result<sift::SearchResponse> {
32262 let targets = resolve_search_index_targets(root, root, None, true)?;
32263 if targets.is_empty() {
32264 if config::Config::submodule_dirs(root)?.is_empty() {
32265 return run_search_with_timeout(
32266 root,
32267 cache_dir,
32268 query,
32269 limit,
32270 timeout_secs,
32271 strategy,
32272 &[],
32273 );
32274 }
32275 return Ok(empty_search_response(root, strategy));
32276 }
32277
32278 let mut responses = Vec::with_capacity(targets.len());
32279 for target in &targets {
32280 let mut response = run_search_with_timeout(
32281 &target.source_root,
32282 cache_dir,
32283 query,
32284 limit,
32285 timeout_secs,
32286 strategy,
32287 std::slice::from_ref(target),
32288 )?;
32289 absolutize_search_hit_paths(&mut response, &target.source_root);
32290 response.root = root.display().to_string();
32291 responses.push(response);
32292 }
32293
32294 Ok(merge_search_responses(root, strategy, limit, responses))
32295}
32296
32297pub(crate) fn federated_symbol_search(
32305 root: &std::path::Path,
32306 query: &str,
32307 limit: usize,
32308 tagpath_opts: &TagpathSearchOpts,
32309) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
32310 let cfg = config::Config::load(root)?;
32311 let submodules = config::Config::submodule_dirs(root)?;
32312 let mut all_hits: Vec<index::SymbolHit> = Vec::new();
32313 let mut combined = TagpathAnnotationDiagnostic::default();
32314 for scope in &submodules {
32315 if !cfg.federation_for_scope(scope) {
32316 continue;
32317 }
32318 let db_path = cfg.db_path_for(root, &scope.id);
32319 if !db_path.exists() {
32320 continue;
32321 }
32322 let db = index::IndexDb::open_read_only(&db_path)?;
32323 let mut hits = db.symbol_search(query, limit)?;
32324 let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
32325 combined.loaded |= diag.loaded;
32326 if diag.stale && !combined.stale {
32327 combined.stale = true;
32328 combined.reason = diag.reason;
32329 }
32330 all_hits.append(&mut hits);
32331 }
32332 all_hits.sort_by(|a, b| {
32333 b.score
32334 .partial_cmp(&a.score)
32335 .unwrap_or(std::cmp::Ordering::Equal)
32336 });
32337 all_hits.truncate(limit);
32338 Ok((all_hits, combined))
32339}
32340
32341#[derive(Debug, Deserialize)]
32342#[serde(tag = "type", rename_all = "lowercase")]
32343enum RipgrepJsonEvent {
32344 Match {
32345 data: RipgrepMatchData,
32346 },
32347 #[serde(other)]
32348 Other,
32349}
32350
32351#[derive(Debug, Deserialize)]
32352struct RipgrepMatchData {
32353 path: RipgrepTextField,
32354 lines: RipgrepTextField,
32355 line_number: Option<usize>,
32356}
32357
32358#[derive(Debug, Deserialize)]
32359struct RipgrepTextField {
32360 text: Option<String>,
32361}
32362
32363pub(crate) fn federated_exact_search(
32364 root: &Path,
32365 query: &str,
32366 limit: usize,
32367 timeout_secs: u64,
32368) -> Result<sift::SearchResponse> {
32369 let cfg = config::Config::load(root)?;
32370 let mut responses = Vec::new();
32371 for scope in config::Config::submodule_dirs(root)? {
32372 if !cfg.federation_for_scope(&scope) {
32373 continue;
32374 }
32375 let mut response =
32376 run_exact_search_with_timeout(&scope.source_root, query, limit, timeout_secs)?;
32377 absolutize_search_hit_paths(&mut response, &scope.source_root);
32378 response.root = root.display().to_string();
32379 responses.push(response);
32380 }
32381
32382 Ok(merge_search_responses(root, "exact", limit, responses))
32383}
32384
32385pub(crate) fn run_sift_search(
32386 search_path: &Path,
32387 cache_dir: &Path,
32388 query: &str,
32389 limit: usize,
32390 strategy: &str,
32391) -> Result<sift::SearchResponse> {
32392 let engine = Sift::builder().with_cache_dir(cache_dir).build();
32393 let options = SearchOptions::default()
32394 .with_limit(limit)
32395 .with_strategy(strategy.to_string());
32396 let input = SearchInput::new(search_path, query).with_options(options);
32397 engine.search(input).context("sift search failed")
32398}
32399
32400fn exact_search_timeout_message(timeout_secs: u64) -> String {
32401 format!(
32402 "tsift search timed out after {}s (strategy: exact). \
32403 Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
32404 timeout_secs
32405 )
32406}
32407
32408fn exact_search_command(search_path: &Path, query: &str) -> Command {
32409 let mut command = Command::new("rg");
32410 command
32411 .arg("--json")
32412 .arg("--fixed-strings")
32413 .arg("--line-number")
32414 .arg("--hidden")
32415 .arg("--")
32416 .arg(query)
32417 .arg(search_path);
32418 command
32419}
32420
32421fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
32422 let observed_unix_secs = SystemTime::now()
32423 .duration_since(UNIX_EPOCH)
32424 .unwrap_or_default()
32425 .as_secs() as i64;
32426 let modified_unix_secs = fs::metadata(path)
32427 .ok()
32428 .and_then(|metadata| metadata.modified().ok())
32429 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
32430 .map(|duration| duration.as_secs() as i64);
32431 sift::ArtifactFreshness {
32432 observed_unix_secs,
32433 modified_unix_secs,
32434 }
32435}
32436
32437fn parse_exact_search_output(
32438 search_path: &Path,
32439 limit: usize,
32440 raw: &str,
32441) -> Result<sift::SearchResponse> {
32442 if limit == 0 {
32443 return Ok(sift::SearchResponse {
32444 strategy: "exact".to_string(),
32445 root: search_path.display().to_string(),
32446 indexed_artifacts: 0,
32447 skipped_artifacts: 0,
32448 coverage: empty_search_coverage(),
32449 hits: Vec::new(),
32450 });
32451 }
32452
32453 let mut hits = Vec::new();
32454 for line in raw.lines() {
32455 let event: RipgrepJsonEvent =
32456 serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
32457 let RipgrepJsonEvent::Match { data } = event else {
32458 continue;
32459 };
32460 let Some(path_text) = data.path.text else {
32461 continue;
32462 };
32463 let Some(lines_text) = data.lines.text else {
32464 continue;
32465 };
32466 let path = PathBuf::from(path_text);
32467 let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
32468 let rank = hits.len() + 1;
32469 hits.push(sift::SearchHit {
32470 artifact_id: format!(
32471 "exact:{}:{}:{}",
32472 path.display(),
32473 data.line_number.unwrap_or(0),
32474 rank
32475 ),
32476 artifact_kind: sift::ContextArtifactKind::File,
32477 path: path.display().to_string(),
32478 rank,
32479 score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
32480 confidence: sift::ScoreConfidence::High,
32481 location: data.line_number.map(|line| format!("line {}", line)),
32482 snippet: snippet.clone(),
32483 provenance: sift::ArtifactProvenance {
32484 adapter: sift::AcquisitionAdapterKind::FileSystem,
32485 source: "ripgrep -F".to_string(),
32486 synthetic: false,
32487 },
32488 freshness: exact_search_file_timestamp(&path),
32489 budget: sift::ArtifactBudget::from_text(&snippet, 1),
32490 });
32491 if hits.len() >= limit {
32492 break;
32493 }
32494 }
32495
32496 Ok(sift::SearchResponse {
32497 strategy: "exact".to_string(),
32498 root: search_path.display().to_string(),
32499 indexed_artifacts: hits.len(),
32500 skipped_artifacts: 0,
32501 coverage: empty_search_coverage(),
32502 hits,
32503 })
32504}
32505
32506fn exact_search_response_from_process(
32507 search_path: &Path,
32508 limit: usize,
32509 status: std::process::ExitStatus,
32510 stdout: &[u8],
32511 stderr: &[u8],
32512) -> Result<sift::SearchResponse> {
32513 if !status.success() && status.code() != Some(1) {
32514 let message = String::from_utf8_lossy(stderr);
32515 let trimmed = message.trim();
32516 if trimmed.is_empty() {
32517 bail!("ripgrep exact search exited with status {}", status);
32518 }
32519 bail!("{}", trimmed);
32520 }
32521
32522 let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
32523 parse_exact_search_output(search_path, limit, &raw)
32524}
32525
32526fn run_exact_search(search_path: &Path, query: &str, limit: usize) -> Result<sift::SearchResponse> {
32527 let output = exact_search_command(search_path, query)
32528 .output()
32529 .context("running exact search with ripgrep")?;
32530 exact_search_response_from_process(
32531 search_path,
32532 limit,
32533 output.status,
32534 &output.stdout,
32535 &output.stderr,
32536 )
32537}
32538
32539pub(crate) fn run_exact_search_with_timeout(
32540 search_path: &Path,
32541 query: &str,
32542 limit: usize,
32543 timeout_secs: u64,
32544) -> Result<sift::SearchResponse> {
32545 if timeout_secs == 0 {
32546 return run_exact_search(search_path, query, limit);
32547 }
32548
32549 let mut child = exact_search_command(search_path, query)
32550 .stdin(Stdio::null())
32551 .stdout(Stdio::piped())
32552 .stderr(Stdio::piped())
32553 .spawn()
32554 .context("spawning timed exact search worker")?;
32555
32556 let timeout = Duration::from_secs(timeout_secs);
32557 let status = wait_for_child_exit(&mut child, timeout)
32558 .context("waiting for timed exact search worker")?;
32559 if status.is_none() {
32560 let _ = child.kill();
32561 let _ = child.wait();
32562 bail!("{}", exact_search_timeout_message(timeout_secs));
32563 }
32564
32565 let status = status.unwrap();
32566 let stdout = read_child_stdout(&mut child)?;
32567 let stderr = read_child_stderr(&mut child)?;
32568 exact_search_response_from_process(
32569 search_path,
32570 limit,
32571 status,
32572 stdout.as_bytes(),
32573 stderr.as_bytes(),
32574 )
32575}
32576
32577pub(crate) fn run_search_with_timeout(
32578 search_path: &Path,
32579 cache_dir: &Path,
32580 query: &str,
32581 limit: usize,
32582 timeout_secs: u64,
32583 strategy: &str,
32584 search_targets: &[SearchIndexTarget],
32585) -> Result<sift::SearchResponse> {
32586 if timeout_secs == 0 {
32587 return run_sift_search(search_path, cache_dir, query, limit, strategy);
32588 }
32589
32590 let output_path = next_search_worker_output_path();
32591 let mut child = Command::new(
32592 std::env::current_exe().context("resolving tsift executable for timed search")?,
32593 )
32594 .arg("__search-worker")
32595 .arg("--path")
32596 .arg(search_path)
32597 .arg("--cache-dir")
32598 .arg(cache_dir)
32599 .arg("--query")
32600 .arg(query)
32601 .arg("--limit")
32602 .arg(limit.to_string())
32603 .arg("--strategy")
32604 .arg(strategy)
32605 .arg("--output")
32606 .arg(&output_path)
32607 .stdin(Stdio::null())
32608 .stdout(Stdio::null())
32609 .stderr(Stdio::piped())
32610 .spawn()
32611 .context("spawning timed sift search worker")?;
32612
32613 let timeout = Duration::from_secs(timeout_secs);
32614 let status =
32615 wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
32616 if status.is_none() {
32617 let _ = child.kill();
32618 let _ = child.wait();
32619 let _ = fs::remove_file(&output_path);
32620 bail!(
32621 "{}",
32622 search_timeout_message(timeout_secs, strategy, search_targets)?
32623 );
32624 }
32625
32626 let status = status.unwrap();
32627 let stderr = read_child_stderr(&mut child)?;
32628 if !status.success() {
32629 let _ = fs::remove_file(&output_path);
32630 let message = stderr.trim();
32631 if message.is_empty() {
32632 bail!("sift search worker exited with status {}", status);
32633 }
32634 bail!("{}", message);
32635 }
32636
32637 let raw = fs::read_to_string(&output_path)
32638 .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
32639 let _ = fs::remove_file(&output_path);
32640 serde_json::from_str(&raw).context("parsing search worker output")
32641}
32642
32643fn next_search_worker_output_path() -> PathBuf {
32644 let stamp = SystemTime::now()
32645 .duration_since(UNIX_EPOCH)
32646 .unwrap_or_default()
32647 .as_nanos();
32648 std::env::temp_dir().join(format!(
32649 "tsift-search-{}-{}.json",
32650 std::process::id(),
32651 stamp
32652 ))
32653}
32654
32655fn wait_for_child_exit(
32656 child: &mut std::process::Child,
32657 timeout: Duration,
32658) -> Result<Option<std::process::ExitStatus>> {
32659 let started = Instant::now();
32660 loop {
32661 if let Some(status) = child.try_wait()? {
32662 return Ok(Some(status));
32663 }
32664 if started.elapsed() >= timeout {
32665 return Ok(None);
32666 }
32667 let remaining = timeout.saturating_sub(started.elapsed());
32668 std::thread::sleep(remaining.min(Duration::from_millis(10)));
32669 }
32670}
32671
32672fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
32673 let mut stderr = String::new();
32674 if let Some(mut pipe) = child.stderr.take() {
32675 pipe.read_to_string(&mut stderr)
32676 .context("reading search worker stderr")?;
32677 }
32678 Ok(stderr)
32679}
32680
32681fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
32682 let mut stdout = String::new();
32683 if let Some(mut pipe) = child.stdout.take() {
32684 pipe.read_to_string(&mut stdout)
32685 .context("reading search worker stdout")?;
32686 }
32687 Ok(stdout)
32688}
32689
32690pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
32691 if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
32692 fs::write(&path, std::process::id().to_string())
32693 .with_context(|| format!("writing search worker pid file: {path}"))?;
32694 }
32695 if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
32696 let delay_ms = ms
32697 .parse::<u64>()
32698 .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
32699 std::thread::sleep(Duration::from_millis(delay_ms));
32700 }
32701 Ok(())
32702}
32703
32704#[cfg(test)]
32705thread_local! {
32706 static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
32707}
32708
32709#[cfg(test)]
32710enum SearchPostPrecheckLockMode {
32711 RollbackJournal,
32712 Wal,
32713}
32714
32715#[cfg(test)]
32716struct SearchPostPrecheckLockHook {
32717 db_path: PathBuf,
32718 mode: SearchPostPrecheckLockMode,
32719}
32720
32721#[cfg(test)]
32722struct SearchPostPrecheckLockGuard;
32723
32724#[cfg(test)]
32725impl Drop for SearchPostPrecheckLockGuard {
32726 fn drop(&mut self) {
32727 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32728 hook.borrow_mut().take();
32729 });
32730 }
32731}
32732
32733#[cfg(test)]
32734fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32735 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
32736}
32737
32738#[cfg(test)]
32739fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32740 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
32741}
32742
32743#[cfg(test)]
32744fn install_search_post_precheck_lock_hook(
32745 db_path: PathBuf,
32746 mode: SearchPostPrecheckLockMode,
32747) -> SearchPostPrecheckLockGuard {
32748 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32749 assert!(
32750 hook.borrow().is_none(),
32751 "search post-precheck lock hook already installed"
32752 );
32753 *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
32754 });
32755 SearchPostPrecheckLockGuard
32756}
32757
32758#[cfg(test)]
32759pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
32760 let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
32761 return Ok(());
32762 };
32763 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
32764 std::thread::spawn(move || {
32765 let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
32766 match hook.mode {
32767 SearchPostPrecheckLockMode::RollbackJournal => {
32768 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
32769 .expect("acquiring rollback-journal hook lock");
32770 fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
32771 .expect("writing rollback journal marker");
32772 }
32773 SearchPostPrecheckLockMode::Wal => {
32774 conn.execute_batch(
32775 "PRAGMA journal_mode=WAL;
32776 PRAGMA wal_autocheckpoint=0;
32777 CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
32778 INSERT INTO search_wal_lock_probe DEFAULT VALUES;
32779 PRAGMA locking_mode=EXCLUSIVE;
32780 BEGIN EXCLUSIVE;",
32781 )
32782 .expect("acquiring WAL hook lock");
32783 assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
32784 }
32785 }
32786 ready_tx.send(()).expect("signaling search lock hook");
32787 std::thread::sleep(Duration::from_millis(200));
32788 drop(conn);
32789 let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
32790 });
32791 ready_rx
32792 .recv_timeout(Duration::from_secs(1))
32793 .context("waiting for search post-precheck lock hook")?;
32794 Ok(())
32795}
32796
32797#[cfg(not(test))]
32798pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
32799 Ok(())
32800}