1mod cli;
2mod commands;
3mod community_detection;
4mod conflict_matrix;
5mod context_pack;
6mod output;
7mod rewrite;
8mod search_budget;
9mod semantic_edit;
10mod session_review_budget;
11mod token_savings;
12mod workflow;
13
14pub(crate) use community_detection::{
15 CommunityDetectionReport, annotate_community_members_with_context,
16 community_tagpath_cache_part, community_tagpath_cache_part_for_loaded,
17 detect_communities_cached, file_communities_from_callers, graph_effectiveness_blocked,
18 graph_effectiveness_ready, resolve_tagpath_handle_for_callee_edge,
19 update_community_annotation_diagnostics,
20};
21#[allow(unused_imports)]
22pub(crate) use conflict_matrix::{
23 ConflictMatrixCandidate, ConflictMatrixGraphPreparedInputs, ConflictMatrixPreparedInputs,
24 ConflictMatrixReport, ConflictMatrixSemanticRef, ConflictMatrixSharedPreparationSummary,
25 ConflictMatrixWorkerFeedback, ConflictMatrixWorkerPromptPacket, build_conflict_matrix_report,
26 build_conflict_matrix_report_from_prepared_graph, cmd_conflict_matrix,
27 collect_conflict_matrix_evidence_packets, conflict_matrix_candidate_from_evidence,
28 conflict_matrix_graph_index, conflict_matrix_semantic_ref,
29 conflict_matrix_shared_preparation_summary, conflict_matrix_source_handle,
30 conflict_matrix_target_scoped_graph_snapshot, conflict_matrix_worker_feedback,
31 conflict_risk_label, extract_conflict_target_refs, hash_bytes_hex, is_planner_config_path,
32 normalize_conflict_target, prepare_conflict_matrix_graph_orchestration,
33 prepare_conflict_matrix_inputs, resolve_conflict_matrix_targets, sorted_intersection,
34 sorted_set,
35};
36#[allow(unused_imports)]
37pub(crate) use context_pack::{
38 ContextPackReport, ContextPackSummaryRefPreview, build_context_pack_diff_preview,
39 build_context_pack_log_preview, build_context_pack_report,
40 build_context_pack_report_with_profile, build_context_pack_test_preview,
41 context_pack_status_reminders, exploration_ref_id, materialize_context_pack_exploration_packet,
42 print_context_pack_human,
43};
44pub use rewrite::rewrite_command;
45pub(crate) use rewrite::{
46 apply_rewrite_output_format, execute_rewritten_command, no_rewrite_message,
47};
48#[cfg(test)]
49use search_budget::{SearchBudgetReport, search_facet_filters_summary};
50pub(crate) use search_budget::{
51 SearchBudgetReportInput, apply_search_facet_filters, build_search_budget_follow_up,
52 build_search_budget_report, print_search_budget_human,
53};
54pub(crate) use semantic_edit::{
55 AstSpanPreview, EditBatch, EditResult, EditStatus, MarkdownEmbeddedSymbol,
56 MarkdownSpanMetadata, MetricDigestOptions, SemanticEditVerifyOptions,
57 apply_edit_plan_atomically, build_edit_plan, cmd_edit_intents,
58};
59#[allow(unused_imports)]
60pub(crate) use session_review_budget::{
61 SessionReviewBudgetFailurePreview, SessionReviewBudgetReport,
62 SessionReviewNextContextBudgetReport, SessionReviewNextTokenAction,
63 build_session_review_budget_report, build_session_review_next_context_budget_report,
64 print_session_review_budget_human, print_session_review_next_context_budget_human,
65};
66
67#[cfg(test)]
68use rewrite::{
69 OutputCap, apply_output_cap, effective_rewrite_run_command, resolve_digest_context_path,
70 rewrite_output_cap,
71};
72#[cfg(test)]
73use std::io::{BufRead as _, BufReader};
74#[cfg(test)]
75use token_savings::{
76 TokenSavingsFamily, TokenSavingsFixture, TokenSavingsFixtureCase,
77 TokenSavingsMarkdownProjectionInput, TokenSavingsMarkdownProjectionInputs,
78 TokenSavingsRawSymbol, TokenSavingsSourceReadInput, TokenSavingsSourceReadInputs,
79 build_token_savings_report,
80};
81
82use anyhow::{Context, Result, bail};
83use clap::Parser;
84use cli::{
85 AstGrepCommand, Cli, Commands, DispatchTraceFormat, GraphDbQuery, KgCommand, LeaseCommand,
86 LocalModelCommand,
87 SemanticRelatedKind, SourceReadStyle,
88};
89
90#[cfg(test)]
91use cli::{GraphDbBackend, TraverseFormat};
92use commands::digests::{
93 cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
94 cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
95};
96#[cfg(test)]
97use commands::graph::cmd_explain;
98use commands::graph::{
99 cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
100};
101#[cfg(test)]
102use commands::index_search::cmd_search;
103use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
104use commands::infra::{
105 StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
106 cmd_rewrite, cmd_route, cmd_sql, cmd_status,
107};
108use commands::memory::cmd_memory;
109use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
110use commands::summarize::cmd_summarize;
111use flate2::{Compression, read::GzDecoder, write::GzEncoder};
112#[cfg(test)]
113use output::ResponseBudgetPreset;
114use output::tagpath::{
115 TagpathAnnotationDiagnostic, TagpathSearchOpts, annotate_communities_with_tagpath,
116 annotate_hits_with_tagpath, annotate_path_nodes_with_tagpath,
117 annotate_stored_edges_with_tagpath, annotate_stored_symbols_with_tagpath,
118};
119use output::{
120 OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric, ToolEnvelopeSummary,
121 TranscriptArtifactRef,
122};
123use rusqlite::{Connection, OptionalExtension, Row};
124use serde::{Deserialize, Serialize};
125use sift::{SearchInput, SearchOptions, Sift};
126#[cfg(test)]
127use std::cell::RefCell;
128use std::cmp::Ordering;
129use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
130use std::env;
131use std::fs;
132use std::io::{Read as _, Write as _};
133use std::path::{Path, PathBuf};
134use std::process::{Command, Stdio};
135use std::sync::{Mutex, OnceLock};
136use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
137use substrate::{
138 ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
139 GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
140 GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
141 SqliteGraphStore, SqliteProjectionRefresh, TerseGraphEdge as SubstrateTerseGraphEdge,
142 TerseGraphNode as SubstrateTerseGraphNode,
143};
144use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
145#[cfg(test)]
146use tsift_agent_doc::session_cost;
147use tsift_agent_doc::session_markdown::{self, AgentDocQueueItem, AgentDocSessionDocument};
148#[cfg(test)]
149use tsift_agent_doc::session_review;
150use tsift_cache::cycle_packet_cache;
151use tsift_core::{
152 NeighborhoodScoring, RankedNeighborhoodOptions, SemanticSeededNeighborhoodOptions,
153};
154use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
155use tsift_graph as graph;
156use tsift_index::{config, index, init, multiplicity, walk};
157use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
158#[cfg(test)]
159use tsift_memory::MemoryEvent;
160use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
161use tsift_resolution as resolution;
162use tsift_search::{impact, sift};
163use tsift_sqlite as substrate;
164use tsift_status::status;
165use tsift_summarize::summarize;
166#[cfg(feature = "backend-surrealdb")]
167use tsift_surrealdb::SurrealdbGraphStore;
168use tsift_tokensave::TokensaveDb;
169
170#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
171pub(crate) enum GraphDbExperimentalBackend {
172 DuckdbDuckpgq,
173 Falkordb,
174 Ladybug,
175 Kuzu,
176 Surrealdb,
177}
178
179#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
180pub(crate) struct SearchFacetFilters {
181 #[serde(skip_serializing_if = "Vec::is_empty", default)]
182 pub(crate) languages: Vec<String>,
183 #[serde(skip_serializing_if = "Vec::is_empty", default)]
184 pub(crate) kinds: Vec<String>,
185 #[serde(skip_serializing_if = "Vec::is_empty", default)]
186 pub(crate) node_kinds: Vec<String>,
187 #[serde(skip_serializing_if = "Vec::is_empty", default)]
188 pub(crate) sections: Vec<String>,
189 #[serde(skip_serializing_if = "Vec::is_empty", default)]
190 pub(crate) parents: Vec<String>,
191 #[serde(skip_serializing_if = "Vec::is_empty", default)]
192 pub(crate) children: Vec<String>,
193 #[serde(skip_serializing_if = "Vec::is_empty", default)]
194 pub(crate) fence_languages: Vec<String>,
195 #[serde(skip_serializing_if = "Vec::is_empty", default)]
196 pub(crate) list_depths: Vec<usize>,
197 #[serde(skip_serializing_if = "Vec::is_empty", default)]
198 pub(crate) heading_levels: Vec<usize>,
199}
200
201impl SearchFacetFilters {
202 pub(crate) fn is_empty(&self) -> bool {
203 self.languages.is_empty()
204 && self.kinds.is_empty()
205 && self.node_kinds.is_empty()
206 && self.sections.is_empty()
207 && self.parents.is_empty()
208 && self.children.is_empty()
209 && self.fence_languages.is_empty()
210 && self.list_depths.is_empty()
211 && self.heading_levels.is_empty()
212 }
213
214 fn needs_ast_context(&self) -> bool {
215 !self.sections.is_empty()
216 || !self.parents.is_empty()
217 || !self.children.is_empty()
218 || !self.fence_languages.is_empty()
219 || !self.list_depths.is_empty()
220 || !self.heading_levels.is_empty()
221 }
222}
223
224#[derive(Serialize)]
225struct GraphDbBackendPromotionGate {
226 status: String,
227 native_adapter_required: bool,
228 required_checks: Vec<String>,
229}
230
231impl GraphDbExperimentalBackend {
232 fn name(self) -> &'static str {
233 match self {
234 Self::DuckdbDuckpgq => "duckdb-duckpgq",
235 Self::Falkordb => "falkordb",
236 Self::Ladybug => "ladybug",
237 Self::Kuzu => "kuzu",
238 Self::Surrealdb => "surrealdb",
239 }
240 }
241
242 fn adapter_label(self) -> &'static str {
243 match self {
244 Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
245 Self::Falkordb => "FalkorDB read-only prototype",
246 Self::Ladybug => "Ladybug read-only prototype",
247 Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
248 Self::Surrealdb => "SurrealDB read-only prototype",
249 }
250 }
251
252 fn projection_load(self) -> &'static str {
253 match self {
254 Self::Falkordb => {
255 "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"
256 }
257 Self::Kuzu => {
258 "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"
259 }
260 Self::Surrealdb => {
261 "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"
262 }
263 _ => {
264 "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
265 }
266 }
267 }
268
269 fn lock_behavior(self) -> &'static str {
270 match self {
271 Self::Falkordb => {
272 "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
273 }
274 Self::Kuzu => {
275 "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"
276 }
277 Self::Surrealdb => {
278 "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
279 }
280 _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
281 }
282 }
283
284 fn install_portability(self) -> &'static str {
285 match self {
286 Self::Falkordb => {
287 "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
288 }
289 Self::Kuzu => {
290 "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"
291 }
292 Self::Surrealdb => {
293 "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"
294 }
295 _ => {
296 "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
297 }
298 }
299 }
300
301 fn prototype_hold_reason(self) -> Option<&'static str> {
302 match self {
303 Self::DuckdbDuckpgq => Some(
304 "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
305 ),
306 Self::Falkordb => Some(
307 "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",
308 ),
309 Self::Ladybug => Some(
310 "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
311 ),
312 Self::Kuzu => Some(
313 "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
314 ),
315 Self::Surrealdb => Some(
316 "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",
317 ),
318 }
319 }
320
321 fn promotion_gate(self) -> GraphDbBackendPromotionGate {
322 match self {
323 Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
324 status: "hold_native_adapter_required".to_string(),
325 native_adapter_required: true,
326 required_checks: vec![
327 "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
328 .to_string(),
329 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
330 .to_string(),
331 "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
332 "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
333 .to_string(),
334 ],
335 },
336 Self::Falkordb => GraphDbBackendPromotionGate {
337 status: "hold_native_adapter_required".to_string(),
338 native_adapter_required: true,
339 required_checks: vec![
340 "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
341 .to_string(),
342 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
343 .to_string(),
344 "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
345 .to_string(),
346 "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
347 .to_string(),
348 ],
349 },
350 Self::Ladybug => GraphDbBackendPromotionGate {
351 status: "hold_native_adapter_required".to_string(),
352 native_adapter_required: true,
353 required_checks: vec![
354 "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
355 .to_string(),
356 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
357 .to_string(),
358 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
359 .to_string(),
360 "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
361 .to_string(),
362 ],
363 },
364 Self::Kuzu => GraphDbBackendPromotionGate {
365 status: "hold_native_adapter_required".to_string(),
366 native_adapter_required: true,
367 required_checks: vec![
368 "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
369 .to_string(),
370 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
371 .to_string(),
372 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
373 .to_string(),
374 "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
375 .to_string(),
376 ],
377 },
378 Self::Surrealdb => GraphDbBackendPromotionGate {
379 status: "hold_native_adapter_required".to_string(),
380 native_adapter_required: true,
381 required_checks: vec![
382 "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
383 .to_string(),
384 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
385 .to_string(),
386 "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
387 .to_string(),
388 "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
389 .to_string(),
390 ],
391 },
392 }
393 }
394
395 fn parse(raw: &str) -> Result<Self> {
396 match raw {
397 "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
398 "falkordb" | "falkor" => Ok(Self::Falkordb),
399 "ladybug" => Ok(Self::Ladybug),
400 "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
401 "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
402 _ => {
403 bail!(
404 "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
405 )
406 }
407 }
408 }
409}
410
411pub fn run() -> Result<()> {
412 let cli = Cli::parse();
413 let compact = cli.compact;
414 let pretty = cli.pretty;
415 let terse = cli.terse || cli.ultra_terse;
416 let ultra_terse = cli.ultra_terse;
417 let absolute = cli.absolute;
418 let tabular = cli.tabular;
419 let schema = cli.schema;
420 let envelope = cli.envelope;
421 match cli.command {
422 Some(Commands::Search {
423 query,
424 path,
425 limit,
426 strategy,
427 exact,
428 scope,
429 federated,
430 lang,
431 kind,
432 node_kind,
433 section,
434 parent,
435 child,
436 fence_language,
437 list_depth,
438 heading_level,
439 json,
440 autoindex,
441 no_autoindex,
442 timeout,
443 max_items,
444 max_bytes,
445 budget,
446 no_tagpath,
447 tagpath_strict,
448 }) => cmd_search_with_budget(
449 query,
450 path,
451 limit,
452 if exact {
453 Some("exact".to_string())
454 } else {
455 strategy
456 },
457 scope,
458 federated,
459 json || terse || schema || envelope,
460 autoindex || !no_autoindex,
461 timeout,
462 compact,
463 pretty,
464 terse,
465 ultra_terse,
466 absolute,
467 tabular,
468 schema,
469 envelope,
470 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
471 TagpathSearchOpts {
472 no_tagpath,
473 strict: tagpath_strict,
474 },
475 SearchFacetFilters {
476 languages: lang,
477 kinds: kind,
478 node_kinds: node_kind,
479 sections: section,
480 parents: parent,
481 children: child,
482 fence_languages: fence_language,
483 list_depths: list_depth,
484 heading_levels: heading_level,
485 },
486 ),
487 Some(Commands::SearchWorker {
488 path,
489 cache_dir,
490 query,
491 limit,
492 strategy,
493 output,
494 fts_index_fresh,
495 }) => cmd_search_worker(
496 &path,
497 &cache_dir,
498 &query,
499 limit,
500 &strategy,
501 &output,
502 fts_index_fresh,
503 ),
504 Some(Commands::DigestRunner {
505 kind,
506 path,
507 runner,
508 shell_command,
509 json,
510 }) => cmd_digest_runner(
511 &kind,
512 &path,
513 runner.as_deref(),
514 &shell_command,
515 OutputFormat {
516 json_output: json || terse || schema || envelope,
517 compact,
518 pretty,
519 terse,
520 ultra_terse,
521 schema,
522 envelope,
523 },
524 ),
525 Some(Commands::AstGrep { command }) => match command {
526 AstGrepCommand::Search {
527 pattern,
528 paths,
529 lang,
530 no_ignore,
531 json,
532 max_items,
533 max_bytes,
534 budget,
535 } => commands::astgrep::cmd_ast_grep_search(
536 &pattern,
537 paths,
538 lang.as_deref(),
539 no_ignore,
540 OutputFormat {
541 json_output: json || terse || schema || envelope,
542 compact,
543 pretty,
544 terse,
545 ultra_terse,
546 schema,
547 envelope,
548 },
549 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
550 ),
551 AstGrepCommand::Rewrite {
552 pattern,
553 rewrite,
554 paths,
555 lang,
556 no_ignore,
557 apply,
558 json,
559 max_items,
560 max_bytes,
561 budget,
562 } => commands::astgrep::cmd_ast_grep_rewrite(
563 &pattern,
564 &rewrite,
565 paths,
566 lang.as_deref(),
567 no_ignore,
568 apply,
569 OutputFormat {
570 json_output: json || terse || schema || envelope,
571 compact,
572 pretty,
573 terse,
574 ultra_terse,
575 schema,
576 envelope,
577 },
578 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
579 ),
580 AstGrepCommand::Languages { json } => {
581 commands::astgrep::cmd_ast_grep_languages(OutputFormat {
582 json_output: json || terse || schema || envelope,
583 compact,
584 pretty,
585 terse,
586 ultra_terse,
587 schema,
588 envelope,
589 })
590 }
591 },
592 Some(Commands::Edit { dry_run, file }) => {
593 cmd_edit(dry_run, file, compact, pretty, terse, schema)
594 }
595 Some(Commands::EditIntents {
596 path,
597 scope,
598 file,
599 json,
600 apply,
601 verify,
602 verify_command,
603 max_items,
604 max_bytes,
605 budget,
606 }) => cmd_edit_intents(
607 &path,
608 scope.as_deref(),
609 file,
610 apply,
611 SemanticEditVerifyOptions {
612 enabled: verify,
613 command: verify_command.as_deref(),
614 },
615 OutputFormat {
616 json_output: json || terse || schema || envelope,
617 compact,
618 pretty,
619 terse,
620 ultra_terse,
621 schema,
622 envelope,
623 },
624 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
625 ),
626 Some(Commands::Index {
627 path,
628 rebuild,
629 check,
630 exit_code,
631 prune,
632 quiet,
633 workspace,
634 submodule,
635 json,
636 }) => cmd_index(
637 &path,
638 rebuild,
639 check,
640 exit_code,
641 prune,
642 quiet,
643 workspace,
644 submodule.as_deref(),
645 json || terse || schema || envelope,
646 compact,
647 pretty,
648 terse,
649 absolute,
650 schema,
651 ),
652 Some(Commands::Rewrite { command, run }) => cmd_rewrite(
653 &command,
654 run,
655 OutputFormat {
656 json_output: terse || schema || envelope,
657 compact,
658 pretty,
659 terse,
660 ultra_terse,
661 schema,
662 envelope,
663 },
664 ),
665 Some(Commands::Route { task, id }) => cmd_route(&task, id),
666 Some(Commands::Memory { command }) => {
667 let json = command.json_output();
668 cmd_memory(
669 command,
670 OutputFormat {
671 json_output: json || terse || schema || envelope,
672 compact,
673 pretty,
674 terse,
675 ultra_terse,
676 schema,
677 envelope,
678 },
679 )
680 }
681 Some(Commands::LocalModel { command }) => {
682 let json = command.json_output();
683 cmd_local_model(
684 command,
685 OutputFormat {
686 json_output: json || terse || schema || envelope,
687 compact,
688 pretty,
689 terse,
690 ultra_terse,
691 schema,
692 envelope,
693 },
694 )
695 }
696 Some(Commands::Kg { command }) => match command {
697 KgCommand::Extract {
698 profile,
699 model,
700 host,
701 input,
702 source_ref,
703 graph_db,
704 no_lease,
705 idle_ttl_seconds,
706 keep_loaded,
707 lease_file,
708 no_context,
709 json,
710 } => commands::kg::cmd_kg_extract(commands::kg::KgExtractArgs {
711 profile,
712 model,
713 host,
714 input,
715 source_ref,
716 graph_db,
717 no_lease,
718 idle_ttl_seconds,
719 keep_loaded,
720 lease_file,
721 no_context,
722 json: json || terse || schema || envelope,
723 }),
724 KgCommand::Status { graph_db, json } => {
725 commands::kg::cmd_kg_status(graph_db, json || terse || schema || envelope)
726 }
727 KgCommand::Refresh {
728 graph_db,
729 apply,
730 profile,
731 model,
732 host,
733 no_lease,
734 idle_ttl_seconds,
735 keep_loaded,
736 lease_file,
737 no_context,
738 json,
739 } => commands::kg::cmd_kg_refresh(commands::kg::KgRefreshArgs {
740 graph_db,
741 json: json || terse || schema || envelope,
742 apply,
743 profile,
744 model,
745 host,
746 no_lease,
747 idle_ttl_seconds,
748 keep_loaded,
749 lease_file,
750 no_context,
751 }),
752 KgCommand::Evidence {
753 symbol,
754 kind,
755 limit,
756 graph_db,
757 json,
758 } => commands::kg::cmd_kg_evidence(
759 symbol,
760 kind,
761 limit,
762 graph_db,
763 json || terse || schema || envelope,
764 ),
765 KgCommand::Unload {
766 profile,
767 model,
768 host,
769 json,
770 } => commands::kg::cmd_kg_unload(
771 profile,
772 model,
773 host,
774 json || terse || schema || envelope,
775 ),
776 KgCommand::Smoke {
777 profile,
778 model,
779 host,
780 unload,
781 json,
782 } => commands::kg::cmd_kg_smoke(
783 profile,
784 model,
785 host,
786 unload,
787 json || terse || schema || envelope,
788 ),
789 },
790 Some(Commands::Finding { command }) => match command {
791 cli::FindingCommand::Add {
792 path,
793 kind,
794 title,
795 body,
796 about,
797 confidence,
798 status,
799 relates,
800 scope,
801 json,
802 } => commands::finding::cmd_finding_add(
803 &path,
804 &kind,
805 &title,
806 &body,
807 &about,
808 confidence,
809 &status,
810 relates.as_deref(),
811 scope.as_deref(),
812 json || terse || schema || envelope,
813 pretty,
814 ),
815 cli::FindingCommand::List {
816 path,
817 about,
818 kind,
819 status,
820 include_stale,
821 scope,
822 json,
823 } => commands::finding::cmd_finding_list(
824 &path,
825 about.as_deref(),
826 kind.as_deref(),
827 status.as_deref(),
828 include_stale,
829 scope.as_deref(),
830 json || terse || schema || envelope,
831 pretty,
832 ),
833 cli::FindingCommand::Harvest { path, scope, json } => {
834 commands::finding::cmd_finding_harvest(
835 &path,
836 scope.as_deref(),
837 json || terse || schema || envelope,
838 pretty,
839 )
840 }
841 cli::FindingCommand::Promote { id, path, json } => {
842 commands::finding::cmd_finding_promote(
843 &path,
844 &id,
845 json || terse || schema || envelope,
846 pretty,
847 )
848 }
849 },
850 Some(Commands::Graph {
851 symbol,
852 path,
853 callers,
854 callees,
855 scope,
856 limit,
857 json,
858 no_tagpath,
859 tagpath_strict,
860 }) => cmd_graph(
861 &symbol,
862 &path,
863 callers,
864 callees,
865 scope.as_deref(),
866 limit,
867 json || terse || schema || envelope,
868 compact,
869 pretty,
870 terse,
871 absolute,
872 tabular,
873 schema,
874 TagpathSearchOpts {
875 no_tagpath,
876 strict: tagpath_strict,
877 },
878 ),
879 Some(Commands::Sql {
880 db,
881 query,
882 table,
883 json,
884 }) => cmd_sql(
885 &db,
886 query,
887 table,
888 json || terse || schema || envelope,
889 compact,
890 pretty,
891 terse,
892 schema,
893 ),
894 Some(Commands::Communities {
895 path,
896 scope,
897 min_size,
898 limit,
899 json,
900 no_tagpath,
901 tagpath_strict,
902 }) => cmd_communities(
903 &path,
904 scope.as_deref(),
905 min_size,
906 limit,
907 json || terse || schema || envelope,
908 compact,
909 pretty,
910 terse,
911 tabular,
912 schema,
913 TagpathSearchOpts {
914 no_tagpath,
915 strict: tagpath_strict,
916 },
917 ),
918 Some(Commands::Analyze {
919 path,
920 scope,
921 entry_points,
922 limit,
923 json,
924 }) => cmd_analyze(
925 &path,
926 scope.as_deref(),
927 &entry_points,
928 limit,
929 OutputFormat {
930 json_output: json || terse || schema || envelope,
931 compact,
932 pretty,
933 terse,
934 ultra_terse,
935 schema,
936 envelope,
937 },
938 ),
939 Some(Commands::Path {
940 from,
941 to,
942 path,
943 scope,
944 json,
945 no_tagpath,
946 tagpath_strict,
947 }) => cmd_path(
948 &from,
949 &to,
950 &path,
951 scope.as_deref(),
952 json || terse || schema || envelope,
953 compact,
954 pretty,
955 terse,
956 schema,
957 TagpathSearchOpts {
958 no_tagpath,
959 strict: tagpath_strict,
960 },
961 ),
962 Some(Commands::Explain {
963 symbol,
964 path,
965 scope,
966 limit,
967 json,
968 max_items,
969 max_bytes,
970 budget,
971 no_tagpath,
972 tagpath_strict,
973 }) => cmd_explain_with_budget(
974 &symbol,
975 &path,
976 scope.as_deref(),
977 limit,
978 json || terse || schema || envelope,
979 compact,
980 pretty,
981 terse,
982 ultra_terse,
983 absolute,
984 tabular,
985 schema,
986 envelope,
987 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
988 TagpathSearchOpts {
989 no_tagpath,
990 strict: tagpath_strict,
991 },
992 ),
993 Some(Commands::Traverse {
994 node,
995 to,
996 path,
997 scope,
998 depth,
999 limit,
1000 format,
1001 convex_snapshot,
1002 }) => cmd_traverse(
1003 node.as_deref(),
1004 to.as_deref(),
1005 &path,
1006 scope.as_deref(),
1007 depth,
1008 limit,
1009 format,
1010 pretty,
1011 terse,
1012 schema,
1013 convex_snapshot.as_deref(),
1014 ),
1015 Some(Commands::ConvexSync {
1016 path,
1017 scope,
1018 snapshot,
1019 chunk_size,
1020 remote_snapshot,
1021 apply,
1022 endpoint,
1023 auth_token_env,
1024 json,
1025 }) => cmd_convex_sync(
1026 ConvexSyncOptions {
1027 path: &path,
1028 scope: scope.as_deref(),
1029 snapshot: snapshot.as_deref(),
1030 chunk_size,
1031 remote_snapshot,
1032 apply,
1033 endpoint: endpoint.as_deref(),
1034 auth_token_env: &auth_token_env,
1035 },
1036 OutputFormat {
1037 json_output: json || terse || schema || envelope,
1038 compact,
1039 pretty,
1040 terse,
1041 ultra_terse,
1042 schema,
1043 envelope,
1044 },
1045 ),
1046 Some(Commands::GraphDb {
1047 path,
1048 scope,
1049 backend,
1050 convex_snapshot,
1051 json,
1052 query,
1053 }) => cmd_graph_db(
1054 &path,
1055 scope.as_deref(),
1056 backend,
1057 convex_snapshot.as_deref(),
1058 query,
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::SourceRead {
1070 file,
1071 path,
1072 style,
1073 start,
1074 lines,
1075 end,
1076 scope,
1077 json,
1078 max_items,
1079 max_bytes,
1080 budget,
1081 }) => cmd_source_read(
1082 &file,
1083 &path,
1084 style,
1085 start,
1086 lines,
1087 end,
1088 scope.as_deref(),
1089 OutputFormat {
1090 json_output: json || terse || schema || envelope,
1091 compact,
1092 pretty,
1093 terse,
1094 ultra_terse,
1095 schema,
1096 envelope,
1097 },
1098 absolute,
1099 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1100 ),
1101 Some(Commands::MarkdownAst {
1102 file,
1103 path,
1104 node,
1105 json,
1106 max_items,
1107 max_bytes,
1108 budget,
1109 }) => cmd_markdown_ast(
1110 &file,
1111 &path,
1112 node.as_deref(),
1113 OutputFormat {
1114 json_output: json || terse || schema || envelope,
1115 compact,
1116 pretty,
1117 terse,
1118 ultra_terse,
1119 schema,
1120 envelope,
1121 },
1122 absolute,
1123 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1124 ),
1125 Some(Commands::SymbolRead {
1126 symbol,
1127 file,
1128 path,
1129 scope,
1130 json,
1131 max_items,
1132 max_bytes,
1133 budget,
1134 }) => cmd_symbol_read(
1135 &symbol,
1136 file.as_deref(),
1137 &path,
1138 scope.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 absolute,
1149 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1150 ),
1151 Some(Commands::Audit {
1152 skills_dir,
1153 manifest,
1154 usage,
1155 cleanup,
1156 report,
1157 json,
1158 }) => cmd_audit(
1159 &skills_dir,
1160 manifest,
1161 usage,
1162 cleanup,
1163 report,
1164 json || terse || schema || envelope,
1165 compact,
1166 pretty,
1167 terse,
1168 schema,
1169 ),
1170 Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
1171 &path,
1172 scope.as_deref(),
1173 json || terse || schema || envelope,
1174 pretty,
1175 terse,
1176 schema,
1177 ),
1178 Some(Commands::Init {
1179 path,
1180 codex,
1181 opencode,
1182 workspace,
1183 }) => cmd_init(&path, codex, opencode, workspace),
1184 Some(Commands::Lint {
1185 file,
1186 index,
1187 entities_from,
1188 json,
1189 }) => cmd_lint(
1190 &file,
1191 index,
1192 entities_from,
1193 json || terse || schema || envelope,
1194 compact,
1195 pretty,
1196 terse,
1197 schema,
1198 ),
1199 Some(Commands::Summarize {
1200 symbol,
1201 file,
1202 extract,
1203 diff,
1204 stats,
1205 path,
1206 profile,
1207 json,
1208 }) => cmd_summarize(
1209 symbol,
1210 file,
1211 extract,
1212 diff,
1213 stats,
1214 &path,
1215 json || terse || schema || envelope,
1216 compact,
1217 pretty,
1218 terse,
1219 schema,
1220 profile,
1221 ),
1222 Some(Commands::Semantic {
1223 query,
1224 path,
1225 scope,
1226 limit,
1227 kind,
1228 profile,
1229 json,
1230 }) => cmd_semantic_related(
1231 &query,
1232 &path,
1233 scope.as_deref(),
1234 limit,
1235 kind,
1236 json || terse || schema || envelope,
1237 compact,
1238 pretty,
1239 terse,
1240 schema,
1241 profile,
1242 ),
1243 Some(Commands::DiffDigest {
1244 path,
1245 cached,
1246 revision,
1247 max_parsed_files,
1248 json,
1249 }) => cmd_diff_digest(
1250 &path,
1251 cached,
1252 revision.as_deref(),
1253 max_parsed_files,
1254 OutputFormat {
1255 json_output: json || terse || schema || envelope,
1256 compact,
1257 pretty,
1258 terse,
1259 ultra_terse,
1260 schema,
1261 envelope,
1262 },
1263 ),
1264 Some(Commands::Impact {
1265 path,
1266 cached,
1267 revision,
1268 scope,
1269 limit,
1270 json,
1271 }) => cmd_impact(
1272 &path,
1273 cached,
1274 revision.as_deref(),
1275 scope.as_deref(),
1276 limit,
1277 OutputFormat {
1278 json_output: json || terse || schema || envelope,
1279 compact,
1280 pretty,
1281 terse,
1282 ultra_terse,
1283 schema,
1284 envelope,
1285 },
1286 ),
1287 Some(Commands::TestDigest {
1288 path,
1289 input,
1290 runner,
1291 json,
1292 }) => cmd_test_digest(
1293 &path,
1294 input.as_deref(),
1295 runner.as_deref(),
1296 OutputFormat {
1297 json_output: json || terse || schema || envelope,
1298 compact,
1299 pretty,
1300 terse,
1301 ultra_terse,
1302 schema,
1303 envelope,
1304 },
1305 ),
1306 Some(Commands::LogDigest {
1307 path,
1308 input,
1309 fixture,
1310 fail_under,
1311 json,
1312 }) => cmd_log_digest(
1313 &path,
1314 input.as_deref(),
1315 fixture.as_deref(),
1316 fail_under,
1317 OutputFormat {
1318 json_output: json || terse || schema || envelope,
1319 compact,
1320 pretty,
1321 terse,
1322 ultra_terse,
1323 schema,
1324 envelope,
1325 },
1326 ),
1327 Some(Commands::ContextPack {
1328 path,
1329 test_input,
1330 runner,
1331 log_input,
1332 json,
1333 max_items,
1334 max_bytes,
1335 budget,
1336 convex_snapshot,
1337 }) => cmd_context_pack(
1338 &path,
1339 test_input.as_deref(),
1340 runner.as_deref(),
1341 log_input.as_deref(),
1342 OutputFormat {
1343 json_output: json || terse || schema || envelope,
1344 compact,
1345 pretty,
1346 terse,
1347 ultra_terse,
1348 schema,
1349 envelope,
1350 },
1351 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1352 convex_snapshot.as_deref(),
1353 ),
1354 Some(Commands::ConflictMatrix {
1355 targets,
1356 path,
1357 scope,
1358 depth,
1359 limit,
1360 impact_limit,
1361 json,
1362 }) => cmd_conflict_matrix(
1363 &path,
1364 scope.as_deref(),
1365 &targets,
1366 depth,
1367 limit,
1368 impact_limit,
1369 OutputFormat {
1370 json_output: json || terse || schema || envelope,
1371 compact,
1372 pretty,
1373 terse,
1374 ultra_terse,
1375 schema,
1376 envelope,
1377 },
1378 ),
1379 Some(Commands::DispatchTrace {
1380 targets,
1381 path,
1382 scope,
1383 depth,
1384 limit,
1385 impact_limit,
1386 format,
1387 json,
1388 }) => cmd_dispatch_trace(
1389 DispatchTraceOptions {
1390 path: &path,
1391 scope: scope.as_deref(),
1392 raw_targets: &targets,
1393 depth,
1394 limit,
1395 impact_limit,
1396 trace_format: if json {
1397 DispatchTraceFormat::Json
1398 } else {
1399 format
1400 },
1401 },
1402 OutputFormat {
1403 json_output: json || terse || schema || envelope,
1404 compact,
1405 pretty,
1406 terse,
1407 ultra_terse,
1408 schema,
1409 envelope,
1410 },
1411 ),
1412 Some(Commands::DependencyDag {
1413 targets,
1414 path,
1415 scope,
1416 depth,
1417 limit,
1418 json,
1419 }) => cmd_dependency_dag(
1420 &path,
1421 scope.as_deref(),
1422 &targets,
1423 depth,
1424 limit,
1425 OutputFormat {
1426 json_output: json || terse || schema || envelope,
1427 compact,
1428 pretty,
1429 terse,
1430 ultra_terse,
1431 schema,
1432 envelope,
1433 },
1434 ),
1435 Some(Commands::TokenSavings {
1436 fixture,
1437 fail_under,
1438 json,
1439 }) => token_savings::cmd_token_savings(
1440 &fixture,
1441 fail_under,
1442 OutputFormat {
1443 json_output: json || terse || schema || envelope,
1444 compact,
1445 pretty,
1446 terse,
1447 ultra_terse,
1448 schema,
1449 envelope,
1450 },
1451 ),
1452 Some(Commands::MetricDigest {
1453 input,
1454 baseline,
1455 metrics,
1456 lower_is_better,
1457 higher_is_better,
1458 history,
1459 top,
1460 json,
1461 }) => cmd_metric_digest(
1462 MetricDigestOptions {
1463 input_path: input.as_deref(),
1464 baseline_path: baseline.as_deref(),
1465 metrics: &metrics,
1466 lower_is_better: &lower_is_better,
1467 higher_is_better: &higher_is_better,
1468 history,
1469 top,
1470 },
1471 OutputFormat {
1472 json_output: json || terse || schema || envelope,
1473 compact,
1474 pretty,
1475 terse,
1476 ultra_terse,
1477 schema,
1478 envelope,
1479 },
1480 ),
1481 Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1482 &fixture,
1483 OutputFormat {
1484 json_output: json || terse || schema || envelope,
1485 compact,
1486 pretty,
1487 terse,
1488 ultra_terse,
1489 schema,
1490 envelope,
1491 },
1492 ),
1493 Some(Commands::TokenGate { command }) => {
1494 cmd_token_gate(
1495 command,
1496 OutputFormat {
1497 json_output: true,
1498 compact,
1499 pretty,
1500 terse,
1501 ultra_terse,
1502 schema,
1503 envelope,
1504 },
1505 )?;
1506 Ok(())
1507 }
1508 Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1509 &topic,
1510 OutputFormat {
1511 json_output: json || terse || schema || envelope,
1512 compact,
1513 pretty,
1514 terse,
1515 ultra_terse,
1516 schema,
1517 envelope,
1518 },
1519 ),
1520 Some(Commands::SessionDigest {
1521 path,
1522 input,
1523 source,
1524 json,
1525 }) => cmd_session_digest(
1526 &path,
1527 input.as_deref(),
1528 source.as_deref(),
1529 OutputFormat {
1530 json_output: json || terse || schema || envelope,
1531 compact,
1532 pretty,
1533 terse,
1534 ultra_terse,
1535 schema,
1536 envelope,
1537 },
1538 ),
1539 Some(Commands::SessionCost {
1540 input,
1541 fixture,
1542 fail_under,
1543 source,
1544 json,
1545 }) => cmd_session_cost(
1546 input.as_deref(),
1547 fixture.as_deref(),
1548 fail_under,
1549 source.as_deref(),
1550 OutputFormat {
1551 json_output: json || terse || schema || envelope,
1552 compact,
1553 pretty,
1554 terse,
1555 ultra_terse,
1556 schema,
1557 envelope,
1558 },
1559 ),
1560 Some(Commands::SessionReview {
1561 path,
1562 next_context,
1563 json,
1564 max_items,
1565 max_bytes,
1566 budget,
1567 }) => cmd_session_review_with_budget(
1568 &path,
1569 next_context,
1570 OutputFormat {
1571 json_output: json || terse || schema || envelope,
1572 compact,
1573 pretty,
1574 terse,
1575 ultra_terse,
1576 schema,
1577 envelope,
1578 },
1579 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1580 ),
1581 Some(Commands::Status {
1582 path,
1583 fix,
1584 no_fix,
1585 json,
1586 }) => cmd_status(
1587 &path,
1588 StatusCommandOptions {
1589 fix,
1590 no_fix,
1591 json_output: json || terse || schema || envelope,
1592 compact,
1593 pretty,
1594 terse,
1595 schema,
1596 },
1597 ),
1598 Some(Commands::Locks { path, scope, json }) => cmd_locks(
1599 &path,
1600 scope.as_deref(),
1601 json || terse || schema || envelope,
1602 compact,
1603 pretty,
1604 terse,
1605 schema,
1606 ),
1607 None => {
1608 println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1609 println!("Run `tsift --help` for usage.");
1610 Ok(())
1611 }
1612 }
1613}
1614
1615fn cmd_local_model(command: LocalModelCommand, output: OutputFormat) -> Result<()> {
1616 match command {
1617 LocalModelCommand::Status { no_probe, .. } => {
1618 let report = tsift_local_model::build_status_report(!no_probe);
1619 if output.json_output {
1620 if output.pretty {
1621 println!("{}", serde_json::to_string_pretty(&report)?);
1622 } else {
1623 println!("{}", serde_json::to_string(&report)?);
1624 }
1625 } else {
1626 print!("{}", tsift_local_model::format_status_human(&report));
1627 }
1628 Ok(())
1629 }
1630 LocalModelCommand::Unload {
1631 profile,
1632 provider_endpoint,
1633 provider_pid,
1634 idle_ttl_seconds,
1635 no_probe,
1636 pre_used_mib,
1637 post_used_mib,
1638 tolerance_mib,
1639 strict,
1640 ..
1641 } => {
1642 let profile = tsift_local_model::profile_by_id(&profile)
1643 .with_context(|| format!("unknown local model profile {profile:?}"))?;
1644 let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1645 let post_probe =
1646 lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1647 let report = tsift_local_model::build_lifecycle_report(
1648 profile,
1649 pre_probe,
1650 post_probe,
1651 provider_endpoint,
1652 provider_pid,
1653 idle_ttl_seconds,
1654 tolerance_mib,
1655 );
1656 if output.json_output {
1657 if output.pretty {
1658 println!("{}", serde_json::to_string_pretty(&report)?);
1659 } else {
1660 println!("{}", serde_json::to_string(&report)?);
1661 }
1662 } else {
1663 print!("{}", tsift_local_model::format_lifecycle_human(&report));
1664 }
1665 if strict && !report.cleanup.cleanup_proven {
1666 bail!(
1667 "local model VRAM cleanup was not proven: {}",
1668 report.cleanup.reason
1669 );
1670 }
1671 Ok(())
1672 }
1673 LocalModelCommand::Lease { command } => cmd_local_model_lease(command, output),
1674 LocalModelCommand::Resolve {
1675 profile,
1676 role,
1677 no_probe,
1678 ..
1679 } => {
1680 let preference_value = profile.as_deref();
1681 let preference = tsift_local_model::ProfilePreference::from_cli(preference_value);
1682 let probe = if no_probe {
1683 tsift_local_model::GpuProbe::unavailable("gpu probe skipped")
1684 } else {
1685 tsift_local_model::probe_nvidia_smi()
1686 };
1687 let resolution = tsift_local_model::resolve_profile_preference(
1688 &preference,
1689 role.to_model_role(),
1690 &probe,
1691 );
1692 if output.json_output {
1693 if output.pretty {
1694 println!("{}", serde_json::to_string_pretty(&resolution)?);
1695 } else {
1696 println!("{}", serde_json::to_string(&resolution)?);
1697 }
1698 } else {
1699 println!(
1700 "preference: {} | role: {:?}",
1701 preference.describe(),
1702 role.to_model_role()
1703 );
1704 println!(
1705 "selected: {} ({})",
1706 resolution.profile.id, resolution.profile.label
1707 );
1708 println!("selectable: {}", resolution.selectable);
1709 println!("source: {:?}", resolution.source);
1710 println!("reason: {}", resolution.reason);
1711 }
1712 Ok(())
1713 }
1714 LocalModelCommand::Swap {
1715 from,
1716 to,
1717 provider_endpoint,
1718 provider_pid,
1719 idle_ttl_seconds,
1720 no_probe,
1721 pre_used_mib,
1722 post_used_mib,
1723 tolerance_mib,
1724 strict,
1725 ..
1726 } => {
1727 let from_profile = tsift_local_model::profile_by_id(&from)
1728 .with_context(|| format!("unknown source local model profile {from:?}"))?;
1729 let to_profile = tsift_local_model::profile_by_id(&to)
1730 .with_context(|| format!("unknown target local model profile {to:?}"))?;
1731 let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1732 let post_probe =
1733 lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1734 let report = tsift_local_model::build_swap_report(
1735 from_profile,
1736 to_profile,
1737 pre_probe,
1738 post_probe,
1739 provider_endpoint,
1740 provider_pid,
1741 idle_ttl_seconds,
1742 tolerance_mib,
1743 );
1744 if output.json_output {
1745 if output.pretty {
1746 println!("{}", serde_json::to_string_pretty(&report)?);
1747 } else {
1748 println!("{}", serde_json::to_string(&report)?);
1749 }
1750 } else {
1751 println!(
1752 "swap: {} -> {} | status: {:?}",
1753 report.from_profile_id, report.to_profile_id, report.swap_status
1754 );
1755 println!(
1756 "unload cleanup: {:?} ({})",
1757 report.unload.cleanup.status, report.unload.cleanup.reason
1758 );
1759 println!(
1760 "target resolution: {:?} -> {} (selectable: {})",
1761 report.target_resolution.source,
1762 report.target_resolution.profile.id,
1763 report.target_resolution.selectable
1764 );
1765 for note in &report.notes {
1766 println!("note: {note}");
1767 }
1768 }
1769 if strict {
1770 match report.swap_status {
1771 tsift_local_model::SwapStatus::UnloadNotProven => {
1772 bail!(
1773 "swap blocked: source unload cleanup was not proven ({})",
1774 report.unload.cleanup.reason
1775 );
1776 }
1777 tsift_local_model::SwapStatus::UnloadProvenTargetUnselectable => {
1778 bail!(
1779 "swap blocked: target {} is not selectable on the post-unload probe",
1780 report.to_profile_id
1781 );
1782 }
1783 _ => {}
1784 }
1785 }
1786 Ok(())
1787 }
1788 }
1789}
1790
1791fn unload_profile_model(
1798 profile_id: &str,
1799 host: Option<&str>,
1800) -> tsift_local_model::UnloadActionResult {
1801 let endpoint = tsift_local_model::resolve_provider_endpoint(
1802 &tsift_local_model::UnloadStrategy::OllamaKeepAliveZero,
1803 host,
1804 );
1805 match tsift_local_model::profile_by_id(profile_id) {
1806 Some(profile) => tsift_local_model::unload_model_at(&endpoint, profile.model_ref),
1807 None => tsift_local_model::unload_model_at(&endpoint, profile_id),
1808 }
1809}
1810
1811fn cmd_local_model_lease(command: LeaseCommand, output: OutputFormat) -> Result<()> {
1812 use tsift_local_model::{
1813 acquire_lease, current_unix_seconds, format_lease_show_human, lease_mode_for_profile,
1814 profile_by_id, reap_leases, release_lease, renew_lease, resolve_lease_file, show_registry,
1815 };
1816 let now = current_unix_seconds();
1817 match command {
1818 LeaseCommand::Acquire {
1819 profile,
1820 holder_pid,
1821 holder_command,
1822 idle_ttl_seconds,
1823 vram_baseline_mib,
1824 no_probe,
1825 lease_file,
1826 strict,
1827 ..
1828 } => {
1829 let profile_lookup = profile_by_id(&profile)
1830 .with_context(|| format!("unknown local model profile {profile:?}"))?;
1831 let _ = lease_mode_for_profile(&profile_lookup);
1833 let pid = holder_pid.unwrap_or_else(std::process::id);
1834 let baseline = match vram_baseline_mib {
1835 Some(value) => value,
1836 None => {
1837 if no_probe {
1838 0
1839 } else {
1840 let probe = tsift_local_model::probe_nvidia_smi();
1841 probe.used_vram_mib.unwrap_or(0)
1842 }
1843 }
1844 };
1845 let path = resolve_lease_file(lease_file.as_deref());
1846 let acquisition = acquire_lease(
1847 &profile,
1848 pid,
1849 &holder_command,
1850 baseline,
1851 idle_ttl_seconds,
1852 now,
1853 &path,
1854 )?;
1855 if output.json_output {
1856 if output.pretty {
1857 println!("{}", serde_json::to_string_pretty(&acquisition)?);
1858 } else {
1859 println!("{}", serde_json::to_string(&acquisition)?);
1860 }
1861 } else {
1862 println!(
1863 "lease {} for {} (pid={}): {:?}",
1864 match acquisition.status {
1865 tsift_local_model::GpuLeaseAcquisitionStatus::Acquired => "acquired",
1866 tsift_local_model::GpuLeaseAcquisitionStatus::Refreshed => "refreshed",
1867 tsift_local_model::GpuLeaseAcquisitionStatus::ReclaimedStale => {
1868 "reclaimed-stale"
1869 }
1870 tsift_local_model::GpuLeaseAcquisitionStatus::CpuOrHashBypass => {
1871 "bypass-cpu-or-hash"
1872 }
1873 tsift_local_model::GpuLeaseAcquisitionStatus::Conflict => "conflicted",
1874 },
1875 acquisition.profile_id,
1876 acquisition.holder_pid,
1877 acquisition.status
1878 );
1879 if let Some(conflict) = &acquisition.conflict {
1880 println!(
1881 "held by pid={} cmd={} acquired {}s ago",
1882 conflict.holder_pid,
1883 conflict.holder_command,
1884 now.saturating_sub(conflict.acquired_at_unix_seconds)
1885 );
1886 }
1887 println!("registry: {}", path.display());
1888 }
1889 if strict
1890 && acquisition.status == tsift_local_model::GpuLeaseAcquisitionStatus::Conflict
1891 {
1892 bail!(
1893 "gpu lease for {profile:?} is held by pid={}",
1894 acquisition
1895 .conflict
1896 .map(|conflict| conflict.holder_pid.to_string())
1897 .unwrap_or_else(|| "unknown".to_string())
1898 );
1899 }
1900 Ok(())
1901 }
1902 LeaseCommand::Release {
1903 profile,
1904 holder_pid,
1905 lease_file,
1906 unload_on_last_release,
1907 host,
1908 ..
1909 } => {
1910 let pid = holder_pid.unwrap_or_else(std::process::id);
1911 let path = resolve_lease_file(lease_file.as_deref());
1912 let release = release_lease(&profile, pid, now, &path)?;
1913 let unloaded = if unload_on_last_release
1916 && release.outcome == tsift_local_model::GpuLeaseReleaseOutcome::Released
1917 && release.remaining_holders == 0
1918 {
1919 Some(unload_profile_model(&profile, host.as_deref()))
1920 } else {
1921 None
1922 };
1923 if output.json_output {
1924 let payload = serde_json::json!({
1925 "release": release,
1926 "unloaded": unloaded,
1927 });
1928 if output.pretty {
1929 println!("{}", serde_json::to_string_pretty(&payload)?);
1930 } else {
1931 println!("{}", serde_json::to_string(&payload)?);
1932 }
1933 } else {
1934 println!(
1935 "release {} for {} (pid={}): {:?} (remaining holders: {})",
1936 match release.outcome {
1937 tsift_local_model::GpuLeaseReleaseOutcome::Released => "ok",
1938 tsift_local_model::GpuLeaseReleaseOutcome::NotHeld => "not-held",
1939 tsift_local_model::GpuLeaseReleaseOutcome::ProfileAbsent => "absent",
1940 },
1941 release.profile_id,
1942 release.holder_pid,
1943 release.outcome,
1944 release.remaining_holders
1945 );
1946 if let Some(result) = &unloaded {
1947 println!("unloaded {} (last reference released): {}", profile, result.outcome);
1948 }
1949 println!("registry: {}", path.display());
1950 }
1951 Ok(())
1952 }
1953 LeaseCommand::Renew {
1954 profile,
1955 holder_pid,
1956 lease_file,
1957 ..
1958 } => {
1959 let pid = holder_pid.unwrap_or_else(std::process::id);
1960 let path = resolve_lease_file(lease_file.as_deref());
1961 let renew = renew_lease(&profile, pid, now, &path)?;
1962 if output.json_output {
1963 if output.pretty {
1964 println!("{}", serde_json::to_string_pretty(&renew)?);
1965 } else {
1966 println!("{}", serde_json::to_string(&renew)?);
1967 }
1968 } else {
1969 println!(
1970 "renew {} (pid={}): {:?}",
1971 renew.profile_id, renew.holder_pid, renew.outcome
1972 );
1973 println!("registry: {}", path.display());
1974 }
1975 Ok(())
1976 }
1977 LeaseCommand::Reap {
1978 lease_file,
1979 unload_empty,
1980 host,
1981 ..
1982 } => {
1983 let path = resolve_lease_file(lease_file.as_deref());
1984 let reap = reap_leases(now, &path)?;
1985 let unloaded: Vec<_> = if unload_empty {
1988 reap.emptied_profiles
1989 .iter()
1990 .filter_map(|profile_id| {
1991 profile_by_id(profile_id).map(|profile| {
1992 serde_json::json!({
1993 "profile": profile_id,
1994 "outcome": unload_profile_model(profile_id, host.as_deref()).outcome,
1995 "model": profile.model_ref,
1996 })
1997 })
1998 })
1999 .collect()
2000 } else {
2001 Vec::new()
2002 };
2003 if output.json_output {
2004 let payload = serde_json::json!({
2005 "reap": reap,
2006 "unloaded": unloaded,
2007 });
2008 if output.pretty {
2009 println!("{}", serde_json::to_string_pretty(&payload)?);
2010 } else {
2011 println!("{}", serde_json::to_string(&payload)?);
2012 }
2013 } else {
2014 println!(
2015 "reaped {} stale holder(s); {} profile(s) dropped to zero references",
2016 reap.reclaimed.len(),
2017 reap.emptied_profiles.len()
2018 );
2019 for profile_id in &reap.emptied_profiles {
2020 println!(" emptied: {profile_id}");
2021 }
2022 if !unloaded.is_empty() {
2023 println!("unloaded {} unreferenced model(s)", unloaded.len());
2024 }
2025 println!("registry: {}", path.display());
2026 }
2027 Ok(())
2028 }
2029 LeaseCommand::Show {
2030 lease_file,
2031 include_stale,
2032 ..
2033 } => {
2034 let path = resolve_lease_file(lease_file.as_deref());
2035 let registry = show_registry(&path, now, include_stale)?;
2036 if output.json_output {
2037 if output.pretty {
2038 println!("{}", serde_json::to_string_pretty(®istry)?);
2039 } else {
2040 println!("{}", serde_json::to_string(®istry)?);
2041 }
2042 } else {
2043 print!("{}", format_lease_show_human(®istry, now));
2044 }
2045 println!("registry: {}", path.display());
2046 Ok(())
2047 }
2048 }
2049}
2050
2051fn lifecycle_probe(
2052 no_probe: bool,
2053 synthetic_used_mib: Option<u64>,
2054 skipped_reason: &str,
2055) -> tsift_local_model::GpuProbe {
2056 if let Some(used_mib) = synthetic_used_mib {
2057 return tsift_local_model::GpuProbe::synthetic_vram(used_mib);
2058 }
2059 if no_probe {
2060 return tsift_local_model::GpuProbe::unavailable(skipped_reason);
2061 }
2062 tsift_local_model::probe_nvidia_smi()
2063}
2064
2065pub fn classify_task(task: &str) -> (&'static str, &'static str) {
2068 let lower = task.to_lowercase();
2069 for signal in &[
2071 "architect",
2072 "architecture",
2073 "design",
2074 "plan",
2075 "strateg",
2076 "analy",
2077 "review",
2078 "evaluate",
2079 "assess",
2080 ] {
2081 if lower.contains(signal) {
2082 return ("opus", "claude-opus-4-6");
2083 }
2084 }
2085 for signal in &[
2087 "edit",
2088 "write",
2089 "fix",
2090 "change",
2091 "update",
2092 "create",
2093 "add ",
2094 "remove",
2095 "delete",
2096 "modify",
2097 "refactor",
2098 "implement",
2099 "build",
2100 ] {
2101 if lower.contains(signal) {
2102 return ("sonnet", "claude-sonnet-4-6");
2103 }
2104 }
2105 ("haiku", "claude-haiku-4-5-20251001")
2107}
2108
2109#[cfg(test)]
2110fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
2111 to_json_schema(val, pretty, terse, false, false)
2112}
2113
2114pub(crate) fn inject_tagpath_stale_into_json(
2121 value: &mut serde_json::Value,
2122 stale: bool,
2123 reason: Option<&str>,
2124) {
2125 if !stale {
2126 return;
2127 }
2128 if let Some(obj) = value.as_object_mut() {
2129 obj.insert(
2130 "tagpath_index_stale".to_string(),
2131 serde_json::Value::Bool(true),
2132 );
2133 if let Some(reason) = reason {
2134 obj.insert(
2135 "tagpath_stale_reason".to_string(),
2136 serde_json::Value::String(reason.to_string()),
2137 );
2138 }
2139 }
2140}
2141
2142pub(crate) fn to_json_schema<T: serde::Serialize>(
2143 val: &T,
2144 pretty: bool,
2145 terse: bool,
2146 ultra_terse: bool,
2147 schema: bool,
2148) -> anyhow::Result<String> {
2149 if terse || schema {
2150 let value = serde_json::to_value(val)?;
2151 let mut transformed = if terse { terse_transform(value) } else { value };
2152 if ultra_terse {
2153 transformed = ultra_terse_transform(transformed);
2154 transformed = edge_index_transform(transformed);
2155 }
2156 if schema {
2157 transformed = schema_transform(transformed);
2158 }
2159 if terse {
2160 let terse_schema = terse_schema_for(&transformed);
2161 let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
2162 if pretty {
2163 Ok(serde_json::to_string_pretty(&wrapped)?)
2164 } else {
2165 Ok(serde_json::to_string(&wrapped)?)
2166 }
2167 } else if pretty {
2168 Ok(serde_json::to_string_pretty(&transformed)?)
2169 } else {
2170 Ok(serde_json::to_string(&transformed)?)
2171 }
2172 } else if pretty {
2173 Ok(serde_json::to_string_pretty(val)?)
2174 } else {
2175 Ok(serde_json::to_string(val)?)
2176 }
2177}
2178
2179pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
2180 ToolEnvelopeMetric {
2181 label: label.to_string(),
2182 value: value.to_string(),
2183 }
2184}
2185
2186pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
2187 let mut seen = HashSet::new();
2188 let mut deduped = Vec::new();
2189 for value in values {
2190 if seen.insert(value.clone()) {
2191 deduped.push(value);
2192 }
2193 }
2194 deduped
2195}
2196
2197pub(crate) fn print_json_or_envelope<T: Serialize>(
2198 report: &T,
2199 format: &OutputFormat,
2200 tool: &str,
2201 view: &str,
2202 summary: ToolEnvelopeSummary,
2203 truncated: bool,
2204 follow_up: Vec<String>,
2205) -> Result<()> {
2206 if format.envelope {
2207 let schema = format.schema || tool == "source-read";
2208 let envelope = ToolEnvelope {
2209 tool,
2210 view,
2211 summary,
2212 truncated,
2213 follow_up: dedupe_preserve_order(follow_up),
2214 report,
2215 };
2216 println!(
2217 "{}",
2218 to_json_schema(
2219 &envelope,
2220 format.pretty,
2221 format.terse,
2222 format.ultra_terse,
2223 schema
2224 )?
2225 );
2226 } else {
2227 println!(
2228 "{}",
2229 to_json_schema(
2230 report,
2231 format.pretty,
2232 format.terse,
2233 format.ultra_terse,
2234 format.schema
2235 )?
2236 );
2237 }
2238 Ok(())
2239}
2240
2241pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
2242 bytes.div_ceil(4)
2243}
2244
2245fn cmd_token_gate(command: cli::TokenGateCommand, format: OutputFormat) -> Result<()> {
2246 match command {
2247 cli::TokenGateCommand::Sample {
2248 surface,
2249 path,
2250 scope,
2251 target,
2252 depth,
2253 sample_index,
2254 json: _,
2255 } => cmd_token_gate_sample(
2256 &surface,
2257 &path,
2258 scope.as_deref(),
2259 target.as_deref(),
2260 depth,
2261 sample_index,
2262 ),
2263 cli::TokenGateCommand::Evaluate {
2264 history,
2265 allowed_regression_percent,
2266 json: _,
2267 } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
2268 }
2269}
2270
2271fn cmd_token_gate_sample(
2272 surface: &str,
2273 path: &Path,
2274 scope: Option<&str>,
2275 target: Option<&str>,
2276 depth: usize,
2277 sample_index: usize,
2278) -> Result<()> {
2279 if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
2280 bail!(
2281 "unknown surface `{}`; expected one of: {}",
2282 surface,
2283 token_gate::TOKEN_GATE_SURFACES.join(", ")
2284 );
2285 }
2286
2287 let path_str = path.to_string_lossy().to_string();
2288 let tsift_bin = std::env::current_exe()?;
2289
2290 let args: Vec<String> = match surface {
2291 "context_pack" => vec!["context-pack".to_string(), "--json".to_string(), path_str],
2292 "session_review_next_context" => vec![
2293 "session-review".to_string(),
2294 "--json".to_string(),
2295 "--next-context".to_string(),
2296 path_str,
2297 ],
2298 "graph_db_evidence" => {
2299 let tgt = target.unwrap_or("default").to_string();
2300 vec![
2301 "graph-db".to_string(),
2302 "--json".to_string(),
2303 "--path".to_string(),
2304 path_str,
2305 "evidence".to_string(),
2306 tgt,
2307 "--depth".to_string(),
2308 depth.to_string(),
2309 ]
2310 }
2311 "conflict_matrix" => {
2312 let tgt = target.unwrap_or("default").to_string();
2313 let mut a = vec![
2314 "conflict-matrix".to_string(),
2315 "--json".to_string(),
2316 "--path".to_string(),
2317 path_str,
2318 "--depth".to_string(),
2319 depth.to_string(),
2320 ];
2321 if let Some(s) = scope {
2322 a.push("--scope".to_string());
2323 a.push(s.to_string());
2324 }
2325 a.push(tgt);
2326 a
2327 }
2328 "dispatch_trace" => {
2329 let tgt = target.unwrap_or("default").to_string();
2330 vec![
2331 "dispatch-trace".to_string(),
2332 "--json".to_string(),
2333 "--path".to_string(),
2334 path_str,
2335 tgt,
2336 ]
2337 }
2338 _ => bail!("unhandled surface: {}", surface),
2339 };
2340
2341 let start = Instant::now();
2342 let child = Command::new(&tsift_bin)
2343 .args(&args)
2344 .stdout(Stdio::piped())
2345 .stderr(Stdio::piped())
2346 .env("TSIFT_QUIET", "1")
2347 .spawn();
2348 let output = match child {
2349 Ok(c) => c.wait_with_output()?,
2350 Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
2351 };
2352 let runtime_micros = start.elapsed().as_micros() as f64;
2353
2354 let stdout = String::from_utf8_lossy(&output.stdout);
2355 let envelope_bytes = stdout.trim().len() as f64;
2356 let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
2357
2358 let cache_hit_rate_percent = 0.0;
2359 let raw_read_avoidance = 0.0;
2360 let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
2361
2362 let timestamp = iso_timestamp_now();
2363 let id = format!(
2364 "{surface}-baseline-{}-sample-{sample_index}",
2365 ×tamp[..10]
2366 );
2367 let label = format!(
2368 "token-gate baseline {surface} sample {sample_index} for {}",
2369 path.display()
2370 );
2371
2372 let mut metrics = BTreeMap::new();
2373 metrics.insert("prompt_tokens".to_string(), prompt_tokens);
2374 metrics.insert("envelope_bytes".to_string(), envelope_bytes);
2375 metrics.insert("runtime_micros".to_string(), runtime_micros);
2376 metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
2377 metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
2378 metrics.insert("useful_hit_density".to_string(), useful_hit_density);
2379
2380 let sample = token_gate::TokenGateSample {
2381 label,
2382 id,
2383 timestamp: Some(timestamp),
2384 surface: surface.to_string(),
2385 metrics,
2386 };
2387
2388 println!("{}", serde_json::to_string_pretty(&sample)?);
2389 Ok(())
2390}
2391
2392fn cmd_token_gate_evaluate(
2393 history_path: Option<&Path>,
2394 allowed_regression_percent: f64,
2395 format: &OutputFormat,
2396) -> Result<()> {
2397 let history_path = history_path.map(PathBuf::from).unwrap_or_else(|| {
2398 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2399 p.push("../../fixtures/token-gate-history.json");
2400 p
2401 });
2402
2403 let raw = std::fs::read_to_string(&history_path).with_context(|| {
2404 format!(
2405 "failed to read token gate history: {}",
2406 history_path.display()
2407 )
2408 })?;
2409 let samples = token_gate::parse_token_history(&raw)?;
2410 let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
2411
2412 if format.json_output {
2413 println!(
2414 "{}",
2415 to_json_schema(&report, format.pretty, format.terse, false, format.schema)?
2416 );
2417 } else {
2418 println!("Token Gate Report");
2419 println!(" min_samples: {}", report.min_samples);
2420 println!(
2421 " allowed_regression: {:.1}%",
2422 report.allowed_regression_percent
2423 );
2424 println!(" decision: {:?}", report.decision);
2425 for eval in &report.surface_evaluations {
2426 println!(
2427 " {} ({} samples): {:?}",
2428 eval.display_name, eval.sample_count, eval.verdict
2429 );
2430 for me in &eval.metric_evaluations {
2431 println!(" {} ({:?}): {}", me.metric, me.direction, me.diagnostic);
2432 }
2433 }
2434 for d in &report.diagnostics {
2435 println!(" ! {}", d);
2436 }
2437 }
2438 Ok(())
2439}
2440
2441fn iso_timestamp_now() -> String {
2442 let dur = SystemTime::now()
2443 .duration_since(UNIX_EPOCH)
2444 .unwrap_or_default();
2445 let total_secs = dur.as_secs();
2446 let days_since_epoch = total_secs / 86400;
2447 let (year, month, day) = days_to_ymd(days_since_epoch);
2448 let time_of_day = total_secs % 86400;
2449 let hour = (time_of_day / 3600) as u8;
2450 let minute = ((time_of_day % 3600) / 60) as u8;
2451 let second = (time_of_day % 60) as u8;
2452 format!(
2453 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
2454 year, month, day, hour, minute, second
2455 )
2456}
2457
2458fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
2459 let mut year = 1970u64;
2460 loop {
2461 let days_in_year = if is_leap(year) { 366 } else { 365 };
2462 if days < days_in_year {
2463 break;
2464 }
2465 days -= days_in_year;
2466 year += 1;
2467 }
2468 let leap = is_leap(year);
2469 let month_days: [u8; 12] = if leap {
2470 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2471 } else {
2472 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2473 };
2474 let mut month: u8 = 1;
2475 for &md in &month_days {
2476 if days < md as u64 {
2477 break;
2478 }
2479 days -= md as u64;
2480 month += 1;
2481 }
2482 let day = days as u8 + 1;
2483 (year, month, day)
2484}
2485
2486fn is_leap(year: u64) -> bool {
2487 year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
2488}
2489
2490fn persist_transcript_artifact(
2491 root: &Path,
2492 prefix: &str,
2493 suffix: &str,
2494 key: &str,
2495 body: &str,
2496 expand: String,
2497) -> Result<TranscriptArtifactRef> {
2498 let handle = stable_handle(prefix, key);
2499 let artifacts_dir = root.join(".tsift/artifacts");
2500 fs::create_dir_all(&artifacts_dir).with_context(|| {
2501 format!(
2502 "creating transcript artifacts dir: {}",
2503 artifacts_dir.display()
2504 )
2505 })?;
2506 let file_name = format!("{handle}.{suffix}");
2507 let artifact_path = artifacts_dir.join(file_name);
2508 fs::write(&artifact_path, body)
2509 .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
2510 let rel_path = relativize_pathbuf(&artifact_path, root);
2511 Ok(TranscriptArtifactRef {
2512 handle,
2513 path: rel_path.display().to_string(),
2514 bytes: body.len(),
2515 lines: body.lines().count(),
2516 expand,
2517 })
2518}
2519
2520fn terse_key(key: &str) -> &str {
2521 match key {
2522 "name" => "n",
2523 "kind" => "k",
2524 "file" => "f",
2525 "line" => "l",
2526 "path" => "p",
2527 "from" => "fr",
2528 "type" => "ty",
2529 "text" => "tx",
2530 "new" => "nw",
2531 "run" => "r",
2532 "use" => "u",
2533 "score" => "sc",
2534 "language" => "la",
2535 "status" => "st",
2536 "state" => "stt",
2537 "error" => "err",
2538 "errors" => "ers",
2539 "hops" => "hp",
2540 "tags" => "tg",
2541 "model" => "ml",
2542 "skill" => "sk",
2543 "count" => "ct",
2544 "total" => "tot",
2545 "column" => "col",
2546 "description" => "dsc",
2547 "end_line" => "el",
2548 "signature" => "sig",
2549 "parent_module" => "pm",
2550 "visibility" => "vis",
2551 "match_type" => "mt",
2552 "caller_file" => "cf",
2553 "caller_name" => "cn",
2554 "caller_line" => "cl",
2555 "callee_name" => "en",
2556 "call_site_line" => "csl",
2557 "members" => "m",
2558 "refs" => "refs",
2559 "role" => "rl",
2560 "peer" => "pr",
2561 "modularity" => "q",
2562 "modularity_contribution" => "mc",
2563 "iterations" => "it",
2564 "node_count" => "nc",
2565 "edge_count" => "ec",
2566 "community_count" => "cc",
2567 "communities" => "cms",
2568 "community" => "cm",
2569 "community_diagnostics" => "cd",
2570 "cache_hit" => "cah",
2571 "tagpath_state" => "tps",
2572 "tagpath_stale_reason" => "tsr",
2573 "annotated_community_count" => "acc",
2574 "annotated_member_count" => "amc",
2575 "ambiguous_member_count" => "ambc",
2576 "ambiguous_members" => "amb",
2577 "candidate_count" => "cand",
2578 "tagpath_candidate_count" => "tcand",
2579 "evidence" => "ev",
2580 "chosen_file" => "chf",
2581 "symbol" => "s",
2582 "symbols" => "sy",
2583 "definitions" => "df",
2584 "callers" => "crs",
2585 "callees" => "ces",
2586 "total_tracked" => "tt",
2587 "modified" => "md",
2588 "deleted" => "dl",
2589 "unchanged" => "uc",
2590 "changes" => "ch",
2591 "prune_stats" => "ps",
2592 "hits" => "h",
2593 "rank" => "rk",
2594 "snippet" => "sn",
2595 "confidence" => "co",
2596 "index" => "ix",
2597 "summaries" => "sms",
2598 "recommendations" => "rec",
2599 "total_files" => "tf",
2600 "stale_files" => "sf",
2601 "last_indexed_secs_ago" => "age",
2602 "cached_files" => "caf",
2603 "total_indexed_files" => "tif",
2604 "coverage_pct" => "cov",
2605 "symbol_name" => "syn",
2606 "file_path" => "fp",
2607 "content_hash" => "hsh",
2608 "summary" => "sum",
2609 "tool" => "tl",
2610 "view" => "vw",
2611 "truncated" => "tr",
2612 "follow_up" => "fu",
2613 "report" => "rp",
2614 "metrics" => "ms",
2615 "label" => "lb",
2616 "value" => "v",
2617 "command" => "cmd",
2618 "exit_code" => "xc",
2619 "success" => "ok",
2620 "artifact" => "art",
2621 "digest" => "dg",
2622 "bytes" => "bt",
2623 "lines" => "lns",
2624 "expand" => "xp",
2625 "entities" => "ent",
2626 "relationships" => "rel",
2627 "concept_labels" => "cls",
2628 "extracted_at" => "at",
2629 "tokens_input" => "ti",
2630 "tokens_output" => "tout",
2631 "total_summaries" => "ts",
2632 "stale_count" => "stc",
2633 "total_tokens_input" => "tti",
2634 "total_tokens_output" => "tto",
2635 "estimated_tokens_saved" => "ets",
2636 "files_processed" => "fps",
2637 "symbols_extracted" => "se",
2638 "skills_dir" => "sd",
2639 "healthy" => "ok",
2640 "broken" => "brk",
2641 "skills" => "sks",
2642 "manifest_diffs" => "mdf",
2643 "similar_pairs" => "sim",
2644 "usage" => "usg",
2645 "cleanup" => "cln",
2646 "has_skill_md" => "hsm",
2647 "is_symlink" => "isl",
2648 "issues" => "iss",
2649 "invocation_count" => "inv",
2650 "reasons" => "rsn",
2651 "token_estimate" => "te",
2652 "skill_a" => "sa",
2653 "skill_b" => "sb",
2654 "desc_a" => "da",
2655 "desc_b" => "db",
2656 "annotations" => "ann",
2657 "entity" => "ety",
2658 "suggestion" => "sug",
2659 "columns" => "cols",
2660 "row_count" => "rc",
2661 "notnull" => "nn",
2662 "default_value" => "dv",
2663 "replace_all" => "ra",
2664 other => other,
2665 }
2666}
2667
2668fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2669 match val {
2670 serde_json::Value::Object(map) => {
2671 let mut new_map = serde_json::Map::new();
2672 for (k, v) in map {
2673 new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2674 }
2675 serde_json::Value::Object(new_map)
2676 }
2677 serde_json::Value::Array(arr) => {
2678 serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2679 }
2680 other => other,
2681 }
2682}
2683
2684fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2685 match val {
2686 serde_json::Value::Object(mut map) => {
2687 let is_graph_node =
2688 map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2689 let is_graph_edge =
2690 map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2691 if is_graph_node || is_graph_edge {
2692 map.remove("properties");
2693 map.remove("provenance");
2694 map.remove("freshness");
2695 }
2696 if is_graph_edge && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2697 *s = abbreviate_edge_kind(s).to_string();
2698 }
2699 let is_coverage = map.contains_key("mode")
2700 && (map.contains_key("total_sector_count")
2701 || map.contains_key("dirty_sector_count"));
2702 if is_coverage {
2703 map.remove("active_rebuild");
2704 map.remove("completed_dirty_sector_count");
2705 map.remove("mounted_sector_count");
2706 map.remove("rebuilding_sector_count");
2707 map.remove("resumed_sector_count");
2708 map.remove("reused_sector_count");
2709 }
2710 if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2711 *s = truncate_for_ultra_terse(s, 80);
2712 }
2713 if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2714 *s = truncate_for_ultra_terse(s, 80);
2715 }
2716 let new_map: serde_json::Map<String, serde_json::Value> = map
2717 .into_iter()
2718 .map(|(k, v)| (k, ultra_terse_transform(v)))
2719 .collect();
2720 serde_json::Value::Object(new_map)
2721 }
2722 serde_json::Value::Array(arr) => {
2723 serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2724 }
2725 other => other,
2726 }
2727}
2728
2729fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2730 match val {
2731 serde_json::Value::Object(mut map) => {
2732 let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2733 nodes.as_array().map(|arr| {
2734 arr.iter()
2735 .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2736 .collect()
2737 })
2738 });
2739 if let Some(ref ids) = node_ids {
2740 let id_map: std::collections::HashMap<&str, usize> = ids
2741 .iter()
2742 .enumerate()
2743 .map(|(i, id)| (id.as_str(), i))
2744 .collect();
2745 if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2746 for edge in edges.iter_mut() {
2747 if let serde_json::Value::Object(edge_map) = edge {
2748 if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id")
2749 {
2750 if let Some(&idx) = id_map.get(fid.as_str()) {
2751 edge_map.insert(
2752 "from".to_string(),
2753 serde_json::Value::Number(idx.into()),
2754 );
2755 } else {
2756 edge_map.insert(
2757 "from_id".to_string(),
2758 serde_json::Value::String(fid),
2759 );
2760 }
2761 }
2762 if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2763 if let Some(&idx) = id_map.get(tid.as_str()) {
2764 edge_map.insert(
2765 "to".to_string(),
2766 serde_json::Value::Number(idx.into()),
2767 );
2768 } else {
2769 edge_map.insert(
2770 "to_id".to_string(),
2771 serde_json::Value::String(tid),
2772 );
2773 }
2774 }
2775 }
2776 }
2777 }
2778 }
2779 let new_map: serde_json::Map<String, serde_json::Value> = map
2780 .into_iter()
2781 .map(|(k, v)| (k, edge_index_transform(v)))
2782 .collect();
2783 serde_json::Value::Object(new_map)
2784 }
2785 serde_json::Value::Array(arr) => {
2786 serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2787 }
2788 other => other,
2789 }
2790}
2791
2792fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2793 if s.len() <= max_len {
2794 s.to_string()
2795 } else {
2796 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2797 format!("{truncated}...")
2798 }
2799}
2800
2801fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2802 let mut keys = HashSet::new();
2803 collect_terse_keys(val, &mut keys);
2804 let mut schema = serde_json::Map::new();
2805 for (long, short) in TERSE_PAIRS {
2806 if keys.contains(*short) {
2807 schema.insert(
2808 short.to_string(),
2809 serde_json::Value::String(long.to_string()),
2810 );
2811 }
2812 }
2813 serde_json::Value::Object(schema)
2814}
2815
2816fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2817 match val {
2818 serde_json::Value::Object(map) => {
2819 for (k, v) in map {
2820 keys.insert(k.clone());
2821 collect_terse_keys(v, keys);
2822 }
2823 }
2824 serde_json::Value::Array(arr) => {
2825 for v in arr {
2826 collect_terse_keys(v, keys);
2827 }
2828 }
2829 _ => {}
2830 }
2831}
2832
2833fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2834 match val {
2835 serde_json::Value::Array(arr) if arr.len() >= 2 => {
2836 if let Some(cols) = homogeneous_keys(&arr) {
2837 let rows: Vec<serde_json::Value> = arr
2838 .into_iter()
2839 .map(|item| {
2840 if let serde_json::Value::Object(map) = item {
2841 let vals: Vec<serde_json::Value> = cols
2842 .iter()
2843 .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2844 .collect();
2845 serde_json::Value::Array(vals)
2846 } else {
2847 item
2848 }
2849 })
2850 .collect();
2851 let col_vals: Vec<serde_json::Value> =
2852 cols.into_iter().map(serde_json::Value::String).collect();
2853 serde_json::json!({"_c": col_vals, "_r": rows})
2854 } else {
2855 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2856 }
2857 }
2858 serde_json::Value::Array(arr) => {
2859 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2860 }
2861 serde_json::Value::Object(map) => {
2862 let new_map: serde_json::Map<String, serde_json::Value> = map
2863 .into_iter()
2864 .map(|(k, v)| (k, schema_transform(v)))
2865 .collect();
2866 serde_json::Value::Object(new_map)
2867 }
2868 other => other,
2869 }
2870}
2871
2872fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2873 let first = arr.first()?.as_object()?;
2874 let keys: Vec<String> = first.keys().cloned().collect();
2875 for item in &arr[1..] {
2876 let obj = item.as_object()?;
2877 if obj.len() != keys.len() {
2878 return None;
2879 }
2880 for k in &keys {
2881 if !obj.contains_key(k) {
2882 return None;
2883 }
2884 }
2885 }
2886 Some(keys)
2887}
2888
2889const TERSE_PAIRS: &[(&str, &str)] = &[
2890 ("name", "n"),
2891 ("kind", "k"),
2892 ("file", "f"),
2893 ("line", "l"),
2894 ("path", "p"),
2895 ("from", "fr"),
2896 ("type", "ty"),
2897 ("text", "tx"),
2898 ("new", "nw"),
2899 ("run", "r"),
2900 ("use", "u"),
2901 ("score", "sc"),
2902 ("language", "la"),
2903 ("status", "st"),
2904 ("state", "stt"),
2905 ("error", "err"),
2906 ("errors", "ers"),
2907 ("hops", "hp"),
2908 ("tags", "tg"),
2909 ("model", "ml"),
2910 ("skill", "sk"),
2911 ("count", "ct"),
2912 ("total", "tot"),
2913 ("column", "col"),
2914 ("description", "dsc"),
2915 ("end_line", "el"),
2916 ("signature", "sig"),
2917 ("parent_module", "pm"),
2918 ("visibility", "vis"),
2919 ("match_type", "mt"),
2920 ("caller_file", "cf"),
2921 ("caller_name", "cn"),
2922 ("caller_line", "cl"),
2923 ("callee_name", "en"),
2924 ("call_site_line", "csl"),
2925 ("members", "m"),
2926 ("refs", "refs"),
2927 ("role", "rl"),
2928 ("peer", "pr"),
2929 ("modularity", "q"),
2930 ("modularity_contribution", "mc"),
2931 ("iterations", "it"),
2932 ("node_count", "nc"),
2933 ("edge_count", "ec"),
2934 ("community_count", "cc"),
2935 ("communities", "cms"),
2936 ("community", "cm"),
2937 ("community_diagnostics", "cd"),
2938 ("cache_hit", "cah"),
2939 ("tagpath_state", "tps"),
2940 ("tagpath_stale_reason", "tsr"),
2941 ("annotated_community_count", "acc"),
2942 ("annotated_member_count", "amc"),
2943 ("ambiguous_member_count", "ambc"),
2944 ("ambiguous_members", "amb"),
2945 ("candidate_count", "cand"),
2946 ("tagpath_candidate_count", "tcand"),
2947 ("evidence", "ev"),
2948 ("chosen_file", "chf"),
2949 ("symbol", "s"),
2950 ("symbols", "sy"),
2951 ("definitions", "df"),
2952 ("callers", "crs"),
2953 ("callees", "ces"),
2954 ("total_tracked", "tt"),
2955 ("modified", "md"),
2956 ("deleted", "dl"),
2957 ("unchanged", "uc"),
2958 ("changes", "ch"),
2959 ("prune_stats", "ps"),
2960 ("hits", "h"),
2961 ("rank", "rk"),
2962 ("snippet", "sn"),
2963 ("confidence", "co"),
2964 ("index", "ix"),
2965 ("summaries", "sms"),
2966 ("recommendations", "rec"),
2967 ("total_files", "tf"),
2968 ("stale_files", "sf"),
2969 ("last_indexed_secs_ago", "age"),
2970 ("cached_files", "caf"),
2971 ("total_indexed_files", "tif"),
2972 ("coverage_pct", "cov"),
2973 ("symbol_name", "syn"),
2974 ("file_path", "fp"),
2975 ("content_hash", "hsh"),
2976 ("summary", "sum"),
2977 ("tool", "tl"),
2978 ("view", "vw"),
2979 ("truncated", "tr"),
2980 ("follow_up", "fu"),
2981 ("report", "rp"),
2982 ("metrics", "ms"),
2983 ("label", "lb"),
2984 ("value", "v"),
2985 ("command", "cmd"),
2986 ("exit_code", "xc"),
2987 ("success", "ok"),
2988 ("artifact", "art"),
2989 ("digest", "dg"),
2990 ("bytes", "bt"),
2991 ("lines", "lns"),
2992 ("expand", "xp"),
2993 ("entities", "ent"),
2994 ("relationships", "rel"),
2995 ("concept_labels", "cls"),
2996 ("extracted_at", "at"),
2997 ("tokens_input", "ti"),
2998 ("tokens_output", "tout"),
2999 ("total_summaries", "ts"),
3000 ("stale_count", "stc"),
3001 ("total_tokens_input", "tti"),
3002 ("total_tokens_output", "tto"),
3003 ("estimated_tokens_saved", "ets"),
3004 ("files_processed", "fps"),
3005 ("symbols_extracted", "se"),
3006 ("skills_dir", "sd"),
3007 ("healthy", "ok"),
3008 ("broken", "brk"),
3009 ("skills", "sks"),
3010 ("manifest_diffs", "mdf"),
3011 ("similar_pairs", "sim"),
3012 ("usage", "usg"),
3013 ("cleanup", "cln"),
3014 ("has_skill_md", "hsm"),
3015 ("is_symlink", "isl"),
3016 ("issues", "iss"),
3017 ("invocation_count", "inv"),
3018 ("reasons", "rsn"),
3019 ("token_estimate", "te"),
3020 ("skill_a", "sa"),
3021 ("skill_b", "sb"),
3022 ("desc_a", "da"),
3023 ("desc_b", "db"),
3024 ("annotations", "ann"),
3025 ("entity", "ety"),
3026 ("suggestion", "sug"),
3027 ("columns", "cols"),
3028 ("row_count", "rc"),
3029 ("notnull", "nn"),
3030 ("default_value", "dv"),
3031 ("replace_all", "ra"),
3032];
3033
3034pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
3035 let root_str = root.to_string_lossy();
3036 let prefix = format!("{}/", root_str.trim_end_matches('/'));
3037 path.strip_prefix(&prefix).unwrap_or(path).to_string()
3038}
3039
3040fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
3041 let canonical = path
3042 .canonicalize()
3043 .with_context(|| format!("canonicalizing {}", path.display()))?;
3044 let start = if canonical.is_dir() {
3045 canonical.clone()
3046 } else {
3047 canonical
3048 .parent()
3049 .map(Path::to_path_buf)
3050 .unwrap_or_else(|| canonical.clone())
3051 };
3052
3053 for ancestor in start.ancestors() {
3054 if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
3055 return Ok(ancestor.to_path_buf());
3056 }
3057 }
3058
3059 Ok(start)
3060}
3061
3062pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
3063 path.strip_prefix(root)
3064 .map(|p| p.to_path_buf())
3065 .unwrap_or_else(|_| path.to_path_buf())
3066}
3067
3068pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
3069 for edge in edges {
3070 edge.caller_file = relativize(&edge.caller_file, root);
3071 }
3072}
3073
3074pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
3075 for sym in symbols {
3076 sym.file = relativize(&sym.file, root);
3077 }
3078}
3079
3080pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
3081 for hit in hits {
3082 hit.file = relativize(&hit.file, root);
3083 }
3084}
3085
3086#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3089pub enum EdgeSide {
3090 Caller,
3091 Callee,
3092}
3093
3094const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
3095
3096pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
3097 let root_str = root.to_string_lossy();
3098 let prefix = format!("{}/", root_str.trim_end_matches('/'));
3099 relativize_json_inner(val, &prefix);
3100}
3101
3102fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
3103 match val {
3104 serde_json::Value::Array(arr) => {
3105 for v in arr {
3106 relativize_json_inner(v, prefix);
3107 }
3108 }
3109 serde_json::Value::Object(map) => {
3110 for (k, v) in map.iter_mut() {
3111 if JSON_PATH_KEYS.contains(&k.as_str())
3112 && let serde_json::Value::String(s) = v
3113 && let Some(rest) = s.strip_prefix(prefix)
3114 {
3115 *s = rest.to_string();
3116 }
3117 relativize_json_inner(v, prefix);
3118 }
3119 }
3120 _ => {}
3121 }
3122}
3123
3124pub(crate) fn format_score(score: f64, compact: bool) -> String {
3125 if compact {
3126 format!("{score:.2}")
3127 } else {
3128 format!("{score:.4}")
3129 }
3130}
3131
3132pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
3133 let trimmed = input.trim();
3134 let count = trimmed.chars().count();
3135 if count <= max_chars {
3136 return trimmed.to_string();
3137 }
3138 let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
3139 format!("{prefix}...")
3140}
3141
3142pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
3143 snippet
3144 .lines()
3145 .find(|line| !line.trim().is_empty())
3146 .map(|line| truncate_for_compact(line, 100))
3147}
3148
3149pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
3150 let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
3151 if names.len() <= limit {
3152 return names.join(", ");
3153 }
3154 format!(
3155 "{} (+{} more)",
3156 names[..limit].join(", "),
3157 names.len() - limit
3158 )
3159}
3160
3161pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
3162 let mut hasher = blake3::Hasher::new();
3163 hasher.update(prefix.as_bytes());
3164 hasher.update(&[0]);
3165 hasher.update(key.as_bytes());
3166 let hex = hasher.finalize().to_hex();
3167 format!("{prefix}-{}", &hex[..10])
3168}
3169
3170#[derive(Clone, Debug, PartialEq, Eq)]
3171struct CanonicalTagFamily {
3172 canonical: String,
3173 tag_alias: String,
3174}
3175
3176fn canonical_family_from_tagpath_family(
3177 family: tagpath_family::TagFamily,
3178) -> Option<CanonicalTagFamily> {
3179 let tag_alias = if family.dimensions.is_empty() {
3180 family.tags.join("/")
3181 } else {
3182 family
3183 .dimensions
3184 .iter()
3185 .filter(|dimension| !dimension.tags.is_empty())
3186 .map(|dimension| dimension.tags.join("."))
3187 .collect::<Vec<_>>()
3188 .join("/")
3189 };
3190
3191 if tag_alias.is_empty() {
3192 None
3193 } else {
3194 Some(CanonicalTagFamily {
3195 canonical: family.canonical,
3196 tag_alias,
3197 })
3198 }
3199}
3200
3201fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
3202 let trimmed = name.trim();
3203 if trimmed.is_empty() {
3204 return None;
3205 }
3206
3207 canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
3208}
3209
3210fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
3211 let canonical = tags
3212 .split(',')
3213 .map(str::trim)
3214 .filter(|tag| !tag.is_empty())
3215 .collect::<Vec<_>>()
3216 .join("_");
3217 if canonical.is_empty() {
3218 None
3219 } else {
3220 canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
3221 }
3222}
3223
3224pub(crate) fn canonical_tag_family_from_symbol(
3225 name: &str,
3226 tags: Option<&str>,
3227) -> Option<CanonicalTagFamily> {
3228 tags.and_then(canonical_tag_family_from_tags)
3229 .or_else(|| canonical_tag_family_from_name(name))
3230}
3231
3232fn tag_alias_from_name(name: &str) -> Option<String> {
3233 canonical_tag_family_from_name(name).map(|family| family.tag_alias)
3234}
3235
3236fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
3237 canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
3238}
3239
3240pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
3241 let query = tag_alias
3242 .split(['/', '.'])
3243 .map(str::trim)
3244 .filter(|part| !part.is_empty())
3245 .collect::<Vec<_>>()
3246 .join(" ");
3247 if query.is_empty() { None } else { Some(query) }
3248}
3249
3250#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3251struct CompactOntologyRefPreview {
3252 handle: String,
3253 tag: String,
3254 path: String,
3255 #[serde(skip_serializing_if = "Option::is_none")]
3256 title: Option<String>,
3257 #[serde(skip_serializing_if = "Option::is_none")]
3258 domain: Option<String>,
3259}
3260
3261#[derive(Clone, Debug)]
3262struct TagOntologyPreviewContext {
3263 project_root: PathBuf,
3264 tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
3265}
3266
3267#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3268struct CompactSymbolRefPreview {
3269 handle: String,
3270 name: String,
3271 #[serde(skip_serializing_if = "Option::is_none")]
3272 tag_alias: Option<String>,
3273 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3274 ontology_refs: Vec<CompactOntologyRefPreview>,
3275}
3276
3277fn build_compact_symbol_ref(
3278 prefix: &str,
3279 key: &str,
3280 name: &str,
3281 tags: Option<&str>,
3282 max_bytes: usize,
3283) -> CompactSymbolRefPreview {
3284 build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
3285}
3286
3287fn build_compact_symbol_ref_with_ontology(
3288 prefix: &str,
3289 key: &str,
3290 name: &str,
3291 tags: Option<&str>,
3292 max_bytes: usize,
3293 ontology: Option<&TagOntologyPreviewContext>,
3294) -> CompactSymbolRefPreview {
3295 let tag_alias = tag_alias_from_tags(name, tags);
3296 let ontology_refs = tag_alias
3297 .as_deref()
3298 .map(|alias| ontology_refs_for_alias(ontology, alias))
3299 .unwrap_or_default();
3300 CompactSymbolRefPreview {
3301 handle: stable_handle(prefix, key),
3302 name: truncate_for_budget(name, max_bytes),
3303 tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
3304 ontology_refs,
3305 }
3306}
3307
3308fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
3309 let report = tagpath_ontology::load_project(root).ok()?;
3310 if report.tags.is_empty() {
3311 return None;
3312 }
3313 Some(TagOntologyPreviewContext {
3314 project_root: report.project_path,
3315 tags: report
3316 .tags
3317 .into_iter()
3318 .map(|tag| (tag.tag.clone(), tag))
3319 .collect(),
3320 })
3321}
3322
3323fn ontology_refs_for_alias(
3324 ontology: Option<&TagOntologyPreviewContext>,
3325 alias: &str,
3326) -> Vec<CompactOntologyRefPreview> {
3327 let Some(ontology) = ontology else {
3328 return Vec::new();
3329 };
3330 let mut seen = BTreeSet::new();
3331 alias
3332 .split('/')
3333 .flat_map(|part| part.split('.'))
3334 .map(str::trim)
3335 .filter(|tag| !tag.is_empty())
3336 .filter_map(|tag| {
3337 let key = tag.to_ascii_lowercase();
3338 if !seen.insert(key.clone()) {
3339 return None;
3340 }
3341 let ontology_tag = ontology.tags.get(&key)?;
3342 let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
3343 Some(CompactOntologyRefPreview {
3344 handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
3345 tag: ontology_tag.tag.clone(),
3346 path,
3347 title: ontology_tag.title.clone(),
3348 domain: ontology_tag.domain.clone(),
3349 })
3350 })
3351 .collect()
3352}
3353
3354fn relativize_ontology_path(path: &Path, root: &Path) -> String {
3355 path.strip_prefix(root)
3356 .unwrap_or(path)
3357 .to_string_lossy()
3358 .replace('\\', "/")
3359}
3360
3361fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
3362 match tag_alias {
3363 Some(alias) => format!("{handle} {name} tag:{alias}"),
3364 None => format!("{handle} {name}"),
3365 }
3366}
3367
3368fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
3369 match summary.tag_alias.as_deref() {
3370 Some(alias) => format!(
3371 "{} {} tag:{} expand:{}",
3372 summary.handle, summary.symbol, alias, summary.expand
3373 ),
3374 None => format!(
3375 "{} {} expand:{}",
3376 summary.handle, summary.symbol, summary.expand
3377 ),
3378 }
3379}
3380
3381fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
3382 match symbol.tag_alias.as_deref() {
3383 Some(alias) => format!("{}@{}", symbol.handle, alias),
3384 None => format!("{}@{}", symbol.handle, symbol.name),
3385 }
3386}
3387
3388pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
3389 let trimmed = input.trim();
3390 if trimmed.len() <= max_bytes {
3391 return trimmed.to_string();
3392 }
3393 if max_bytes <= 3 {
3394 return ".".repeat(max_bytes);
3395 }
3396
3397 let mut end = 0usize;
3398 for (idx, ch) in trimmed.char_indices() {
3399 let next = idx + ch.len_utf8();
3400 if next > max_bytes.saturating_sub(3) {
3401 break;
3402 }
3403 end = next;
3404 }
3405
3406 if end == 0 {
3407 "...".to_string()
3408 } else {
3409 format!("{}...", &trimmed[..end])
3410 }
3411}
3412
3413struct TokenCappedPreview {
3414 preview: Vec<SourceLinePreview>,
3415 capped_end: usize,
3416 was_capped: bool,
3417}
3418
3419fn build_token_capped_preview(
3420 all_lines: &[&str],
3421 start: usize,
3422 end: usize,
3423 max_bytes: usize,
3424 token_cap: usize,
3425) -> TokenCappedPreview {
3426 let mut preview = Vec::new();
3427 let mut accumulated_tokens = 0usize;
3428 let mut capped_end = end;
3429 let mut was_capped = false;
3430
3431 for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
3432 let truncated = truncate_for_budget(line, max_bytes);
3433 let line_tokens = estimated_tokens_from_bytes(truncated.len());
3434 if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
3435 capped_end = start + idx - 1;
3436 was_capped = true;
3437 break;
3438 }
3439 accumulated_tokens += line_tokens;
3440 preview.push(SourceLinePreview {
3441 line: start + idx,
3442 text: truncated,
3443 });
3444 }
3445
3446 TokenCappedPreview {
3447 preview,
3448 capped_end,
3449 was_capped,
3450 }
3451}
3452
3453pub(crate) fn abbreviate_kind(kind: &str) -> &str {
3454 match kind {
3455 "function" => "fn",
3456 "method" => "meth",
3457 "module" | "mod" => "mod",
3458 "struct" => "struct",
3459 "trait" => "trait",
3460 "impl" => "impl",
3461 "class" => "cls",
3462 "interface" => "iface",
3463 "type_alias" => "type",
3464 "data_class" => "data_cls",
3465 "sealed_class" => "sealed_cls",
3466 "enum_class" => "enum_cls",
3467 "companion_object" => "comp_obj",
3468 "object" => "obj",
3469 "heading" => "h",
3470 "code_block" => "code",
3471 "alias" => "alias",
3472 other => other,
3473 }
3474}
3475
3476pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
3477 match kind {
3478 "calls" => "c",
3479 "defines" => "d",
3480 "contains" => "ct",
3481 "imports" => "i",
3482 "mentions" => "m",
3483 "mentions_concept" => "mc",
3484 "mentions_entity" => "me",
3485 "semantic_relation" => "sr",
3486 "belongs_to" => "bt",
3487 "scopes_context" => "sctx",
3488 "scopes_source" => "ssrc",
3489 "requests_context" => "rctx",
3490 "explains_result" => "er",
3491 "tagged_concept" => "tc",
3492 "tagged_entity" => "te",
3493 "related_concept" => "relc",
3494 "handled_by" => "hb",
3495 "defines_route" => "dr",
3496 "handles_route" => "hr",
3497 "targets" => "tgt",
3498 "has_vector_handle" => "hv",
3499 "parent" => "p",
3500 "child" => "ch",
3501 "uses" => "u",
3502 "projects_source" => "psrc",
3503 "records_memory_source" => "rms",
3504 "records_memory_event" => "rme",
3505 "has_ast_span" => "ha",
3506 "represents_symbol" => "rs",
3507 "contains_embedded_symbol" => "ces",
3508 "embedded_in_fence" => "ef",
3509 "contains_markdown_block" => "cmb",
3510 "contains_embedded_code" => "cec",
3511 "enclosing_module" => "em",
3512 "enclosing_section" => "es",
3513 "previous_sibling" => "psib",
3514 "next_sibling" => "nsib",
3515 "explicit_depends_on" => "edo",
3516 "worker_result_follow_up" => "wrf",
3517 "shared_resource" => "shr",
3518 "community_member" => "cm",
3519 other => other,
3520 }
3521}
3522
3523pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
3524 match mt {
3525 "exact_name" => "exact",
3526 "all_tags" => "all_tags",
3527 "partial_tags" => "partial",
3528 other => other,
3529 }
3530}
3531
3532pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
3533 path.iter()
3534 .map(|n| n.name.as_str())
3535 .collect::<Vec<_>>()
3536 .join(" -> ")
3537}
3538
3539const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
3540
3541struct SearchHitGroup {
3542 path: String,
3543 first_rank: usize,
3544 top_score: f64,
3545 confidence: String,
3546 hits: usize,
3547 samples: Vec<String>,
3548}
3549
3550fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
3551 let snippet = compact_snippet(&hit.snippet)?;
3552 Some(match hit.location.as_deref() {
3553 Some(location) => format!("{location}: {snippet}"),
3554 None => snippet,
3555 })
3556}
3557
3558pub(crate) fn group_search_hits(
3559 hits: &[sift::SearchHit],
3560 root: &Path,
3561 absolute: bool,
3562) -> Vec<SearchHitGroup> {
3563 let mut positions = BTreeMap::new();
3564 let mut groups = Vec::new();
3565 for hit in hits {
3566 let path = if absolute {
3567 hit.path.clone()
3568 } else {
3569 relativize(&hit.path, root)
3570 };
3571 let entry = positions.entry(path.clone()).or_insert_with(|| {
3572 groups.push(SearchHitGroup {
3573 path: path.clone(),
3574 first_rank: hit.rank,
3575 top_score: hit.score,
3576 confidence: format!("{:?}", hit.confidence),
3577 hits: 0,
3578 samples: Vec::new(),
3579 });
3580 groups.len() - 1
3581 });
3582 let group = &mut groups[*entry];
3583 group.hits += 1;
3584 if hit.rank < group.first_rank {
3585 group.first_rank = hit.rank;
3586 }
3587 if hit.score > group.top_score {
3588 group.top_score = hit.score;
3589 }
3590 if let Some(sample) = format_search_sample(hit)
3591 && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
3592 && !group.samples.contains(&sample)
3593 {
3594 group.samples.push(sample);
3595 }
3596 }
3597 groups.sort_by_key(|group| group.first_rank);
3598 groups
3599}
3600
3601pub(crate) fn should_collapse_search_hits(
3602 hits: &[sift::SearchHit],
3603 root: &Path,
3604 absolute: bool,
3605) -> bool {
3606 let groups = group_search_hits(hits, root, absolute);
3607 let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
3608 max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
3609}
3610
3611pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
3612 let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
3613 for edge in edges {
3614 let key = edge.caller_file.as_str();
3615 let name = if use_callers {
3616 edge.caller_name.as_str()
3617 } else {
3618 edge.callee_name.as_str()
3619 };
3620 let names = grouped.entry(key).or_default();
3621 if !names.contains(&name) {
3622 names.push(name);
3623 }
3624 }
3625
3626 grouped
3627 .into_iter()
3628 .map(|(file, names)| format!(" {} ({}): {}", file, names.len(), names.join(", ")))
3629 .collect()
3630}
3631
3632pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
3633 let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
3634 for edge in edges {
3635 *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
3636 }
3637 let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
3638 max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
3639}
3640
3641fn resolve_query_index_target(
3642 root: &Path,
3643 path_hint: &Path,
3644 scope: Option<&str>,
3645) -> Result<SearchIndexTarget> {
3646 let cfg = config::Config::load(root)?;
3647 if let Some(scope_name) = scope {
3648 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3649 return Ok(SearchIndexTarget {
3650 label: format!("submodule `{}` index", scope.id),
3651 db_path: cfg.db_path_for(root, &scope.id),
3652 source_root: scope.source_root.clone(),
3653 scope_name: Some(scope.id.clone()),
3654 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3655 });
3656 }
3657 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3658 return Ok(cargo_package_index_target(root, package));
3659 }
3660 config::Config::resolve_submodule(root, scope_name)?;
3661 }
3662
3663 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3664 return Ok(SearchIndexTarget {
3665 label: format!("submodule `{}` index", scope.id),
3666 db_path: cfg.db_path_for(root, &scope.id),
3667 source_root: scope.source_root.clone(),
3668 scope_name: Some(scope.id.clone()),
3669 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3670 });
3671 }
3672
3673 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3674 return Ok(cargo_package_index_target(root, package));
3675 }
3676
3677 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3678 return Ok(SearchIndexTarget {
3679 label: format!("submodule `{}` index", scope.id),
3680 db_path: cfg.db_path_for(root, &scope.id),
3681 source_root: scope.source_root.clone(),
3682 scope_name: Some(scope.id.clone()),
3683 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3684 });
3685 }
3686
3687 let db_path = root.join(".tsift/index.db");
3688 if db_path.exists() {
3689 return Ok(SearchIndexTarget {
3690 label: "index".to_string(),
3691 db_path,
3692 source_root: root.to_path_buf(),
3693 scope_name: None,
3694 reindex_cmd: format!("tsift index {}", root.display()),
3695 });
3696 }
3697
3698 let scopes = config::Config::submodule_dirs(root)?;
3699 if scopes.is_empty() {
3700 return Ok(SearchIndexTarget {
3701 label: "index".to_string(),
3702 db_path,
3703 source_root: root.to_path_buf(),
3704 scope_name: None,
3705 reindex_cmd: format!("tsift index {}", root.display()),
3706 });
3707 }
3708
3709 let available_scopes = scopes
3710 .iter()
3711 .map(|scope| scope.id.as_str())
3712 .collect::<Vec<_>>()
3713 .join(", ");
3714 let indexed_scopes = scopes
3715 .iter()
3716 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3717 .map(|scope| scope.id.as_str())
3718 .collect::<Vec<_>>();
3719 let indexed_label = if indexed_scopes.is_empty() {
3720 "none".to_string()
3721 } else {
3722 indexed_scopes.join(", ")
3723 };
3724
3725 bail!(
3726 "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: {}.",
3727 root.display(),
3728 db_path.display(),
3729 available_scopes,
3730 indexed_label
3731 );
3732}
3733
3734pub(crate) fn resolve_query_db_path(
3735 root: &Path,
3736 path_hint: &Path,
3737 scope: Option<&str>,
3738) -> Result<PathBuf> {
3739 Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3740}
3741
3742fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3743 let state = inspect_search_index(target)?;
3744 let Some(reason) = index_reason_for_state(state) else {
3745 return Ok(());
3746 };
3747
3748 match apply_search_index_update(root, target) {
3749 Ok(_) => {
3750 index::inspect_scope_invalidate_all();
3751 Ok(())
3752 }
3753 Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3754 eprintln!(
3755 "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3756 Continuing with the current read-only index snapshot; graph results may lag. \
3757 Retry `{}` after the active writer finishes for fresh graph results.",
3758 index_reason_detail(target, reason),
3759 target.reindex_cmd
3760 );
3761 Ok(())
3762 }
3763 Err(err) => Err(err),
3764 }
3765}
3766
3767pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3768 let root = lint::resolve_project_root_or_canonical_path(path)?;
3769 let target = resolve_query_index_target(&root, path, scope)?;
3770 ensure_query_index_current(&root, &target)?;
3771 let db_path = target.db_path;
3772 if !db_path.exists() {
3773 bail!(
3774 "no index found at {}. Run `tsift index` first.",
3775 db_path.display()
3776 );
3777 }
3778 index::IndexDb::open_read_only_resilient(&db_path)
3779}
3780
3781pub(crate) fn query_tagpath_root(
3782 root: &std::path::Path,
3783 path_hint: &std::path::Path,
3784 scope: Option<&str>,
3785) -> Result<PathBuf> {
3786 if let Some(scope_name) = scope {
3787 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3788 return Ok(scope.source_root);
3789 }
3790 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3791 return Ok(package.package_root);
3792 }
3793 config::Config::resolve_submodule(root, scope_name)?;
3794 }
3795 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3796 return Ok(scope.source_root);
3797 }
3798 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3799 return Ok(package.package_root);
3800 }
3801 Ok(root.to_path_buf())
3802}
3803
3804#[derive(Clone, Debug, Serialize, PartialEq)]
3805struct TraversalNode {
3806 handle: String,
3807 kind: String,
3808 label: String,
3809 #[serde(skip_serializing_if = "Option::is_none")]
3810 ref_id: Option<String>,
3811 #[serde(skip_serializing_if = "Option::is_none")]
3812 path: Option<String>,
3813 #[serde(skip_serializing_if = "Option::is_none")]
3814 line: Option<i64>,
3815 #[serde(skip_serializing_if = "Option::is_none")]
3816 detail: Option<String>,
3817 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3818 properties: BTreeMap<String, String>,
3819 expand: String,
3820}
3821
3822#[derive(Clone, Debug, Serialize, PartialEq)]
3823struct TraversalEdge {
3824 from: String,
3825 to: String,
3826 relation: String,
3827 #[serde(skip_serializing_if = "Option::is_none")]
3828 label: Option<String>,
3829 weight: usize,
3830}
3831
3832#[derive(Clone, Debug, Default)]
3833struct TraversalGraphBuild {
3834 nodes: BTreeMap<String, TraversalNode>,
3835 edges: Vec<TraversalEdge>,
3836 edge_keys: BTreeSet<(String, String, String)>,
3837 warnings: Vec<String>,
3838}
3839
3840pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3841const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3842const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3843const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3844const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3845 "context-pack-graph-orchestration-v1";
3846const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3847const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3848const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3849const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3850const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3851const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3852const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3853
3854#[derive(Debug, Serialize, PartialEq)]
3855struct TraversalTotals {
3856 nodes: usize,
3857 edges: usize,
3858}
3859
3860#[derive(Debug, Serialize, PartialEq)]
3861struct TraversalPathReport {
3862 from: TraversalNode,
3863 to: TraversalNode,
3864 hops: usize,
3865 nodes: Vec<TraversalNode>,
3866 edges: Vec<TraversalEdge>,
3867}
3868
3869#[derive(Debug, Serialize, PartialEq)]
3870struct TraversalRecommendation {
3871 handle: String,
3872 kind: String,
3873 label: String,
3874 reason: String,
3875 score: usize,
3876 expand: String,
3877}
3878
3879#[derive(Debug, Serialize, PartialEq)]
3880struct TraversalReport {
3881 root: String,
3882 #[serde(skip_serializing_if = "Option::is_none")]
3883 scope: Option<String>,
3884 mode: String,
3885 totals: TraversalTotals,
3886 #[serde(skip_serializing_if = "Option::is_none")]
3887 query: Option<String>,
3888 #[serde(skip_serializing_if = "Option::is_none")]
3889 target: Option<String>,
3890 nodes: Vec<TraversalNode>,
3891 edges: Vec<TraversalEdge>,
3892 #[serde(skip_serializing_if = "Option::is_none")]
3893 shortest_path: Option<TraversalPathReport>,
3894 recommendations: Vec<TraversalRecommendation>,
3895 exploration: ExplorationPacket,
3896 truncated: bool,
3897 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3898 warnings: Vec<String>,
3899}
3900
3901#[derive(Debug, Serialize, PartialEq)]
3902struct SemanticRelatedReport {
3903 root: String,
3904 #[serde(skip_serializing_if = "Option::is_none")]
3905 scope: Option<String>,
3906 query: String,
3907 embedding_model: String,
3908 count: usize,
3909 items: Vec<SemanticRelatedItem>,
3910 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3911 warnings: Vec<String>,
3912}
3913
3914#[derive(Clone, Debug, Serialize, PartialEq)]
3915struct SemanticRelatedItem {
3916 handle: String,
3917 kind: String,
3918 label: String,
3919 score: f64,
3920 #[serde(skip_serializing_if = "Option::is_none")]
3921 file_path: Option<String>,
3922 #[serde(skip_serializing_if = "Option::is_none")]
3923 source_symbol: Option<String>,
3924 #[serde(skip_serializing_if = "Option::is_none")]
3925 detail: Option<String>,
3926 expand: String,
3927}
3928
3929#[derive(Clone)]
3930struct TraversalSymbolIndexEntry {
3931 handle: String,
3932 node: TraversalNode,
3933 tokens: BTreeSet<String>,
3934}
3935
3936#[derive(Clone)]
3937struct TraversalFileIndexEntry {
3938 handle: String,
3939 node: TraversalNode,
3940 tokens: BTreeSet<String>,
3941}
3942
3943#[derive(Clone)]
3944struct TraversalRouteIndexEntry {
3945 handle: String,
3946 node: TraversalNode,
3947 tokens: BTreeSet<String>,
3948}
3949
3950#[derive(Clone)]
3951struct TraversalAstSpanIndexEntry {
3952 handle: String,
3953 symbol_handle: String,
3954 file_handle: Option<String>,
3955 file: String,
3956 name: String,
3957 kind: String,
3958 language: String,
3959 node_kind: String,
3960 start_byte: usize,
3961 end_byte: usize,
3962 parent_module: Option<String>,
3963 markdown: Option<MarkdownSpanMetadata>,
3964}
3965
3966#[derive(Clone)]
3967struct TraversalMultiplicityIndexEntry {
3968 handle: String,
3969 node: TraversalNode,
3970 tokens: BTreeSet<String>,
3971}
3972
3973struct TraversalCodeLookup<'a> {
3974 symbols: &'a [TraversalSymbolIndexEntry],
3975 files: &'a [TraversalFileIndexEntry],
3976 routes: &'a [TraversalRouteIndexEntry],
3977 multiplicities: &'a [TraversalMultiplicityIndexEntry],
3978 symbol_index: HashMap<String, Vec<usize>>,
3979 file_index: HashMap<String, Vec<usize>>,
3980 route_index: HashMap<String, Vec<usize>>,
3981 multiplicity_index: HashMap<String, Vec<usize>>,
3982 file_path_index: HashMap<String, String>,
3983}
3984
3985#[derive(Clone, Debug, Serialize, PartialEq)]
3986struct ExplorationBudget {
3987 project_size: String,
3988 max_source_windows: usize,
3989 lines_per_window: usize,
3990 relationship_limit: usize,
3991}
3992
3993#[derive(Clone, Debug, Serialize, PartialEq)]
3994struct ExplorationRelation {
3995 from: String,
3996 relation: String,
3997 to: String,
3998 #[serde(skip_serializing_if = "Option::is_none")]
3999 label: Option<String>,
4000}
4001
4002#[derive(Clone, Debug, Serialize, PartialEq)]
4003struct ExplorationSourceWindow {
4004 handle: String,
4005 file: String,
4006 start: usize,
4007 end: usize,
4008 reason: String,
4009 expand: String,
4010}
4011
4012#[derive(Clone, Debug, Serialize, PartialEq)]
4013struct ExplorationWorkerContext {
4014 handle: String,
4015 target: String,
4016 summary: String,
4017 expand: String,
4018}
4019
4020#[derive(Clone, Debug, Serialize, PartialEq)]
4021struct ExplorationPacket {
4022 budget: ExplorationBudget,
4023 relationship_map: Vec<ExplorationRelation>,
4024 source_windows: Vec<ExplorationSourceWindow>,
4025 #[serde(skip_serializing_if = "Vec::is_empty", default)]
4026 worker_context: Vec<ExplorationWorkerContext>,
4027 no_reread_guidance: String,
4028}
4029
4030impl TraversalGraphBuild {
4031 fn add_node(&mut self, node: TraversalNode) {
4032 self.nodes.entry(node.handle.clone()).or_insert(node);
4033 }
4034
4035 fn add_edge(
4036 &mut self,
4037 from: &str,
4038 to: &str,
4039 relation: &str,
4040 label: Option<String>,
4041 weight: usize,
4042 ) {
4043 if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
4044 return;
4045 }
4046 let key = (from.to_string(), to.to_string(), relation.to_string());
4047 if self.edge_keys.insert(key) {
4048 self.edges.push(TraversalEdge {
4049 from: from.to_string(),
4050 to: to.to_string(),
4051 relation: relation.to_string(),
4052 label,
4053 weight,
4054 });
4055 }
4056 }
4057}
4058
4059pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
4060 match scope {
4061 Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
4062 None => root.join(".tsift/graph.db"),
4063 }
4064}
4065
4066fn graph_projection_meta_id(scope: Option<&str>) -> String {
4067 format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
4068}
4069
4070pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
4071 let bytes = serde_json::to_vec(value)?;
4072 Ok(blake3::hash(&bytes).to_hex().to_string())
4073}
4074
4075fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
4076 let mut hashable = node.clone();
4077 hashable.freshness = None;
4078 node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4079 Ok(node)
4080}
4081
4082fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
4083 let mut hashable = edge.clone();
4084 hashable.freshness = None;
4085 edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4086 Ok(edge)
4087}
4088
4089const SEMANTIC_EMBEDDING_DIM: usize = 32;
4090const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
4091
4092fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
4093 match kind {
4094 SemanticRelatedKind::Concept => "concept",
4095 SemanticRelatedKind::Entity => "entity",
4096 SemanticRelatedKind::All => "all",
4097 }
4098}
4099
4100fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
4101 format!(
4102 "tsift semantic {} --path {} --kind {} --limit 10",
4103 shell_quote(query),
4104 shell_quote(root.to_string_lossy().as_ref()),
4105 semantic_related_kind_name(kind)
4106 )
4107}
4108
4109fn semantic_embedding(input: &str) -> Vec<f64> {
4110 let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
4111 let mut tokens = traversal_tokens(input);
4112 if tokens.is_empty() {
4113 let trimmed = input.trim().to_ascii_lowercase();
4114 if !trimmed.is_empty() {
4115 tokens.insert(trimmed);
4116 }
4117 }
4118
4119 for token in tokens {
4120 let hash = blake3::hash(token.as_bytes());
4121 let bytes = hash.as_bytes();
4122 let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
4123 let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
4124 vector[idx] += sign;
4125 }
4126
4127 let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
4128 if norm > 0.0 {
4129 for value in &mut vector {
4130 *value /= norm;
4131 }
4132 }
4133 vector
4134}
4135
4136fn semantic_embedding_property(input: &str) -> String {
4137 semantic_embedding(input)
4138 .iter()
4139 .map(|value| format!("{value:.6}"))
4140 .collect::<Vec<_>>()
4141 .join(",")
4142}
4143
4144fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
4145 let parsed = value
4146 .split(',')
4147 .map(str::trim)
4148 .map(str::parse::<f64>)
4149 .collect::<std::result::Result<Vec<_>, _>>()
4150 .ok()?;
4151 (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
4152}
4153
4154fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
4155 if left.len() != right.len() {
4156 return 0.0;
4157 }
4158 left.iter()
4159 .zip(right.iter())
4160 .map(|(left, right)| left * right)
4161 .sum::<f64>()
4162}
4163
4164fn semantic_entity_handle(name: &str, kind: &str) -> String {
4165 stable_handle(
4166 "gent",
4167 &format!(
4168 "entity:{}:{}",
4169 kind.trim().to_ascii_lowercase(),
4170 name.trim().to_ascii_lowercase()
4171 ),
4172 )
4173}
4174
4175fn semantic_concept_handle(label: &str) -> String {
4176 stable_handle(
4177 "gcon",
4178 &format!("concept:{}", label.trim().to_ascii_lowercase()),
4179 )
4180}
4181
4182fn summary_source_handles(
4183 summary: &summarize::Summary,
4184 file_node_by_path: &BTreeMap<String, String>,
4185 symbol_node_by_file_label: &BTreeMap<(String, String), String>,
4186) -> Vec<String> {
4187 let mut handles = Vec::new();
4188 if let Some(handle) = file_node_by_path.get(&summary.file_path) {
4189 handles.push(handle.clone());
4190 }
4191 if let Some(handle) =
4192 symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
4193 && !handles.iter().any(|existing| existing == handle)
4194 {
4195 handles.push(handle.clone());
4196 }
4197 handles
4198}
4199
4200fn semantic_entity_node(
4201 root: &Path,
4202 summary: &summarize::Summary,
4203 name: &str,
4204 kind: &str,
4205 description: &str,
4206 provenance: &GraphProvenance,
4207) -> SubstrateGraphNode {
4208 let handle = semantic_entity_handle(name, kind);
4209 let detail = if description.trim().is_empty() {
4210 format!("{kind} entity from cached summaries")
4211 } else {
4212 format!("{kind}: {description}")
4213 };
4214 SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
4215 .with_property("handle", handle)
4216 .with_property("ref_id", name.to_string())
4217 .with_property("detail", detail)
4218 .with_property("entity_kind", kind.to_string())
4219 .with_property("description", description.to_string())
4220 .with_property("source_file", summary.file_path.clone())
4221 .with_property("source_symbol", summary.symbol_name.clone())
4222 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4223 .with_property(
4224 "embedding",
4225 semantic_embedding_property(&format!("{name} {kind} {description}")),
4226 )
4227 .with_property(
4228 "expand",
4229 semantic_related_command(root, name, SemanticRelatedKind::Entity),
4230 )
4231 .with_provenance(provenance.clone())
4232}
4233
4234fn semantic_concept_node(
4235 root: &Path,
4236 summary: &summarize::Summary,
4237 label: &str,
4238 provenance: &GraphProvenance,
4239) -> SubstrateGraphNode {
4240 let handle = semantic_concept_handle(label);
4241 SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
4242 .with_property("handle", handle)
4243 .with_property("ref_id", label.to_string())
4244 .with_property("detail", "concept label from cached summaries".to_string())
4245 .with_property("source_file", summary.file_path.clone())
4246 .with_property("source_symbol", summary.symbol_name.clone())
4247 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4248 .with_property("embedding", semantic_embedding_property(label))
4249 .with_property(
4250 "expand",
4251 semantic_related_command(root, label, SemanticRelatedKind::Concept),
4252 )
4253 .with_provenance(provenance.clone())
4254}
4255
4256fn insert_semantic_edge(
4257 edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
4258 edge: SubstrateGraphEdge,
4259) {
4260 edge_map
4261 .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
4262 .or_insert(edge);
4263}
4264
4265fn append_summary_semantic_projection_rows(
4266 root: &Path,
4267 graph: &TraversalGraphBuild,
4268 provenance: &GraphProvenance,
4269 nodes: &mut Vec<SubstrateGraphNode>,
4270 edges: &mut Vec<SubstrateGraphEdge>,
4271) -> Result<()> {
4272 let summaries_db = root.join(".tsift/summaries.db");
4273 if !summaries_db.exists() {
4274 return Ok(());
4275 }
4276
4277 let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
4278 let summaries = summary_db.all()?;
4279 if summaries.is_empty() {
4280 return Ok(());
4281 }
4282
4283 let file_node_by_path = graph
4284 .nodes
4285 .values()
4286 .filter(|node| node.kind == "file")
4287 .filter_map(|node| {
4288 node.path
4289 .as_ref()
4290 .map(|path| (path.clone(), node.handle.clone()))
4291 })
4292 .collect::<BTreeMap<_, _>>();
4293 let symbol_node_by_file_label = graph
4294 .nodes
4295 .values()
4296 .filter(|node| node.kind == "symbol")
4297 .filter_map(|node| {
4298 Some((
4299 (node.path.clone()?, node.label.clone()),
4300 node.handle.clone(),
4301 ))
4302 })
4303 .collect::<BTreeMap<_, _>>();
4304
4305 let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
4306 let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
4307
4308 for summary in &summaries {
4309 let source_handles =
4310 summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
4311 let mut entity_ids_by_name = BTreeMap::<String, String>::new();
4312
4313 if let Some(entities) = &summary.entities {
4314 for entity in entities {
4315 let node = semantic_entity_node(
4316 root,
4317 summary,
4318 &entity.name,
4319 &entity.kind,
4320 &entity.description,
4321 provenance,
4322 );
4323 let entity_id = node.id.clone();
4324 entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
4325 semantic_nodes.entry(entity_id.clone()).or_insert(node);
4326
4327 for source_handle in &source_handles {
4328 insert_semantic_edge(
4329 &mut semantic_edges,
4330 SubstrateGraphEdge::new(
4331 source_handle.clone(),
4332 entity_id.clone(),
4333 "mentions_entity",
4334 )
4335 .with_property("label", format!("summary entity: {}", entity.name))
4336 .with_property("source_file", summary.file_path.clone())
4337 .with_provenance(provenance.clone()),
4338 );
4339 }
4340 }
4341 }
4342
4343 let mut concept_ids = Vec::new();
4344 if let Some(labels) = &summary.concept_labels {
4345 for label in labels
4346 .iter()
4347 .map(|label| label.trim())
4348 .filter(|label| !label.is_empty())
4349 {
4350 let node = semantic_concept_node(root, summary, label, provenance);
4351 let concept_id = node.id.clone();
4352 semantic_nodes.entry(concept_id.clone()).or_insert(node);
4353 concept_ids.push(concept_id.clone());
4354
4355 for source_handle in &source_handles {
4356 insert_semantic_edge(
4357 &mut semantic_edges,
4358 SubstrateGraphEdge::new(
4359 source_handle.clone(),
4360 concept_id.clone(),
4361 "mentions_concept",
4362 )
4363 .with_property("label", format!("summary concept: {label}"))
4364 .with_property("source_file", summary.file_path.clone())
4365 .with_provenance(provenance.clone()),
4366 );
4367 }
4368 }
4369 }
4370
4371 for entity_id in entity_ids_by_name.values() {
4372 for concept_id in &concept_ids {
4373 insert_semantic_edge(
4374 &mut semantic_edges,
4375 SubstrateGraphEdge::new(
4376 entity_id.clone(),
4377 concept_id.clone(),
4378 "tagged_concept",
4379 )
4380 .with_property("label", "entity concept label".to_string())
4381 .with_property("source_file", summary.file_path.clone())
4382 .with_provenance(provenance.clone()),
4383 );
4384 }
4385 }
4386
4387 for idx in 0..concept_ids.len() {
4388 for next_idx in (idx + 1)..concept_ids.len() {
4389 insert_semantic_edge(
4390 &mut semantic_edges,
4391 SubstrateGraphEdge::new(
4392 concept_ids[idx].clone(),
4393 concept_ids[next_idx].clone(),
4394 "related_concept",
4395 )
4396 .with_property("label", format!("co-occurs in {}", summary.symbol_name))
4397 .with_property("source_file", summary.file_path.clone())
4398 .with_provenance(provenance.clone()),
4399 );
4400 }
4401 }
4402
4403 if let Some(relationships) = &summary.relationships {
4404 for relationship in relationships {
4405 let from_id = entity_ids_by_name
4406 .get(&relationship.from.to_ascii_lowercase())
4407 .cloned()
4408 .unwrap_or_else(|| {
4409 let node = semantic_entity_node(
4410 root,
4411 summary,
4412 &relationship.from,
4413 "unknown",
4414 "",
4415 provenance,
4416 );
4417 let id = node.id.clone();
4418 semantic_nodes.entry(id.clone()).or_insert(node);
4419 id
4420 });
4421 let to_id = entity_ids_by_name
4422 .get(&relationship.to.to_ascii_lowercase())
4423 .cloned()
4424 .unwrap_or_else(|| {
4425 let node = semantic_entity_node(
4426 root,
4427 summary,
4428 &relationship.to,
4429 "unknown",
4430 "",
4431 provenance,
4432 );
4433 let id = node.id.clone();
4434 semantic_nodes.entry(id.clone()).or_insert(node);
4435 id
4436 });
4437 insert_semantic_edge(
4438 &mut semantic_edges,
4439 SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
4440 .with_property("relationship_kind", relationship.kind.clone())
4441 .with_property("label", relationship.kind.clone())
4442 .with_property("source_file", summary.file_path.clone())
4443 .with_property("source_symbol", summary.symbol_name.clone())
4444 .with_provenance(provenance.clone()),
4445 );
4446 }
4447 }
4448 }
4449
4450 for node in semantic_nodes.into_values() {
4451 nodes.push(node_with_content_freshness(node)?);
4452 }
4453 for edge in semantic_edges.into_values() {
4454 edges.push(edge_with_content_freshness(edge)?);
4455 }
4456
4457 Ok(())
4458}
4459
4460fn projection_content_hash(
4461 nodes: &[SubstrateGraphNode],
4462 edges: &[SubstrateGraphEdge],
4463) -> Result<String> {
4464 #[derive(Serialize)]
4465 struct Payload<'a> {
4466 version: &'static str,
4467 nodes: &'a [SubstrateGraphNode],
4468 edges: &'a [SubstrateGraphEdge],
4469 }
4470
4471 content_hash(&Payload {
4472 version: GRAPH_PROJECTION_VERSION,
4473 nodes,
4474 edges,
4475 })
4476}
4477
4478pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
4479 projection
4480 .nodes
4481 .iter()
4482 .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
4483 .and_then(|node| node.properties.get("content_hash").cloned())
4484}
4485
4486fn traversal_projection_from_graph(
4487 root: &Path,
4488 scope: Option<&str>,
4489 graph: &TraversalGraphBuild,
4490) -> Result<GraphProjection> {
4491 let provenance = GraphProvenance::new(
4492 "tsift.traverse",
4493 format!("{}:{}", root.display(), scope.unwrap_or("root")),
4494 );
4495 let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
4496 for node in graph.nodes.values() {
4497 let mut projected =
4498 SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
4499 .with_property("handle", node.handle.clone())
4500 .with_property("expand", node.expand.clone())
4501 .with_provenance(provenance.clone());
4502 if let Some(ref_id) = &node.ref_id {
4503 projected = projected.with_property("ref_id", ref_id.clone());
4504 }
4505 if let Some(path) = &node.path {
4506 projected = projected.with_property("path", path.clone());
4507 }
4508 if let Some(line) = node.line {
4509 projected = projected.with_property("line", line.to_string());
4510 }
4511 if let Some(detail) = &node.detail {
4512 projected = projected.with_property("detail", detail.clone());
4513 }
4514 for (key, value) in &node.properties {
4515 projected = projected.with_property(key.clone(), value.clone());
4516 }
4517 nodes.push(node_with_content_freshness(projected)?);
4518 }
4519
4520 let mut edges = Vec::with_capacity(graph.edges.len());
4521 for edge in &graph.edges {
4522 let mut projected =
4523 SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
4524 .with_property("weight", edge.weight.to_string())
4525 .with_provenance(provenance.clone());
4526 if let Some(label) = &edge.label {
4527 projected = projected.with_property("label", label.clone());
4528 }
4529 edges.push(edge_with_content_freshness(projected)?);
4530 }
4531
4532 append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4533 append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4534 append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
4535
4536 let projection_hash = projection_content_hash(&nodes, &edges)?;
4537 let meta = SubstrateGraphNode::new(
4538 graph_projection_meta_id(scope),
4539 GRAPH_PROJECTION_META_KIND,
4540 "tsift traversal projection",
4541 )
4542 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
4543 .with_property("content_hash", projection_hash.clone())
4544 .with_property("root", root.to_string_lossy().to_string())
4545 .with_property("scope", scope.unwrap_or("root"))
4546 .with_property("node_count", graph.nodes.len().to_string())
4547 .with_property("edge_count", graph.edges.len().to_string())
4548 .with_provenance(provenance)
4549 .with_freshness(GraphFreshness::content_hash(projection_hash));
4550 nodes.push(meta);
4551
4552 Ok(GraphProjection { nodes, edges })
4553}
4554
4555#[allow(clippy::too_many_arguments)]
4556fn ensure_traversal_source_handle(
4557 root: &Path,
4558 provenance: &GraphProvenance,
4559 file_node_by_path: &BTreeMap<String, String>,
4560 node: &TraversalNode,
4561 budget: &ExplorationBudget,
4562 source_handle_by_node: &mut BTreeMap<String, String>,
4563 seen_windows: &mut BTreeMap<(String, usize, usize), String>,
4564 nodes: &mut Vec<SubstrateGraphNode>,
4565 edges: &mut Vec<SubstrateGraphEdge>,
4566) -> Result<Option<String>> {
4567 if let Some(handle) = source_handle_by_node.get(&node.handle) {
4568 return Ok(Some(handle.clone()));
4569 }
4570 let Some(window) = exploration_source_window_for_node(root, node, budget) else {
4571 return Ok(None);
4572 };
4573 let window_key = (window.file.clone(), window.start, window.end);
4574 let handle = if let Some(handle) = seen_windows.get(&window_key) {
4575 handle.clone()
4576 } else {
4577 let label = format!("{}:{}-{}", window.file, window.start, window.end);
4578 let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
4579 .with_property("handle", window.handle.clone())
4580 .with_property("file", window.file.clone())
4581 .with_property("start", window.start.to_string())
4582 .with_property("end", window.end.to_string())
4583 .with_property("reason", window.reason.clone())
4584 .with_property("expand", window.expand.clone())
4585 .with_provenance(provenance.clone());
4586 nodes.push(node_with_content_freshness(projected)?);
4587
4588 if let Some(file_handle) = file_node_by_path.get(&window.file) {
4589 let edge = SubstrateGraphEdge::new(
4590 window.handle.clone(),
4591 file_handle.clone(),
4592 "expands_source",
4593 )
4594 .with_property("label", window.reason.clone())
4595 .with_provenance(provenance.clone());
4596 edges.push(edge_with_content_freshness(edge)?);
4597 }
4598 if node.kind != "file" {
4599 let edge = SubstrateGraphEdge::new(
4600 window.handle.clone(),
4601 node.handle.clone(),
4602 "anchors_source",
4603 )
4604 .with_property("label", window.reason.clone())
4605 .with_provenance(provenance.clone());
4606 edges.push(edge_with_content_freshness(edge)?);
4607 }
4608 seen_windows.insert(window_key, window.handle.clone());
4609 window.handle
4610 };
4611 source_handle_by_node.insert(node.handle.clone(), handle.clone());
4612 Ok(Some(handle))
4613}
4614
4615fn push_traversal_backlog_target_handles<'a>(
4616 backlog: &TraversalNode,
4617 edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
4618 node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
4619 max_handles: usize,
4620 seen_target_nodes: &mut BTreeSet<String>,
4621 target_node_handles: &mut Vec<String>,
4622) {
4623 for edge in edges_by_from
4624 .get(backlog.handle.as_str())
4625 .into_iter()
4626 .flatten()
4627 .filter(|edge| edge.relation == "mentions")
4628 {
4629 let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
4630 continue;
4631 };
4632 if !matches!(
4633 target_node.kind.as_str(),
4634 "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
4635 ) {
4636 continue;
4637 }
4638 if target_node
4639 .path
4640 .as_deref()
4641 .zip(backlog.path.as_deref())
4642 .is_some_and(|(target_path, backlog_path)| {
4643 target_path == backlog_path && target_path.ends_with(".md")
4644 })
4645 {
4646 continue;
4647 }
4648 if seen_target_nodes.insert(target_node.handle.clone()) {
4649 target_node_handles.push(target_node.handle.clone());
4650 }
4651 if target_node_handles.len() >= max_handles {
4652 break;
4653 }
4654 }
4655}
4656
4657fn append_traversal_context_projection_rows(
4658 root: &Path,
4659 graph: &TraversalGraphBuild,
4660 provenance: &GraphProvenance,
4661 nodes: &mut Vec<SubstrateGraphNode>,
4662 edges: &mut Vec<SubstrateGraphEdge>,
4663) -> Result<()> {
4664 let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4665 let file_node_by_path = graph
4666 .nodes
4667 .values()
4668 .filter(|node| node.kind == "file")
4669 .filter_map(|node| {
4670 node.path
4671 .as_ref()
4672 .map(|path| (path.clone(), node.handle.clone()))
4673 })
4674 .collect::<BTreeMap<_, _>>();
4675
4676 let node_by_handle = graph
4677 .nodes
4678 .values()
4679 .map(|node| (node.handle.as_str(), node))
4680 .collect::<BTreeMap<_, _>>();
4681 let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4682 for edge in &graph.edges {
4683 edges_by_from
4684 .entry(edge.from.as_str())
4685 .or_default()
4686 .push(edge);
4687 }
4688 for rows in edges_by_from.values_mut() {
4689 rows.sort_by(|left, right| {
4690 right
4691 .weight
4692 .cmp(&left.weight)
4693 .then(left.relation.cmp(&right.relation))
4694 .then(left.to.cmp(&right.to))
4695 });
4696 }
4697
4698 let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4699 let mut source_handle_by_node = BTreeMap::<String, String>::new();
4700
4701 let mut code_context_count = 0usize;
4702 let code_context_limit = budget.relationship_limit.min(8);
4703 for node in graph.nodes.values() {
4704 if !matches!(
4705 node.kind.as_str(),
4706 "backlog" | "job_packet" | "worker_result"
4707 ) {
4708 continue;
4709 }
4710 let mut target_node_handles = Vec::new();
4711 let mut fallback_target_handles = Vec::new();
4712 let mut seen_target_nodes = BTreeSet::new();
4713 if node.kind == "backlog" || node.kind == "worker_result" {
4714 push_traversal_backlog_target_handles(
4715 node,
4716 &edges_by_from,
4717 &node_by_handle,
4718 budget.max_source_windows,
4719 &mut seen_target_nodes,
4720 &mut target_node_handles,
4721 );
4722 fallback_target_handles.push(node.handle.clone());
4723 } else {
4724 for edge in edges_by_from
4725 .get(node.handle.as_str())
4726 .into_iter()
4727 .flatten()
4728 .filter(|edge| edge.relation == "targets")
4729 {
4730 let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4731 continue;
4732 };
4733 fallback_target_handles.push(backlog.handle.clone());
4734 push_traversal_backlog_target_handles(
4735 backlog,
4736 &edges_by_from,
4737 &node_by_handle,
4738 budget.max_source_windows,
4739 &mut seen_target_nodes,
4740 &mut target_node_handles,
4741 );
4742 if target_node_handles.len() >= budget.max_source_windows {
4743 break;
4744 }
4745 }
4746 if fallback_target_handles.is_empty() {
4747 continue;
4748 }
4749 }
4750 let code_context = !target_node_handles.is_empty();
4751 if target_node_handles.is_empty() {
4752 target_node_handles = dedupe_preserve_order(fallback_target_handles);
4753 } else if code_context_count >= code_context_limit {
4754 continue;
4755 }
4756
4757 let mut worker_source_handles = Vec::new();
4758 let mut seen_worker_handles = BTreeSet::new();
4759 for target_handle in target_node_handles {
4760 if worker_source_handles.len() >= budget.max_source_windows {
4761 break;
4762 }
4763 let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4764 continue;
4765 };
4766 let Some(handle) = ensure_traversal_source_handle(
4767 root,
4768 provenance,
4769 &file_node_by_path,
4770 target_node,
4771 &budget,
4772 &mut source_handle_by_node,
4773 &mut seen_windows,
4774 nodes,
4775 edges,
4776 )?
4777 else {
4778 continue;
4779 };
4780 if seen_worker_handles.insert(handle.clone()) {
4781 worker_source_handles.push(handle);
4782 }
4783 }
4784 if worker_source_handles.is_empty() {
4785 continue;
4786 }
4787 let target = node
4788 .path
4789 .clone()
4790 .unwrap_or_else(|| root.to_string_lossy().to_string());
4791 let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4792 let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4793 let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4794 .with_property("handle", handle.clone())
4795 .with_property("target", target.clone())
4796 .with_property("summary", summary)
4797 .with_property(
4798 "source_handle_count",
4799 worker_source_handles.len().to_string(),
4800 )
4801 .with_property(
4802 "expand",
4803 format!(
4804 "tsift --envelope context-pack {} --budget normal",
4805 shell_quote(&target)
4806 ),
4807 )
4808 .with_provenance(provenance.clone());
4809 nodes.push(node_with_content_freshness(projected)?);
4810
4811 let request_edge =
4812 SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4813 .with_property("label", "bounded worker context".to_string())
4814 .with_provenance(provenance.clone());
4815 edges.push(edge_with_content_freshness(request_edge)?);
4816
4817 for source_handle in &worker_source_handles {
4818 let scope_edge =
4819 SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4820 .with_property("label", "bounded worker source window".to_string())
4821 .with_provenance(provenance.clone());
4822 edges.push(edge_with_content_freshness(scope_edge)?);
4823 }
4824 if code_context {
4825 code_context_count += 1;
4826 }
4827 }
4828
4829 Ok(())
4830}
4831
4832fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4833 let handle = node
4834 .properties
4835 .get("handle")
4836 .cloned()
4837 .unwrap_or_else(|| node.id.clone());
4838 TraversalNode {
4839 expand: node
4840 .properties
4841 .get("expand")
4842 .cloned()
4843 .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4844 handle,
4845 kind: node.kind,
4846 label: node.label,
4847 ref_id: node.properties.get("ref_id").cloned(),
4848 path: node.properties.get("path").cloned(),
4849 line: node
4850 .properties
4851 .get("line")
4852 .and_then(|value| value.parse::<i64>().ok()),
4853 detail: node.properties.get("detail").cloned(),
4854 properties: node.properties,
4855 }
4856}
4857
4858fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4859 let mut graph = TraversalGraphBuild::default();
4860 for node in store.all_nodes()? {
4861 if node.kind == GRAPH_PROJECTION_META_KIND {
4862 continue;
4863 }
4864 graph.add_node(traversal_node_from_graph_node(root, node));
4865 }
4866 for edge in store.all_edges()? {
4867 graph.add_edge(
4868 &edge.from_id,
4869 &edge.to_id,
4870 &edge.kind,
4871 edge.properties.get("label").cloned(),
4872 edge.properties
4873 .get("weight")
4874 .and_then(|value| value.parse::<usize>().ok())
4875 .unwrap_or(1),
4876 );
4877 }
4878 Ok(graph)
4879}
4880
4881pub(crate) fn convex_rows_from_graph_store(
4882 store: &impl GraphStore,
4883) -> Result<ConvexProjectionRows> {
4884 Ok(GraphProjection {
4885 nodes: store.all_nodes()?,
4886 edges: store.all_edges()?,
4887 }
4888 .to_convex_rows())
4889}
4890
4891#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4892struct ConvexRequiredIndex {
4893 table: String,
4894 name: String,
4895 fields: Vec<String>,
4896}
4897
4898#[derive(Clone, Debug, Serialize, PartialEq)]
4899struct ConvexSyncChunk {
4900 operation: String,
4901 chunk: usize,
4902 count: usize,
4903 keys: Vec<String>,
4904 max_attempts: usize,
4905 retry_policy: String,
4906}
4907
4908#[derive(Clone, Debug, Serialize, PartialEq)]
4909struct ConvexTransportSummary {
4910 endpoint_env: String,
4911 endpoint_configured: bool,
4912 auth_token_env: String,
4913 auth_configured: bool,
4914 remote_snapshot: bool,
4915 applied_chunks: usize,
4916}
4917
4918#[derive(Clone, Debug, Serialize, PartialEq)]
4919struct ConvexTransportReceipt {
4920 operation: String,
4921 chunk: usize,
4922 attempt: usize,
4923 status: String,
4924 message: Option<String>,
4925}
4926
4927#[derive(Serialize)]
4928#[serde(rename_all = "camelCase")]
4929struct ConvexTransportRequest<'a> {
4930 operation: &'a str,
4931 chunk: usize,
4932 projection_version: &'a str,
4933 projection_hash: Option<&'a str>,
4934 #[serde(skip_serializing_if = "Option::is_none")]
4935 projection_meta_id: Option<&'a str>,
4936 node_rows: Vec<ConvexNodeRow>,
4937 edge_rows: Vec<ConvexEdgeRow>,
4938 keys: Vec<String>,
4939 #[serde(skip_serializing_if = "Option::is_none")]
4940 cursor: Option<String>,
4941 #[serde(skip_serializing_if = "Option::is_none")]
4942 limit: Option<usize>,
4943}
4944
4945#[derive(Deserialize)]
4946#[serde(rename_all = "camelCase")]
4947struct ConvexTransportResponse {
4948 status: Option<String>,
4949 message: Option<String>,
4950 rows: Option<ConvexProjectionRows>,
4951 #[serde(default)]
4952 meta: Option<ConvexSnapshotMeta>,
4953 #[serde(default)]
4954 page: Option<ConvexSnapshotPage>,
4955}
4956
4957#[derive(Deserialize, Debug, Clone)]
4958#[serde(rename_all = "camelCase")]
4959struct ConvexSnapshotMeta {
4960 #[serde(default)]
4964 #[allow(dead_code)]
4965 indexes: Vec<ConvexRequiredIndex>,
4966 #[serde(default)]
4967 #[allow(dead_code)]
4968 node_count: Option<usize>,
4969 #[serde(default)]
4970 #[allow(dead_code)]
4971 edge_count: Option<usize>,
4972 #[serde(default)]
4973 projection_hash: Option<String>,
4974 #[serde(default)]
4975 #[allow(dead_code)]
4976 page_size: Option<usize>,
4977}
4978
4979#[derive(Deserialize, Debug, Clone)]
4984#[serde(rename_all = "camelCase")]
4985struct ConvexSnapshotPage {
4986 rows: Vec<serde_json::Value>,
4987 #[serde(default)]
4988 next_cursor: Option<String>,
4989}
4990
4991#[derive(Clone, Debug, Serialize, PartialEq)]
4992struct ConvexProjectionFreshness {
4993 status: String,
4994 fail_closed: bool,
4995 local_hash: Option<String>,
4996 snapshot_hash: Option<String>,
4997 missing_nodes: Vec<String>,
4998 stale_nodes: Vec<String>,
4999 missing_edges: Vec<String>,
5000 stale_edges: Vec<String>,
5001 diagnostics: Vec<String>,
5002}
5003
5004const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
5005
5006impl ConvexProjectionFreshness {
5007 fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
5008 Self {
5009 status: "current".to_string(),
5010 fail_closed: false,
5011 local_hash,
5012 snapshot_hash,
5013 missing_nodes: Vec::new(),
5014 stale_nodes: Vec::new(),
5015 missing_edges: Vec::new(),
5016 stale_edges: Vec::new(),
5017 diagnostics: Vec::new(),
5018 }
5019 }
5020}
5021
5022#[derive(Clone, Debug, Serialize, PartialEq)]
5023struct ConvexSyncReport {
5024 root: String,
5025 #[serde(skip_serializing_if = "Option::is_none")]
5026 scope: Option<String>,
5027 graph_db: String,
5028 dry_run: bool,
5029 projection_version: String,
5030 projection_hash: Option<String>,
5031 required_indexes: Vec<ConvexRequiredIndex>,
5032 node_upserts: Vec<ConvexNodeRow>,
5033 edge_upserts: Vec<ConvexEdgeRow>,
5034 node_tombstones: Vec<String>,
5035 edge_tombstones: Vec<String>,
5036 chunks: Vec<ConvexSyncChunk>,
5037 freshness: ConvexProjectionFreshness,
5038 transport: Option<ConvexTransportSummary>,
5039 receipts: Vec<ConvexTransportReceipt>,
5040 diagnostics: Vec<String>,
5041 warnings: Vec<String>,
5042}
5043
5044fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
5045 vec![
5046 ConvexRequiredIndex {
5047 table: "nodes".to_string(),
5048 name: "by_external_id".to_string(),
5049 fields: vec!["externalId".to_string()],
5050 },
5051 ConvexRequiredIndex {
5052 table: "nodes".to_string(),
5053 name: "by_kind".to_string(),
5054 fields: vec!["kind".to_string()],
5055 },
5056 ConvexRequiredIndex {
5057 table: "edges".to_string(),
5058 name: "by_edge_key".to_string(),
5059 fields: vec!["edgeKey".to_string()],
5060 },
5061 ConvexRequiredIndex {
5062 table: "edges".to_string(),
5063 name: "by_from_kind".to_string(),
5064 fields: vec!["fromExternalId".to_string(), "kind".to_string()],
5065 },
5066 ConvexRequiredIndex {
5067 table: "edges".to_string(),
5068 name: "by_to_kind".to_string(),
5069 fields: vec!["toExternalId".to_string(), "kind".to_string()],
5070 },
5071 ]
5072}
5073
5074pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
5075 let content = fs::read_to_string(path)
5076 .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
5077 serde_json::from_str(&content)
5078 .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
5079}
5080
5081fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
5082 let mut diagnostics = Vec::new();
5083 let mut node_counts = BTreeMap::<&str, usize>::new();
5084 for row in &rows.nodes {
5085 *node_counts.entry(row.external_id.as_str()).or_default() += 1;
5086 }
5087 for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
5088 diagnostics.push(format!(
5089 "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
5090 ));
5091 }
5092
5093 let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
5094 let mut edge_counts = BTreeMap::<&str, usize>::new();
5095 for edge in &rows.edges {
5096 *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
5097 if !node_ids.contains(edge.from_external_id.as_str()) {
5098 diagnostics.push(format!(
5099 "Convex snapshot edge {} references missing from node {}",
5100 edge.edge_key, edge.from_external_id
5101 ));
5102 }
5103 if !node_ids.contains(edge.to_external_id.as_str()) {
5104 diagnostics.push(format!(
5105 "Convex snapshot edge {} references missing to node {}",
5106 edge.edge_key, edge.to_external_id
5107 ));
5108 }
5109 let expected_key =
5110 ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
5111 if edge.edge_key != expected_key {
5112 diagnostics.push(format!(
5113 "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
5114 edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
5115 ));
5116 }
5117 }
5118 for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
5119 diagnostics.push(format!(
5120 "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
5121 ));
5122 }
5123 diagnostics
5124}
5125
5126pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
5127 let diagnostics = convex_projection_row_diagnostics(rows);
5128 if diagnostics.is_empty() {
5129 Ok(())
5130 } else {
5131 bail!("{}", diagnostics.join("; "))
5132 }
5133}
5134
5135pub(crate) struct ConvexHttpTransport {
5136 endpoint: String,
5137 auth_token_env: String,
5138 auth_token: Option<String>,
5139}
5140
5141impl ConvexHttpTransport {
5142 fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
5143 let endpoint = endpoint
5144 .map(str::to_string)
5145 .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
5146 .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
5147 let auth_token = env::var(auth_token_env)
5148 .ok()
5149 .filter(|value| !value.trim().is_empty());
5150 Ok(Self {
5151 endpoint,
5152 auth_token_env: auth_token_env.to_string(),
5153 auth_token,
5154 })
5155 }
5156
5157 fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
5158 ConvexTransportSummary {
5159 endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
5160 endpoint_configured: true,
5161 auth_token_env: self.auth_token_env.clone(),
5162 auth_configured: self.auth_token.is_some(),
5163 remote_snapshot,
5164 applied_chunks,
5165 }
5166 }
5167
5168 fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
5169 let mut builder = ureq::post(&self.endpoint);
5170 if let Some(token) = &self.auth_token {
5171 builder = builder.header("Authorization", &format!("Bearer {token}"));
5172 }
5173 builder
5174 .send_json(request)
5175 .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
5176 .body_mut()
5177 .read_json::<ConvexTransportResponse>()
5178 .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
5179 }
5180
5181 fn fetch_snapshot(
5192 &self,
5193 projection_version: &str,
5194 scope: Option<&str>,
5195 local_hash: Option<&str>,
5196 local_rows: Option<&ConvexProjectionRows>,
5197 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5198 match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
5199 Ok(rows) => Ok(rows),
5200 Err(err) => {
5201 let msg = format!("{err:#}");
5206 let is_unknown_op = msg.contains("unknown operation")
5207 || msg.contains("snapshot_meta")
5208 || msg.contains("404");
5209 if !is_unknown_op {
5210 return Err(err);
5211 }
5212 self.fetch_snapshot_legacy(projection_version)
5213 .map(|rows| (rows, Vec::new()))
5214 }
5215 }
5216 }
5217
5218 fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
5219 let response = self.post(&ConvexTransportRequest {
5220 operation: "snapshot",
5221 chunk: 0,
5222 projection_version,
5223 projection_hash: None,
5224 projection_meta_id: None,
5225 node_rows: Vec::new(),
5226 edge_rows: Vec::new(),
5227 keys: Vec::new(),
5228 cursor: None,
5229 limit: None,
5230 })?;
5231 response
5232 .rows
5233 .context("Convex snapshot response did not include rows")
5234 }
5235
5236 fn fetch_snapshot_paginated(
5237 &self,
5238 projection_version: &str,
5239 scope: Option<&str>,
5240 local_hash: Option<&str>,
5241 local_rows: Option<&ConvexProjectionRows>,
5242 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5243 let projection_meta_id = graph_projection_meta_id(scope);
5244 let meta_response = self.post(&ConvexTransportRequest {
5245 operation: "snapshot_meta",
5246 chunk: 0,
5247 projection_version,
5248 projection_hash: None,
5249 projection_meta_id: Some(&projection_meta_id),
5250 node_rows: Vec::new(),
5251 edge_rows: Vec::new(),
5252 keys: Vec::new(),
5253 cursor: None,
5254 limit: None,
5255 })?;
5256 if matches!(meta_response.status.as_deref(), Some("error")) {
5257 anyhow::bail!(
5258 "Convex snapshot_meta returned error: {}",
5259 meta_response.message.unwrap_or_default()
5260 );
5261 }
5262 let meta = meta_response
5263 .meta
5264 .context("Convex snapshot_meta response did not include meta")?;
5265 if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
5266 (meta.projection_hash.as_deref(), local_hash, local_rows)
5267 && remote_hash == local_hash
5268 {
5269 return Ok((
5270 local_rows.clone(),
5271 vec![
5272 "remote projection hash matched local graph; skipped full row-page snapshot diff"
5273 .to_string(),
5274 ],
5275 ));
5276 }
5277
5278 let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
5279 let mut node_cursor: Option<String> = None;
5280 loop {
5281 let response = self.post(&ConvexTransportRequest {
5282 operation: "snapshot_nodes_page",
5283 chunk: 0,
5284 projection_version,
5285 projection_hash: None,
5286 projection_meta_id: None,
5287 node_rows: Vec::new(),
5288 edge_rows: Vec::new(),
5289 keys: Vec::new(),
5290 cursor: node_cursor.clone(),
5291 limit: None,
5292 })?;
5293 let page = response
5294 .page
5295 .context("Convex snapshot_nodes_page response did not include page")?;
5296 for raw in page.rows {
5297 let row: ConvexNodeRow =
5298 serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
5299 nodes.push(row);
5300 }
5301 match page.next_cursor {
5302 Some(next) => node_cursor = Some(next),
5303 None => break,
5304 }
5305 }
5306
5307 let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
5308 let mut edge_cursor: Option<String> = None;
5309 loop {
5310 let response = self.post(&ConvexTransportRequest {
5311 operation: "snapshot_edges_page",
5312 chunk: 0,
5313 projection_version,
5314 projection_hash: None,
5315 projection_meta_id: None,
5316 node_rows: Vec::new(),
5317 edge_rows: Vec::new(),
5318 keys: Vec::new(),
5319 cursor: edge_cursor.clone(),
5320 limit: None,
5321 })?;
5322 let page = response
5323 .page
5324 .context("Convex snapshot_edges_page response did not include page")?;
5325 for raw in page.rows {
5326 let row: ConvexEdgeRow =
5327 serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
5328 edges.push(row);
5329 }
5330 match page.next_cursor {
5331 Some(next) => edge_cursor = Some(next),
5332 None => break,
5333 }
5334 }
5335
5336 Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
5337 }
5338
5339 fn apply_chunk(
5340 &self,
5341 report: &ConvexSyncReport,
5342 chunk: &ConvexSyncChunk,
5343 ) -> Result<ConvexTransportReceipt> {
5344 let node_rows = if chunk.operation == "upsert_nodes" {
5345 report
5346 .node_upserts
5347 .iter()
5348 .filter(|row| chunk.keys.contains(&row.external_id))
5349 .cloned()
5350 .collect()
5351 } else {
5352 Vec::new()
5353 };
5354 let edge_rows = if chunk.operation == "upsert_edges" {
5355 report
5356 .edge_upserts
5357 .iter()
5358 .filter(|row| chunk.keys.contains(&row.edge_key))
5359 .cloned()
5360 .collect()
5361 } else {
5362 Vec::new()
5363 };
5364 let request = ConvexTransportRequest {
5365 operation: &chunk.operation,
5366 chunk: chunk.chunk,
5367 projection_version: &report.projection_version,
5368 projection_hash: report.projection_hash.as_deref(),
5369 projection_meta_id: None,
5370 node_rows,
5371 edge_rows,
5372 keys: chunk.keys.clone(),
5373 cursor: None,
5374 limit: None,
5375 };
5376 let mut last_error = None;
5377 for attempt in 1..=chunk.max_attempts {
5378 match self.post(&request) {
5379 Ok(response) => {
5380 return Ok(ConvexTransportReceipt {
5381 operation: chunk.operation.clone(),
5382 chunk: chunk.chunk,
5383 attempt,
5384 status: response.status.unwrap_or_else(|| "ok".to_string()),
5385 message: response.message,
5386 });
5387 }
5388 Err(err) => {
5389 last_error = Some(err);
5390 if attempt < chunk.max_attempts {
5391 std::thread::sleep(Duration::from_millis(100 * attempt as u64));
5392 }
5393 }
5394 }
5395 }
5396 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
5397 .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
5398 }
5399}
5400
5401fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
5402 let meta_id = graph_projection_meta_id(scope);
5403 rows.nodes
5404 .iter()
5405 .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
5406 .and_then(|row| row.properties.get("content_hash").cloned())
5407}
5408
5409fn convex_projection_freshness(
5410 local: &ConvexProjectionRows,
5411 snapshot: Option<&ConvexProjectionRows>,
5412 scope: Option<&str>,
5413) -> ConvexProjectionFreshness {
5414 let local_hash = convex_projection_hash(local, scope);
5415 let Some(snapshot) = snapshot else {
5416 return ConvexProjectionFreshness {
5417 status: "unchecked".to_string(),
5418 fail_closed: false,
5419 local_hash,
5420 snapshot_hash: None,
5421 missing_nodes: Vec::new(),
5422 stale_nodes: Vec::new(),
5423 missing_edges: Vec::new(),
5424 stale_edges: Vec::new(),
5425 diagnostics: vec![
5426 "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
5427 ],
5428 };
5429 };
5430
5431 let snapshot_hash = convex_projection_hash(snapshot, scope);
5432 let snapshot_nodes = snapshot
5433 .nodes
5434 .iter()
5435 .map(|row| (row.external_id.as_str(), row))
5436 .collect::<BTreeMap<_, _>>();
5437 let snapshot_edges = snapshot
5438 .edges
5439 .iter()
5440 .map(|row| (row.edge_key.as_str(), row))
5441 .collect::<BTreeMap<_, _>>();
5442
5443 let mut missing_nodes = Vec::new();
5444 let mut stale_nodes = Vec::new();
5445 for row in &local.nodes {
5446 match snapshot_nodes.get(row.external_id.as_str()) {
5447 Some(snapshot_row) if *snapshot_row == row => {}
5448 Some(_) => stale_nodes.push(row.external_id.clone()),
5449 None => missing_nodes.push(row.external_id.clone()),
5450 }
5451 }
5452
5453 let mut missing_edges = Vec::new();
5454 let mut stale_edges = Vec::new();
5455 for row in &local.edges {
5456 match snapshot_edges.get(row.edge_key.as_str()) {
5457 Some(snapshot_row) if *snapshot_row == row => {}
5458 Some(_) => stale_edges.push(row.edge_key.clone()),
5459 None => missing_edges.push(row.edge_key.clone()),
5460 }
5461 }
5462
5463 let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
5464 let rows_current = missing_nodes.is_empty()
5465 && stale_nodes.is_empty()
5466 && missing_edges.is_empty()
5467 && stale_edges.is_empty();
5468 if hash_current && rows_current {
5469 return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
5470 }
5471
5472 let mut diagnostics = Vec::new();
5473 if local_hash != snapshot_hash {
5474 diagnostics.push(format!(
5475 "projection hash mismatch: local={} snapshot={}",
5476 local_hash.as_deref().unwrap_or("missing"),
5477 snapshot_hash.as_deref().unwrap_or("missing")
5478 ));
5479 }
5480 if !missing_nodes.is_empty() || !missing_edges.is_empty() {
5481 diagnostics.push(format!(
5482 "Convex snapshot is missing {} node(s) and {} edge(s)",
5483 missing_nodes.len(),
5484 missing_edges.len()
5485 ));
5486 }
5487 if !stale_nodes.is_empty() || !stale_edges.is_empty() {
5488 diagnostics.push(format!(
5489 "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
5490 stale_nodes.len(),
5491 stale_edges.len()
5492 ));
5493 }
5494
5495 ConvexProjectionFreshness {
5496 status: "stale".to_string(),
5497 fail_closed: true,
5498 local_hash,
5499 snapshot_hash,
5500 missing_nodes,
5501 stale_nodes,
5502 missing_edges,
5503 stale_edges,
5504 diagnostics,
5505 }
5506}
5507
5508pub(crate) fn verify_convex_projection_snapshot(
5509 root: &Path,
5510 scope: Option<&str>,
5511 snapshot_path: &Path,
5512) -> Result<()> {
5513 let graph_db = graph_substrate_db_path(root, scope);
5514 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5515 let local = convex_rows_from_graph_store(&store)?;
5516 let snapshot = load_convex_projection_rows(snapshot_path)?;
5517 validate_convex_projection_rows(&snapshot)?;
5518 let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
5519 if freshness.fail_closed {
5520 bail!(
5521 "Convex graph projection is not current for {}: {}",
5522 root.display(),
5523 freshness.diagnostics.join("; ")
5524 );
5525 }
5526 Ok(())
5527}
5528
5529fn convex_rows_diff(
5530 local: &ConvexProjectionRows,
5531 snapshot: Option<&ConvexProjectionRows>,
5532) -> (
5533 Vec<ConvexNodeRow>,
5534 Vec<ConvexEdgeRow>,
5535 Vec<String>,
5536 Vec<String>,
5537) {
5538 let Some(snapshot) = snapshot else {
5539 return (
5540 local.nodes.clone(),
5541 local.edges.clone(),
5542 Vec::new(),
5543 Vec::new(),
5544 );
5545 };
5546 let local_nodes = local
5547 .nodes
5548 .iter()
5549 .map(|row| (row.external_id.as_str(), row))
5550 .collect::<BTreeMap<_, _>>();
5551 let local_edges = local
5552 .edges
5553 .iter()
5554 .map(|row| (row.edge_key.as_str(), row))
5555 .collect::<BTreeMap<_, _>>();
5556 let snapshot_nodes = snapshot
5557 .nodes
5558 .iter()
5559 .map(|row| (row.external_id.as_str(), row))
5560 .collect::<BTreeMap<_, _>>();
5561 let snapshot_edges = snapshot
5562 .edges
5563 .iter()
5564 .map(|row| (row.edge_key.as_str(), row))
5565 .collect::<BTreeMap<_, _>>();
5566
5567 let node_upserts = local
5568 .nodes
5569 .iter()
5570 .filter(|row| {
5571 snapshot_nodes
5572 .get(row.external_id.as_str())
5573 .is_none_or(|snapshot_row| *snapshot_row != *row)
5574 })
5575 .cloned()
5576 .collect::<Vec<_>>();
5577 let edge_upserts = local
5578 .edges
5579 .iter()
5580 .filter(|row| {
5581 snapshot_edges
5582 .get(row.edge_key.as_str())
5583 .is_none_or(|snapshot_row| *snapshot_row != *row)
5584 })
5585 .cloned()
5586 .collect::<Vec<_>>();
5587 let node_tombstones = snapshot
5588 .nodes
5589 .iter()
5590 .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
5591 .map(|row| row.external_id.clone())
5592 .collect::<Vec<_>>();
5593 let edge_tombstones = snapshot
5594 .edges
5595 .iter()
5596 .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
5597 .map(|row| row.edge_key.clone())
5598 .collect::<Vec<_>>();
5599
5600 (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
5601}
5602
5603fn push_sync_chunks(
5604 chunks: &mut Vec<ConvexSyncChunk>,
5605 operation: &str,
5606 keys: Vec<String>,
5607 size: usize,
5608) {
5609 if keys.is_empty() {
5610 return;
5611 }
5612 for (idx, chunk) in keys.chunks(size).enumerate() {
5613 chunks.push(ConvexSyncChunk {
5614 operation: operation.to_string(),
5615 chunk: idx + 1,
5616 count: chunk.len(),
5617 keys: chunk.to_vec(),
5618 max_attempts: 3,
5619 retry_policy:
5620 "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
5621 .to_string(),
5622 });
5623 }
5624}
5625
5626pub(crate) fn build_convex_sync_report_with_snapshot(
5627 path: &Path,
5628 scope: Option<&str>,
5629 snapshot: Option<ConvexProjectionRows>,
5630 chunk_size: usize,
5631 dry_run: bool,
5632) -> Result<ConvexSyncReport> {
5633 if chunk_size == 0 {
5634 bail!("--chunk-size must be greater than zero");
5635 }
5636 let root = lint::resolve_project_root_or_canonical_path(path)?;
5637 let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
5638 let graph_db = graph_substrate_db_path(&root, scope);
5639 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5640 let local = convex_rows_from_graph_store(&store)?;
5641 let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
5642 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
5643 convex_rows_diff(&local, snapshot.as_ref());
5644
5645 let mut chunks = Vec::new();
5646 push_sync_chunks(
5647 &mut chunks,
5648 "delete_edges",
5649 edge_tombstones.clone(),
5650 chunk_size,
5651 );
5652 push_sync_chunks(
5653 &mut chunks,
5654 "upsert_nodes",
5655 node_upserts
5656 .iter()
5657 .map(|row| row.external_id.clone())
5658 .collect(),
5659 chunk_size,
5660 );
5661 push_sync_chunks(
5662 &mut chunks,
5663 "upsert_edges",
5664 edge_upserts
5665 .iter()
5666 .map(|row| row.edge_key.clone())
5667 .collect(),
5668 chunk_size,
5669 );
5670 push_sync_chunks(
5671 &mut chunks,
5672 "delete_nodes",
5673 node_tombstones.clone(),
5674 chunk_size,
5675 );
5676
5677 let mut diagnostics = vec![
5678 "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5679 .to_string(),
5680 ];
5681 if dry_run {
5682 diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5683 }
5684 if freshness.fail_closed {
5685 diagnostics.push(
5686 "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5687 .to_string(),
5688 );
5689 }
5690
5691 Ok(ConvexSyncReport {
5692 root: root.to_string_lossy().to_string(),
5693 scope: scope.map(str::to_string),
5694 graph_db: graph_db.to_string_lossy().to_string(),
5695 dry_run,
5696 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5697 projection_hash: convex_projection_hash(&local, scope),
5698 required_indexes: convex_required_indexes(),
5699 node_upserts,
5700 edge_upserts,
5701 node_tombstones,
5702 edge_tombstones,
5703 chunks,
5704 freshness,
5705 transport: None,
5706 receipts: Vec::new(),
5707 diagnostics,
5708 warnings: graph.warnings,
5709 })
5710}
5711
5712#[cfg(test)]
5713fn build_convex_sync_report(
5714 path: &Path,
5715 scope: Option<&str>,
5716 snapshot_path: Option<&Path>,
5717 chunk_size: usize,
5718) -> Result<ConvexSyncReport> {
5719 let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5720 build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5721}
5722
5723pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5724 if compact {
5725 println!(
5726 "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5727 report.node_upserts.len(),
5728 report.node_tombstones.len(),
5729 report.edge_upserts.len(),
5730 report.edge_tombstones.len(),
5731 report.chunks.len(),
5732 report.freshness.status
5733 );
5734 return;
5735 }
5736
5737 println!(
5738 "Convex graph sync {}",
5739 if report.dry_run { "dry-run" } else { "apply" }
5740 );
5741 println!("root: {}", report.root);
5742 println!("graph_db: {}", report.graph_db);
5743 println!(
5744 "upserts: {} node(s), {} edge(s)",
5745 report.node_upserts.len(),
5746 report.edge_upserts.len()
5747 );
5748 println!(
5749 "tombstones: {} node(s), {} edge(s)",
5750 report.node_tombstones.len(),
5751 report.edge_tombstones.len()
5752 );
5753 println!("chunks: {}", report.chunks.len());
5754 println!("freshness: {}", report.freshness.status);
5755 if let Some(transport) = &report.transport {
5756 println!(
5757 "transport: endpoint_env={} auth_env={} applied_chunks={}",
5758 transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5759 );
5760 }
5761 for receipt in &report.receipts {
5762 println!(
5763 "receipt: {} chunk {} attempt {} {}",
5764 receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5765 );
5766 }
5767 for diagnostic in report
5768 .diagnostics
5769 .iter()
5770 .chain(report.freshness.diagnostics.iter())
5771 {
5772 println!("- {}", diagnostic);
5773 }
5774}
5775
5776pub(crate) struct ConvexSyncOptions<'a> {
5777 path: &'a Path,
5778 scope: Option<&'a str>,
5779 snapshot: Option<&'a Path>,
5780 chunk_size: usize,
5781 remote_snapshot: bool,
5782 apply: bool,
5783 endpoint: Option<&'a str>,
5784 auth_token_env: &'a str,
5785}
5786
5787#[derive(Serialize)]
5788struct GraphDbSchemaField {
5789 name: &'static str,
5790 value_type: &'static str,
5791 description: &'static str,
5792}
5793
5794#[derive(Serialize)]
5795struct GraphDbSchemaOperation {
5796 command: &'static str,
5797 description: &'static str,
5798}
5799
5800#[derive(Serialize)]
5801struct GraphDbSchemaContract {
5802 name: &'static str,
5803 version: &'static str,
5804 description: &'static str,
5805}
5806
5807#[derive(Serialize)]
5808struct GraphDbSchema {
5809 contract_versions: Vec<GraphDbSchemaContract>,
5810 node_fields: Vec<GraphDbSchemaField>,
5811 edge_fields: Vec<GraphDbSchemaField>,
5812 operations: Vec<GraphDbSchemaOperation>,
5813}
5814
5815#[derive(Clone, Serialize, Deserialize)]
5816struct GraphDbFreshnessReport {
5817 status: String,
5818 fail_closed: bool,
5819 projection_version: Option<String>,
5820 content_hash: Option<String>,
5821 source_watermark: Option<String>,
5822 diagnostics: Vec<String>,
5823}
5824
5825#[derive(Clone, Debug, Serialize)]
5826pub(crate) struct GraphEffectivenessReadiness {
5827 pub(crate) status: String,
5828 pub(crate) fail_closed: bool,
5829 pub(crate) reason: String,
5830 pub(crate) diagnostics: Vec<String>,
5831 pub(crate) next_commands: Vec<String>,
5832}
5833
5834#[derive(Clone, Debug, Serialize, PartialEq)]
5835struct GraphDbPropertyFilter {
5836 key: String,
5837 value: String,
5838}
5839
5840#[derive(Clone, Debug, Default)]
5841struct GraphDbQueryOptions {
5842 cursor: Option<String>,
5843 limit: Option<usize>,
5844 property_filters: Vec<GraphDbPropertyFilter>,
5845}
5846
5847#[derive(Clone, Debug, Serialize, PartialEq)]
5848struct GraphDbPageReport {
5849 #[serde(skip_serializing_if = "Option::is_none")]
5850 cursor: Option<String>,
5851 #[serde(skip_serializing_if = "Option::is_none")]
5852 limit: Option<usize>,
5853 #[serde(skip_serializing_if = "Option::is_none")]
5854 next_cursor: Option<String>,
5855 returned_nodes: usize,
5856 returned_edges: usize,
5857 truncated: bool,
5858 property_filters: Vec<GraphDbPropertyFilter>,
5859 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5860 diagnostics: Vec<String>,
5861}
5862
5863type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5864
5865#[derive(Clone, Debug, Serialize)]
5866struct CommunityTruncationSummary {
5867 total_communities: usize,
5868 fully_kept: usize,
5869 partially_pruned: usize,
5870 fully_pruned: usize,
5871 pruned_community_kinds: Vec<String>,
5872 pruned_community_top_labels: Vec<String>,
5873}
5874
5875#[derive(Clone, Debug, Serialize)]
5876struct GraphDbRankedNeighborhoodComparison {
5877 traversal_nodes: usize,
5878 traversal_edges: usize,
5879 pruned_count: usize,
5880 total_discovered: usize,
5881 latency_micros: u128,
5882 overlap_with_unranked_pct: f64,
5883 useful_hit_density_ranked: f64,
5884 useful_hit_density_unranked: f64,
5885 duplicate_name_count_ranked: usize,
5886 duplicate_name_count_unranked: usize,
5887 handle_coverage_ranked_pct: f64,
5888 handle_coverage_unranked_pct: f64,
5889 #[serde(skip_serializing_if = "Option::is_none")]
5890 community_truncation_summary: Option<CommunityTruncationSummary>,
5891 diagnostics: Vec<String>,
5892}
5893
5894#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5895struct GraphDbDroppedByBudget {
5896 item: String,
5897 kind: String,
5898 dropped: usize,
5899 reason: String,
5900}
5901
5902#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5903struct GraphDbOutputBudgetReport {
5904 max_tokens: usize,
5905 estimated_tokens: usize,
5906 selected_nodes: usize,
5907 selected_edges: usize,
5908 candidate_nodes: usize,
5909 candidate_edges: usize,
5910 dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5911 diagnostics: Vec<String>,
5912}
5913
5914#[derive(Clone, Debug, Serialize, PartialEq)]
5915struct GraphDbKnowledgeRetrieval {
5916 mode: String,
5917 query: String,
5918 seed_kind: String,
5919 seed_limit: usize,
5920 seed_count: usize,
5921 depth: usize,
5922 limit: usize,
5923 node_count: usize,
5924 edge_count: usize,
5925 truncated: bool,
5926 traversal: String,
5927 freshness_boundary: String,
5928 privacy_boundary: String,
5929 diagnostics: Vec<String>,
5930}
5931
5932struct GraphDbSemanticSeededSubgraph {
5933 nodes: Vec<SubstrateGraphNode>,
5934 edges: Vec<SubstrateGraphEdge>,
5935 truncated: bool,
5936 diagnostics: Vec<String>,
5937}
5938
5939type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5940
5941#[derive(Serialize)]
5942struct GraphDbReport {
5943 root: String,
5944 #[serde(skip_serializing_if = "Option::is_none")]
5945 scope: Option<String>,
5946 backend: String,
5947 query: String,
5948 freshness: GraphDbFreshnessReport,
5949 #[serde(skip_serializing_if = "Option::is_none")]
5950 readiness: Option<GraphEffectivenessReadiness>,
5951 #[serde(skip_serializing_if = "Option::is_none")]
5952 schema: Option<GraphDbSchema>,
5953 #[serde(skip_serializing_if = "Option::is_none")]
5954 node: Option<SubstrateTerseGraphNode>,
5955 #[serde(skip_serializing_if = "Option::is_none")]
5956 edge: Option<SubstrateTerseGraphEdge>,
5957 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5958 nodes: Vec<SubstrateTerseGraphNode>,
5959 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5960 edges: Vec<SubstrateTerseGraphEdge>,
5961 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5962 ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5963 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5964 semantic_related: Vec<SemanticRelatedItem>,
5965 #[serde(skip_serializing_if = "Option::is_none")]
5966 neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5967 #[serde(skip_serializing_if = "Option::is_none")]
5968 ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5969 #[serde(skip_serializing_if = "Option::is_none")]
5970 knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5971 #[serde(skip_serializing_if = "Option::is_none")]
5972 output_budget: Option<GraphDbOutputBudgetReport>,
5973 #[serde(skip_serializing_if = "Option::is_none")]
5974 path: Option<substrate::GraphPath>,
5975 #[serde(skip_serializing_if = "Option::is_none")]
5976 page: Option<GraphDbPageReport>,
5977 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5978 warnings: Vec<String>,
5979}
5980
5981struct ExperimentalReadOnlyGraphStore {
5982 backend: GraphDbExperimentalBackend,
5983 nodes: BTreeMap<String, SubstrateGraphNode>,
5984 edges: BTreeMap<String, SubstrateGraphEdge>,
5985 node_ids_by_kind: BTreeMap<String, Vec<String>>,
5986 outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5987}
5988
5989impl ExperimentalReadOnlyGraphStore {
5990 fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5991 validate_convex_projection_rows(rows)?;
5992 let nodes = rows
5993 .nodes
5994 .iter()
5995 .map(|row| {
5996 let node = SubstrateGraphNode {
5997 id: row.external_id.clone(),
5998 kind: row.kind.clone(),
5999 label: row.label.clone(),
6000 properties: row.properties.clone(),
6001 provenance: row.provenance.clone(),
6002 freshness: row.freshness.clone(),
6003 };
6004 (node.id.clone(), node)
6005 })
6006 .collect::<BTreeMap<_, _>>();
6007 let edges = rows
6008 .edges
6009 .iter()
6010 .map(|row| {
6011 let edge = SubstrateGraphEdge {
6012 id: row.edge_key.clone(),
6013 from_id: row.from_external_id.clone(),
6014 to_id: row.to_external_id.clone(),
6015 kind: row.kind.clone(),
6016 properties: row.properties.clone(),
6017 provenance: row.provenance.clone(),
6018 freshness: row.freshness.clone(),
6019 };
6020 (graph_db_edge_key(&edge), edge)
6021 })
6022 .collect::<BTreeMap<_, _>>();
6023 let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
6024 for node in nodes.values() {
6025 node_ids_by_kind
6026 .entry(node.kind.clone())
6027 .or_default()
6028 .push(node.id.clone());
6029 }
6030 for ids in node_ids_by_kind.values_mut() {
6031 ids.sort();
6032 }
6033 let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
6034 for edge in edges.values() {
6035 outgoing_edge_keys_by_from
6036 .entry(edge.from_id.clone())
6037 .or_default()
6038 .push(graph_db_edge_key(edge));
6039 }
6040 for edge_keys in outgoing_edge_keys_by_from.values_mut() {
6041 edge_keys.sort_by(|left_key, right_key| {
6042 let left = &edges[left_key];
6043 let right = &edges[right_key];
6044 left.to_id
6045 .cmp(&right.to_id)
6046 .then(left.kind.cmp(&right.kind))
6047 .then(left_key.cmp(right_key))
6048 });
6049 }
6050 Ok(Self {
6051 backend,
6052 nodes,
6053 edges,
6054 node_ids_by_kind,
6055 outgoing_edge_keys_by_from,
6056 })
6057 }
6058}
6059
6060impl GraphStore for ExperimentalReadOnlyGraphStore {
6061 fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
6062 bail!("{} backend-eval adapter is read-only", self.backend.name())
6063 }
6064
6065 fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
6066 bail!("{} backend-eval adapter is read-only", self.backend.name())
6067 }
6068
6069 fn delete_node(&self, _id: &str) -> Result<usize> {
6070 bail!("{} backend-eval adapter is read-only", self.backend.name())
6071 }
6072
6073 fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
6074 bail!("{} backend-eval adapter is read-only", self.backend.name())
6075 }
6076
6077 fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
6078 Ok(self.nodes.get(id).cloned())
6079 }
6080
6081 fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
6082 Ok(self.nodes.values().cloned().collect())
6083 }
6084
6085 fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
6086 let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
6087 edges.sort_by(|left, right| {
6088 left.from_id
6089 .cmp(&right.from_id)
6090 .then(left.kind.cmp(&right.kind))
6091 .then(left.to_id.cmp(&right.to_id))
6092 });
6093 Ok(edges)
6094 }
6095
6096 fn graph_counts(&self) -> Result<(usize, usize)> {
6097 Ok((self.nodes.len(), self.edges.len()))
6098 }
6099
6100 fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
6101 let mut edges = self
6102 .edges
6103 .values()
6104 .filter(|edge| edge.from_id != edge.to_id)
6105 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6106 .cloned()
6107 .collect::<Vec<_>>();
6108 edges.sort_by(|left, right| {
6109 left.from_id
6110 .cmp(&right.from_id)
6111 .then(left.kind.cmp(&right.kind))
6112 .then(left.to_id.cmp(&right.to_id))
6113 });
6114 Ok(edges.into_iter().next())
6115 }
6116
6117 fn sample_edge_with_property(
6118 &self,
6119 ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
6120 Ok(self
6121 .edges
6122 .values()
6123 .filter(|edge| edge.from_id != edge.to_id)
6124 .filter_map(|edge| {
6125 edge.properties.iter().next().map(|(key, value)| {
6126 (
6127 edge,
6128 GraphPropertyFilter {
6129 key: key.clone(),
6130 value: value.clone(),
6131 },
6132 )
6133 })
6134 })
6135 .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
6136 left_filter
6137 .key
6138 .cmp(&right_filter.key)
6139 .then(left_filter.value.cmp(&right_filter.value))
6140 .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
6141 })
6142 .map(|(edge, filter)| (edge.clone(), filter)))
6143 }
6144
6145 fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
6146 Ok(self
6147 .node_ids_by_kind
6148 .get(kind)
6149 .into_iter()
6150 .flatten()
6151 .filter_map(|id| self.nodes.get(id).cloned())
6152 .collect())
6153 }
6154
6155 fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
6156 Ok(self
6157 .outgoing_edge_keys_by_from
6158 .get(from_id)
6159 .into_iter()
6160 .flatten()
6161 .filter_map(|key| self.edges.get(key))
6162 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6163 .cloned()
6164 .collect())
6165 }
6166
6167 fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
6168 Ok(self
6169 .edges
6170 .values()
6171 .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
6172 .cloned()
6173 .collect())
6174 }
6175
6176 fn shortest_path(
6177 &self,
6178 from_id: &str,
6179 to_id: &str,
6180 kind: Option<&str>,
6181 ) -> Result<Option<substrate::GraphPath>> {
6182 if from_id == to_id {
6183 return Ok(Some(substrate::GraphPath {
6184 nodes: vec![from_id.to_string()],
6185 hops: 0,
6186 }));
6187 }
6188
6189 let mut queue = VecDeque::new();
6190 let mut parent = BTreeMap::<String, String>::new();
6191 parent.insert(from_id.to_string(), String::new());
6192 queue.push_back(from_id.to_string());
6193
6194 while let Some(current) = queue.pop_front() {
6195 for edge in self.outgoing_edges(¤t, kind)? {
6196 if parent.contains_key(&edge.to_id) {
6197 continue;
6198 }
6199 parent.insert(edge.to_id.clone(), current.clone());
6200 if edge.to_id == to_id {
6201 let mut nodes = vec![to_id.to_string()];
6202 let mut cursor = to_id;
6203 while let Some(previous) = parent.get(cursor) {
6204 if previous.is_empty() {
6205 break;
6206 }
6207 nodes.push(previous.clone());
6208 cursor = previous;
6209 }
6210 nodes.reverse();
6211 return Ok(Some(substrate::GraphPath {
6212 hops: nodes.len().saturating_sub(1),
6213 nodes,
6214 }));
6215 }
6216 queue.push_back(edge.to_id);
6217 }
6218 }
6219
6220 Ok(None)
6221 }
6222
6223 fn reachable_nodes_by_kinds(
6224 &self,
6225 from_id: &str,
6226 kinds: &[&str],
6227 depth: usize,
6228 limit: usize,
6229 ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
6230 let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
6231 let mut rows = requested
6232 .iter()
6233 .map(|kind| {
6234 (
6235 (*kind).to_string(),
6236 BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
6237 )
6238 })
6239 .collect::<BTreeMap<_, _>>();
6240 if requested.is_empty() {
6241 return Ok(BTreeMap::new());
6242 }
6243
6244 let mut seen = BTreeSet::from([from_id.to_string()]);
6245 let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
6246 while let Some((current, path)) = queue.pop_front() {
6247 let current_depth = path.len().saturating_sub(1);
6248 if current_depth >= depth {
6249 continue;
6250 }
6251 for edge in self.outgoing_edges(¤t, None)? {
6252 if !seen.insert(edge.to_id.clone()) {
6253 continue;
6254 }
6255 let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
6256 continue;
6257 };
6258 let mut next_path = path.clone();
6259 next_path.push(edge.to_id.clone());
6260 let graph_path = substrate::GraphPath {
6261 hops: next_path.len().saturating_sub(1),
6262 nodes: next_path.clone(),
6263 };
6264 if requested.contains(node.kind.as_str()) {
6265 rows.entry(node.kind.clone())
6266 .or_default()
6267 .entry(node.id.clone())
6268 .or_insert((node.clone(), graph_path));
6269 }
6270 queue.push_back((edge.to_id, next_path));
6271 }
6272 }
6273
6274 Ok(rows
6275 .into_iter()
6276 .map(|(kind, values)| {
6277 let mut values = values.into_values().collect::<Vec<_>>();
6278 values.sort_by(|(left_node, left_path), (right_node, right_path)| {
6279 left_path
6280 .hops
6281 .cmp(&right_path.hops)
6282 .then(left_node.label.cmp(&right_node.label))
6283 .then(left_node.id.cmp(&right_node.id))
6284 });
6285 if limit > 0 && values.len() > limit {
6286 values.truncate(limit);
6287 }
6288 (kind, values)
6289 })
6290 .collect())
6291 }
6292}
6293
6294pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
6295pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
6296pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
6297const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
6298pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
6299const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
6300const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
6301const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
6302const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
6303
6304#[derive(Clone, Serialize, Deserialize)]
6305pub(crate) struct GraphDbBackendEvalPhaseTiming {
6306 name: String,
6307 duration_micros: u128,
6308 detail: String,
6309}
6310
6311#[derive(Serialize, Deserialize)]
6312struct GraphDbBackendEvalFullProjectionCache {
6313 version: String,
6314 key: String,
6315 source_watermark: String,
6316 projection: GraphProjection,
6317 warnings: Vec<String>,
6318}
6319
6320#[derive(Clone, Default)]
6321struct GraphDbBackendEvalFullProjectionCacheStats {
6322 hit: bool,
6323 disk_bytes: u64,
6324 json_bytes: u64,
6325 pruned_files: usize,
6326 pruned_bytes: u64,
6327}
6328
6329#[derive(Serialize)]
6330struct GraphDbBackendEvalRawSourceWatermarkRow {
6331 path: String,
6332 bytes: u64,
6333 content_hash: String,
6334}
6335
6336#[derive(Clone)]
6337struct GraphDbBackendEvalFullProjectionSourceWatermark {
6338 value: String,
6339 detail: String,
6340}
6341
6342#[derive(Serialize)]
6343pub(crate) struct GraphDbBackendEvalConfig {
6344 high_degree_nodes: usize,
6345 high_degree_fanout: usize,
6346 deep_chain_nodes: usize,
6347 deep_chain_fanout: usize,
6348 depth: usize,
6349 limit: usize,
6350 impact_limit: usize,
6351 path_max_hops: usize,
6352 path_direct_hop_budget: usize,
6353 path_deep_chain_hop_budget: usize,
6354 path_extended_hop_budgets: Vec<usize>,
6355 path_hop_policy: String,
6356 path_probe_strategy: String,
6357 path_query_plan_checks: Vec<String>,
6358 full_projection_enabled: bool,
6359 full_projection_profile: String,
6360 normalization_row_unit: usize,
6361}
6362
6363#[derive(Clone)]
6364struct GraphDbBackendEvalSignature {
6365 operation: String,
6366 value: serde_json::Value,
6367}
6368
6369#[derive(Serialize)]
6370struct GraphDbBackendEvalOperation {
6371 name: String,
6372 supported: bool,
6373 status: String,
6374 duration_micros: u128,
6375 #[serde(skip_serializing_if = "Option::is_none")]
6376 rows: Option<usize>,
6377 #[serde(skip_serializing_if = "Option::is_none")]
6378 error: Option<String>,
6379}
6380
6381#[derive(Serialize)]
6382struct GraphDbBackendEvalParity {
6383 matches_sqlite: bool,
6384 diagnostics: Vec<String>,
6385}
6386
6387#[derive(Serialize)]
6388struct GraphDbBackendEvalBackendReport {
6389 backend: String,
6390 adapter: String,
6391 read_only: bool,
6392 projection_load: String,
6393 operations: Vec<GraphDbBackendEvalOperation>,
6394 total_micros: u128,
6395 parity: GraphDbBackendEvalParity,
6396 lock_behavior: String,
6397 install_portability: String,
6398}
6399
6400#[derive(Serialize)]
6401struct GraphDbBackendEvalDataset {
6402 name: String,
6403 target_count: usize,
6404 nodes: usize,
6405 edges: usize,
6406 backends: Vec<GraphDbBackendEvalBackendReport>,
6407}
6408
6409#[derive(Serialize)]
6410struct GraphDbBackendPromotionDecision {
6411 backend: String,
6412 decision: String,
6413 reasons: Vec<String>,
6414 gate: GraphDbBackendPromotionGate,
6415}
6416
6417#[derive(Serialize)]
6418struct GraphDbBackendEvalPerformanceGate {
6419 baseline_fixture: String,
6420 ci_profile: String,
6421 opt_in_real_profile: String,
6422 full_projection_cache_hit_gate: String,
6423 allowed_regression_percent: f64,
6424 minimum_sample_runs: usize,
6425 normalized_metric_unit: String,
6426 required_metrics: Vec<String>,
6427 digest_command: String,
6428 repeated_sample_command: String,
6429 hop_cap_promotion: GraphDbHopCapPromotionGate,
6430 backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
6431}
6432
6433#[derive(Serialize)]
6434struct GraphDbHopCapPromotionGate {
6435 status: String,
6436 current_default_hops: usize,
6437 candidate_hop_tiers: Vec<usize>,
6438 required_backend: String,
6439 required_workloads: Vec<String>,
6440 required_metrics: Vec<String>,
6441 allowed_regression_percent: f64,
6442 minimum_sample_runs: usize,
6443 decision_rule: String,
6444}
6445
6446#[derive(Serialize)]
6447struct GraphDbBackendAdapterSpikeGate {
6448 status: String,
6449 candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
6450 required_workloads: Vec<String>,
6451 required_checks: Vec<String>,
6452 decision_rule: String,
6453 evidence_plan: String,
6454}
6455
6456#[derive(Serialize)]
6457struct GraphDbBackendAdapterSpikeCandidate {
6458 backend: String,
6459 adapter_label: String,
6460 projection_load: String,
6461 lock_behavior: String,
6462 install_portability: String,
6463}
6464
6465#[derive(Serialize)]
6466pub(crate) struct GraphDbBackendEvalReport {
6467 root: String,
6468 #[serde(skip_serializing_if = "Option::is_none")]
6469 scope: Option<String>,
6470 label: String,
6471 baseline_backend: String,
6472 candidates: Vec<String>,
6473 targets: Vec<String>,
6474 config: GraphDbBackendEvalConfig,
6475 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6476 datasets: Vec<GraphDbBackendEvalDataset>,
6477 promotion: Vec<GraphDbBackendPromotionDecision>,
6478 performance_gate: GraphDbBackendEvalPerformanceGate,
6479 metrics: BTreeMap<String, f64>,
6480 metric_digest_command: String,
6481 warnings: Vec<String>,
6482}
6483
6484#[derive(Clone, Debug, Serialize)]
6485struct GraphDbDoctorCheck {
6486 name: String,
6487 status: String,
6488 fail_closed: bool,
6489 diagnostics: Vec<String>,
6490 repair_commands: Vec<String>,
6491}
6492
6493#[derive(Serialize)]
6494pub(crate) struct GraphDbDoctorReport {
6495 root: String,
6496 #[serde(skip_serializing_if = "Option::is_none")]
6497 scope: Option<String>,
6498 backend: String,
6499 graph_db: String,
6500 #[serde(skip_serializing_if = "Option::is_none")]
6501 convex_snapshot: Option<String>,
6502 status: String,
6503 fail_closed: bool,
6504 checks: Vec<GraphDbDoctorCheck>,
6505 repair_commands: Vec<String>,
6506 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6507 required_indexes: Vec<ConvexRequiredIndex>,
6508}
6509
6510#[derive(Serialize)]
6511struct GraphDbDriftSummary {
6512 node_upserts: usize,
6513 edge_upserts: usize,
6514 node_tombstones: usize,
6515 edge_tombstones: usize,
6516 stale_nodes: usize,
6517 stale_edges: usize,
6518 stale_projection_metadata: usize,
6519 duplicate_failures: usize,
6520 orphan_failures: usize,
6521 missing_required_indexes: usize,
6522}
6523
6524#[derive(Serialize)]
6525struct GraphDbDriftReport {
6526 root: String,
6527 #[serde(skip_serializing_if = "Option::is_none")]
6528 scope: Option<String>,
6529 graph_db: String,
6530 convex_snapshot: String,
6531 status: String,
6532 graph_reads_allowed: bool,
6533 projection_version: String,
6534 local_hash: Option<String>,
6535 snapshot_hash: Option<String>,
6536 summary: GraphDbDriftSummary,
6537 node_upserts: Vec<String>,
6538 edge_upserts: Vec<String>,
6539 node_tombstones: Vec<String>,
6540 edge_tombstones: Vec<String>,
6541 stale_nodes: Vec<String>,
6542 stale_edges: Vec<String>,
6543 diagnostics: Vec<String>,
6544 next_commands: Vec<String>,
6545 required_indexes: Vec<ConvexRequiredIndex>,
6546 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6547 warnings: Vec<String>,
6548}
6549
6550#[derive(Clone, Serialize)]
6551struct GraphDbTombstoneCounts {
6552 nodes: usize,
6553 edges: usize,
6554 total: usize,
6555}
6556
6557#[derive(Clone, Serialize)]
6558struct GraphDbOperatorCounts {
6559 nodes: usize,
6560 edges: usize,
6561 tombstones: GraphDbTombstoneCounts,
6562 #[serde(skip_serializing_if = "Option::is_none")]
6563 file_size_bytes: Option<u64>,
6564 #[serde(skip_serializing_if = "Option::is_none")]
6565 freelist_bytes: Option<u64>,
6566}
6567
6568#[derive(Clone, Serialize)]
6569struct GraphDbCompactionPolicy {
6570 status: String,
6571 tombstone_scan_rows: usize,
6572 live_rows: usize,
6573 file_size_bytes: Option<u64>,
6574 freelist_bytes: Option<u64>,
6575 safe_to_prune_tombstones: bool,
6576 requires_convex_reconciliation: bool,
6577 recommendations: Vec<String>,
6578 proof: Vec<String>,
6579}
6580
6581#[derive(Serialize)]
6582pub(crate) struct GraphDbRefreshSummary {
6583 scope: String,
6584 projection_version: String,
6585 mode: String,
6586 #[serde(skip_serializing_if = "Option::is_none")]
6587 source_watermark: Option<String>,
6588 tombstoned_nodes: usize,
6589 tombstoned_edges: usize,
6590 upserted_nodes: usize,
6591 upserted_edges: usize,
6592 unchanged_nodes: usize,
6593 unchanged_edges: usize,
6594 upserted_properties: usize,
6595 unchanged_properties: usize,
6596 deleted_properties: usize,
6597 deleted_nodes: usize,
6598 deleted_edges: usize,
6599 pruned_tombstones: usize,
6600 #[serde(skip_serializing_if = "Option::is_none")]
6601 file_size_bytes_before: Option<u64>,
6602 #[serde(skip_serializing_if = "Option::is_none")]
6603 file_size_bytes_after: Option<u64>,
6604 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6605 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6606}
6607
6608#[derive(Serialize)]
6609struct GraphDbOperatorReport {
6610 root: String,
6611 #[serde(skip_serializing_if = "Option::is_none")]
6612 scope: Option<String>,
6613 graph_db: String,
6614 operation: String,
6615 status: String,
6616 materialized: bool,
6617 freshness: GraphDbFreshnessReport,
6618 readiness: GraphEffectivenessReadiness,
6619 counts: GraphDbOperatorCounts,
6620 #[serde(skip_serializing_if = "Option::is_none")]
6621 refresh: Option<GraphDbRefreshSummary>,
6622 compaction: GraphDbCompactionPolicy,
6623 #[serde(skip_serializing_if = "Option::is_none")]
6624 recovery: Option<index::ReadOnlyRecovery>,
6625 next_commands: Vec<String>,
6626 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6627 warnings: Vec<String>,
6628}
6629
6630#[derive(Serialize)]
6631pub(crate) struct GraphDbCompactionReport {
6632 root: String,
6633 #[serde(skip_serializing_if = "Option::is_none")]
6634 scope: Option<String>,
6635 graph_db: String,
6636 applied: bool,
6637 pruned_tombstones: usize,
6638 counts_before: GraphDbOperatorCounts,
6639 counts_after: GraphDbOperatorCounts,
6640 compaction_before: GraphDbCompactionPolicy,
6641 compaction_after: GraphDbCompactionPolicy,
6642 reclaimed_bytes: i64,
6643 next_commands: Vec<String>,
6644 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6645 warnings: Vec<String>,
6646}
6647
6648#[derive(Clone, Serialize, Deserialize)]
6649struct GraphDbEvidencePath {
6650 to: String,
6651 kind: String,
6652 label: String,
6653 #[serde(skip_serializing_if = "Option::is_none")]
6654 path: Option<substrate::GraphPath>,
6655 #[serde(skip_serializing_if = "Option::is_none")]
6656 expand: Option<String>,
6657}
6658
6659#[derive(Clone, Serialize, Deserialize)]
6660struct GraphDbFixtureCoverage {
6661 test: String,
6662 fixture: String,
6663 assertions: Vec<String>,
6664}
6665
6666#[derive(Clone, Serialize, Deserialize)]
6667struct GraphDbEvidenceReport {
6668 root: String,
6669 #[serde(skip_serializing_if = "Option::is_none")]
6670 scope: Option<String>,
6671 backend: String,
6672 contract_version: String,
6673 target: String,
6674 packet_id: String,
6675 #[serde(skip_serializing_if = "Option::is_none")]
6676 projection_hash: Option<String>,
6677 freshness: GraphDbFreshnessReport,
6678 target_node: SubstrateTerseGraphNode,
6679 worker_context: Vec<SubstrateTerseGraphNode>,
6680 source_handles: Vec<SubstrateTerseGraphNode>,
6681 worker_results: Vec<SubstrateTerseGraphNode>,
6682 semantic_related: Vec<SubstrateTerseGraphNode>,
6683 shortest_paths: Vec<GraphDbEvidencePath>,
6684 #[serde(skip_serializing_if = "Option::is_none")]
6685 output_budget: Option<GraphDbOutputBudgetReport>,
6686 #[serde(default)]
6687 truncated: bool,
6688 #[serde(skip_serializing_if = "Option::is_none")]
6689 next_cursor: Option<String>,
6690 next_commands: Vec<String>,
6691 replay_commands: Vec<String>,
6692 repair_commands: Vec<String>,
6693 fixture_coverage: GraphDbFixtureCoverage,
6694 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6695 warnings: Vec<String>,
6696}
6697
6698pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6699 root: &'a Path,
6700 scope: Option<&'a str>,
6701 backend: &'a str,
6702 target: &'a str,
6703 preferred_path: Option<&'a str>,
6704 depth: usize,
6705 limit: usize,
6706 cursor: Option<&'a str>,
6707 store: &'a S,
6708 freshness: GraphDbFreshnessReport,
6709 warnings: Vec<String>,
6710}
6711
6712impl GraphDbDoctorReport {
6713 fn new(
6714 root: &Path,
6715 scope: Option<&str>,
6716 backend: &str,
6717 graph_db: &Path,
6718 convex_snapshot: Option<&Path>,
6719 ) -> Self {
6720 Self {
6721 root: root.to_string_lossy().to_string(),
6722 scope: scope.map(str::to_string),
6723 backend: backend.to_string(),
6724 graph_db: graph_db.to_string_lossy().to_string(),
6725 convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6726 status: "ok".to_string(),
6727 fail_closed: false,
6728 checks: Vec::new(),
6729 repair_commands: Vec::new(),
6730 required_indexes: Vec::new(),
6731 }
6732 }
6733
6734 fn push_check(&mut self, check: GraphDbDoctorCheck) {
6735 self.checks.push(check);
6736 }
6737
6738 fn finalize(&mut self) {
6739 self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6740 self.status = if self.fail_closed {
6741 "fail_closed"
6742 } else {
6743 "ok"
6744 }
6745 .to_string();
6746 let mut commands = BTreeSet::new();
6747 for check in &self.checks {
6748 commands.extend(check.repair_commands.iter().cloned());
6749 }
6750 self.repair_commands = commands.into_iter().collect();
6751 }
6752
6753 fn summary(&self) -> String {
6754 self.checks
6755 .iter()
6756 .filter(|check| check.fail_closed)
6757 .flat_map(|check| check.diagnostics.iter())
6758 .take(3)
6759 .cloned()
6760 .collect::<Vec<_>>()
6761 .join("; ")
6762 }
6763}
6764
6765fn graph_db_doctor_check(
6766 name: impl Into<String>,
6767 diagnostics: Vec<String>,
6768 repair_commands: Vec<String>,
6769) -> GraphDbDoctorCheck {
6770 let fail_closed = !diagnostics.is_empty();
6771 GraphDbDoctorCheck {
6772 name: name.into(),
6773 status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6774 fail_closed,
6775 diagnostics,
6776 repair_commands: if fail_closed {
6777 repair_commands
6778 } else {
6779 Vec::new()
6780 },
6781 }
6782}
6783
6784pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6785 scope
6786 .map(|scope| format!(" --scope {}", shell_quote(scope)))
6787 .unwrap_or_default()
6788}
6789
6790fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6791 format!(
6792 "tsift graph-db --path {}{} refresh --json",
6793 shell_quote(root.to_string_lossy().as_ref()),
6794 graph_db_scope_arg(scope)
6795 )
6796}
6797
6798fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6799 graph_db_refresh_command(root, scope)
6800}
6801
6802fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6803 let backup = format!("{}.bak", graph_db.to_string_lossy());
6804 format!(
6805 "mv {} {} && {}",
6806 shell_quote(graph_db.to_string_lossy().as_ref()),
6807 shell_quote(&backup),
6808 graph_db_rebuild_command(root, scope)
6809 )
6810}
6811
6812fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6813 format!(
6814 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6815 shell_quote(root.to_string_lossy().as_ref()),
6816 graph_db_scope_arg(scope)
6817 )
6818}
6819
6820fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6821 substrate::open_graph_read_only_connection_resilient(graph_db)
6822}
6823
6824fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6825 conn.query_row(
6826 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6827 [table],
6828 |row| row.get::<_, bool>(0),
6829 )
6830 .map_err(Into::into)
6831}
6832
6833fn row_usize(row: &Row<'_>, idx: usize) -> rusqlite::Result<usize> {
6834 let value: i64 = row.get(idx)?;
6835 usize::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6836}
6837
6838fn row_u64(row: &Row<'_>, idx: usize) -> rusqlite::Result<u64> {
6839 let value: i64 = row.get(idx)?;
6840 u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6841}
6842
6843fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6844 let sql = match table {
6845 "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6846 "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6847 "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6848 other => bail!("unsupported graph count table {other}"),
6849 };
6850 conn.query_row(sql, [], |row| row_usize(row, 0))
6851 .map_err(Into::into)
6852}
6853
6854fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6855 if !sqlite_table_exists(conn, "graph_tombstones")? {
6856 return Ok(GraphDbTombstoneCounts {
6857 nodes: 0,
6858 edges: 0,
6859 total: 0,
6860 });
6861 }
6862 let mut stmt =
6863 conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6864 let mut rows = stmt.query([])?;
6865 let mut nodes = 0usize;
6866 let mut edges = 0usize;
6867 while let Some(row) = rows.next()? {
6868 let row_kind: String = row.get(0)?;
6869 let count = row_usize(row, 1)?;
6870 match row_kind.as_str() {
6871 "node" => nodes = count,
6872 "edge" => edges = count,
6873 _ => {}
6874 }
6875 }
6876 Ok(GraphDbTombstoneCounts {
6877 nodes,
6878 edges,
6879 total: nodes + edges,
6880 })
6881}
6882
6883fn sqlite_graph_counts_from_cache(
6884 conn: &Connection,
6885 scope: &str,
6886) -> Result<Option<GraphDbOperatorCounts>> {
6887 if !sqlite_table_exists(conn, "graph_operator_stats")? {
6888 return Ok(None);
6889 }
6890 let row = conn
6891 .query_row(
6892 r#"
6893 SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6894 FROM graph_operator_stats
6895 WHERE scope = ?1
6896 "#,
6897 [scope],
6898 |row| {
6899 Ok((
6900 row_usize(row, 0)?,
6901 row_usize(row, 1)?,
6902 row_usize(row, 2)?,
6903 row_usize(row, 3)?,
6904 row.get::<_, Option<i64>>(4)?,
6905 row.get::<_, Option<i64>>(5)?,
6906 ))
6907 },
6908 )
6909 .optional()?;
6910 Ok(row.map(
6911 |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6912 GraphDbOperatorCounts {
6913 nodes,
6914 edges,
6915 tombstones: GraphDbTombstoneCounts {
6916 nodes: tombstone_nodes,
6917 edges: tombstone_edges,
6918 total: tombstone_nodes + tombstone_edges,
6919 },
6920 file_size_bytes: file_size_bytes
6921 .and_then(|value| u64::try_from(value).ok())
6922 .or_else(|| sqlite_database_size_bytes(conn).ok()),
6923 freelist_bytes: freelist_bytes
6924 .and_then(|value| u64::try_from(value).ok())
6925 .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6926 }
6927 },
6928 ))
6929}
6930
6931fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6932 if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6933 return Ok(counts);
6934 }
6935 let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6936 sqlite_known_table_count(conn, "graph_nodes")?
6937 } else {
6938 0
6939 };
6940 let edges = if sqlite_table_exists(conn, "graph_edges")? {
6941 sqlite_known_table_count(conn, "graph_edges")?
6942 } else {
6943 0
6944 };
6945 Ok(GraphDbOperatorCounts {
6946 nodes,
6947 edges,
6948 tombstones: sqlite_tombstone_counts(conn)?,
6949 file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6950 freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6951 })
6952}
6953
6954fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6955 if !sqlite_table_exists(conn, "graph_nodes")? {
6956 return Ok(0);
6957 }
6958 let count: i64 = conn.query_row(
6959 "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6960 [],
6961 |row| row.get(0),
6962 )?;
6963 Ok(count as usize)
6964}
6965
6966pub(crate) fn graph_db_compaction_policy(
6967 root: &Path,
6968 scope: Option<&str>,
6969 counts: &GraphDbOperatorCounts,
6970 prune_confirmed: bool,
6971) -> GraphDbCompactionPolicy {
6972 let live_rows = counts.nodes + counts.edges;
6973 let tombstone_scan_rows = counts.tombstones.total;
6974 let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6975 let freelist_heavy = counts
6976 .file_size_bytes
6977 .zip(counts.freelist_bytes)
6978 .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6979 let status = if tombstone_heavy || freelist_heavy {
6980 "recommended"
6981 } else {
6982 "not_needed"
6983 }
6984 .to_string();
6985 let mut recommendations = vec![
6986 convex_refresh_command(root, scope),
6987 graph_db_refresh_command(root, scope),
6988 format!(
6989 "tsift graph-db --path {}{} compact --apply --json",
6990 shell_quote(root.to_string_lossy().as_ref()),
6991 graph_db_scope_arg(scope)
6992 ),
6993 ];
6994 if prune_confirmed {
6995 recommendations.push(format!(
6996 "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6997 shell_quote(root.to_string_lossy().as_ref()),
6998 graph_db_scope_arg(scope)
6999 ));
7000 }
7001 let proof = vec![
7002 format!("{live_rows} live graph row(s)"),
7003 format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
7004 format!(
7005 "graph.db file_size={} byte(s), freelist={} byte(s)",
7006 counts.file_size_bytes.unwrap_or(0),
7007 counts.freelist_bytes.unwrap_or(0)
7008 ),
7009 ];
7010 GraphDbCompactionPolicy {
7011 status,
7012 tombstone_scan_rows,
7013 live_rows,
7014 file_size_bytes: counts.file_size_bytes,
7015 freelist_bytes: counts.freelist_bytes,
7016 safe_to_prune_tombstones: prune_confirmed,
7017 requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
7018 recommendations,
7019 proof,
7020 }
7021}
7022
7023fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
7024 let page_count = conn.query_row("PRAGMA page_count", [], |row| row_u64(row, 0))?;
7025 let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
7026 Ok(page_count.saturating_mul(page_size))
7027}
7028
7029fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
7030 let freelist_count = conn.query_row("PRAGMA freelist_count", [], |row| row_u64(row, 0))?;
7031 let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
7032 Ok(freelist_count.saturating_mul(page_size))
7033}
7034
7035fn sqlite_graph_tombstone_retention_diagnostics(
7036 conn: &Connection,
7037 scope: &str,
7038) -> Result<Vec<String>> {
7039 if !sqlite_table_exists(conn, "graph_tombstones")? {
7040 return Ok(Vec::new());
7041 }
7042 let cached = sqlite_graph_counts_from_cache(conn, scope)?;
7043 let counts = match cached.clone() {
7044 Some(counts) => counts,
7045 None => sqlite_graph_counts(conn, scope)?,
7046 };
7047 let live_rows = counts.nodes + counts.edges;
7048 let file_size = counts.file_size_bytes.unwrap_or(0);
7049 let freelist = counts.freelist_bytes.unwrap_or(0);
7050 let stale_live_tombstones = if cached.is_some() {
7051 0
7052 } else {
7053 let mut live_keys = BTreeSet::new();
7054 if sqlite_table_exists(conn, "graph_nodes")? {
7055 let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
7056 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7057 live_keys.insert(format!("node:{}", row?));
7058 }
7059 }
7060 if sqlite_table_exists(conn, "graph_edges")? {
7061 let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
7062 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7063 live_keys.insert(format!("edge:{}", row?));
7064 }
7065 }
7066 let mut stale_live_tombstones = 0usize;
7067 let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
7068 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7069 if live_keys.contains(&row?) {
7070 stale_live_tombstones += 1;
7071 }
7072 }
7073 stale_live_tombstones
7074 };
7075
7076 let mut diagnostics = Vec::new();
7077 if stale_live_tombstones > 0 {
7078 diagnostics.push(format!(
7079 "{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"
7080 ));
7081 }
7082 if counts.tombstones.total > live_rows.max(1) {
7083 let source = if cached.is_some() {
7084 "cached refresh stats"
7085 } else {
7086 "live row scan"
7087 };
7088 diagnostics.push(format!(
7089 "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.",
7090 counts.tombstones.total,
7091 live_rows,
7092 source,
7093 file_size,
7094 freelist,
7095 counts.tombstones.total
7096 ));
7097 }
7098 Ok(diagnostics)
7099}
7100
7101fn sqlite_graph_freshness_from_conn(
7102 conn: &Connection,
7103 scope: &str,
7104) -> Result<GraphDbFreshnessReport> {
7105 if !sqlite_table_exists(conn, "graph_projection_versions")? {
7106 return Ok(GraphDbFreshnessReport {
7107 status: "missing".to_string(),
7108 fail_closed: true,
7109 projection_version: None,
7110 content_hash: None,
7111 source_watermark: None,
7112 diagnostics: vec![
7113 "graph projection metadata table is missing; refresh graph.db before trusting reads"
7114 .to_string(),
7115 ],
7116 });
7117 }
7118 let version = conn
7119 .query_row(
7120 r#"
7121 SELECT projection_version, content_hash, source_watermark
7122 FROM graph_projection_versions
7123 WHERE scope = ?1
7124 "#,
7125 [scope],
7126 |row| {
7127 Ok((
7128 row.get::<_, String>(0)?,
7129 row.get::<_, Option<String>>(1)?,
7130 row.get::<_, Option<String>>(2)?,
7131 ))
7132 },
7133 )
7134 .optional()?;
7135 let Some((projection_version, content_hash, source_watermark)) = version else {
7136 return Ok(GraphDbFreshnessReport {
7137 status: "missing".to_string(),
7138 fail_closed: true,
7139 projection_version: None,
7140 content_hash: None,
7141 source_watermark: None,
7142 diagnostics: vec![
7143 "graph projection metadata is missing; refresh graph.db before trusting reads"
7144 .to_string(),
7145 ],
7146 });
7147 };
7148
7149 let mut diagnostics = Vec::new();
7150 if projection_version != GRAPH_PROJECTION_VERSION {
7151 diagnostics.push(format!(
7152 "projection version mismatch: expected {} got {}",
7153 GRAPH_PROJECTION_VERSION, projection_version
7154 ));
7155 }
7156 if content_hash.is_none() {
7157 diagnostics.push("projection content hash is missing".to_string());
7158 }
7159 let fail_closed = !diagnostics.is_empty();
7160 Ok(GraphDbFreshnessReport {
7161 status: if fail_closed { "stale" } else { "current" }.to_string(),
7162 fail_closed,
7163 projection_version: Some(projection_version),
7164 content_hash,
7165 source_watermark,
7166 diagnostics,
7167 })
7168}
7169
7170fn graph_db_operator_next_commands(
7171 root: &Path,
7172 scope: Option<&str>,
7173 include_refresh: bool,
7174) -> Vec<String> {
7175 let mut commands = Vec::new();
7176 if include_refresh {
7177 commands.push(graph_db_refresh_command(root, scope));
7178 }
7179 commands.push(format!(
7180 "tsift graph-db --path {}{} doctor --json",
7181 shell_quote(root.to_string_lossy().as_ref()),
7182 graph_db_scope_arg(scope)
7183 ));
7184 commands.push(format!(
7185 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
7186 shell_quote(root.to_string_lossy().as_ref()),
7187 graph_db_scope_arg(scope)
7188 ));
7189 commands.push(format!(
7190 "tsift convex-sync {}{} --remote-snapshot --apply --json",
7191 shell_quote(root.to_string_lossy().as_ref()),
7192 graph_db_scope_arg(scope)
7193 ));
7194 commands
7195}
7196
7197pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
7198 match recovery {
7199 index::ReadOnlyRecovery::SnapshotFallback => {
7200 "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
7201 }
7202 index::ReadOnlyRecovery::SnapshotFallbackWal => {
7203 "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
7204 }
7205 }
7206}
7207
7208fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
7209 let mut stmt = conn.prepare(sql)?;
7210 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7211 let mut values = BTreeSet::new();
7212 for row in rows {
7213 values.insert(row?);
7214 }
7215 Ok(values)
7216}
7217
7218fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
7219 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
7220 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
7221 let mut columns = BTreeSet::new();
7222 for row in rows {
7223 columns.insert(row?);
7224 }
7225 Ok(columns)
7226}
7227
7228fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7229 let mut diagnostics = Vec::new();
7230 let user_version: i64 =
7231 conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
7232 if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
7233 diagnostics.push(format!(
7234 "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7235 ));
7236 } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
7237 diagnostics.push(format!(
7238 "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7239 ));
7240 }
7241
7242 let tables = sqlite_string_set(
7243 conn,
7244 "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
7245 )?;
7246 let required_tables = [
7247 (
7248 "graph_nodes",
7249 vec![
7250 "id",
7251 "kind",
7252 "label",
7253 "properties_json",
7254 "provenance_json",
7255 "freshness_json",
7256 "row_hash",
7257 "source_watermark",
7258 ],
7259 ),
7260 (
7261 "graph_edges",
7262 vec![
7263 "edge_key",
7264 "from_id",
7265 "to_id",
7266 "kind",
7267 "properties_json",
7268 "provenance_json",
7269 "freshness_json",
7270 "row_hash",
7271 "source_watermark",
7272 ],
7273 ),
7274 (
7275 "graph_projection_versions",
7276 vec![
7277 "scope",
7278 "projection_version",
7279 "content_hash",
7280 "source_watermark",
7281 "observed_at_unix",
7282 ],
7283 ),
7284 (
7285 "graph_tombstones",
7286 vec!["row_key", "row_kind", "deleted_at_unix"],
7287 ),
7288 ("graph_node_properties", vec!["node_id", "key", "value"]),
7289 ("graph_edge_properties", vec!["edge_key", "key", "value"]),
7290 ];
7291 for (table, required_columns) in required_tables {
7292 if !tables.contains(table) {
7293 diagnostics.push(format!("graph.db schema drift: missing table {table}"));
7294 continue;
7295 }
7296 let columns = sqlite_column_names(conn, table)?;
7297 for column in required_columns {
7298 if !columns.contains(column) {
7299 diagnostics.push(format!(
7300 "graph.db schema drift: missing column {table}.{column}"
7301 ));
7302 }
7303 }
7304 }
7305
7306 let indexes = sqlite_string_set(
7307 conn,
7308 "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
7309 )?;
7310 for index in [
7311 "idx_graph_nodes_kind",
7312 "idx_graph_edges_from_kind",
7313 "idx_graph_edges_to_kind",
7314 "idx_graph_edges_edge_key",
7315 "idx_graph_node_properties_key_value_node",
7316 "idx_graph_edge_properties_key_value_edge",
7317 ] {
7318 if !indexes.contains(index) {
7319 diagnostics.push(format!("graph.db schema drift: missing index {index}"));
7320 }
7321 }
7322
7323 if tables.contains("graph_edges") {
7324 let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
7325 let rows = stmt.query_map([], |row| {
7326 Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
7327 })?;
7328 let mut fks = BTreeSet::new();
7329 for row in rows {
7330 fks.insert(row?);
7331 }
7332 for expected in [
7333 ("from_id".to_string(), "id".to_string()),
7334 ("to_id".to_string(), "id".to_string()),
7335 ] {
7336 if !fks.contains(&expected) {
7337 diagnostics.push(format!(
7338 "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
7339 expected.0, expected.1
7340 ));
7341 }
7342 }
7343 }
7344
7345 Ok(diagnostics)
7346}
7347
7348fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
7349 let mut stmt = conn.prepare(sql)?;
7350 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7351 let mut diagnostics = Vec::new();
7352 for row in rows {
7353 diagnostics.push(row?);
7354 }
7355 Ok(diagnostics)
7356}
7357
7358fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7359 let mut diagnostics = sqlite_query_diagnostics(
7360 conn,
7361 r#"
7362 SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
7363 FROM graph_nodes
7364 GROUP BY id
7365 HAVING COUNT(*) > 1
7366 ORDER BY id
7367 "#,
7368 )?;
7369 diagnostics.extend(sqlite_query_diagnostics(
7370 conn,
7371 r#"
7372 SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
7373 FROM graph_edges
7374 GROUP BY from_id, to_id, kind
7375 HAVING COUNT(*) > 1
7376 ORDER BY from_id, kind, to_id
7377 "#,
7378 )?);
7379 diagnostics.extend(sqlite_query_diagnostics(
7380 conn,
7381 r#"
7382 SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
7383 FROM graph_edges
7384 GROUP BY edge_key
7385 HAVING COUNT(*) > 1
7386 ORDER BY edge_key
7387 "#,
7388 )?);
7389 Ok(diagnostics)
7390}
7391
7392fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7393 sqlite_query_diagnostics(
7394 conn,
7395 r#"
7396 SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7397 FROM graph_edges e
7398 LEFT JOIN graph_nodes n ON n.id = e.from_id
7399 WHERE n.id IS NULL
7400 UNION ALL
7401 SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7402 FROM graph_edges e
7403 LEFT JOIN graph_nodes n ON n.id = e.to_id
7404 WHERE n.id IS NULL
7405 ORDER BY 1
7406 "#,
7407 )
7408}
7409
7410fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7411 let mut diagnostics = Vec::new();
7412 let mut node_stmt = conn.prepare(
7413 "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7414 )?;
7415 let node_rows = node_stmt.query_map([], |row| {
7416 Ok((
7417 row.get::<_, String>(0)?,
7418 row.get::<_, String>(1)?,
7419 row.get::<_, String>(2)?,
7420 row.get::<_, Option<String>>(3)?,
7421 ))
7422 })?;
7423 for row in node_rows {
7424 let (id, properties_json, provenance_json, freshness_json) = row?;
7425 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7426 diagnostics.push(format!(
7427 "graph_nodes {id} properties_json is invalid: {err}"
7428 ));
7429 }
7430 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7431 diagnostics.push(format!(
7432 "graph_nodes {id} provenance_json is invalid: {err}"
7433 ));
7434 }
7435 if let Some(freshness_json) = freshness_json
7436 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7437 {
7438 diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
7439 }
7440 }
7441
7442 let mut edge_stmt = conn.prepare(
7443 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7444 )?;
7445 let edge_rows = edge_stmt.query_map([], |row| {
7446 Ok((
7447 row.get::<_, String>(0)?,
7448 row.get::<_, String>(1)?,
7449 row.get::<_, String>(2)?,
7450 row.get::<_, String>(3)?,
7451 row.get::<_, String>(4)?,
7452 row.get::<_, String>(5)?,
7453 row.get::<_, Option<String>>(6)?,
7454 ))
7455 })?;
7456 for row in edge_rows {
7457 let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
7458 row?;
7459 let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
7460 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7461 diagnostics.push(format!(
7462 "graph_edges {edge} properties_json is invalid: {err}"
7463 ));
7464 }
7465 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7466 diagnostics.push(format!(
7467 "graph_edges {edge} provenance_json is invalid: {err}"
7468 ));
7469 }
7470 if let Some(freshness_json) = freshness_json
7471 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7472 {
7473 diagnostics.push(format!(
7474 "graph_edges {edge} freshness_json is invalid: {err}"
7475 ));
7476 }
7477 }
7478 Ok(diagnostics)
7479}
7480
7481fn sqlite_graph_projection_metadata_diagnostics(
7482 conn: &Connection,
7483 scope: Option<&str>,
7484) -> Result<Vec<String>> {
7485 let mut diagnostics = Vec::new();
7486 let scope_key = scope.unwrap_or("root");
7487 let version = conn
7488 .query_row(
7489 r#"
7490 SELECT projection_version, content_hash, source_watermark
7491 FROM graph_projection_versions
7492 WHERE scope = ?1
7493 "#,
7494 [scope_key],
7495 |row| {
7496 Ok((
7497 row.get::<_, String>(0)?,
7498 row.get::<_, Option<String>>(1)?,
7499 row.get::<_, Option<String>>(2)?,
7500 ))
7501 },
7502 )
7503 .optional()?;
7504 let Some((projection_version, content_hash, _source_watermark)) = version else {
7505 diagnostics.push(format!(
7506 "graph projection metadata is missing for scope {scope_key}"
7507 ));
7508 return Ok(diagnostics);
7509 };
7510 if projection_version != GRAPH_PROJECTION_VERSION {
7511 diagnostics.push(format!(
7512 "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
7513 ));
7514 }
7515 if content_hash.is_none() {
7516 diagnostics.push("projection content hash is missing".to_string());
7517 }
7518
7519 let meta_id = graph_projection_meta_id(scope);
7520 let meta_properties = conn
7521 .query_row(
7522 "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
7523 (&meta_id, GRAPH_PROJECTION_META_KIND),
7524 |row| row.get::<_, String>(0),
7525 )
7526 .optional()?;
7527 let Some(meta_properties) = meta_properties else {
7528 diagnostics.push(format!("projection_meta node {meta_id} is missing"));
7529 return Ok(diagnostics);
7530 };
7531 let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
7532 .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
7533 if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
7534 diagnostics.push(format!(
7535 "projection_meta node {meta_id} has stale projection_version"
7536 ));
7537 }
7538 if properties.get("content_hash") != content_hash.as_ref() {
7539 diagnostics.push(format!(
7540 "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
7541 ));
7542 }
7543 Ok(diagnostics)
7544}
7545
7546pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
7547 let mut node_stmt = conn.prepare(
7548 "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7549 )?;
7550 let node_rows = node_stmt.query_map([], |row| {
7551 let properties_json: String = row.get(3)?;
7552 let provenance_json: String = row.get(4)?;
7553 let freshness_json: Option<String> = row.get(5)?;
7554 Ok((
7555 row.get::<_, String>(0)?,
7556 row.get::<_, String>(1)?,
7557 row.get::<_, String>(2)?,
7558 properties_json,
7559 provenance_json,
7560 freshness_json,
7561 ))
7562 })?;
7563 let mut nodes = Vec::new();
7564 for row in node_rows {
7565 let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
7566 nodes.push(ConvexNodeRow {
7567 external_id,
7568 kind,
7569 label,
7570 properties: serde_json::from_str(&properties_json)?,
7571 provenance: serde_json::from_str(&provenance_json)?,
7572 freshness: freshness_json
7573 .map(|value| serde_json::from_str(&value))
7574 .transpose()?,
7575 });
7576 }
7577
7578 let mut edge_stmt = conn.prepare(
7579 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7580 )?;
7581 let edge_rows = edge_stmt.query_map([], |row| {
7582 let properties_json: String = row.get(4)?;
7583 let provenance_json: String = row.get(5)?;
7584 let freshness_json: Option<String> = row.get(6)?;
7585 Ok((
7586 row.get::<_, String>(0)?,
7587 row.get::<_, String>(1)?,
7588 row.get::<_, String>(2)?,
7589 row.get::<_, String>(3)?,
7590 properties_json,
7591 provenance_json,
7592 freshness_json,
7593 ))
7594 })?;
7595 let mut edges = Vec::new();
7596 for row in edge_rows {
7597 let (
7598 edge_key,
7599 from_external_id,
7600 to_external_id,
7601 kind,
7602 properties_json,
7603 provenance_json,
7604 freshness_json,
7605 ) = row?;
7606 edges.push(ConvexEdgeRow {
7607 edge_key,
7608 from_external_id,
7609 to_external_id,
7610 kind,
7611 properties: serde_json::from_str(&properties_json)?,
7612 provenance: serde_json::from_str(&provenance_json)?,
7613 freshness: freshness_json
7614 .map(|value| serde_json::from_str(&value))
7615 .transpose()?,
7616 });
7617 }
7618 Ok(ConvexProjectionRows { nodes, edges })
7619}
7620
7621fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
7622 format!("{}.{}({})", index.table, index.name, index.fields.join(","))
7623}
7624
7625fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
7626 value
7627 .get("indexes")
7628 .or_else(|| value.get("requiredIndexes"))
7629 .or_else(|| {
7630 value
7631 .get("metadata")
7632 .and_then(|metadata| metadata.get("indexes"))
7633 })
7634}
7635
7636fn convex_snapshot_declared_indexes(
7637 value: &serde_json::Value,
7638) -> Result<Option<Vec<ConvexRequiredIndex>>> {
7639 convex_snapshot_index_value(value)
7640 .map(|indexes| {
7641 serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
7642 .context("parsing Convex snapshot index metadata")
7643 })
7644 .transpose()
7645}
7646
7647fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
7648 let required = convex_required_indexes();
7649 let Some(declared) = convex_snapshot_declared_indexes(value)? else {
7650 return Ok(vec![format!(
7651 "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
7652 required
7653 .iter()
7654 .map(convex_required_index_label)
7655 .collect::<Vec<_>>()
7656 .join(", ")
7657 )]);
7658 };
7659 let declared = declared.into_iter().collect::<BTreeSet<_>>();
7660 let missing = required
7661 .iter()
7662 .filter(|index| !declared.contains(*index))
7663 .map(convex_required_index_label)
7664 .collect::<Vec<_>>();
7665 if missing.is_empty() {
7666 Ok(Vec::new())
7667 } else {
7668 Ok(vec![format!(
7669 "Convex snapshot is missing required index metadata: {}",
7670 missing.join(", ")
7671 )])
7672 }
7673}
7674
7675pub(crate) fn load_convex_projection_snapshot_value(
7676 snapshot_path: &Path,
7677) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7678 let content = fs::read_to_string(snapshot_path).with_context(|| {
7679 format!(
7680 "reading Convex projection snapshot {}",
7681 snapshot_path.display()
7682 )
7683 })?;
7684 let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7685 format!(
7686 "parsing Convex projection snapshot {}",
7687 snapshot_path.display()
7688 )
7689 })?;
7690 let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7691 .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7692 Ok((rows, value))
7693}
7694
7695pub(crate) fn append_sqlite_graph_doctor_checks(
7696 report: &mut GraphDbDoctorReport,
7697 root: &Path,
7698 scope: Option<&str>,
7699 graph_db: &Path,
7700) -> Option<substrate::SqliteReadOnlyConnection> {
7701 let rebuild = graph_db_rebuild_command(root, scope);
7702 let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7703 if !graph_db.exists() {
7704 report.push_check(graph_db_doctor_check(
7705 "sqlite_graph_db_exists",
7706 vec![format!("graph.db is missing at {}", graph_db.display())],
7707 vec![rebuild],
7708 ));
7709 return None;
7710 }
7711 report.push_check(graph_db_doctor_check(
7712 "sqlite_graph_db_exists",
7713 Vec::new(),
7714 vec![rebuild.clone()],
7715 ));
7716
7717 let conn = match open_sqlite_graph_db_readonly(graph_db) {
7718 Ok(conn) => conn,
7719 Err(err) => {
7720 report.push_check(graph_db_doctor_check(
7721 "sqlite_graph_db_open",
7722 vec![err.to_string()],
7723 vec![backup_rebuild],
7724 ));
7725 return None;
7726 }
7727 };
7728 report.push_check(graph_db_doctor_check(
7729 "sqlite_graph_db_open",
7730 Vec::new(),
7731 vec![rebuild.clone()],
7732 ));
7733 if let Some(recovery) = conn.recovery() {
7734 report.push_check(GraphDbDoctorCheck {
7735 name: "sqlite_graph_db_read_recovery".to_string(),
7736 status: "recovered".to_string(),
7737 fail_closed: false,
7738 diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7739 repair_commands: Vec::new(),
7740 });
7741 }
7742
7743 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7744 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7745 report.push_check(graph_db_doctor_check(
7746 "sqlite_schema",
7747 schema_diagnostics,
7748 vec![backup_rebuild.clone()],
7749 ));
7750
7751 let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7752 .unwrap_or_else(|err| {
7753 vec![format!(
7754 "graph projection metadata inspection failed: {err}"
7755 )]
7756 });
7757 report.push_check(graph_db_doctor_check(
7758 "sqlite_projection_metadata",
7759 metadata_diagnostics,
7760 vec![rebuild.clone()],
7761 ));
7762
7763 let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7764 .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7765 report.push_check(graph_db_doctor_check(
7766 "sqlite_duplicate_ids",
7767 duplicate_diagnostics,
7768 vec![backup_rebuild.clone()],
7769 ));
7770
7771 let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7772 .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7773 report.push_check(graph_db_doctor_check(
7774 "sqlite_orphan_edges",
7775 orphan_diagnostics,
7776 vec![rebuild.clone()],
7777 ));
7778
7779 let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7780 .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7781 report.push_check(graph_db_doctor_check(
7782 "sqlite_row_json",
7783 json_diagnostics,
7784 vec![backup_rebuild],
7785 ));
7786
7787 let tombstone_diagnostics =
7788 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7789 .unwrap_or_else(|err| {
7790 vec![format!(
7791 "graph tombstone retention inspection failed: {err}"
7792 )]
7793 });
7794 report.push_check(GraphDbDoctorCheck {
7795 name: "sqlite_tombstone_retention".to_string(),
7796 status: if tombstone_diagnostics.is_empty() {
7797 "ok".to_string()
7798 } else {
7799 "warning".to_string()
7800 },
7801 fail_closed: false,
7802 diagnostics: tombstone_diagnostics,
7803 repair_commands: Vec::new(),
7804 });
7805 let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7806 Ok(counts) => {
7807 let policy = graph_db_compaction_policy(root, scope, &counts, false);
7808 GraphDbDoctorCheck {
7809 name: "sqlite_compaction_policy".to_string(),
7810 status: policy.status.clone(),
7811 fail_closed: false,
7812 diagnostics: policy.proof,
7813 repair_commands: if policy.status == "recommended" {
7814 policy.recommendations
7815 } else {
7816 Vec::new()
7817 },
7818 }
7819 }
7820 Err(err) => GraphDbDoctorCheck {
7821 name: "sqlite_compaction_policy".to_string(),
7822 status: "warning".to_string(),
7823 fail_closed: false,
7824 diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7825 repair_commands: Vec::new(),
7826 },
7827 };
7828 report.push_check(compaction_check);
7829
7830 Some(conn)
7831}
7832
7833pub(crate) fn append_convex_snapshot_doctor_checks(
7834 report: &mut GraphDbDoctorReport,
7835 root: &Path,
7836 scope: Option<&str>,
7837 local_rows: Option<&ConvexProjectionRows>,
7838 snapshot_path: Option<&Path>,
7839) {
7840 let repair = convex_refresh_command(root, scope);
7841 let Some(snapshot_path) = snapshot_path else {
7842 report.push_check(graph_db_doctor_check(
7843 "convex_snapshot_present",
7844 vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7845 vec![format!(
7846 "tsift convex-sync {}{} --json > convex-rows.json",
7847 shell_quote(root.to_string_lossy().as_ref()),
7848 graph_db_scope_arg(scope)
7849 )],
7850 ));
7851 return;
7852 };
7853 report.push_check(graph_db_doctor_check(
7854 "convex_snapshot_present",
7855 Vec::new(),
7856 vec![repair.clone()],
7857 ));
7858
7859 let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7860 Ok(snapshot) => snapshot,
7861 Err(err) => {
7862 report.push_check(graph_db_doctor_check(
7863 "convex_snapshot_parse",
7864 vec![err.to_string()],
7865 vec![repair],
7866 ));
7867 return;
7868 }
7869 };
7870 report.push_check(graph_db_doctor_check(
7871 "convex_snapshot_parse",
7872 Vec::new(),
7873 vec![repair.clone()],
7874 ));
7875
7876 let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7877 report.push_check(graph_db_doctor_check(
7878 "convex_snapshot_rows",
7879 row_diagnostics,
7880 vec![repair.clone()],
7881 ));
7882
7883 let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7884 .unwrap_or_else(|err| vec![err.to_string()]);
7885 report.required_indexes = convex_required_indexes();
7886 report.push_check(graph_db_doctor_check(
7887 "convex_required_indexes",
7888 index_diagnostics,
7889 vec![
7890 "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7891 .to_string(),
7892 ],
7893 ));
7894
7895 if let Some(local_rows) = local_rows {
7896 let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7897 report.push_check(graph_db_doctor_check(
7898 "convex_projection_freshness",
7899 freshness.diagnostics,
7900 vec![repair],
7901 ));
7902 } else {
7903 report.push_check(graph_db_doctor_check(
7904 "convex_projection_freshness",
7905 vec![
7906 "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7907 .to_string(),
7908 ],
7909 vec![graph_db_rebuild_command(root, scope)],
7910 ));
7911 }
7912}
7913
7914fn graph_db_convex_snapshot_doctor_command(
7915 root: &Path,
7916 scope: Option<&str>,
7917 snapshot_path: &Path,
7918) -> String {
7919 format!(
7920 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7921 shell_quote(root.to_string_lossy().as_ref()),
7922 graph_db_scope_arg(scope),
7923 shell_quote(snapshot_path.to_string_lossy().as_ref())
7924 )
7925}
7926
7927fn graph_db_convex_snapshot_read_command(
7928 root: &Path,
7929 scope: Option<&str>,
7930 snapshot_path: &Path,
7931) -> String {
7932 format!(
7933 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7934 shell_quote(root.to_string_lossy().as_ref()),
7935 graph_db_scope_arg(scope),
7936 shell_quote(snapshot_path.to_string_lossy().as_ref())
7937 )
7938}
7939
7940fn convex_sync_snapshot_diff_command(
7941 root: &Path,
7942 scope: Option<&str>,
7943 snapshot_path: &Path,
7944) -> String {
7945 format!(
7946 "tsift convex-sync {}{} --snapshot {} --json",
7947 shell_quote(root.to_string_lossy().as_ref()),
7948 graph_db_scope_arg(scope),
7949 shell_quote(snapshot_path.to_string_lossy().as_ref())
7950 )
7951}
7952
7953pub(crate) struct GraphDbDriftInput<'a> {
7954 root: &'a Path,
7955 scope: Option<&'a str>,
7956 graph_db: &'a Path,
7957 snapshot_path: &'a Path,
7958 local: &'a ConvexProjectionRows,
7959 snapshot: &'a ConvexProjectionRows,
7960 snapshot_value: &'a serde_json::Value,
7961 warnings: Vec<String>,
7962}
7963
7964pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7965 let GraphDbDriftInput {
7966 root,
7967 scope,
7968 graph_db,
7969 snapshot_path,
7970 local,
7971 snapshot,
7972 snapshot_value,
7973 warnings,
7974 } = input;
7975 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7976 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7977 convex_rows_diff(local, Some(snapshot));
7978 let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7979 let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7980 .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7981 let local_hash = freshness.local_hash.clone();
7982 let snapshot_hash = freshness.snapshot_hash.clone();
7983 let stale_nodes = freshness.stale_nodes.clone();
7984 let stale_edges = freshness.stale_edges.clone();
7985
7986 let duplicate_failures = row_diagnostics
7987 .iter()
7988 .filter(|diagnostic| diagnostic.contains("duplicate"))
7989 .count();
7990 let orphan_failures = row_diagnostics
7991 .iter()
7992 .filter(|diagnostic| diagnostic.contains("references missing"))
7993 .count();
7994 let missing_required_indexes = index_diagnostics.len();
7995 let stale_projection_metadata =
7996 usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7997 let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7998 let has_drift = freshness.fail_closed
7999 || !node_upserts.is_empty()
8000 || !edge_upserts.is_empty()
8001 || !node_tombstones.is_empty()
8002 || !edge_tombstones.is_empty();
8003 let status = if hard_failures > 0 {
8004 "fail_closed"
8005 } else if has_drift {
8006 "drift"
8007 } else {
8008 "current"
8009 }
8010 .to_string();
8011
8012 let mut diagnostics = Vec::new();
8013 diagnostics.extend(row_diagnostics);
8014 diagnostics.extend(index_diagnostics);
8015 diagnostics.extend(freshness.diagnostics.clone());
8016 if has_drift {
8017 diagnostics.push(format!(
8018 "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
8019 node_upserts.len(),
8020 edge_upserts.len(),
8021 node_tombstones.len(),
8022 edge_tombstones.len()
8023 ));
8024 }
8025
8026 let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
8027 root,
8028 scope,
8029 snapshot_path,
8030 )];
8031 if status == "current" {
8032 next_commands.push(graph_db_convex_snapshot_read_command(
8033 root,
8034 scope,
8035 snapshot_path,
8036 ));
8037 } else {
8038 next_commands.push(convex_sync_snapshot_diff_command(
8039 root,
8040 scope,
8041 snapshot_path,
8042 ));
8043 next_commands.push(convex_refresh_command(root, scope));
8044 }
8045
8046 GraphDbDriftReport {
8047 root: root.to_string_lossy().to_string(),
8048 scope: scope.map(str::to_string),
8049 graph_db: graph_db.to_string_lossy().to_string(),
8050 convex_snapshot: snapshot_path.to_string_lossy().to_string(),
8051 status: status.clone(),
8052 graph_reads_allowed: status == "current",
8053 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
8054 local_hash,
8055 snapshot_hash,
8056 summary: GraphDbDriftSummary {
8057 node_upserts: node_upserts.len(),
8058 edge_upserts: edge_upserts.len(),
8059 node_tombstones: node_tombstones.len(),
8060 edge_tombstones: edge_tombstones.len(),
8061 stale_nodes: stale_nodes.len(),
8062 stale_edges: stale_edges.len(),
8063 stale_projection_metadata,
8064 duplicate_failures,
8065 orphan_failures,
8066 missing_required_indexes,
8067 },
8068 node_upserts: node_upserts
8069 .into_iter()
8070 .map(|row| row.external_id)
8071 .collect(),
8072 edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
8073 node_tombstones,
8074 edge_tombstones,
8075 stale_nodes,
8076 stale_edges,
8077 diagnostics,
8078 next_commands,
8079 required_indexes: convex_required_indexes(),
8080 warnings,
8081 }
8082}
8083
8084pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
8085 println!(
8086 "graph-db drift status: {} reads_allowed: {}",
8087 report.status, report.graph_reads_allowed
8088 );
8089 println!("graph_db: {}", report.graph_db);
8090 println!("convex_snapshot: {}", report.convex_snapshot);
8091 println!(
8092 "upserts: {} node(s), {} edge(s)",
8093 report.summary.node_upserts, report.summary.edge_upserts
8094 );
8095 println!(
8096 "tombstones: {} node(s), {} edge(s)",
8097 report.summary.node_tombstones, report.summary.edge_tombstones
8098 );
8099 for diagnostic in &report.diagnostics {
8100 println!("diagnostic: {diagnostic}");
8101 }
8102 for command in &report.next_commands {
8103 println!("next: {command}");
8104 }
8105}
8106
8107pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
8108 println!(
8109 "graph-db doctor backend: {} status: {}",
8110 report.backend, report.status
8111 );
8112 println!("graph_db: {}", report.graph_db);
8113 if let Some(snapshot) = &report.convex_snapshot {
8114 println!("convex_snapshot: {snapshot}");
8115 }
8116 for check in &report.checks {
8117 println!("check: {} {}", check.name, check.status);
8118 for diagnostic in &check.diagnostics {
8119 println!(" diagnostic: {diagnostic}");
8120 }
8121 }
8122 for command in &report.repair_commands {
8123 println!("repair: {command}");
8124 }
8125}
8126
8127pub(crate) fn graph_db_operator_report_from_disk(
8128 root: &Path,
8129 scope: Option<&str>,
8130 graph_db: &Path,
8131 operation: &str,
8132 refresh: Option<GraphDbRefreshSummary>,
8133 warnings: Vec<String>,
8134) -> Result<GraphDbOperatorReport> {
8135 if !graph_db.exists() {
8136 let next_commands = graph_db_operator_next_commands(root, scope, true);
8137 let counts = GraphDbOperatorCounts {
8138 nodes: 0,
8139 edges: 0,
8140 tombstones: GraphDbTombstoneCounts {
8141 nodes: 0,
8142 edges: 0,
8143 total: 0,
8144 },
8145 file_size_bytes: None,
8146 freelist_bytes: None,
8147 };
8148 return Ok(GraphDbOperatorReport {
8149 root: root.to_string_lossy().to_string(),
8150 scope: scope.map(str::to_string),
8151 graph_db: graph_db.to_string_lossy().to_string(),
8152 operation: operation.to_string(),
8153 status: "missing".to_string(),
8154 materialized: false,
8155 freshness: GraphDbFreshnessReport {
8156 status: "missing".to_string(),
8157 fail_closed: true,
8158 projection_version: None,
8159 content_hash: None,
8160 source_watermark: None,
8161 diagnostics: vec![
8162 "graph.db is missing; run graph-db refresh before trusting graph reads"
8163 .to_string(),
8164 ],
8165 },
8166 readiness: graph_effectiveness_blocked(
8167 "graph_db_missing",
8168 vec![
8169 "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
8170 ],
8171 next_commands.clone(),
8172 ),
8173 counts: counts.clone(),
8174 refresh,
8175 compaction: graph_db_compaction_policy(root, scope, &counts, false),
8176 recovery: None,
8177 next_commands,
8178 warnings,
8179 });
8180 }
8181
8182 let conn = open_sqlite_graph_db_readonly(graph_db)?;
8183 let recovery = conn.recovery();
8184 let mut warnings = warnings;
8185 if let Some(recovery) = recovery {
8186 warnings.push(graph_db_read_recovery_diagnostic(recovery));
8187 }
8188 let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
8189 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
8190 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
8191 if !schema_diagnostics.is_empty() {
8192 freshness.diagnostics.extend(schema_diagnostics);
8193 freshness.fail_closed = true;
8194 freshness.status = "stale".to_string();
8195 }
8196 let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
8197 let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
8198 warnings.extend(
8199 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
8200 .unwrap_or_else(|err| {
8201 vec![format!(
8202 "graph tombstone retention inspection failed: {err}"
8203 )]
8204 }),
8205 );
8206 let status = if freshness.fail_closed {
8207 "stale"
8208 } else {
8209 "current"
8210 }
8211 .to_string();
8212
8213 Ok(GraphDbOperatorReport {
8214 root: root.to_string_lossy().to_string(),
8215 scope: scope.map(str::to_string),
8216 graph_db: graph_db.to_string_lossy().to_string(),
8217 operation: operation.to_string(),
8218 status,
8219 materialized: true,
8220 freshness,
8221 readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
8222 compaction: graph_db_compaction_policy(root, scope, &counts, false),
8223 counts,
8224 refresh,
8225 recovery,
8226 next_commands: graph_db_operator_next_commands(root, scope, false),
8227 warnings,
8228 })
8229}
8230
8231fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
8232 println!(
8233 "graph-db {} status: {} materialized: {}",
8234 report.operation, report.status, report.materialized
8235 );
8236 println!("graph_db: {}", report.graph_db);
8237 println!(
8238 "projection: version={} hash={} watermark={}",
8239 report
8240 .freshness
8241 .projection_version
8242 .as_deref()
8243 .unwrap_or("<missing>"),
8244 report
8245 .freshness
8246 .content_hash
8247 .as_deref()
8248 .unwrap_or("<missing>"),
8249 report
8250 .freshness
8251 .source_watermark
8252 .as_deref()
8253 .unwrap_or("<missing>")
8254 );
8255 println!(
8256 "rows: {} node(s), {} edge(s), {} tombstone(s)",
8257 report.counts.nodes, report.counts.edges, report.counts.tombstones.total
8258 );
8259 println!(
8260 "readiness: {} reason: {} fail_closed: {}",
8261 report.readiness.status, report.readiness.reason, report.readiness.fail_closed
8262 );
8263 if let Some(file_size) = report.counts.file_size_bytes {
8264 println!(
8265 "storage: {} byte(s), {} free byte(s)",
8266 file_size,
8267 report.counts.freelist_bytes.unwrap_or(0)
8268 );
8269 }
8270 if let Some(refresh) = &report.refresh {
8271 println!(
8272 "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
8273 refresh.tombstoned_nodes, refresh.tombstoned_edges
8274 );
8275 println!(
8276 "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)",
8277 refresh.upserted_nodes,
8278 refresh.upserted_edges,
8279 refresh.upserted_properties,
8280 refresh.unchanged_nodes,
8281 refresh.unchanged_edges,
8282 refresh.unchanged_properties,
8283 refresh.deleted_properties,
8284 refresh.pruned_tombstones
8285 );
8286 }
8287 println!(
8288 "compaction: {} tombstone_scan_rows={} live_rows={}",
8289 report.compaction.status,
8290 report.compaction.tombstone_scan_rows,
8291 report.compaction.live_rows
8292 );
8293 for proof in &report.compaction.proof {
8294 println!("compaction proof: {proof}");
8295 }
8296 if let Some(recovery) = report.recovery {
8297 println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
8298 }
8299 for diagnostic in &report.freshness.diagnostics {
8300 println!("diagnostic: {diagnostic}");
8301 }
8302 for diagnostic in &report.readiness.diagnostics {
8303 println!("readiness diagnostic: {diagnostic}");
8304 }
8305 for warning in &report.warnings {
8306 println!("warning: {warning}");
8307 }
8308 for command in &report.readiness.next_commands {
8309 println!("readiness next: {command}");
8310 }
8311 for command in &report.next_commands {
8312 println!("next: {command}");
8313 }
8314}
8315
8316pub(crate) fn print_graph_db_operator_report(
8317 report: &GraphDbOperatorReport,
8318 format: OutputFormat,
8319) -> Result<()> {
8320 if format.json_output {
8321 print_json_or_envelope(
8322 report,
8323 &format,
8324 "graph-db",
8325 &report.operation,
8326 ToolEnvelopeSummary {
8327 text: format!(
8328 "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
8329 report.operation,
8330 report.status,
8331 report.counts.nodes,
8332 report.counts.edges,
8333 report.counts.tombstones.total
8334 ),
8335 metrics: vec![
8336 envelope_metric("operation", &report.operation),
8337 envelope_metric("status", &report.status),
8338 envelope_metric("nodes", report.counts.nodes),
8339 envelope_metric("edges", report.counts.edges),
8340 envelope_metric("tombstones", report.counts.tombstones.total),
8341 envelope_metric("compaction", &report.compaction.status),
8342 envelope_metric("readiness", &report.readiness.status),
8343 ],
8344 },
8345 false,
8346 report.next_commands.clone(),
8347 )
8348 } else {
8349 print_graph_db_operator_human(report);
8350 Ok(())
8351 }
8352}
8353
8354fn status_run_command_without_notes(run: &str) -> &str {
8355 run.split_once(" (")
8356 .map(|(command, _)| command)
8357 .unwrap_or(run)
8358}
8359
8360fn status_summarize_extract_command(run: &str) -> &str {
8361 let run = status_run_command_without_notes(run);
8362 run.split(" && ")
8363 .find(|command| command.contains("summarize --extract"))
8364 .unwrap_or(run)
8365}
8366
8367fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
8368 report
8369 .recommendations
8370 .run
8371 .as_deref()
8372 .filter(|command| command.contains("summarize --extract"))
8373 .map(status_summarize_extract_command)
8374 .unwrap_or("tsift summarize --extract .")
8375 .to_string()
8376}
8377
8378fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
8379 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8380 readiness.diagnostics.push(format!(
8381 "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
8382 ));
8383 readiness
8384}
8385
8386fn graph_db_semantic_readiness(
8387 root: &Path,
8388 scope: Option<&str>,
8389 semantic_row_count: Option<usize>,
8390) -> GraphEffectivenessReadiness {
8391 if let Some(row_count) = semantic_row_count
8392 && row_count > 0
8393 {
8394 return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
8395 }
8396
8397 let report = match status::check_status(root) {
8398 Ok(report) => report,
8399 Err(err) => {
8400 return graph_effectiveness_blocked(
8401 "status_check_unavailable",
8402 vec![format!(
8403 "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
8404 )],
8405 vec![graph_db_refresh_command(root, scope)],
8406 );
8407 }
8408 };
8409
8410 match &report.summaries {
8411 status::SummaryStatus::Available {
8412 cached_files,
8413 total_indexed_files,
8414 coverage_pct,
8415 ..
8416 } => {
8417 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8418 readiness.diagnostics.push(format!(
8419 "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
8420 ));
8421 readiness
8422 }
8423 status::SummaryStatus::None { .. } => {
8424 let summarize = graph_db_status_summarize_command(&report);
8425 let index_command = report
8426 .recommendations
8427 .run
8428 .as_deref()
8429 .filter(|cmd| cmd.contains("index"))
8430 .map(str::to_string);
8431 let mut repair = Vec::new();
8432 if let Some(cmd) = index_command {
8433 repair.push(cmd);
8434 }
8435 repair.push(summarize.clone());
8436 repair.push(graph_db_refresh_command(root, scope));
8437 graph_effectiveness_blocked(
8438 "summary_cache_empty",
8439 vec![format!(
8440 "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8441 summarize,
8442 root.display(),
8443 graph_db_refresh_command(root, scope)
8444 )],
8445 repair,
8446 )
8447 }
8448 status::SummaryStatus::Unavailable => {
8449 let mut repair: Vec<String> = report.recommendations.run.clone().into_iter().collect();
8450 let summarize = "tsift summarize --extract .".to_string();
8451 repair.push(summarize);
8452 repair.push(graph_db_refresh_command(root, scope));
8453 graph_effectiveness_blocked(
8454 "summary_cache_unavailable",
8455 vec![
8456 "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(),
8457 ],
8458 repair,
8459 )
8460 }
8461 }
8462}
8463
8464pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
8465 let report = match status::check_status(root) {
8466 Ok(report) => report,
8467 Err(err) => {
8468 return vec![format!(
8469 "status check unavailable after graph-db refresh: {err:#}"
8470 )];
8471 }
8472 };
8473
8474 let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8475 Some(graph_db_status_summarize_command(&report))
8476 } else {
8477 None
8478 };
8479 let mut warnings = report.reminders;
8480 if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8481 let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
8482 warnings.push(format!(
8483 "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8484 run,
8485 root.display(),
8486 graph_db_refresh_command(root, scope)
8487 ));
8488 }
8489 dedupe_preserve_order(warnings)
8490}
8491
8492pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
8493 println!(
8494 "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
8495 report.applied, report.pruned_tombstones, report.reclaimed_bytes
8496 );
8497 println!("graph_db: {}", report.graph_db);
8498 println!(
8499 "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8500 report.counts_before.nodes,
8501 report.counts_before.edges,
8502 report.counts_before.tombstones.total,
8503 report.counts_before.file_size_bytes.unwrap_or(0),
8504 report.counts_before.freelist_bytes.unwrap_or(0)
8505 );
8506 println!(
8507 "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8508 report.counts_after.nodes,
8509 report.counts_after.edges,
8510 report.counts_after.tombstones.total,
8511 report.counts_after.file_size_bytes.unwrap_or(0),
8512 report.counts_after.freelist_bytes.unwrap_or(0)
8513 );
8514 for proof in &report.compaction_after.proof {
8515 println!("proof: {proof}");
8516 }
8517 for warning in &report.warnings {
8518 println!("warning: {warning}");
8519 }
8520 for command in &report.next_commands {
8521 println!("next: {command}");
8522 }
8523}
8524
8525fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
8526 raw.iter()
8527 .map(|value| {
8528 let (key, filter_value) = value
8529 .split_once('=')
8530 .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
8531 let key = key.trim();
8532 let filter_value = filter_value.trim();
8533 if key.is_empty() || filter_value.is_empty() {
8534 bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
8535 }
8536 Ok(GraphDbPropertyFilter {
8537 key: key.to_string(),
8538 value: filter_value.to_string(),
8539 })
8540 })
8541 .collect()
8542}
8543
8544fn graph_db_query_options(
8545 cursor: Option<String>,
8546 limit: Option<usize>,
8547 property_filters: &[String],
8548) -> Result<GraphDbQueryOptions> {
8549 Ok(GraphDbQueryOptions {
8550 cursor,
8551 limit: limit.filter(|limit| *limit > 0),
8552 property_filters: parse_graph_db_property_filters(property_filters)?,
8553 })
8554}
8555
8556fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
8557 GraphQueryOptions {
8558 cursor: options.cursor.clone(),
8559 limit: options.limit,
8560 property_filters: options
8561 .property_filters
8562 .iter()
8563 .map(|filter| GraphPropertyFilter {
8564 key: filter.key.clone(),
8565 value: filter.value.clone(),
8566 })
8567 .collect(),
8568 }
8569}
8570
8571fn graph_db_page_report_from_store(
8572 page: GraphQueryPage,
8573 property_filters: Vec<GraphDbPropertyFilter>,
8574) -> GraphDbPageReport {
8575 GraphDbPageReport {
8576 cursor: page.cursor,
8577 limit: page.limit,
8578 next_cursor: page.next_cursor,
8579 returned_nodes: page.returned_nodes,
8580 returned_edges: page.returned_edges,
8581 truncated: page.truncated,
8582 property_filters,
8583 diagnostics: page.diagnostics,
8584 }
8585}
8586
8587fn graph_db_neighborhood_ranking_gate(
8588 ranked_neighbor_cap: usize,
8589) -> GraphDbNeighborhoodRankingGate {
8590 GraphDbNeighborhoodRankingGate {
8591 status: "held_default_order_unchanged".to_string(),
8592 ranked_output_default: false,
8593 default_order: "stable_node_id".to_string(),
8594 default_change_gate: "community_search_quality_metrics".to_string(),
8595 required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
8596 .iter()
8597 .map(|workload| (*workload).to_string())
8598 .collect(),
8599 required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
8600 .iter()
8601 .map(|metric| (*metric).to_string())
8602 .collect(),
8603 max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
8604 min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
8605 min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
8606 min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
8607 diagnostics: vec![
8608 "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
8609 format!(
8610 "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
8611 ),
8612 "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
8613 ],
8614 }
8615}
8616
8617fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
8618 match limit {
8619 Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
8620 Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
8621 }
8622}
8623
8624fn graph_db_ranked_neighbors(
8625 center_id: &str,
8626 nodes: &[SubstrateGraphNode],
8627 edges: &[SubstrateGraphEdge],
8628 cap: usize,
8629) -> Vec<GraphDbRankedNeighbor> {
8630 resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
8631}
8632
8633fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
8634 center_id: &str,
8635 depth: usize,
8636 edge_kind: Option<&str>,
8637 limit: Option<usize>,
8638 unranked_nodes: &[SubstrateGraphNode],
8639 unranked_edges: &[SubstrateGraphEdge],
8640 store: &S,
8641) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
8642 use std::time::Instant;
8643 let max_nodes = match limit {
8644 Some(0) | None => 200,
8645 Some(n) => n.clamp(10, 500),
8646 };
8647 let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
8648 .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
8649 if let Some(kind) = edge_kind {
8650 options = options.with_edge_kind(kind);
8651 }
8652 let start = Instant::now();
8653 let result = store.ranked_neighborhood(center_id, &options)?;
8654 let latency = start.elapsed().as_micros();
8655 let Some(ranked) = result else {
8656 return Ok(None);
8657 };
8658 let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
8659 let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
8660 let overlap_count = ranked_ids.intersection(&unranked_ids).count();
8661 let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
8662 0.0
8663 } else {
8664 (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8665 };
8666 let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8667 let mut name_count = BTreeMap::<&str, usize>::new();
8668 for n in nodes {
8669 *name_count.entry(&n.label).or_default() += 1;
8670 }
8671 name_count.values().filter(|&&c| c > 1).count()
8672 };
8673 let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8674 if nodes.is_empty() {
8675 return 100.0;
8676 }
8677 let with_handle = nodes
8678 .iter()
8679 .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8680 .count();
8681 (with_handle as f64 / nodes.len() as f64) * 100.0
8682 };
8683 let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8684 if nodes.is_empty() {
8685 return 0.0;
8686 }
8687 let semantic_kinds = [
8688 "semantic_concept",
8689 "semantic_entity",
8690 "symbol",
8691 "file",
8692 "source_handle",
8693 ];
8694 let useful = nodes
8695 .iter()
8696 .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8697 .count();
8698 let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8699 let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8700 (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8701 / nodes.len() as f64
8702 };
8703 let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8704 let edge_pairs: Vec<(String, String)> = ranked
8705 .edges
8706 .iter()
8707 .map(|e| (e.from_id.clone(), e.to_id.clone()))
8708 .collect();
8709 let cr = tsift_graph::detect_communities(&edge_pairs);
8710 let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8711 let mut fully_kept = 0usize;
8712 let mut partially_pruned = 0usize;
8713 let mut fully_pruned = 0usize;
8714 let mut pruned_kinds = BTreeSet::new();
8715 let mut pruned_labels = Vec::new();
8716 for comm in &cr.communities {
8717 let kept_in_comm: Vec<&str> = comm
8718 .members
8719 .iter()
8720 .filter(|m| kept_labels.contains(m.name.as_str()))
8721 .map(|m| m.name.as_str())
8722 .collect();
8723 if kept_in_comm.len() == comm.members.len() {
8724 fully_kept += 1;
8725 } else if kept_in_comm.is_empty() {
8726 fully_pruned += 1;
8727 for m in &comm.members {
8728 if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8729 pruned_kinds.insert(n.kind.clone());
8730 }
8731 pruned_labels.push(m.name.clone());
8732 }
8733 } else {
8734 partially_pruned += 1;
8735 }
8736 }
8737 pruned_labels.truncate(5);
8738 Some(CommunityTruncationSummary {
8739 total_communities: cr.communities.len(),
8740 fully_kept,
8741 partially_pruned,
8742 fully_pruned,
8743 pruned_community_kinds: pruned_kinds.into_iter().collect(),
8744 pruned_community_top_labels: pruned_labels,
8745 })
8746 } else {
8747 None
8748 };
8749 Ok(Some(GraphDbRankedNeighborhoodComparison {
8750 traversal_nodes: ranked.nodes.len(),
8751 traversal_edges: ranked.edges.len(),
8752 pruned_count: ranked.pruned_count,
8753 total_discovered: ranked.total_discovered,
8754 latency_micros: latency,
8755 overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8756 useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8757 / 1000.0,
8758 useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8759 .round()
8760 / 1000.0,
8761 duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8762 duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8763 handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8764 handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8765 / 100.0,
8766 community_truncation_summary,
8767 diagnostics: vec![
8768 format!(
8769 "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8770 ranked.nodes.len(),
8771 ranked.edges.len(),
8772 ranked.pruned_count,
8773 ranked.total_discovered,
8774 latency
8775 ),
8776 format!(
8777 "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8778 overlap_pct,
8779 overlap_count,
8780 unranked_ids.len(),
8781 ranked_ids.len()
8782 ),
8783 "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8784 ],
8785 }))
8786}
8787
8788struct GraphDbBudgetedSubgraph {
8789 nodes: Vec<SubstrateGraphNode>,
8790 edges: Vec<SubstrateGraphEdge>,
8791 report: GraphDbOutputBudgetReport,
8792 truncated: bool,
8793 next_cursor: Option<String>,
8794}
8795
8796const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8797const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8798const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8799
8800fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8801 match limit {
8802 Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8803 Some(limit) => limit
8804 .saturating_mul(320)
8805 .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8806 }
8807}
8808
8809fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8810 if matches!(limit, Some(0) | None) {
8811 return match kind {
8812 "source_handle" => 10,
8813 "worker_context" | "worker_result" => 8,
8814 "semantic_concept" | "semantic_entity" => 10,
8815 "file" | "symbol" | "route" => 12,
8816 _ => 8,
8817 };
8818 }
8819 let base = limit.unwrap_or(0).max(1);
8820 match kind {
8821 "source_handle" => base.saturating_add(4),
8822 "worker_context" | "worker_result" => base.saturating_add(2),
8823 "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8824 "file" | "symbol" | "route" => base.saturating_add(4),
8825 _ => base.saturating_add(1),
8826 }
8827}
8828
8829fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8830 if matches!(limit, Some(0) | None) {
8831 return match kind {
8832 "mentions" | "mentions_concept" | "mentions_entity" => 24,
8833 "semantic_relation" | "calls" | "defines" => 20,
8834 _ => 16,
8835 };
8836 }
8837 let base = limit.unwrap_or(0).max(1);
8838 match kind {
8839 "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8840 "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8841 _ => base.saturating_add(2),
8842 }
8843}
8844
8845fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8846 serde_json::to_vec(value)
8847 .map(|bytes| bytes.len().div_ceil(4).max(1))
8848 .unwrap_or(1)
8849}
8850
8851fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8852 let mut parts = vec![node.kind.clone(), node.label.clone()];
8853 for key in [
8854 "detail",
8855 "description",
8856 "source_ref",
8857 "path",
8858 "source_file",
8859 "source_symbol",
8860 "text_preview",
8861 ] {
8862 if let Some(value) = node.properties.get(key) {
8863 parts.push(value.clone());
8864 }
8865 }
8866 parts.join(" ")
8867}
8868
8869fn graph_db_semantic_scores_for_query(
8870 query: Option<&str>,
8871 nodes: &[SubstrateGraphNode],
8872) -> BTreeMap<String, f64> {
8873 let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8874 return BTreeMap::new();
8875 };
8876 let query_embedding = semantic_embedding(query);
8877 nodes
8878 .iter()
8879 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8880 .filter_map(|node| {
8881 let embedding = node
8882 .properties
8883 .get("embedding")
8884 .and_then(|value| parse_semantic_embedding_property(value))?;
8885 Some((
8886 node.id.clone(),
8887 semantic_cosine(&query_embedding, &embedding),
8888 ))
8889 })
8890 .collect()
8891}
8892
8893fn graph_db_depth_by_id(
8894 origin_ids: &[String],
8895 edges: &[SubstrateGraphEdge],
8896) -> BTreeMap<String, usize> {
8897 let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8898 for edge in edges {
8899 adjacency
8900 .entry(edge.from_id.clone())
8901 .or_default()
8902 .push(edge.to_id.clone());
8903 adjacency
8904 .entry(edge.to_id.clone())
8905 .or_default()
8906 .push(edge.from_id.clone());
8907 }
8908
8909 let mut depth_by_id = BTreeMap::<String, usize>::new();
8910 let mut queue = VecDeque::<String>::new();
8911 for origin in origin_ids {
8912 if depth_by_id.insert(origin.clone(), 0).is_none() {
8913 queue.push_back(origin.clone());
8914 }
8915 }
8916 while let Some(current) = queue.pop_front() {
8917 let depth = depth_by_id.get(¤t).copied().unwrap_or(0);
8918 for next in adjacency.get(¤t).into_iter().flatten() {
8919 if depth_by_id.contains_key(next) {
8920 continue;
8921 }
8922 depth_by_id.insert(next.clone(), depth.saturating_add(1));
8923 queue.push_back(next.clone());
8924 }
8925 }
8926 depth_by_id
8927}
8928
8929fn graph_db_source_covered_ids(
8930 nodes: &[SubstrateGraphNode],
8931 edges: &[SubstrateGraphEdge],
8932) -> BTreeSet<String> {
8933 let source_ids = nodes
8934 .iter()
8935 .filter(|node| node.kind == "source_handle")
8936 .map(|node| node.id.as_str())
8937 .collect::<BTreeSet<_>>();
8938 let mut covered = source_ids
8939 .iter()
8940 .map(|id| (*id).to_string())
8941 .collect::<BTreeSet<_>>();
8942 for edge in edges {
8943 if source_ids.contains(edge.from_id.as_str()) {
8944 covered.insert(edge.to_id.clone());
8945 }
8946 if source_ids.contains(edge.to_id.as_str()) {
8947 covered.insert(edge.from_id.clone());
8948 }
8949 }
8950 covered
8951}
8952
8953fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8954 for key in [
8955 "observed_at_unix",
8956 "completed_at_unix",
8957 "created_at_unix",
8958 "started_at_unix",
8959 ] {
8960 if let Some(value) = node.properties.get(key)
8961 && let Ok(epoch) = value.parse::<i64>()
8962 {
8963 return epoch.div_euclid(86_400).clamp(0, 40_000);
8964 }
8965 }
8966 0
8967}
8968
8969fn graph_db_node_kind_score(kind: &str) -> i64 {
8970 match kind {
8971 "source_handle" => 180,
8972 "worker_context" => 170,
8973 "worker_result" => 160,
8974 "semantic_concept" | "semantic_entity" => 150,
8975 "backlog" | "job_packet" => 130,
8976 "symbol" => 120,
8977 "file" => 110,
8978 "route" => 105,
8979 "session" => 90,
8980 _ => 40,
8981 }
8982}
8983
8984fn graph_db_edge_kind_score(kind: &str) -> i64 {
8985 match kind {
8986 "mentions_concept" | "mentions_entity" => 180,
8987 "semantic_relation" => 170,
8988 "mentions" => 165,
8989 "requests_context" | "scopes_context" | "scopes_source" => 155,
8990 "explains_result" => 150,
8991 "calls" => 145,
8992 "defines" | "handled_by" | "defines_route" => 130,
8993 "contains" | "targets" => 120,
8994 "records_memory_source" | "has_vector_handle" => 115,
8995 _ => 40,
8996 }
8997}
8998
8999fn graph_db_node_usefulness_score(
9000 node: &SubstrateGraphNode,
9001 depth_by_id: &BTreeMap<String, usize>,
9002 semantic_scores: &BTreeMap<String, f64>,
9003 source_covered_ids: &BTreeSet<String>,
9004 origin_ids: &[String],
9005) -> i64 {
9006 if origin_ids.iter().any(|origin| origin == &node.id) {
9007 return 1_000_000;
9008 }
9009 let semantic = semantic_scores
9010 .get(&node.id)
9011 .map(|score| (score.max(0.0) * 1_000.0) as i64)
9012 .unwrap_or(0);
9013 let depth_penalty = depth_by_id
9014 .get(&node.id)
9015 .map(|depth| (*depth as i64).saturating_mul(55))
9016 .unwrap_or(180);
9017 let source_coverage = if source_covered_ids.contains(&node.id)
9018 || node.properties.contains_key("source_ref")
9019 || node.properties.contains_key("path")
9020 {
9021 120
9022 } else {
9023 0
9024 };
9025 graph_db_node_kind_score(&node.kind)
9026 + semantic
9027 + source_coverage
9028 + graph_db_recency_score(node).min(80)
9029 - depth_penalty
9030}
9031
9032fn graph_db_edge_usefulness_score(
9033 edge: &SubstrateGraphEdge,
9034 node_score_by_id: &BTreeMap<String, i64>,
9035 depth_by_id: &BTreeMap<String, usize>,
9036) -> i64 {
9037 let endpoint_score = node_score_by_id
9038 .get(&edge.from_id)
9039 .copied()
9040 .unwrap_or_default()
9041 .max(
9042 node_score_by_id
9043 .get(&edge.to_id)
9044 .copied()
9045 .unwrap_or_default(),
9046 );
9047 let depth_penalty = depth_by_id
9048 .get(&edge.from_id)
9049 .into_iter()
9050 .chain(depth_by_id.get(&edge.to_id))
9051 .min()
9052 .map(|depth| (*depth as i64).saturating_mul(35))
9053 .unwrap_or(140);
9054 graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
9055}
9056
9057fn graph_db_push_drop(
9058 drops: &mut BTreeMap<(String, String, String), usize>,
9059 item: &str,
9060 kind: &str,
9061 reason: &str,
9062) {
9063 *drops
9064 .entry((item.to_string(), kind.to_string(), reason.to_string()))
9065 .or_default() += 1;
9066}
9067
9068fn graph_db_budget_drop_report(
9069 drops: BTreeMap<(String, String, String), usize>,
9070) -> Vec<GraphDbDroppedByBudget> {
9071 drops
9072 .into_iter()
9073 .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
9074 item,
9075 kind,
9076 reason,
9077 dropped,
9078 })
9079 .collect()
9080}
9081
9082fn graph_db_apply_output_budget(
9083 origin_ids: &[String],
9084 semantic_scores: &BTreeMap<String, f64>,
9085 nodes: Vec<SubstrateGraphNode>,
9086 edges: Vec<SubstrateGraphEdge>,
9087 limit: Option<usize>,
9088) -> GraphDbBudgetedSubgraph {
9089 graph_db_apply_output_budget_with_depths_and_cursor(
9090 origin_ids,
9091 semantic_scores,
9092 nodes,
9093 edges,
9094 limit,
9095 None,
9096 None,
9097 )
9098}
9099
9100fn graph_db_apply_output_budget_with_depths_and_cursor(
9101 origin_ids: &[String],
9102 semantic_scores: &BTreeMap<String, f64>,
9103 nodes: Vec<SubstrateGraphNode>,
9104 edges: Vec<SubstrateGraphEdge>,
9105 limit: Option<usize>,
9106 depth_overrides: Option<&BTreeMap<String, usize>>,
9107 cursor: Option<&str>,
9108) -> GraphDbBudgetedSubgraph {
9109 let max_tokens = graph_db_output_token_cap(limit);
9110 let candidate_nodes = nodes.len();
9111 let candidate_edges = edges.len();
9112 let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
9113 if let Some(depth_overrides) = depth_overrides {
9114 for (id, depth) in depth_overrides {
9115 depth_by_id
9116 .entry(id.clone())
9117 .and_modify(|current| *current = (*current).min(*depth))
9118 .or_insert(*depth);
9119 }
9120 }
9121 let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
9122 let node_score_by_id = nodes
9123 .iter()
9124 .map(|node| {
9125 (
9126 node.id.clone(),
9127 graph_db_node_usefulness_score(
9128 node,
9129 &depth_by_id,
9130 semantic_scores,
9131 &source_covered_ids,
9132 origin_ids,
9133 ),
9134 )
9135 })
9136 .collect::<BTreeMap<_, _>>();
9137
9138 let mut node_candidates = nodes.iter().collect::<Vec<_>>();
9139 node_candidates.sort_by(|left, right| {
9140 node_score_by_id
9141 .get(&right.id)
9142 .cmp(&node_score_by_id.get(&left.id))
9143 .then_with(|| left.kind.cmp(&right.kind))
9144 .then_with(|| left.label.cmp(&right.label))
9145 .then_with(|| left.id.cmp(&right.id))
9146 });
9147
9148 let cursor_skip = if let Some(cursor) = cursor {
9149 node_candidates
9150 .iter()
9151 .position(|node| node.id == cursor)
9152 .map(|pos| pos.saturating_add(1))
9153 .unwrap_or(0)
9154 } else {
9155 0
9156 };
9157 if cursor_skip > 0 {
9158 node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
9159 }
9160
9161 let mut selected_node_ids = BTreeSet::new();
9162 let mut selected_node_counts = BTreeMap::<String, usize>::new();
9163 let mut estimated_tokens = 0usize;
9164 let mut drops = BTreeMap::<(String, String, String), usize>::new();
9165 for node in &node_candidates {
9166 let kind_count = selected_node_counts
9167 .get(&node.kind)
9168 .copied()
9169 .unwrap_or_default();
9170 if !origin_ids.iter().any(|origin| origin == &node.id)
9171 && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
9172 {
9173 graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
9174 continue;
9175 }
9176 let tokens = graph_db_estimated_tokens(node);
9177 if !origin_ids.iter().any(|origin| origin == &node.id)
9178 && estimated_tokens.saturating_add(tokens) > max_tokens
9179 {
9180 graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
9181 continue;
9182 }
9183 selected_node_ids.insert(node.id.clone());
9184 *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
9185 estimated_tokens = estimated_tokens.saturating_add(tokens);
9186 }
9187
9188 let has_remaining_candidates = node_candidates
9189 .iter()
9190 .any(|node| !selected_node_ids.contains(&node.id));
9191
9192 let mut selected_nodes = nodes
9193 .into_iter()
9194 .filter(|node| selected_node_ids.contains(&node.id))
9195 .collect::<Vec<_>>();
9196
9197 let mut edge_candidates = edges
9198 .iter()
9199 .filter(|edge| {
9200 selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
9201 })
9202 .collect::<Vec<_>>();
9203 let edge_score_by_key = edge_candidates
9204 .iter()
9205 .map(|edge| {
9206 (
9207 graph_db_edge_key(edge),
9208 graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
9209 )
9210 })
9211 .collect::<BTreeMap<_, _>>();
9212 edge_candidates.sort_by(|left, right| {
9213 edge_score_by_key
9214 .get(&graph_db_edge_key(right))
9215 .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
9216 .then_with(|| left.kind.cmp(&right.kind))
9217 .then_with(|| left.from_id.cmp(&right.from_id))
9218 .then_with(|| left.to_id.cmp(&right.to_id))
9219 });
9220
9221 let endpoint_dropped_edges = edges
9222 .iter()
9223 .filter(|edge| {
9224 !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
9225 })
9226 .count();
9227 if endpoint_dropped_edges > 0 {
9228 drops.insert(
9229 (
9230 "edge".to_string(),
9231 "*".to_string(),
9232 "endpoint_node_dropped".to_string(),
9233 ),
9234 endpoint_dropped_edges,
9235 );
9236 }
9237
9238 let mut selected_edge_ids = BTreeSet::new();
9239 let mut selected_edge_counts = BTreeMap::<String, usize>::new();
9240 for edge in edge_candidates {
9241 let kind_count = selected_edge_counts
9242 .get(&edge.kind)
9243 .copied()
9244 .unwrap_or_default();
9245 if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
9246 graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
9247 continue;
9248 }
9249 let tokens = graph_db_estimated_tokens(edge);
9250 if estimated_tokens.saturating_add(tokens) > max_tokens {
9251 graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
9252 continue;
9253 }
9254 selected_edge_ids.insert(graph_db_edge_key(edge));
9255 *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
9256 estimated_tokens = estimated_tokens.saturating_add(tokens);
9257 }
9258
9259 let selected_edges = edges
9260 .into_iter()
9261 .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
9262 .collect::<Vec<_>>();
9263 let dropped_by_budget = graph_db_budget_drop_report(drops);
9264 let truncated = has_remaining_candidates;
9265 let next_cursor = if truncated {
9266 selected_nodes.last().map(|node| node.id.clone())
9267 } else {
9268 None
9269 };
9270 let mut diagnostics = vec![
9271 "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
9272 .to_string(),
9273 format!(
9274 "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
9275 selected_nodes.len(),
9276 candidate_nodes,
9277 selected_edges.len(),
9278 candidate_edges,
9279 max_tokens
9280 ),
9281 ];
9282 if cursor.is_some() {
9283 diagnostics.push(format!(
9284 "cursor skipped {} previously returned candidate(s)",
9285 cursor_skip
9286 ));
9287 }
9288 if next_cursor.is_some() {
9289 diagnostics.push(
9290 "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
9291 );
9292 }
9293 selected_nodes.shrink_to_fit();
9294
9295 GraphDbBudgetedSubgraph {
9296 nodes: selected_nodes,
9297 edges: selected_edges,
9298 report: GraphDbOutputBudgetReport {
9299 max_tokens,
9300 estimated_tokens,
9301 selected_nodes: selected_node_ids.len(),
9302 selected_edges: selected_edge_ids.len(),
9303 candidate_nodes,
9304 candidate_edges,
9305 dropped_by_budget,
9306 diagnostics,
9307 },
9308 truncated,
9309 next_cursor,
9310 }
9311}
9312
9313fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
9314 if edge.id.is_empty() {
9315 substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
9316 } else {
9317 edge.id.clone()
9318 }
9319}
9320
9321fn graph_db_schema() -> GraphDbSchema {
9322 GraphDbSchema {
9323 contract_versions: vec![
9324 GraphDbSchemaContract {
9325 name: "graph_db_evidence",
9326 version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9327 description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
9328 },
9329 GraphDbSchemaContract {
9330 name: "worker_prompt_packet",
9331 version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
9332 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",
9333 },
9334 GraphDbSchemaContract {
9335 name: "conflict_matrix",
9336 version: CONFLICT_MATRIX_CONTRACT_VERSION,
9337 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",
9338 },
9339 GraphDbSchemaContract {
9340 name: "context_pack_graph_orchestration",
9341 version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
9342 description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
9343 },
9344 GraphDbSchemaContract {
9345 name: "session_review_follow_up",
9346 version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
9347 description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
9348 },
9349 GraphDbSchemaContract {
9350 name: "dispatch_trace",
9351 version: DISPATCH_TRACE_CONTRACT_VERSION,
9352 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",
9353 },
9354 GraphDbSchemaContract {
9355 name: "dependency_dag",
9356 version: DEPENDENCY_DAG_CONTRACT_VERSION,
9357 description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
9358 },
9359 ],
9360 node_fields: vec![
9361 GraphDbSchemaField {
9362 name: "id",
9363 value_type: "string",
9364 description: "Stable provider-neutral node id",
9365 },
9366 GraphDbSchemaField {
9367 name: "kind",
9368 value_type: "string",
9369 description: "Application-defined node family such as file, symbol, or backlog",
9370 },
9371 GraphDbSchemaField {
9372 name: "label",
9373 value_type: "string",
9374 description: "Human-readable label",
9375 },
9376 GraphDbSchemaField {
9377 name: "properties",
9378 value_type: "object<string,string>",
9379 description: "Adapter-specific string properties",
9380 },
9381 GraphDbSchemaField {
9382 name: "provenance",
9383 value_type: "array",
9384 description: "Source system and source reference metadata",
9385 },
9386 GraphDbSchemaField {
9387 name: "freshness",
9388 value_type: "object|null",
9389 description: "Optional content hash and observed timestamp",
9390 },
9391 ],
9392 edge_fields: vec![
9393 GraphDbSchemaField {
9394 name: "id",
9395 value_type: "string",
9396 description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
9397 },
9398 GraphDbSchemaField {
9399 name: "from_id",
9400 value_type: "string",
9401 description: "Source node id",
9402 },
9403 GraphDbSchemaField {
9404 name: "to_id",
9405 value_type: "string",
9406 description: "Target node id",
9407 },
9408 GraphDbSchemaField {
9409 name: "kind",
9410 value_type: "string",
9411 description: "Application-defined edge relation",
9412 },
9413 GraphDbSchemaField {
9414 name: "properties",
9415 value_type: "object<string,string>",
9416 description: "Adapter-specific string properties",
9417 },
9418 GraphDbSchemaField {
9419 name: "provenance",
9420 value_type: "array",
9421 description: "Source system and source reference metadata",
9422 },
9423 GraphDbSchemaField {
9424 name: "freshness",
9425 value_type: "object|null",
9426 description: "Optional content hash and observed timestamp",
9427 },
9428 ],
9429 operations: vec![
9430 GraphDbSchemaOperation {
9431 command: "refresh",
9432 description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
9433 },
9434 GraphDbSchemaOperation {
9435 command: "status",
9436 description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
9437 },
9438 GraphDbSchemaOperation {
9439 command: "doctor",
9440 description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
9441 },
9442 GraphDbSchemaOperation {
9443 command: "drift",
9444 description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
9445 },
9446 GraphDbSchemaOperation {
9447 command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
9448 description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
9449 },
9450 GraphDbSchemaOperation {
9451 command: "snapshot-export <output.db.gz> [--force]",
9452 description: "Export the current SQLite graph.db as a gzip-compressed shareable artifact only after freshness, doctor, WAL, and sidecar checks pass",
9453 },
9454 GraphDbSchemaOperation {
9455 command: "snapshot-import <artifact.db.gz> [--replace]",
9456 description: "Stage and validate a compressed SQLite graph.db artifact through doctor and freshness checks before replacing the local graph.db",
9457 },
9458 GraphDbSchemaOperation {
9459 command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
9460 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",
9461 },
9462 GraphDbSchemaOperation {
9463 command: "evidence <target> [--depth N] [--limit N]",
9464 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",
9465 },
9466 GraphDbSchemaOperation {
9467 command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
9468 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",
9469 },
9470 GraphDbSchemaOperation {
9471 command: "dispatch-trace [target...] --path <session> [--format json|html]",
9472 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",
9473 },
9474 GraphDbSchemaOperation {
9475 command: "dependency-dag [target...] --path <session>",
9476 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",
9477 },
9478 GraphDbSchemaOperation {
9479 command: "schema",
9480 description: "Return record and operation schemas",
9481 },
9482 GraphDbSchemaOperation {
9483 command: "node <id>",
9484 description: "Return one node by stable id",
9485 },
9486 GraphDbSchemaOperation {
9487 command: "edge <id>",
9488 description: "Return one edge by stable edge id",
9489 },
9490 GraphDbSchemaOperation {
9491 command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9492 description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
9493 },
9494 GraphDbSchemaOperation {
9495 command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9496 description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
9497 },
9498 GraphDbSchemaOperation {
9499 command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
9500 description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
9501 },
9502 GraphDbSchemaOperation {
9503 command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
9504 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",
9505 },
9506 GraphDbSchemaOperation {
9507 command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
9508 description: "Return the shortest directed path by node id, optionally bounded by hop count",
9509 },
9510 ],
9511 }
9512}
9513
9514pub(crate) fn sqlite_graph_freshness(
9515 store: &SqliteGraphStore,
9516 scope: &str,
9517) -> Result<GraphDbFreshnessReport> {
9518 let version = store.projection_version(scope)?;
9519 let Some(version) = version else {
9520 return Ok(GraphDbFreshnessReport {
9521 status: "missing".to_string(),
9522 fail_closed: true,
9523 projection_version: None,
9524 content_hash: None,
9525 source_watermark: None,
9526 diagnostics: vec![
9527 "graph projection metadata is missing; rebuild the graph before trusting reads"
9528 .to_string(),
9529 ],
9530 });
9531 };
9532 let mut diagnostics = Vec::new();
9533 let fail_closed =
9534 version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
9535 if version.projection_version != GRAPH_PROJECTION_VERSION {
9536 diagnostics.push(format!(
9537 "projection version mismatch: expected {} got {}",
9538 GRAPH_PROJECTION_VERSION, version.projection_version
9539 ));
9540 }
9541 if version.content_hash.is_none() {
9542 diagnostics.push("projection content hash is missing".to_string());
9543 }
9544 Ok(GraphDbFreshnessReport {
9545 status: if fail_closed { "stale" } else { "current" }.to_string(),
9546 fail_closed,
9547 projection_version: Some(version.projection_version),
9548 content_hash: version.content_hash,
9549 source_watermark: version.source_watermark,
9550 diagnostics,
9551 })
9552}
9553
9554pub(crate) fn convex_graph_freshness(
9555 local: &ConvexProjectionRows,
9556 snapshot: &ConvexProjectionRows,
9557 scope: Option<&str>,
9558) -> GraphDbFreshnessReport {
9559 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
9560 GraphDbFreshnessReport {
9561 status: freshness.status,
9562 fail_closed: freshness.fail_closed,
9563 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
9564 content_hash: freshness.snapshot_hash,
9565 source_watermark: None,
9566 diagnostics: freshness.diagnostics,
9567 }
9568}
9569
9570pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
9571 let (nodes, edges) = store.graph_counts()?;
9572 let files = store.file_count()?;
9573 Ok(GraphDbFreshnessReport {
9574 status: "current".to_string(),
9575 fail_closed: false,
9576 projection_version: Some("tokensave-readonly".to_string()),
9577 content_hash: None,
9578 source_watermark: Some(store.db_path().to_string_lossy().to_string()),
9579 diagnostics: vec![format!(
9580 "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
9581 nodes, edges, files
9582 )],
9583 })
9584}
9585
9586pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
9587 match TokensaveDb::discover(root) {
9588 Ok(Some(store)) => {
9589 report.push_check(GraphDbDoctorCheck {
9590 name: "tokensave_db_open".to_string(),
9591 status: "ok".to_string(),
9592 fail_closed: false,
9593 diagnostics: vec![format!(
9594 "opened tokensave database at {}",
9595 store.db_path().display()
9596 )],
9597 repair_commands: Vec::new(),
9598 });
9599 match (store.node_count(), store.edge_count(), store.file_count()) {
9600 (Ok(nodes), Ok(edges), Ok(files)) => {
9601 report.push_check(GraphDbDoctorCheck {
9602 name: "tokensave_counts".to_string(),
9603 status: "ok".to_string(),
9604 fail_closed: false,
9605 diagnostics: vec![format!(
9606 "tokensave contains {} node(s), {} edge(s), {} file(s)",
9607 nodes, edges, files
9608 )],
9609 repair_commands: Vec::new(),
9610 });
9611 }
9612 (nodes, edges, files) => {
9613 report.push_check(graph_db_doctor_check(
9614 "tokensave_counts",
9615 vec![format!(
9616 "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
9617 nodes.err(),
9618 edges.err(),
9619 files.err()
9620 )],
9621 Vec::new(),
9622 ));
9623 }
9624 }
9625 }
9626 Ok(None) => report.push_check(graph_db_doctor_check(
9627 "tokensave_db_exists",
9628 vec![format!(
9629 "tokensave database is missing at {}",
9630 root.join(".tokensave").join("tokensave.db").display()
9631 )],
9632 Vec::new(),
9633 )),
9634 Err(err) => report.push_check(graph_db_doctor_check(
9635 "tokensave_db_open",
9636 vec![err.to_string()],
9637 Vec::new(),
9638 )),
9639 }
9640}
9641
9642const GRAPH_DB_EVIDENCE_TARGET_KINDS: &[&str] = &[
9643 "backlog",
9644 "job_packet",
9645 "worker_result",
9646 "worker_context",
9647 "source_handle",
9648];
9649
9650pub(crate) fn graph_db_evidence_preferred_path(root: &Path, path_hint: &Path) -> Option<String> {
9651 hinted_markdown_file(root, path_hint).map(|path| {
9652 relativize_pathbuf(&path, root)
9653 .to_string_lossy()
9654 .replace('\\', "/")
9655 })
9656}
9657
9658fn graph_db_ambiguous_target_message(
9659 target: &str,
9660 kind: &str,
9661 candidates: &[SubstrateGraphNode],
9662) -> String {
9663 let mut by_path = BTreeMap::<String, String>::new();
9664 for candidate in candidates.iter().filter(|node| node.kind == kind) {
9665 let path = candidate
9666 .properties
9667 .get("path")
9668 .cloned()
9669 .unwrap_or_else(|| "<no path>".to_string());
9670 by_path.entry(path).or_insert_with(|| candidate.id.clone());
9671 }
9672 let examples = by_path
9673 .iter()
9674 .take(5)
9675 .map(|(path, node_id)| format!("{node_id} path={path}"))
9676 .collect::<Vec<_>>()
9677 .join(", ");
9678 format!(
9679 "graph-db evidence target {target} is ambiguous across {} {kind} node paths: {examples}; rerun with --path <agent-doc.md> or use an exact graph node id",
9680 by_path.len()
9681 )
9682}
9683
9684pub(crate) fn graph_db_resolve_evidence_target_with_path(
9685 store: &impl GraphStore,
9686 target: &str,
9687 preferred_path: Option<&str>,
9688) -> Result<Option<SubstrateGraphNode>> {
9689 if let Some(node) = store.node(target)? {
9690 return Ok(Some(node));
9691 }
9692 let candidates =
9693 store.evidence_target_candidates(target, GRAPH_DB_EVIDENCE_TARGET_KINDS, preferred_path)?;
9694 if candidates.is_empty() {
9695 return Ok(None);
9696 }
9697 if preferred_path.is_none() {
9698 let first_kind = candidates[0].kind.as_str();
9699 let distinct_paths = candidates
9700 .iter()
9701 .filter(|node| node.kind == first_kind)
9702 .map(|node| {
9703 node.properties
9704 .get("path")
9705 .map(String::as_str)
9706 .unwrap_or("")
9707 })
9708 .collect::<BTreeSet<_>>();
9709 if distinct_paths.len() > 1 {
9710 bail!(
9711 "{}",
9712 graph_db_ambiguous_target_message(target, first_kind, &candidates)
9713 );
9714 }
9715 }
9716 Ok(candidates.into_iter().next())
9717}
9718
9719pub(crate) fn graph_db_resolve_evidence_target(
9720 store: &impl GraphStore,
9721 target: &str,
9722) -> Result<Option<SubstrateGraphNode>> {
9723 graph_db_resolve_evidence_target_with_path(store, target, None)
9724}
9725
9726fn graph_db_reachable_nodes_by_kind(
9727 store: &impl GraphStore,
9728 from_id: &str,
9729 kind: &str,
9730 depth: usize,
9731 limit: usize,
9732) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
9733 store.reachable_nodes_by_kind(from_id, kind, depth, limit)
9734}
9735
9736fn graph_db_evidence_completed_queue_drift_warnings(
9737 store: &impl GraphStore,
9738 target: &SubstrateGraphNode,
9739 worker_results: &[SubstrateGraphNode],
9740) -> Result<Vec<String>> {
9741 let ref_id = target.properties.get("ref_id").map(String::as_str);
9742 let has_completed_result = worker_results.iter().any(|node| {
9743 node.properties.get("status").map(String::as_str) == Some("completed")
9744 && node.properties.get("ref_id").map(String::as_str) == ref_id
9745 });
9746 if !has_completed_result {
9747 return Ok(Vec::new());
9748 }
9749 let active_jobs = store
9750 .nodes_by_kind("job_packet")?
9751 .into_iter()
9752 .filter(|node| {
9753 node.properties.get("ref_id").map(String::as_str) == ref_id
9754 && node.label.starts_with("do #")
9755 })
9756 .collect::<Vec<_>>();
9757 if active_jobs.is_empty() {
9758 return Ok(Vec::new());
9759 }
9760 let repair = match (target.properties.get("path"), ref_id) {
9761 (Some(path), Some(id)) => format!(
9762 "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9763 shell_quote(path),
9764 shell_quote(id),
9765 shell_quote(id)
9766 ),
9767 _ => {
9768 "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9769 }
9770 };
9771 Ok(vec![format!(
9772 "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",
9773 target.label,
9774 active_jobs.len()
9775 )])
9776}
9777
9778fn graph_db_evidence_next_commands(
9779 root: &Path,
9780 scope: Option<&str>,
9781 target: &SubstrateGraphNode,
9782 worker_context: &[SubstrateGraphNode],
9783 source_handles: &[SubstrateGraphNode],
9784 worker_results: &[SubstrateGraphNode],
9785 semantic_related: &[SubstrateGraphNode],
9786) -> Vec<String> {
9787 let mut commands = BTreeSet::new();
9788 if let Some(expand) = target.properties.get("expand") {
9789 commands.insert(expand.clone());
9790 }
9791 for worker in worker_context {
9792 if let Some(expand) = worker.properties.get("expand") {
9793 commands.insert(expand.clone());
9794 }
9795 }
9796 for source in source_handles {
9797 if let Some(expand) = source.properties.get("expand") {
9798 commands.insert(expand.clone());
9799 }
9800 }
9801 for result in worker_results {
9802 if let Some(expand) = result.properties.get("expand") {
9803 commands.insert(expand.clone());
9804 }
9805 }
9806 for semantic in semantic_related {
9807 if let Some(expand) = semantic.properties.get("expand") {
9808 commands.insert(expand.clone());
9809 }
9810 }
9811 commands.insert(format!(
9812 "tsift graph-db --path {}{} status --json",
9813 shell_quote(root.to_string_lossy().as_ref()),
9814 graph_db_scope_arg(scope)
9815 ));
9816 commands.insert(format!(
9817 "tsift graph-db --path {}{} doctor --json",
9818 shell_quote(root.to_string_lossy().as_ref()),
9819 graph_db_scope_arg(scope)
9820 ));
9821 commands.into_iter().collect()
9822}
9823
9824fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9825 vec![
9826 format!(
9827 "tsift graph-db --path {}{} refresh --json",
9828 shell_quote(root.to_string_lossy().as_ref()),
9829 graph_db_scope_arg(scope)
9830 ),
9831 format!(
9832 "tsift graph-db --path {}{} doctor --json",
9833 shell_quote(root.to_string_lossy().as_ref()),
9834 graph_db_scope_arg(scope)
9835 ),
9836 ]
9837}
9838
9839fn graph_db_evidence_replay_commands(
9840 root: &Path,
9841 scope: Option<&str>,
9842 target: &str,
9843 depth: usize,
9844 limit: usize,
9845) -> Vec<String> {
9846 vec![
9847 format!(
9848 "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9849 shell_quote(root.to_string_lossy().as_ref()),
9850 graph_db_scope_arg(scope),
9851 shell_quote(target),
9852 depth,
9853 limit
9854 ),
9855 format!(
9856 "tsift conflict-matrix --path {} {} --json",
9857 shell_quote(root.to_string_lossy().as_ref()),
9858 shell_quote(target)
9859 ),
9860 ]
9861}
9862
9863fn graph_db_evidence_packet_id(
9864 target: &str,
9865 target_node: &SubstrateGraphNode,
9866 freshness: &GraphDbFreshnessReport,
9867) -> String {
9868 stable_handle(
9869 "gevd",
9870 &format!(
9871 "{}:{}:{}:{}",
9872 GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9873 target,
9874 target_node.id,
9875 freshness.content_hash.as_deref().unwrap_or("no-hash")
9876 ),
9877 )
9878}
9879
9880pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9881 input: GraphDbEvidenceInput<'_, S>,
9882) -> Result<GraphDbEvidenceReport> {
9883 let GraphDbEvidenceInput {
9884 root,
9885 scope,
9886 backend,
9887 target,
9888 preferred_path,
9889 depth,
9890 limit,
9891 cursor,
9892 store,
9893 freshness,
9894 mut warnings,
9895 } = input;
9896 let repair_commands = graph_db_repair_commands(root, scope);
9897 if freshness.fail_closed {
9898 bail!(
9899 "graph database evidence failed closed for {} backend: {}; repair: {}",
9900 backend,
9901 freshness.diagnostics.join("; "),
9902 repair_commands.join("; ")
9903 );
9904 }
9905 let semantic_readiness =
9906 graph_db_semantic_readiness(root, scope, graph_store_semantic_node_count(store).ok());
9907 if semantic_readiness.fail_closed {
9908 warnings.push(format!(
9909 "graph evidence semantic readiness blocked: {} — {}",
9910 semantic_readiness.reason,
9911 semantic_readiness.diagnostics.join("; ")
9912 ));
9913 warnings.push(format!(
9914 "repair: {}",
9915 semantic_readiness.next_commands.join("; then ")
9916 ));
9917 }
9918 let target_node = graph_db_resolve_evidence_target_with_path(store, target, preferred_path)?
9919 .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9920 let max_rows = if limit == 0 { usize::MAX } else { limit };
9921 let mut reachable = store.reachable_nodes_by_kinds(
9922 &target_node.id,
9923 &[
9924 "worker_context",
9925 "source_handle",
9926 "worker_result",
9927 "semantic_concept",
9928 "semantic_entity",
9929 ],
9930 depth,
9931 max_rows,
9932 )?;
9933 let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9934 let source_paths = reachable.remove("source_handle").unwrap_or_default();
9935 let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9936 let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9937 semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9938 semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9939 left_path
9940 .hops
9941 .cmp(&right_path.hops)
9942 .then(left_node.kind.cmp(&right_node.kind))
9943 .then(left_node.label.cmp(&right_node.label))
9944 .then(left_node.id.cmp(&right_node.id))
9945 });
9946 if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9947 semantic_paths.truncate(max_rows);
9948 }
9949
9950 let evidence_nodes = worker_paths
9951 .iter()
9952 .chain(source_paths.iter())
9953 .chain(worker_result_paths.iter())
9954 .chain(semantic_paths.iter())
9955 .map(|(node, _)| node.clone())
9956 .collect::<Vec<_>>();
9957 let evidence_depth_by_id = worker_paths
9958 .iter()
9959 .chain(source_paths.iter())
9960 .chain(worker_result_paths.iter())
9961 .chain(semantic_paths.iter())
9962 .map(|(node, path)| (node.id.clone(), path.hops))
9963 .collect::<BTreeMap<_, _>>();
9964 let target_query = graph_db_node_search_text(&target_node);
9965 let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9966 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9967 std::slice::from_ref(&target_node.id),
9968 &semantic_scores,
9969 evidence_nodes,
9970 Vec::new(),
9971 Some(limit),
9972 Some(&evidence_depth_by_id),
9973 cursor,
9974 );
9975 let output_budget = budgeted.report;
9976 let truncated = budgeted.truncated;
9977 let next_cursor = budgeted.next_cursor;
9978 let retained_evidence_ids = budgeted
9979 .nodes
9980 .iter()
9981 .map(|node| node.id.as_str())
9982 .collect::<BTreeSet<_>>();
9983 let worker_context = worker_paths
9984 .iter()
9985 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9986 .map(|(node, _)| node.clone())
9987 .collect::<Vec<_>>();
9988 let source_handles = source_paths
9989 .iter()
9990 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9991 .map(|(node, _)| node.clone())
9992 .collect::<Vec<_>>();
9993 let worker_results = worker_result_paths
9994 .iter()
9995 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9996 .map(|(node, _)| node.clone())
9997 .collect::<Vec<_>>();
9998 let semantic_related = semantic_paths
9999 .iter()
10000 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
10001 .map(|(node, _)| node.clone())
10002 .collect::<Vec<_>>();
10003 warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
10004 store,
10005 &target_node,
10006 &worker_results,
10007 )?);
10008 if worker_context.is_empty()
10009 && source_handles.is_empty()
10010 && worker_results.is_empty()
10011 && semantic_related.is_empty()
10012 {
10013 warnings.push(format!(
10014 "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",
10015 target, target_node.kind
10016 ));
10017 }
10018 let shortest_paths = worker_paths
10019 .iter()
10020 .chain(source_paths.iter())
10021 .chain(worker_result_paths.iter())
10022 .chain(semantic_paths.iter())
10023 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
10024 .map(|(node, path)| GraphDbEvidencePath {
10025 to: node.id.clone(),
10026 kind: node.kind.clone(),
10027 label: node.label.clone(),
10028 path: Some(path.clone()),
10029 expand: node.properties.get("expand").cloned(),
10030 })
10031 .collect::<Vec<_>>();
10032 let next_commands = graph_db_evidence_next_commands(
10033 root,
10034 scope,
10035 &target_node,
10036 &worker_context,
10037 &source_handles,
10038 &worker_results,
10039 &semantic_related,
10040 );
10041 let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
10042 let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
10043 let projection_hash = freshness.content_hash.clone();
10044
10045 Ok(GraphDbEvidenceReport {
10046 root: root.to_string_lossy().to_string(),
10047 scope: scope.map(str::to_string),
10048 backend: backend.to_string(),
10049 contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
10050 target: target.to_string(),
10051 packet_id,
10052 projection_hash,
10053 freshness,
10054 target_node: target_node.into(),
10055 worker_context: worker_context.into_iter().map(Into::into).collect(),
10056 source_handles: source_handles.into_iter().map(Into::into).collect(),
10057 worker_results: worker_results.into_iter().map(Into::into).collect(),
10058 semantic_related: semantic_related.into_iter().map(Into::into).collect(),
10059 shortest_paths,
10060 output_budget: Some(output_budget),
10061 truncated,
10062 next_cursor,
10063 next_commands,
10064 replay_commands,
10065 repair_commands,
10066 fixture_coverage: GraphDbFixtureCoverage {
10067 test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
10068 .to_string(),
10069 fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
10070 assertions: vec![
10071 "backlog id and job packet handle resolve to graph nodes".to_string(),
10072 "worker_context rows are reachable from queued work".to_string(),
10073 "source_handle rows are reachable through bounded shortest paths".to_string(),
10074 "worker_result rows are reachable from completed or blocked work".to_string(),
10075 ],
10076 },
10077 warnings,
10078 })
10079}
10080
10081fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
10082 println!(
10083 "graph-db evidence backend: {} target: {} [{}] packet:{}",
10084 report.backend, report.target_node.id, report.target_node.kind, report.packet_id
10085 );
10086 let page_info = if report.truncated {
10087 let cursor = report.next_cursor.as_deref().unwrap_or("?");
10088 format!(" (truncated, next_cursor: {cursor})")
10089 } else {
10090 String::new()
10091 };
10092 println!(
10093 "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
10094 report.worker_context.len(),
10095 report.source_handles.len(),
10096 report.worker_results.len(),
10097 report.semantic_related.len(),
10098 report.shortest_paths.len()
10099 );
10100 for path in &report.shortest_paths {
10101 if let Some(graph_path) = &path.path {
10102 println!(
10103 "path: {} hop(s) {}",
10104 graph_path.hops,
10105 graph_path.nodes.join(" -> ")
10106 );
10107 }
10108 }
10109 for command in &report.next_commands {
10110 println!("next: {command}");
10111 }
10112 for warning in &report.warnings {
10113 println!("warning: {warning}");
10114 }
10115}
10116
10117pub(crate) fn print_graph_db_evidence_report(
10118 report: &GraphDbEvidenceReport,
10119 format: OutputFormat,
10120) -> Result<()> {
10121 if format.json_output {
10122 let page_info = if report.truncated {
10123 let cursor = report.next_cursor.as_deref().unwrap_or("?");
10124 format!(" (truncated, next_cursor: {cursor})")
10125 } else {
10126 String::new()
10127 };
10128 print_json_or_envelope(
10129 report,
10130 &format,
10131 "graph-db",
10132 "evidence",
10133 ToolEnvelopeSummary {
10134 text: format!(
10135 "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}",
10136 report.target,
10137 report.worker_context.len(),
10138 report.source_handles.len(),
10139 report.worker_results.len(),
10140 report.semantic_related.len(),
10141 report.shortest_paths.len()
10142 ),
10143 metrics: vec![
10144 envelope_metric("backend", &report.backend),
10145 envelope_metric("worker_context", report.worker_context.len()),
10146 envelope_metric("source_handles", report.source_handles.len()),
10147 envelope_metric("worker_results", report.worker_results.len()),
10148 envelope_metric("semantic_related", report.semantic_related.len()),
10149 envelope_metric("paths", report.shortest_paths.len()),
10150 ],
10151 },
10152 report.truncated,
10153 report.next_commands.clone(),
10154 )
10155 } else {
10156 print_graph_db_evidence_human(report);
10157 Ok(())
10158 }
10159}
10160
10161pub(crate) fn graph_db_report_from_store(
10162 root: &Path,
10163 scope: Option<&str>,
10164 backend: &str,
10165 query: GraphDbQuery,
10166 store: &impl GraphStore,
10167 freshness: GraphDbFreshnessReport,
10168 warnings: Vec<String>,
10169) -> Result<GraphDbReport> {
10170 if freshness.fail_closed {
10171 bail!(
10172 "graph database read failed closed for {} backend: {}",
10173 backend,
10174 freshness.diagnostics.join("; ")
10175 );
10176 }
10177 let mut report = GraphDbReport {
10178 root: root.to_string_lossy().to_string(),
10179 scope: scope.map(str::to_string),
10180 backend: backend.to_string(),
10181 query: format!("{query:?}"),
10182 freshness,
10183 readiness: None,
10184 schema: None,
10185 node: None,
10186 edge: None,
10187 nodes: Vec::new(),
10188 edges: Vec::new(),
10189 ranked_neighbors: Vec::new(),
10190 semantic_related: Vec::new(),
10191 neighborhood_ranking_gate: None,
10192 ranked_neighborhood_comparison: None,
10193 knowledge_retrieval: None,
10194 output_budget: None,
10195 path: None,
10196 page: None,
10197 warnings,
10198 };
10199
10200 match query {
10201 GraphDbQuery::Refresh => {
10202 bail!("graph-db refresh must be handled by the refresh command path");
10203 }
10204 GraphDbQuery::Status => {
10205 bail!("graph-db status must be handled by the status command path");
10206 }
10207 GraphDbQuery::Doctor => {
10208 bail!("graph-db doctor must be handled by the doctor command path");
10209 }
10210 GraphDbQuery::Drift => {
10211 bail!("graph-db drift must be handled by the drift command path");
10212 }
10213 GraphDbQuery::Compact { .. } => {
10214 bail!("graph-db compact must be handled by the compact command path");
10215 }
10216 GraphDbQuery::SnapshotExport { .. } => {
10217 bail!("graph-db snapshot-export must be handled by the snapshot command path");
10218 }
10219 GraphDbQuery::SnapshotImport { .. } => {
10220 bail!("graph-db snapshot-import must be handled by the snapshot command path");
10221 }
10222 GraphDbQuery::BackendEval { .. } => {
10223 bail!("graph-db backend-eval must be handled by the benchmark command path");
10224 }
10225 GraphDbQuery::Evidence { .. } => {
10226 bail!("graph-db evidence must be handled by the evidence command path");
10227 }
10228 GraphDbQuery::Related {
10229 query,
10230 kind,
10231 depth,
10232 seed_limit,
10233 limit,
10234 } => {
10235 let semantic =
10236 semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
10237 let SemanticRelatedReport {
10238 items,
10239 warnings: semantic_warnings,
10240 ..
10241 } = semantic;
10242 let readiness = graph_db_semantic_readiness(
10243 root,
10244 scope,
10245 (!items.is_empty()).then_some(items.len()),
10246 );
10247 report.warnings.extend(semantic_warnings);
10248 let seed_ids = items
10249 .iter()
10250 .map(|item| item.handle.clone())
10251 .collect::<Vec<_>>();
10252 let semantic_scores = items
10253 .iter()
10254 .map(|item| (item.handle.clone(), item.score))
10255 .collect::<BTreeMap<_, _>>();
10256 let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
10257 let seed_count = seed_ids.len();
10258 let mut diagnostics = subgraph.diagnostics;
10259 let budgeted = graph_db_apply_output_budget(
10260 &seed_ids,
10261 &semantic_scores,
10262 subgraph.nodes,
10263 subgraph.edges,
10264 Some(limit),
10265 );
10266 let budget_report = budgeted.report;
10267 let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
10268 diagnostics.extend(budget_report.diagnostics.clone());
10269 diagnostics.extend(readiness.diagnostics.clone());
10270
10271 report.readiness = Some(readiness);
10272 report.semantic_related = items;
10273 if let Some(seed_id) = seed_ids.first() {
10274 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
10275 report.ranked_neighbors = graph_db_ranked_neighbors(
10276 seed_id,
10277 &budgeted.nodes,
10278 &budgeted.edges,
10279 ranked_neighbor_cap,
10280 );
10281 report.neighborhood_ranking_gate =
10282 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10283 }
10284 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10285 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10286 report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
10287 mode: "semantic_seeded_neighborhood".to_string(),
10288 query,
10289 seed_kind: semantic_related_kind_name(kind).to_string(),
10290 seed_limit,
10291 seed_count,
10292 depth,
10293 limit,
10294 node_count: report.nodes.len(),
10295 edge_count: report.edges.len(),
10296 truncated: subgraph.truncated || dropped_by_budget,
10297 traversal: "incident_plus_outgoing_edges".to_string(),
10298 freshness_boundary:
10299 "semantic rows must come from refreshed summary or tsift-memory graph records"
10300 .to_string(),
10301 privacy_boundary:
10302 "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
10303 .to_string(),
10304 diagnostics,
10305 });
10306 report.output_budget = Some(budget_report);
10307 }
10308 GraphDbQuery::Schema => {
10309 report.schema = Some(graph_db_schema());
10310 }
10311 GraphDbQuery::Node { id } => {
10312 report.node = store.node(&id)?.map(Into::into);
10313 }
10314 GraphDbQuery::Edge { id } => {
10315 report.edge = store.edge(&id)?.map(Into::into);
10316 }
10317 GraphDbQuery::Edges {
10318 edge_kind,
10319 cursor,
10320 limit,
10321 property_filters,
10322 } => {
10323 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10324 let paged = store.paged_edges(
10325 edge_kind.as_deref(),
10326 graph_db_query_options_for_store(&options),
10327 )?;
10328 report.edges = paged.edges.into_iter().map(Into::into).collect();
10329 report.page = Some(graph_db_page_report_from_store(
10330 paged.page,
10331 options.property_filters,
10332 ));
10333 }
10334 GraphDbQuery::Incident {
10335 id,
10336 edge_kind,
10337 cursor,
10338 limit,
10339 property_filters,
10340 } => {
10341 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10342 let paged = store.paged_incident_edges(
10343 &id,
10344 edge_kind.as_deref(),
10345 graph_db_query_options_for_store(&options),
10346 )?;
10347 report.edges = paged.edges.into_iter().map(Into::into).collect();
10348 report.page = Some(graph_db_page_report_from_store(
10349 paged.page,
10350 options.property_filters,
10351 ));
10352 }
10353 GraphDbQuery::Kind {
10354 kind,
10355 cursor,
10356 limit,
10357 property_filters,
10358 } => {
10359 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10360 let paged =
10361 store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
10362 report.nodes = paged.nodes.into_iter().map(Into::into).collect();
10363 report.edges = paged.edges.into_iter().map(Into::into).collect();
10364 report.page = Some(graph_db_page_report_from_store(
10365 paged.page,
10366 options.property_filters,
10367 ));
10368 }
10369 GraphDbQuery::Neighborhood {
10370 id,
10371 depth,
10372 edge_kind,
10373 cursor,
10374 limit,
10375 property_filters,
10376 } => {
10377 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10378 if let Some(paged) = store.paged_neighborhood(
10379 &id,
10380 depth,
10381 edge_kind.as_deref(),
10382 graph_db_query_options_for_store(&options),
10383 )? {
10384 let budgeted = graph_db_apply_output_budget(
10385 std::slice::from_ref(&id),
10386 &BTreeMap::new(),
10387 paged.nodes,
10388 paged.edges,
10389 options.limit,
10390 );
10391 let budget_report = budgeted.report;
10392 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
10393 let ranked_neighbors = graph_db_ranked_neighbors(
10394 &id,
10395 &budgeted.nodes,
10396 &budgeted.edges,
10397 ranked_neighbor_cap,
10398 );
10399 let comparison = graph_db_ranked_neighborhood_comparison(
10400 &id,
10401 depth,
10402 edge_kind.as_deref(),
10403 options.limit,
10404 &budgeted.nodes,
10405 &budgeted.edges,
10406 store,
10407 )?;
10408 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10409 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10410 report.ranked_neighbors = ranked_neighbors;
10411 report.neighborhood_ranking_gate =
10412 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10413 let mut page =
10414 graph_db_page_report_from_store(paged.page, options.property_filters);
10415 page.returned_nodes = report.nodes.len();
10416 page.returned_edges = report.edges.len();
10417 page.truncated |= !budget_report.dropped_by_budget.is_empty();
10418 page.diagnostics.extend(budget_report.diagnostics.clone());
10419 report.page = Some(page);
10420 report.output_budget = Some(budget_report);
10421 if let Some(comparison) = comparison {
10422 report.ranked_neighborhood_comparison = Some(comparison);
10423 }
10424 }
10425 }
10426 GraphDbQuery::Path {
10427 from,
10428 to,
10429 edge_kind,
10430 max_hops,
10431 } => {
10432 report.path =
10433 store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
10434 if let Some(max_hops) = max_hops
10435 && report.path.is_none()
10436 {
10437 report.warnings.push(format!(
10438 "no directed path found within --max-hops {}",
10439 max_hops
10440 ));
10441 }
10442 }
10443 GraphDbQuery::Map { .. } => {
10444 bail!("graph-db map must be handled by the map command path");
10445 }
10446 }
10447 Ok(report)
10448}
10449
10450pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
10451 if compact {
10452 println!(
10453 "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
10454 report.backend,
10455 report.query,
10456 report.nodes.len() + usize::from(report.node.is_some()),
10457 report.edges.len() + usize::from(report.edge.is_some()),
10458 report.freshness.status
10459 );
10460 return;
10461 }
10462 println!("graph-db backend: {}", report.backend);
10463 println!("freshness: {}", report.freshness.status);
10464 if let Some(readiness) = &report.readiness {
10465 println!(
10466 "readiness: {} reason: {} fail_closed: {}",
10467 readiness.status, readiness.reason, readiness.fail_closed
10468 );
10469 for diagnostic in &readiness.diagnostics {
10470 println!("readiness diagnostic: {diagnostic}");
10471 }
10472 for command in &readiness.next_commands {
10473 println!("readiness next: {command}");
10474 }
10475 }
10476 if let Some(schema) = &report.schema {
10477 println!(
10478 "schema: {} node fields, {} edge fields, {} operations",
10479 schema.node_fields.len(),
10480 schema.edge_fields.len(),
10481 schema.operations.len()
10482 );
10483 }
10484 if let Some(node) = &report.node {
10485 println!("node: {} [{}] {}", node.id, node.kind, node.label);
10486 }
10487 if let Some(edge) = &report.edge {
10488 let edge_full: SubstrateGraphEdge = edge.into();
10489 println!(
10490 "edge: {} {} -{}-> {}",
10491 graph_db_edge_key(&edge_full),
10492 edge.from_id,
10493 edge.kind,
10494 edge.to_id
10495 );
10496 }
10497 if let Some(knowledge) = &report.knowledge_retrieval {
10498 println!(
10499 "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
10500 knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
10501 );
10502 }
10503 for item in &report.semantic_related {
10504 println!(
10505 "semantic_seed: {:.3} [{}] {} ({})",
10506 item.score, item.kind, item.label, item.handle
10507 );
10508 }
10509 for node in &report.nodes {
10510 println!("node: {} [{}] {}", node.id, node.kind, node.label);
10511 }
10512 for edge in &report.edges {
10513 let edge_full: SubstrateGraphEdge = edge.into();
10514 println!(
10515 "edge: {} {} -{}-> {}",
10516 graph_db_edge_key(&edge_full),
10517 edge.from_id,
10518 edge.kind,
10519 edge.to_id
10520 );
10521 }
10522 for neighbor in &report.ranked_neighbors {
10523 println!(
10524 "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
10525 neighbor.rank,
10526 neighbor.score,
10527 neighbor
10528 .depth
10529 .map(|depth| depth.to_string())
10530 .unwrap_or_else(|| "unknown".to_string()),
10531 neighbor.node_id,
10532 neighbor.kind,
10533 neighbor.label
10534 );
10535 }
10536 if let Some(gate) = &report.neighborhood_ranking_gate {
10537 println!(
10538 "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
10539 gate.status, gate.default_order, gate.ranked_output_default
10540 );
10541 }
10542 if let Some(path) = &report.path {
10543 println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
10544 }
10545 if let Some(page) = &report.page {
10546 if let Some(next_cursor) = &page.next_cursor {
10547 println!("next_cursor: {next_cursor}");
10548 }
10549 for diagnostic in &page.diagnostics {
10550 println!("page: {diagnostic}");
10551 }
10552 }
10553 for warning in &report.warnings {
10554 println!("warning: {warning}");
10555 }
10556}
10557
10558pub(crate) fn graph_db_backend_eval_phase_timing(
10559 name: &str,
10560 duration_micros: u128,
10561 detail: &str,
10562) -> GraphDbBackendEvalPhaseTiming {
10563 GraphDbBackendEvalPhaseTiming {
10564 name: name.to_string(),
10565 duration_micros,
10566 detail: detail.to_string(),
10567 }
10568}
10569
10570pub(crate) fn graph_db_backend_eval_timed_phase<T>(
10571 phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
10572 name: &str,
10573 detail: &str,
10574 run: impl FnOnce() -> Result<T>,
10575) -> Result<T> {
10576 let started = Instant::now();
10577 let result = run();
10578 phases.push(graph_db_backend_eval_phase_timing(
10579 name,
10580 started.elapsed().as_micros(),
10581 detail,
10582 ));
10583 result
10584}
10585
10586pub(crate) fn graph_db_backend_eval_refresh_total_micros(
10587 phases: &[GraphDbBackendEvalPhaseTiming],
10588) -> u128 {
10589 phases
10590 .iter()
10591 .filter(|phase| phase.name != "conflict_matrix_preparation")
10592 .map(|phase| phase.duration_micros)
10593 .sum()
10594}
10595
10596pub(crate) fn graph_db_backend_eval_cached_refresh(
10597 root: &Path,
10598 scope: Option<&str>,
10599 source_watermark: Option<&str>,
10600) -> Result<
10601 Option<(
10602 TraversalGraphBuild,
10603 SqliteProjectionRefresh,
10604 Vec<GraphDbBackendEvalPhaseTiming>,
10605 )>,
10606> {
10607 let Some(source_watermark) = source_watermark else {
10608 return Ok(None);
10609 };
10610 let graph_db = graph_substrate_db_path(root, scope);
10611 if !graph_db.exists() {
10612 return Ok(None);
10613 }
10614
10615 let started = Instant::now();
10616 let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
10617 Ok(store) => store,
10618 Err(_) => return Ok(None),
10619 };
10620 if store.has_user_triggers().unwrap_or(true) {
10621 return Ok(None);
10622 }
10623 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
10624 if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
10625 return Ok(None);
10626 }
10627
10628 let phases = vec![
10629 graph_db_backend_eval_phase_timing(
10630 "source_graph_build",
10631 started.elapsed().as_micros(),
10632 "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10633 ),
10634 graph_db_backend_eval_phase_timing(
10635 "projection_rows",
10636 0,
10637 "reused cached provider-neutral projection rows from graph.db",
10638 ),
10639 graph_db_backend_eval_phase_timing(
10640 "sqlite_open",
10641 0,
10642 "reused existing graph.db projection without opening a write transaction",
10643 ),
10644 ];
10645 let refresh = SqliteProjectionRefresh {
10646 scope: scope.unwrap_or("root").to_string(),
10647 projection_version: freshness
10648 .projection_version
10649 .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
10650 source_watermark: Some(source_watermark.to_string()),
10651 tombstoned_nodes: Vec::new(),
10652 tombstoned_edges: Vec::new(),
10653 upserted_nodes: 0,
10654 upserted_edges: 0,
10655 unchanged_nodes: 0,
10656 unchanged_edges: 0,
10657 upserted_properties: 0,
10658 unchanged_properties: 0,
10659 deleted_properties: 0,
10660 deleted_nodes: 0,
10661 deleted_edges: 0,
10662 pruned_tombstones: 0,
10663 file_size_bytes_before: None,
10664 file_size_bytes_after: None,
10665 phase_timings: Vec::new(),
10666 };
10667 Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
10668}
10669
10670pub(crate) fn graph_db_backend_eval_reused_cached_projection(
10671 phases: &[GraphDbBackendEvalPhaseTiming],
10672) -> bool {
10673 phases.iter().any(|phase| {
10674 phase.name == "source_graph_build"
10675 && phase.detail.contains("reused current graph.db projection")
10676 })
10677}
10678
10679pub(crate) fn graph_db_backend_eval_update_source_watermark(
10680 root: &Path,
10681 path_hint: &Path,
10682 scope: Option<&str>,
10683) -> Result<()> {
10684 let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
10685 return Ok(());
10686 };
10687 let graph_db = graph_substrate_db_path(root, scope);
10688 let mut store = SqliteGraphStore::open(&graph_db)?;
10689 store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
10690 Ok(())
10691}
10692
10693pub(crate) fn graph_db_backend_eval_refresh_with_profile(
10694 root: &Path,
10695 path_hint: &Path,
10696 scope: Option<&str>,
10697) -> Result<(
10698 TraversalGraphBuild,
10699 SqliteProjectionRefresh,
10700 Vec<GraphDbBackendEvalPhaseTiming>,
10701)> {
10702 let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
10703 if let Some(cached) =
10704 graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
10705 {
10706 return Ok(cached);
10707 }
10708
10709 let mut phases = Vec::new();
10710 let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
10711 "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"
10712 } else {
10713 "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
10714 };
10715 let source_graph = graph_db_backend_eval_timed_phase(
10716 &mut phases,
10717 "source_graph_build",
10718 source_graph_detail,
10719 || build_traversal_graph_source_with_options(root, path_hint, scope, false),
10720 )?;
10721 let projection = graph_db_backend_eval_timed_phase(
10722 &mut phases,
10723 "projection_rows",
10724 "provider-neutral GraphStore node/edge row construction before SQLite persistence",
10725 || traversal_projection_from_graph(root, scope, &source_graph),
10726 )?;
10727 let graph_db = graph_substrate_db_path(root, scope);
10728 let mut store = graph_db_backend_eval_timed_phase(
10729 &mut phases,
10730 "sqlite_open",
10731 "open the local SQLite graph.db with WAL and busy-timeout settings",
10732 || SqliteGraphStore::open(&graph_db),
10733 )?;
10734 let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
10735 .ok()
10736 .flatten();
10737 let refresh = store.replace_projection_with_version(
10738 scope.unwrap_or("root"),
10739 &projection,
10740 Some(GRAPH_PROJECTION_VERSION),
10741 refreshed_source_watermark
10742 .or(source_watermark)
10743 .or_else(|| graph_projection_content_hash(&projection)),
10744 )?;
10745 phases.extend(
10746 refresh
10747 .phase_timings
10748 .iter()
10749 .map(|phase| GraphDbBackendEvalPhaseTiming {
10750 name: phase.name.clone(),
10751 duration_micros: phase.duration_micros,
10752 detail: phase.detail.clone(),
10753 }),
10754 );
10755 Ok((source_graph, refresh, phases))
10756}
10757
10758fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10759 root.join(".tsift/backend-eval-cache")
10760}
10761
10762fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10763 graph_db_backend_eval_disk_cache_dir(root)
10764 .join(kind)
10765 .join(format!("{key}.json.gz"))
10766}
10767
10768fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10769 graph_db_backend_eval_disk_cache_dir(root)
10770 .join(kind)
10771 .join(format!("{key}.json"))
10772}
10773
10774#[derive(Default, Clone)]
10775struct GraphDbBackendEvalDiskCacheReadProfile {
10776 file_read_micros: u128,
10777 gzip_decode_micros: u128,
10778 serde_decode_micros: u128,
10779 legacy: bool,
10780}
10781
10782fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10783 root: &Path,
10784 kind: &str,
10785 key: &str,
10786) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10787 let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10788 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10789 let read_started = Instant::now();
10790 let read_result = fs::read(&path);
10791 profile.file_read_micros = read_started.elapsed().as_micros();
10792 if let Ok(bytes) = read_result {
10793 let decode_started = Instant::now();
10794 let mut decoder = GzDecoder::new(bytes.as_slice());
10795 let mut decoded = Vec::new();
10796 let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10797 profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10798 if decode_ok {
10799 let serde_started = Instant::now();
10800 let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10801 profile.serde_decode_micros = serde_started.elapsed().as_micros();
10802 if let Some(value) = parsed {
10803 return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10804 }
10805 }
10806 }
10807
10808 let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10809 let legacy_started = Instant::now();
10810 let bytes = fs::read(legacy_path).ok()?;
10811 profile.file_read_micros = profile
10812 .file_read_micros
10813 .saturating_add(legacy_started.elapsed().as_micros());
10814 let serde_started = Instant::now();
10815 let value = serde_json::from_slice(&bytes).ok()?;
10816 profile.serde_decode_micros = profile
10817 .serde_decode_micros
10818 .saturating_add(serde_started.elapsed().as_micros());
10819 profile.legacy = true;
10820 Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10821}
10822
10823#[derive(Default, Clone)]
10824struct GraphDbBackendEvalDiskCacheWriteProfile {
10825 serde_encode_micros: u128,
10826 gzip_encode_micros: u128,
10827 file_write_micros: u128,
10828}
10829
10830fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10831 root: &Path,
10832 kind: &str,
10833 key: &str,
10834 value: &T,
10835) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10836 let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10837 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10838 let parent = path.parent()?;
10839 if fs::create_dir_all(parent).is_err() {
10840 return None;
10841 }
10842 let serde_started = Instant::now();
10843 let bytes = serde_json::to_vec(value).ok()?;
10844 profile.serde_encode_micros = serde_started.elapsed().as_micros();
10845 let gzip_started = Instant::now();
10846 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10847 if encoder.write_all(&bytes).is_err() {
10848 return None;
10849 }
10850 let encoded = encoder.finish().ok()?;
10851 profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10852 let write_started = Instant::now();
10853 if fs::write(&path, &encoded).is_err() {
10854 return None;
10855 }
10856 profile.file_write_micros = write_started.elapsed().as_micros();
10857 Some((encoded.len() as u64, bytes.len() as u64, profile))
10858}
10859
10860fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10861 let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10862 let Ok(entries) = fs::read_dir(dir) else {
10863 return (0, 0);
10864 };
10865 let keep_name = format!("{keep_key}.json.gz");
10866 let mut pruned_files = 0usize;
10867 let mut pruned_bytes = 0u64;
10868 for entry in entries.flatten() {
10869 let path = entry.path();
10870 if !path.is_file() {
10871 continue;
10872 }
10873 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10874 continue;
10875 };
10876 if name == keep_name {
10877 continue;
10878 }
10879 let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10880 if !is_backend_eval_cache {
10881 continue;
10882 }
10883 let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10884 if fs::remove_file(&path).is_ok() {
10885 pruned_files += 1;
10886 pruned_bytes += bytes;
10887 }
10888 }
10889 (pruned_files, pruned_bytes)
10890}
10891
10892fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10893 root: &Path,
10894 source_root: &Path,
10895) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10896 let mut rows = Vec::new();
10897 let mut entries = walk::walk_files(source_root)?;
10898 entries.sort_by(|left, right| left.path.cmp(&right.path));
10899 for entry in entries {
10900 if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10901 continue;
10902 }
10903 if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10904 continue;
10905 }
10906 let bytes = fs::read(&entry.path)
10907 .with_context(|| format!("reading source input {}", entry.path.display()))?;
10908 rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10909 path: traversal_watermark_path(root, &entry.path),
10910 bytes: bytes.len() as u64,
10911 content_hash: content_hash(&bytes)?,
10912 });
10913 }
10914 Ok(rows)
10915}
10916
10917fn graph_db_backend_eval_full_projection_source_watermark(
10918 root: &Path,
10919 scope: Option<&str>,
10920) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10921 let path_hint = root;
10922 let mut detail_parts = Vec::new();
10923 let mut parts = vec![
10924 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10925 format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10926 "watermark_kind:stable_full_projection_inputs".to_string(),
10927 format!("scope:{}", scope.unwrap_or("root")),
10928 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10929 ];
10930
10931 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10932 match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10933 Some(db_path) => {
10934 let db = index::IndexDb::open_read_only_resilient(db_path)?;
10935 parts.push("index_mode:indexed".to_string());
10936 detail_parts.push("mode=indexed".to_string());
10937 parts.push(format!(
10938 "index_source_root:{}",
10939 traversal_watermark_path(root, &gate.source_root)
10940 ));
10941
10942 let symbols = db
10943 .all_symbols()?
10944 .into_iter()
10945 .filter(|symbol| {
10946 !traversal_path_is_generated_artifact(
10947 root,
10948 &gate.source_root,
10949 Path::new(&symbol.file),
10950 ) && !traversal_path_is_session_markdown(
10951 root,
10952 &gate.source_root,
10953 Path::new(&symbol.file),
10954 )
10955 })
10956 .collect::<Vec<_>>();
10957 let symbols_hash = content_hash(&symbols)?;
10958 detail_parts.push(format!("symbols={symbols_hash}"));
10959 parts.push(format!("index_symbols:{symbols_hash}"));
10960
10961 let edges = db
10962 .all_stored_edges()?
10963 .into_iter()
10964 .filter(|edge| {
10965 !traversal_path_is_generated_artifact(
10966 root,
10967 &gate.source_root,
10968 Path::new(&edge.caller_file),
10969 ) && !traversal_path_is_session_markdown(
10970 root,
10971 &gate.source_root,
10972 Path::new(&edge.caller_file),
10973 )
10974 })
10975 .collect::<Vec<_>>();
10976 let edges_hash = content_hash(&edges)?;
10977 detail_parts.push(format!("call_edges={edges_hash}"));
10978 parts.push(format!("index_call_edges:{edges_hash}"));
10979
10980 let routes = db
10981 .all_routes()?
10982 .into_iter()
10983 .filter(|route| {
10984 !traversal_path_is_generated_artifact(
10985 root,
10986 &gate.source_root,
10987 Path::new(&route.file),
10988 ) && !traversal_path_is_session_markdown(
10989 root,
10990 &gate.source_root,
10991 Path::new(&route.file),
10992 )
10993 })
10994 .collect::<Vec<_>>();
10995 let routes_hash = content_hash(&routes)?;
10996 detail_parts.push(format!("routes={routes_hash}"));
10997 parts.push(format!("index_routes:{routes_hash}"));
10998 }
10999 None => {
11000 parts.push("index_mode:raw_fallback".to_string());
11001 detail_parts.push("mode=raw_fallback".to_string());
11002 parts.push(format!(
11003 "raw_source_root:{}",
11004 traversal_watermark_path(root, &gate.source_root)
11005 ));
11006 let raw_rows =
11007 graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
11008 let raw_hash = content_hash(&raw_rows)?;
11009 detail_parts.push(format!("raw_source_files={raw_hash}"));
11010 parts.push(format!("raw_source_files:{raw_hash}"));
11011 }
11012 }
11013
11014 parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
11015 detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
11016 let summaries_start = parts.len();
11017 push_traversal_summaries_watermark_part(root, &mut parts)?;
11018 let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
11019 detail_parts.push(format!("summaries={summaries_hash}"));
11020 let value = content_hash(&parts)?;
11021 detail_parts.push(format!("watermark={value}"));
11022 Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
11023 value,
11024 detail: detail_parts.join(" "),
11025 })
11026}
11027
11028fn graph_db_backend_eval_full_projection_cache_key(
11029 root: &Path,
11030 scope: Option<&str>,
11031) -> Result<(String, String, String)> {
11032 let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
11033 let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
11034 root,
11035 scope,
11036 &source_watermark.value,
11037 )?;
11038 Ok((source_watermark.value, key, source_watermark.detail))
11039}
11040
11041fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
11042 root: &Path,
11043 scope: Option<&str>,
11044 source_watermark: &str,
11045) -> Result<String> {
11046 content_hash(&serde_json::json!({
11047 "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
11048 "root": root.display().to_string(),
11049 "scope": scope.unwrap_or("root"),
11050 "source_watermark": source_watermark,
11051 }))
11052}
11053
11054pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
11055 root: &Path,
11056 scope: Option<&str>,
11057) -> Result<(
11058 GraphProjection,
11059 Vec<String>,
11060 Vec<GraphDbBackendEvalPhaseTiming>,
11061 GraphDbBackendEvalFullProjectionCacheStats,
11062)> {
11063 let (source_watermark, key, source_watermark_detail) =
11064 graph_db_backend_eval_full_projection_cache_key(root, scope)?;
11065 let lookup_started = Instant::now();
11066 if let Some((cached, disk_bytes, json_bytes, read_profile)) =
11067 graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
11068 root,
11069 "full_projection",
11070 &key,
11071 )
11072 && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
11073 && cached.key == key
11074 && cached.source_watermark == source_watermark
11075 {
11076 let lookup_overhead_micros = lookup_started
11077 .elapsed()
11078 .as_micros()
11079 .saturating_sub(read_profile.file_read_micros)
11080 .saturating_sub(read_profile.gzip_decode_micros)
11081 .saturating_sub(read_profile.serde_decode_micros);
11082 let prune_started = Instant::now();
11083 let (pruned_files, pruned_bytes) =
11084 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
11085 let prune_micros = prune_started.elapsed().as_micros();
11086 let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
11087 hit: true,
11088 disk_bytes,
11089 json_bytes,
11090 pruned_files,
11091 pruned_bytes,
11092 };
11093 let read_detail_suffix = if read_profile.legacy {
11094 " (legacy uncompressed cache path)"
11095 } else {
11096 ""
11097 };
11098 return Ok((
11099 cached.projection,
11100 cached.warnings,
11101 vec![
11102 graph_db_backend_eval_phase_timing(
11103 "full_projection.cache_lookup",
11104 lookup_overhead_micros,
11105 &format!(
11106 "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
11107 ),
11108 ),
11109 graph_db_backend_eval_phase_timing(
11110 "full_projection.cache.file_read",
11111 read_profile.file_read_micros,
11112 &format!(
11113 "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
11114 ),
11115 ),
11116 graph_db_backend_eval_phase_timing(
11117 "full_projection.cache.gzip_decode",
11118 read_profile.gzip_decode_micros,
11119 "gunzip the compressed projection cache bytes",
11120 ),
11121 graph_db_backend_eval_phase_timing(
11122 "full_projection.cache.serde_decode",
11123 read_profile.serde_decode_micros,
11124 "serde_json deserialize the decoded projection cache payload",
11125 ),
11126 graph_db_backend_eval_phase_timing(
11127 "full_projection.cache.prune",
11128 prune_micros,
11129 "prune sibling cache files older than the current key",
11130 ),
11131 graph_db_backend_eval_phase_timing(
11132 "full_projection.source_graph_build",
11133 0,
11134 "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
11135 ),
11136 graph_db_backend_eval_phase_timing(
11137 "full_projection.projection_rows",
11138 0,
11139 "reused cached provider-neutral full-project projection rows",
11140 ),
11141 ],
11142 cache_stats,
11143 ));
11144 }
11145
11146 let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
11147 let mut phases = vec![graph_db_backend_eval_phase_timing(
11148 "full_projection.cache_lookup",
11149 lookup_started.elapsed().as_micros(),
11150 &format!(
11151 "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
11152 ),
11153 )];
11154 let full_source = graph_db_backend_eval_timed_phase(
11155 &mut phases,
11156 "full_projection.source_graph_build",
11157 "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
11158 || build_traversal_graph_source_with_options(root, root, scope, false),
11159 )?;
11160 let projection = graph_db_backend_eval_timed_phase(
11161 &mut phases,
11162 "full_projection.projection_rows",
11163 "provider-neutral row construction for the opt-in full-project projection dataset",
11164 || traversal_projection_from_graph(root, scope, &full_source),
11165 )?;
11166 let warnings = full_source.warnings;
11167 let refreshed_source_watermark =
11168 graph_db_backend_eval_full_projection_source_watermark(root, scope)
11169 .map(|watermark| watermark.value)
11170 .unwrap_or_else(|_| source_watermark.clone());
11171 let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
11172 root,
11173 scope,
11174 &refreshed_source_watermark,
11175 )?;
11176 let cache = GraphDbBackendEvalFullProjectionCache {
11177 version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
11178 key: write_key.clone(),
11179 source_watermark: refreshed_source_watermark,
11180 projection: projection.clone(),
11181 warnings: warnings.clone(),
11182 };
11183 if let Some((disk_bytes, json_bytes, write_profile)) =
11184 graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
11185 {
11186 cache_stats.disk_bytes = disk_bytes;
11187 cache_stats.json_bytes = json_bytes;
11188 phases.push(graph_db_backend_eval_phase_timing(
11189 "full_projection.cache.serde_encode",
11190 write_profile.serde_encode_micros,
11191 "serde_json serialize the projection cache payload before compression",
11192 ));
11193 phases.push(graph_db_backend_eval_phase_timing(
11194 "full_projection.cache.gzip_encode",
11195 write_profile.gzip_encode_micros,
11196 "gzip-compress the serialized projection cache payload",
11197 ));
11198 phases.push(graph_db_backend_eval_phase_timing(
11199 "full_projection.cache.file_write",
11200 write_profile.file_write_micros,
11201 "write the compressed projection cache bytes to .tsift/backend-eval-cache",
11202 ));
11203 }
11204 let prune_started = Instant::now();
11205 let (pruned_files, pruned_bytes) =
11206 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
11207 phases.push(graph_db_backend_eval_phase_timing(
11208 "full_projection.cache.prune",
11209 prune_started.elapsed().as_micros(),
11210 "prune sibling cache files older than the current key",
11211 ));
11212 cache_stats.pruned_files = pruned_files;
11213 cache_stats.pruned_bytes = pruned_bytes;
11214 Ok((projection, warnings, phases, cache_stats))
11215}
11216
11217fn graph_db_backend_eval_timed(
11218 name: &str,
11219 run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
11220) -> (
11221 GraphDbBackendEvalOperation,
11222 Option<GraphDbBackendEvalSignature>,
11223) {
11224 let started = Instant::now();
11225 match run() {
11226 Ok((rows, value)) => (
11227 GraphDbBackendEvalOperation {
11228 name: name.to_string(),
11229 supported: true,
11230 status: "ok".to_string(),
11231 duration_micros: started.elapsed().as_micros(),
11232 rows,
11233 error: None,
11234 },
11235 Some(GraphDbBackendEvalSignature {
11236 operation: name.to_string(),
11237 value,
11238 }),
11239 ),
11240 Err(err) => (
11241 GraphDbBackendEvalOperation {
11242 name: name.to_string(),
11243 supported: false,
11244 status: "error".to_string(),
11245 duration_micros: started.elapsed().as_micros(),
11246 rows: None,
11247 error: Some(format!("{err:#}")),
11248 },
11249 None,
11250 ),
11251 }
11252}
11253
11254fn graph_db_backend_eval_parity(
11255 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11256 candidate_signatures: &[GraphDbBackendEvalSignature],
11257) -> GraphDbBackendEvalParity {
11258 let Some(sqlite_signatures) = sqlite_signatures else {
11259 return GraphDbBackendEvalParity {
11260 matches_sqlite: true,
11261 diagnostics: Vec::new(),
11262 };
11263 };
11264 let sqlite = sqlite_signatures
11265 .iter()
11266 .map(|signature| (signature.operation.as_str(), &signature.value))
11267 .collect::<BTreeMap<_, _>>();
11268 let candidate = candidate_signatures
11269 .iter()
11270 .map(|signature| (signature.operation.as_str(), &signature.value))
11271 .collect::<BTreeMap<_, _>>();
11272 let mut diagnostics = Vec::new();
11273 for (operation, sqlite_value) in sqlite {
11274 match candidate.get(operation) {
11275 Some(candidate_value) if *candidate_value == sqlite_value => {}
11276 Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
11277 None => diagnostics.push(format!(
11278 "{operation} did not complete for candidate backend"
11279 )),
11280 }
11281 }
11282 GraphDbBackendEvalParity {
11283 matches_sqlite: diagnostics.is_empty(),
11284 diagnostics,
11285 }
11286}
11287
11288pub(crate) fn graph_db_backend_eval_targets(
11289 store: &impl GraphStore,
11290 requested: &[String],
11291) -> Result<Vec<String>> {
11292 let requested = requested
11293 .iter()
11294 .filter_map(|target| normalize_conflict_target(target))
11295 .collect::<Vec<_>>();
11296 if !requested.is_empty() {
11297 return Ok(requested);
11298 }
11299
11300 for kind in ["backlog", "job_packet"] {
11301 let nodes = store.nodes_by_kind(kind)?;
11302 if let Some(node) = nodes.first() {
11303 if let Some(ref_id) = node.properties.get("ref_id") {
11304 return Ok(vec![ref_id.clone()]);
11305 }
11306 return Ok(vec![node.id.clone()]);
11307 }
11308 }
11309 Ok(Vec::new())
11310}
11311
11312fn graph_db_backend_eval_path_targets(
11313 store: &impl GraphStore,
11314 max_hops: usize,
11315) -> Result<Option<(String, String, usize)>> {
11316 let synthetic_from = "gsym-synthetic-0000";
11317 let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
11318 if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
11319 let outgoing = store.outgoing_edges(synthetic_from, None)?;
11320 if outgoing.len() > 1
11321 && let Some(edge) = outgoing.first()
11322 {
11323 return Ok(Some((
11324 edge.from_id.clone(),
11325 edge.to_id.clone(),
11326 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11327 )));
11328 }
11329 return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
11330 }
11331
11332 Ok(store.sample_edge(None)?.map(|edge| {
11333 (
11334 edge.from_id,
11335 edge.to_id,
11336 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11337 )
11338 }))
11339}
11340
11341fn graph_db_backend_eval_path_operation<S: GraphStore>(
11342 store: &S,
11343 configured_max_hops: usize,
11344) -> (
11345 GraphDbBackendEvalOperation,
11346 Option<GraphDbBackendEvalSignature>,
11347) {
11348 let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11349 "path_max_hops".to_string()
11350 } else {
11351 format!("path_max_hops_{configured_max_hops}")
11352 };
11353 graph_db_backend_eval_timed(&operation_name, || {
11354 let (from, to, effective_max_hops) =
11355 graph_db_backend_eval_path_targets(store, configured_max_hops)?
11356 .context("backend-eval path probe requires at least one traversable edge")?;
11357 let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
11358 let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11359 Some(format!(
11360 "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
11361 GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
11362 ))
11363 } else if path.is_none() && effective_max_hops == configured_max_hops {
11364 Some(format!(
11365 "path probe truncated at {configured_max_hops} hops before a route was found"
11366 ))
11367 } else {
11368 None
11369 };
11370 Ok((
11371 path.as_ref().map(|path| path.nodes.len()),
11372 serde_json::json!({
11373 "from": from,
11374 "to": to,
11375 "configured_max_hops": configured_max_hops,
11376 "effective_max_hops": effective_max_hops,
11377 "hops": path.as_ref().map(|path| path.hops),
11378 "nodes": path.as_ref().map(|path| &path.nodes),
11379 "found": path.is_some(),
11380 "warning": warning,
11381 }),
11382 ))
11383 })
11384}
11385
11386fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
11387 store: &S,
11388 depth: usize,
11389 limit: usize,
11390) -> (
11391 GraphDbBackendEvalOperation,
11392 Option<GraphDbBackendEvalSignature>,
11393) {
11394 graph_db_backend_eval_timed("neighborhood", || {
11395 let edge = match store.sample_edge(Some("calls"))? {
11396 Some(edge) => edge,
11397 None => store.sample_edge(None)?.context(
11398 "backend-eval neighborhood probe requires at least one traversable edge",
11399 )?,
11400 };
11401 let page = store
11402 .paged_neighborhood(
11403 &edge.from_id,
11404 depth,
11405 Some(&edge.kind),
11406 GraphQueryOptions {
11407 limit: Some(limit.max(1)),
11408 ..GraphQueryOptions::default()
11409 },
11410 )?
11411 .with_context(|| {
11412 format!(
11413 "backend-eval neighborhood target not found: {}",
11414 edge.from_id
11415 )
11416 })?;
11417 Ok((
11418 Some(page.nodes.len() + page.edges.len()),
11419 serde_json::json!({
11420 "center": edge.from_id,
11421 "kind": edge.kind,
11422 "depth": depth,
11423 "limit": limit.max(1),
11424 "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11425 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11426 "truncated": page.page.truncated,
11427 }),
11428 ))
11429 })
11430}
11431
11432fn graph_db_backend_eval_related_operation<S: GraphStore>(
11433 root: &Path,
11434 scope: Option<&str>,
11435 store: &S,
11436 depth: usize,
11437 limit: usize,
11438) -> (
11439 GraphDbBackendEvalOperation,
11440 Option<GraphDbBackendEvalSignature>,
11441) {
11442 graph_db_backend_eval_timed("related", || {
11443 let query = "backend evaluation";
11444 let semantic = semantic_related_report_from_store(
11445 root,
11446 scope,
11447 query,
11448 3,
11449 SemanticRelatedKind::All,
11450 store,
11451 )?;
11452 let seed_ids = semantic
11453 .items
11454 .iter()
11455 .map(|item| item.handle.clone())
11456 .collect::<Vec<_>>();
11457 let subgraph =
11458 graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
11459 Ok((
11460 Some(subgraph.nodes.len() + subgraph.edges.len()),
11461 serde_json::json!({
11462 "query": query,
11463 "seed_ids": seed_ids,
11464 "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11465 "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11466 "truncated": subgraph.truncated,
11467 "warnings": semantic.warnings,
11468 "diagnostics": subgraph.diagnostics,
11469 }),
11470 ))
11471 })
11472}
11473
11474fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
11475 serde_json::json!({
11476 "target": report.target,
11477 "target_node_id": report.target_node.id,
11478 "target_kind": report.target_node.kind,
11479 "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
11480 "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
11481 "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
11482 "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
11483 "path_count": report.shortest_paths.len(),
11484 })
11485}
11486
11487fn graph_db_backend_eval_target_resolution_signature(
11488 resolved: &[(String, SubstrateGraphNode)],
11489) -> serde_json::Value {
11490 serde_json::json!({
11491 "targets": resolved.iter().map(|(target, node)| {
11492 serde_json::json!({
11493 "target": target,
11494 "target_node_id": node.id,
11495 "target_kind": node.kind,
11496 "target_label": node.label,
11497 })
11498 }).collect::<Vec<_>>(),
11499 })
11500}
11501
11502fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
11503 serde_json::json!({
11504 "targets": report.targets,
11505 "can_parallel": report.can_parallel,
11506 "fail_closed": report.fail_closed,
11507 "cross_target_parallel_safe": report.cross_target_parallel_safe,
11508 "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
11509 "candidates": report.candidates.iter().map(|candidate| {
11510 serde_json::json!({
11511 "target": candidate.target,
11512 "risk": conflict_risk_label(candidate.risk),
11513 "owned_files": candidate.owned_files,
11514 "owned_symbols": candidate.owned_symbols,
11515 "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
11516 "previously_completed": candidate.previously_completed,
11517 "parallel_safe": candidate.parallel_safe,
11518 })
11519 }).collect::<Vec<_>>(),
11520 "conflicts": report.conflicts.iter().map(|pair| {
11521 serde_json::json!({
11522 "left": pair.left,
11523 "right": pair.right,
11524 "risk": conflict_risk_label(pair.risk),
11525 })
11526 }).collect::<Vec<_>>(),
11527 })
11528}
11529
11530fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
11531 serde_json::json!({
11532 "targets": report.targets,
11533 "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11534 "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
11535 "evidence_packet_ids": report.evidence_packet_ids,
11536 "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
11537 "truncated": report.truncated,
11538 })
11539}
11540
11541fn graph_db_backend_eval_edge_scan_probe(
11542 store: &impl GraphStore,
11543) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
11544 if let Some((edge, filter)) = store.sample_edge_with_property()? {
11545 return Ok((edge, vec![filter]));
11546 }
11547 let edge = store
11548 .sample_edge(None)?
11549 .context("backend-eval edge scan requires at least one edge")?;
11550 Ok((edge, Vec::new()))
11551}
11552
11553#[allow(clippy::too_many_arguments)]
11554fn graph_db_backend_eval_report_for_store<S: GraphStore>(
11555 backend: &str,
11556 adapter: &str,
11557 read_only: bool,
11558 root: &Path,
11559 path: &Path,
11560 scope: Option<&str>,
11561 targets: &[String],
11562 depth: usize,
11563 limit: usize,
11564 impact_limit: usize,
11565 store: &S,
11566 freshness: GraphDbFreshnessReport,
11567 refresh_operation: GraphDbBackendEvalOperation,
11568 refresh_signature: Option<GraphDbBackendEvalSignature>,
11569 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11570 extra_warnings: Vec<String>,
11571 prepared: &ConflictMatrixPreparedInputs,
11572 projection_load: &str,
11573 lock_behavior: &str,
11574 install_portability: &str,
11575) -> (
11576 GraphDbBackendEvalBackendReport,
11577 Vec<GraphDbBackendEvalSignature>,
11578) {
11579 let mut operations = vec![refresh_operation];
11580 let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
11581
11582 let (operation, signature) = graph_db_backend_eval_timed("status", || {
11583 let (nodes, edges) = store.graph_counts()?;
11584 Ok((
11585 Some(nodes + edges),
11586 serde_json::json!({
11587 "freshness": freshness.status,
11588 "nodes": nodes,
11589 "edges": edges,
11590 }),
11591 ))
11592 });
11593 operations.push(operation);
11594 signatures.extend(signature);
11595
11596 let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
11597 let edge = store
11598 .sample_edge(None)?
11599 .context("backend-eval edge lookup requires at least one edge")?;
11600 let edge_id = graph_db_edge_key(&edge);
11601 let found = store
11602 .edge(&edge_id)?
11603 .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
11604 Ok((
11605 Some(1),
11606 serde_json::json!({
11607 "edge_id": edge_id,
11608 "from_id": found.from_id,
11609 "to_id": found.to_id,
11610 "kind": found.kind,
11611 }),
11612 ))
11613 });
11614 operations.push(operation);
11615 signatures.extend(signature);
11616
11617 let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
11618 let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
11619 let page = store.paged_edges(
11620 Some(&edge.kind),
11621 GraphQueryOptions {
11622 limit: Some(limit.max(1)),
11623 property_filters: filters.clone(),
11624 ..GraphQueryOptions::default()
11625 },
11626 )?;
11627 Ok((
11628 Some(page.edges.len()),
11629 serde_json::json!({
11630 "kind": edge.kind,
11631 "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
11632 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11633 "truncated": page.page.truncated,
11634 }),
11635 ))
11636 });
11637 operations.push(operation);
11638 signatures.extend(signature);
11639
11640 let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
11641 let edge = store
11642 .sample_edge(None)?
11643 .context("backend-eval incident edge scan requires at least one edge")?;
11644 let page = store.paged_incident_edges(
11645 &edge.from_id,
11646 Some(&edge.kind),
11647 GraphQueryOptions {
11648 limit: Some(limit.max(1)),
11649 ..GraphQueryOptions::default()
11650 },
11651 )?;
11652 Ok((
11653 Some(page.edges.len()),
11654 serde_json::json!({
11655 "node_id": edge.from_id,
11656 "kind": edge.kind,
11657 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11658 "truncated": page.page.truncated,
11659 }),
11660 ))
11661 });
11662 operations.push(operation);
11663 signatures.extend(signature);
11664
11665 let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
11666 operations.push(operation);
11667 signatures.extend(signature);
11668
11669 let (operation, signature) =
11670 graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
11671 operations.push(operation);
11672 signatures.extend(signature);
11673
11674 for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
11675 .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
11676 {
11677 let (operation, signature) =
11678 graph_db_backend_eval_path_operation(store, configured_max_hops);
11679 operations.push(operation);
11680 signatures.extend(signature);
11681 }
11682
11683 let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
11684 let resolved = targets
11685 .iter()
11686 .map(|target| {
11687 let node = graph_db_resolve_evidence_target(store, target)?
11688 .with_context(|| format!("backend-eval target not found: {target}"))?;
11689 Ok((target.clone(), node))
11690 })
11691 .collect::<Result<Vec<_>>>()?;
11692 let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
11693 Ok((Some(resolved.len()), signature))
11694 });
11695 operations.push(operation);
11696 signatures.extend(signature);
11697
11698 let mut evidence_for_report = None;
11699 let mut graph_snapshot_for_trace = None;
11700 let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
11701 let resolved_targets =
11702 resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
11703 let evidence = collect_conflict_matrix_evidence_packets(
11704 root,
11705 scope,
11706 backend,
11707 &resolved_targets,
11708 depth,
11709 limit,
11710 store,
11711 freshness.clone(),
11712 )?;
11713 let report = &evidence
11714 .first()
11715 .context("backend-eval evidence requires at least one target")?
11716 .report;
11717 let rows = evidence
11718 .iter()
11719 .map(|entry| {
11720 entry.report.worker_context.len()
11721 + entry.report.source_handles.len()
11722 + entry.report.worker_results.len()
11723 + entry.report.semantic_related.len()
11724 })
11725 .sum();
11726 let signature = graph_db_backend_eval_evidence_signature(report);
11727 evidence_for_report = Some((resolved_targets, evidence));
11728 Ok((Some(rows), signature))
11729 });
11730 operations.push(operation);
11731 signatures.extend(signature);
11732
11733 let mut conflict_for_trace = None;
11734 let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
11735 let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
11736 let graph =
11737 conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
11738 let shared_preparation =
11739 conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
11740 ConflictMatrixGraphPreparedInputs {
11741 targets,
11742 graph,
11743 evidence,
11744 shared_preparation,
11745 }
11746 } else {
11747 prepare_conflict_matrix_graph_orchestration(
11748 root,
11749 scope,
11750 backend,
11751 targets,
11752 prepared,
11753 depth,
11754 limit,
11755 store,
11756 freshness.clone(),
11757 )?
11758 };
11759 let report = build_conflict_matrix_report_from_prepared_graph(
11760 root,
11761 path,
11762 scope,
11763 depth,
11764 limit,
11765 impact_limit,
11766 freshness.clone(),
11767 extra_warnings.clone(),
11768 prepared,
11769 &graph_prepared,
11770 )?;
11771 let signature = graph_db_backend_eval_conflict_signature(&report);
11772 let rows = report.candidates.len() + report.conflicts.len();
11773 conflict_for_trace = Some(report);
11774 graph_snapshot_for_trace = Some(graph_prepared.graph);
11775 Ok((Some(rows), signature))
11776 });
11777 operations.push(operation);
11778 signatures.extend(signature);
11779
11780 let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11781 let conflict = conflict_for_trace
11782 .take()
11783 .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11784 let graph = graph_snapshot_for_trace
11785 .take()
11786 .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11787 let report = build_dispatch_trace_report_from_conflict_snapshot(
11788 root,
11789 scope,
11790 conflict,
11791 graph.nodes,
11792 graph.edges,
11793 depth,
11794 limit,
11795 Vec::new(),
11796 )?;
11797 Ok((
11798 Some(report.nodes.len() + report.edges.len()),
11799 graph_db_backend_eval_dispatch_signature(&report),
11800 ))
11801 });
11802 operations.push(operation);
11803 signatures.extend(signature);
11804
11805 let total_micros = operations
11806 .iter()
11807 .map(|operation| operation.duration_micros)
11808 .sum();
11809 let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11810 (
11811 GraphDbBackendEvalBackendReport {
11812 backend: backend.to_string(),
11813 adapter: adapter.to_string(),
11814 read_only,
11815 projection_load: projection_load.to_string(),
11816 operations,
11817 total_micros,
11818 parity,
11819 lock_behavior: lock_behavior.to_string(),
11820 install_portability: install_portability.to_string(),
11821 },
11822 signatures,
11823 )
11824}
11825
11826pub(crate) fn graph_db_backend_eval_refresh_operation(
11827 duration_micros: u128,
11828 rows: usize,
11829 value: serde_json::Value,
11830) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11831 (
11832 GraphDbBackendEvalOperation {
11833 name: "refresh".to_string(),
11834 supported: true,
11835 status: "ok".to_string(),
11836 duration_micros,
11837 rows: Some(rows),
11838 error: None,
11839 },
11840 GraphDbBackendEvalSignature {
11841 operation: "refresh".to_string(),
11842 value,
11843 },
11844 )
11845}
11846
11847pub(crate) fn graph_db_backend_eval_synthetic_projection(
11848 nodes: usize,
11849 fanout: usize,
11850) -> GraphProjection {
11851 let nodes = nodes.max(12);
11852 let symbol_count = nodes.saturating_sub(9).max(1);
11853 let source = GraphProvenance::new("backend-eval", "synthetic");
11854 let mut projection_nodes = vec![
11855 SubstrateGraphNode::new(
11856 "projection:tsift-traversal:synthetic",
11857 GRAPH_PROJECTION_META_KIND,
11858 "synthetic projection",
11859 )
11860 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11861 .with_property(
11862 "content_hash",
11863 format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11864 )
11865 .with_provenance(source.clone()),
11866 SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11867 .with_property("ref_id", "synthetic-session"),
11868 SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11869 .with_property("ref_id", "synthetic")
11870 .with_property("path", "tasks/software/synthetic.md")
11871 .with_property("line", "1")
11872 .with_property(
11873 "expand",
11874 "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11875 ),
11876 SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11877 .with_property("ref_id", "synthetic"),
11878 SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11879 .with_property("target", "synthetic")
11880 .with_property("summary", "Synthetic worker owns synthetic.rs")
11881 .with_property(
11882 "expand",
11883 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11884 ),
11885 SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11886 .with_property("file", "synthetic.rs")
11887 .with_property("start", "1")
11888 .with_property("end", "80")
11889 .with_property(
11890 "expand",
11891 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11892 ),
11893 SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11894 .with_property("path", "synthetic.rs"),
11895 SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11896 .with_property("handle", "gsem-synthetic")
11897 .with_property("label", "backend evaluation")
11898 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11899 .with_property(
11900 "embedding",
11901 semantic_embedding_property("backend evaluation"),
11902 ),
11903 SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11904 .with_property("ref_id", "synthetic")
11905 .with_property("status", "completed")
11906 .with_property("touched_files", "synthetic.rs")
11907 .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11908 ];
11909 for idx in 0..symbol_count {
11910 projection_nodes.push(
11911 SubstrateGraphNode::new(
11912 format!("gsym-synthetic-{idx:04}"),
11913 "symbol",
11914 format!("synthetic_symbol_{idx:04}"),
11915 )
11916 .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11917 .with_property("path", "synthetic.rs")
11918 .with_property("line", (idx + 1).to_string()),
11919 );
11920 }
11921
11922 let mut projection_edges = vec![
11923 SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11924 SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11925 SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11926 SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11927 SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11928 SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11929 SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11930 SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11931 SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11932 ];
11933 for idx in 0..symbol_count {
11934 let from = format!("gsym-synthetic-{idx:04}");
11935 for offset in 1..=fanout.max(1).min(symbol_count) {
11936 let to_idx = (idx + offset) % symbol_count;
11937 if to_idx != idx {
11938 projection_edges.push(SubstrateGraphEdge::new(
11939 from.clone(),
11940 format!("gsym-synthetic-{to_idx:04}"),
11941 "calls",
11942 ));
11943 }
11944 }
11945 }
11946
11947 GraphProjection {
11948 nodes: projection_nodes,
11949 edges: projection_edges
11950 .into_iter()
11951 .map(|edge| {
11952 edge.with_property("dataset", "synthetic")
11953 .with_provenance(source.clone())
11954 })
11955 .collect(),
11956 }
11957}
11958
11959pub(crate) fn graph_db_backend_eval_promotion(
11960 datasets: &[GraphDbBackendEvalDataset],
11961 candidates: &[GraphDbExperimentalBackend],
11962) -> Vec<GraphDbBackendPromotionDecision> {
11963 let mut decisions = Vec::new();
11964 for candidate in candidates {
11965 let mut reasons = Vec::new();
11966 let mut faster_everywhere = true;
11967 let mut parity_everywhere = true;
11968 for dataset in datasets {
11969 let Some(sqlite_report) = dataset
11970 .backends
11971 .iter()
11972 .find(|backend| backend.backend == "sqlite")
11973 else {
11974 parity_everywhere = false;
11975 faster_everywhere = false;
11976 reasons.push(format!(
11977 "{} dataset is missing SQLite baseline",
11978 dataset.name
11979 ));
11980 continue;
11981 };
11982 let sqlite_total = sqlite_report.total_micros;
11983 let Some(candidate_report) = dataset
11984 .backends
11985 .iter()
11986 .find(|backend| backend.backend == candidate.name())
11987 else {
11988 parity_everywhere = false;
11989 reasons.push(format!("{} dataset did not run", dataset.name));
11990 continue;
11991 };
11992 if !candidate_report.parity.matches_sqlite {
11993 parity_everywhere = false;
11994 reasons.push(format!("{} parity differed from SQLite", dataset.name));
11995 }
11996 if candidate_report.total_micros >= sqlite_total {
11997 faster_everywhere = false;
11998 reasons.push(format!(
11999 "{} total {}us did not beat SQLite {}us",
12000 dataset.name, candidate_report.total_micros, sqlite_total
12001 ));
12002 }
12003 let sqlite_operations = sqlite_report
12004 .operations
12005 .iter()
12006 .map(|operation| (operation.name.as_str(), operation.duration_micros))
12007 .collect::<BTreeMap<_, _>>();
12008 for operation in &candidate_report.operations {
12009 if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
12010 && operation.duration_micros >= *sqlite_duration
12011 {
12012 faster_everywhere = false;
12013 reasons.push(format!(
12014 "{} {} operation {}us did not beat SQLite {}us",
12015 dataset.name, operation.name, operation.duration_micros, sqlite_duration
12016 ));
12017 }
12018 }
12019 if candidate_report
12020 .operations
12021 .iter()
12022 .any(|operation| operation.status != "ok")
12023 {
12024 parity_everywhere = false;
12025 reasons.push(format!("{} has failed benchmark operations", dataset.name));
12026 }
12027 }
12028 let decision = if let Some(reason) = candidate.prototype_hold_reason() {
12029 reasons.push(reason.to_string());
12030 reasons.push(
12031 "current bounded prototype timings are benchmark evidence, not a backend switch approval"
12032 .to_string(),
12033 );
12034 "hold"
12035 } else if parity_everywhere && faster_everywhere {
12036 reasons.push(
12037 "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
12038 .to_string(),
12039 );
12040 "eligible"
12041 } else {
12042 reasons.push(
12043 "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
12044 .to_string(),
12045 );
12046 "hold"
12047 };
12048 decisions.push(GraphDbBackendPromotionDecision {
12049 backend: candidate.name().to_string(),
12050 decision: decision.to_string(),
12051 reasons: dedupe_preserve_order(reasons),
12052 gate: candidate.promotion_gate(),
12053 });
12054 }
12055 decisions
12056}
12057
12058pub(crate) fn graph_db_backend_eval_metrics(
12059 datasets: &[GraphDbBackendEvalDataset],
12060) -> BTreeMap<String, f64> {
12061 let mut metrics = BTreeMap::new();
12062 for dataset in datasets {
12063 let graph_rows = graph_db_backend_eval_graph_rows(dataset);
12064 metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
12065 metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
12066 metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
12067 for backend in &dataset.backends {
12068 let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
12069 metrics.insert(
12070 format!("{prefix}.total_duration_micros"),
12071 backend.total_micros as f64,
12072 );
12073 append_graph_db_backend_eval_normalized_duration_metric(
12074 &mut metrics,
12075 &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
12076 backend.total_micros,
12077 graph_rows,
12078 );
12079 for operation in &backend.operations {
12080 metrics.insert(
12081 format!("{prefix}.{}.duration_micros", operation.name),
12082 operation.duration_micros as f64,
12083 );
12084 append_graph_db_backend_eval_normalized_duration_metric(
12085 &mut metrics,
12086 &format!(
12087 "{prefix}.{}.duration_micros_per_1k_graph_rows",
12088 operation.name
12089 ),
12090 operation.duration_micros,
12091 graph_rows,
12092 );
12093 if let Some(rows) = operation.rows {
12094 metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
12095 }
12096 }
12097 }
12098 }
12099 metrics
12100}
12101
12102pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
12103 dataset.nodes + dataset.edges
12104}
12105
12106pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
12107 metrics: &mut BTreeMap<String, f64>,
12108 key: &str,
12109 duration_micros: u128,
12110 graph_rows: usize,
12111) {
12112 if graph_rows == 0 {
12113 return;
12114 }
12115 metrics.insert(
12116 key.to_string(),
12117 duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
12118 );
12119}
12120
12121pub(crate) fn append_graph_db_backend_eval_phase_metrics(
12122 metrics: &mut BTreeMap<String, f64>,
12123 dataset: &str,
12124 graph_rows: usize,
12125 phases: &[GraphDbBackendEvalPhaseTiming],
12126) {
12127 for phase in phases {
12128 metrics.insert(
12129 format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
12130 phase.duration_micros as f64,
12131 );
12132 append_graph_db_backend_eval_normalized_duration_metric(
12133 metrics,
12134 &format!(
12135 "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
12136 phase.name
12137 ),
12138 phase.duration_micros,
12139 graph_rows,
12140 );
12141 }
12142}
12143
12144fn graph_db_backend_eval_base_command(
12145 root: &Path,
12146 scope: Option<&str>,
12147 full_projection: bool,
12148) -> String {
12149 let full_projection_arg = if full_projection {
12150 " --full-projection"
12151 } else {
12152 ""
12153 };
12154 format!(
12155 "tsift graph-db --path {}{} --json backend-eval{}",
12156 shell_quote(root.to_string_lossy().as_ref()),
12157 graph_db_scope_arg(scope),
12158 full_projection_arg
12159 )
12160}
12161
12162pub(crate) fn graph_db_backend_eval_metric_digest_command(
12163 root: &Path,
12164 scope: Option<&str>,
12165 full_projection: bool,
12166) -> String {
12167 format!(
12168 "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12169 graph_db_backend_eval_base_command(root, scope, full_projection)
12170 )
12171}
12172
12173fn graph_db_backend_eval_repeated_sample_command(
12174 root: &Path,
12175 scope: Option<&str>,
12176 full_projection: bool,
12177) -> String {
12178 format!(
12179 "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12180 graph_db_backend_eval_base_command(root, scope, full_projection)
12181 )
12182}
12183
12184fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
12185 let mut required_metrics = Vec::new();
12186 for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
12187 required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
12188 required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
12189 for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
12190 required_metrics.push(format!(
12191 "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
12192 ));
12193 required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
12194 }
12195 }
12196 GraphDbHopCapPromotionGate {
12197 status: "hold_64_default_until_gate_passes".to_string(),
12198 current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
12199 candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
12200 required_backend: perf_gate::BASELINE_BACKEND.to_string(),
12201 required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
12202 .iter()
12203 .map(|workload| (*workload).to_string())
12204 .collect(),
12205 required_metrics,
12206 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12207 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12208 decision_rule:
12209 "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"
12210 .to_string(),
12211 }
12212}
12213
12214fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
12215 let candidate_backends = [
12216 GraphDbExperimentalBackend::Falkordb,
12217 GraphDbExperimentalBackend::Kuzu,
12218 GraphDbExperimentalBackend::Surrealdb,
12219 ]
12220 .into_iter()
12221 .map(|backend| GraphDbBackendAdapterSpikeCandidate {
12222 backend: backend.name().to_string(),
12223 adapter_label: backend.adapter_label().to_string(),
12224 projection_load: backend.projection_load().to_string(),
12225 lock_behavior: backend.lock_behavior().to_string(),
12226 install_portability: backend.install_portability().to_string(),
12227 })
12228 .collect();
12229
12230 GraphDbBackendAdapterSpikeGate {
12231 status: "hold_real_optional_adapter_required".to_string(),
12232 candidate_backends,
12233 required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
12234 .iter()
12235 .map(|workload| (*workload).to_string())
12236 .collect(),
12237 required_checks: vec![
12238 "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
12239 "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
12240 "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
12241 "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
12242 "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
12243 .to_string(),
12244 "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
12245 "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
12246 ],
12247 decision_rule:
12248 "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"
12249 .to_string(),
12250 evidence_plan: "plans/gback-evidence.md".to_string(),
12251 }
12252}
12253
12254pub(crate) fn graph_db_backend_eval_performance_gate(
12255 root: &Path,
12256 scope: Option<&str>,
12257 full_projection: bool,
12258) -> GraphDbBackendEvalPerformanceGate {
12259 let mut required_metrics = vec![
12260 "real.sqlite.refresh.duration_micros".to_string(),
12261 "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
12262 "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
12263 "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
12264 "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12265 "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12266 "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
12267 "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
12268 "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12269 "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
12270 "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
12271 "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
12272 "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
12273 "real.sqlite.conflict_matrix.duration_micros".to_string(),
12274 "real.sqlite.dispatch_trace.duration_micros".to_string(),
12275 "real.sqlite.path_max_hops.duration_micros".to_string(),
12276 "real.sqlite.path_max_hops_128.duration_micros".to_string(),
12277 "real.sqlite.path_max_hops_256.duration_micros".to_string(),
12278 "real.sqlite.path_max_hops_512.duration_micros".to_string(),
12279 "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
12280 "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
12281 "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
12282 "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
12283 "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12284 "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12285 "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
12286 .to_string(),
12287 "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12288 .to_string(),
12289 "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12290 "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12291 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
12292 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
12293 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
12294 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
12295 "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12296 .to_string(),
12297 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
12298 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
12299 .to_string(),
12300 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
12301 .to_string(),
12302 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
12303 .to_string(),
12304 ];
12305 if full_projection {
12306 required_metrics.extend([
12307 "full_projection.cache.hit".to_string(),
12308 "full_projection.cache.disk_bytes".to_string(),
12309 "full_projection.cache.compression_ratio".to_string(),
12310 "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
12311 "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12312 "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
12313 .to_string(),
12314 "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
12315 .to_string(),
12316 "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
12317 "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
12318 "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
12319 "full_projection.sqlite.neighborhood.duration_micros".to_string(),
12320 "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
12321 "full_projection.sqlite.evidence.duration_micros".to_string(),
12322 "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
12323 "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
12324 "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
12325 "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
12326 "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
12327 "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
12328 ]);
12329 }
12330 GraphDbBackendEvalPerformanceGate {
12331 baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
12332 ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
12333 .to_string(),
12334 opt_in_real_profile:
12335 "pass --full-projection to add the full-project dataset when checking for large projection regressions"
12336 .to_string(),
12337 full_projection_cache_hit_gate: if full_projection {
12338 "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"
12339 .to_string()
12340 } else {
12341 "not evaluated until --full-projection is enabled".to_string()
12342 },
12343 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12344 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12345 normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
12346 required_metrics,
12347 digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
12348 repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
12349 root,
12350 scope,
12351 full_projection,
12352 ),
12353 hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
12354 backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
12355 }
12356}
12357
12358#[cfg(feature = "backend-surrealdb")]
12359fn graph_db_backend_eval_path_segment(value: &str) -> String {
12360 value
12361 .chars()
12362 .map(|ch| {
12363 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
12364 ch
12365 } else {
12366 '_'
12367 }
12368 })
12369 .collect()
12370}
12371
12372#[cfg(feature = "backend-surrealdb")]
12373fn graph_db_backend_eval_surrealdb_store_path(
12374 root: &Path,
12375 scope: Option<&str>,
12376 dataset: &str,
12377) -> PathBuf {
12378 root.join(".tsift/backend-eval-cache/surrealdb")
12379 .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
12380 .join(graph_db_backend_eval_path_segment(dataset))
12381 .join("surrealkv")
12382}
12383
12384pub(crate) struct GraphDbBackendEvalOptions<'a> {
12385 path: &'a Path,
12386 scope: Option<&'a str>,
12387 candidates: &'a [String],
12388 targets: &'a [String],
12389 full_projection: bool,
12390}
12391
12392#[allow(clippy::too_many_arguments)]
12393pub(crate) fn graph_db_backend_eval_dataset(
12394 name: &str,
12395 root: &Path,
12396 path: &Path,
12397 scope: Option<&str>,
12398 targets: &[String],
12399 depth: usize,
12400 limit: usize,
12401 impact_limit: usize,
12402 candidates: &[GraphDbExperimentalBackend],
12403 sqlite_store: &SqliteGraphStore,
12404 sqlite_freshness: GraphDbFreshnessReport,
12405 sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
12406 sqlite_rows: ConvexProjectionRows,
12407 extra_warnings: Vec<String>,
12408 prepared: &ConflictMatrixPreparedInputs,
12409) -> Result<GraphDbBackendEvalDataset> {
12410 let (nodes, edges) = sqlite_store.graph_counts()?;
12411 let (sqlite_operation, sqlite_signature) = sqlite_refresh;
12412 let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
12413 "sqlite",
12414 "SQLite GraphStore correctness baseline",
12415 false,
12416 root,
12417 path,
12418 scope,
12419 targets,
12420 depth,
12421 limit,
12422 impact_limit,
12423 sqlite_store,
12424 sqlite_freshness,
12425 sqlite_operation,
12426 Some(sqlite_signature),
12427 None,
12428 extra_warnings.clone(),
12429 prepared,
12430 "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
12431 "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
12432 "bundled rusqlite baseline; no external service or runtime required",
12433 );
12434
12435 let mut backends = vec![sqlite_report];
12436 for candidate in candidates {
12437 #[cfg(feature = "backend-surrealdb")]
12438 if *candidate == GraphDbExperimentalBackend::Surrealdb {
12439 let started = Instant::now();
12440 let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
12441 let (store, warm_start) =
12442 SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
12443 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12444 let rows = candidate_nodes + candidate_edges;
12445 let mut refresh_meta = serde_json::json!({
12446 "nodes": candidate_nodes,
12447 "edges": candidate_edges,
12448 });
12449 if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
12450 refresh_meta["warm_start"] = serde_json::json!("cache_hit");
12451 }
12452 let refresh = graph_db_backend_eval_refresh_operation(
12453 started.elapsed().as_micros(),
12454 rows,
12455 refresh_meta,
12456 );
12457 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12458 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12459 candidate.name(),
12460 "SurrealDB SurrealKV optional adapter spike",
12461 false,
12462 root,
12463 path,
12464 scope,
12465 targets,
12466 depth,
12467 limit,
12468 impact_limit,
12469 &store,
12470 freshness,
12471 refresh.0,
12472 Some(refresh.1),
12473 Some(&sqlite_signatures),
12474 extra_warnings.clone(),
12475 prepared,
12476 "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",
12477 "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
12478 "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
12479 );
12480 backends.push(candidate_report);
12481 continue;
12482 }
12483 let started = Instant::now();
12484 let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
12485 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12486 let rows = candidate_nodes + candidate_edges;
12487 let refresh = graph_db_backend_eval_refresh_operation(
12488 started.elapsed().as_micros(),
12489 rows,
12490 serde_json::json!({
12491 "nodes": candidate_nodes,
12492 "edges": candidate_edges,
12493 }),
12494 );
12495 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12496 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12497 candidate.name(),
12498 candidate.adapter_label(),
12499 true,
12500 root,
12501 path,
12502 scope,
12503 targets,
12504 depth,
12505 limit,
12506 impact_limit,
12507 &store,
12508 freshness,
12509 refresh.0,
12510 Some(refresh.1),
12511 Some(&sqlite_signatures),
12512 extra_warnings.clone(),
12513 prepared,
12514 candidate.projection_load(),
12515 candidate.lock_behavior(),
12516 candidate.install_portability(),
12517 );
12518 backends.push(candidate_report);
12519 }
12520
12521 Ok(GraphDbBackendEvalDataset {
12522 name: name.to_string(),
12523 target_count: targets.len(),
12524 nodes,
12525 edges,
12526 backends,
12527 })
12528}
12529
12530pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
12531 println!(
12532 "graph-db backend-eval baseline:{} candidates:{}",
12533 report.baseline_backend,
12534 report.candidates.join(", ")
12535 );
12536 for phase in &report.phase_timings {
12537 println!(
12538 "phase:{} {}us {}",
12539 phase.name, phase.duration_micros, phase.detail
12540 );
12541 }
12542 for dataset in &report.datasets {
12543 println!(
12544 "dataset:{} targets:{} rows:{}",
12545 dataset.name,
12546 dataset.target_count,
12547 dataset.nodes + dataset.edges
12548 );
12549 for backend in &dataset.backends {
12550 println!(
12551 " backend:{} total:{}us parity:{}",
12552 backend.backend, backend.total_micros, backend.parity.matches_sqlite
12553 );
12554 println!(" projection-load: {}", backend.projection_load);
12555 println!(" lock-behavior: {}", backend.lock_behavior);
12556 println!(" install-portability: {}", backend.install_portability);
12557 for operation in &backend.operations {
12558 println!(
12559 " {} {} {}us",
12560 operation.name, operation.status, operation.duration_micros
12561 );
12562 }
12563 for diagnostic in &backend.parity.diagnostics {
12564 println!(" parity: {diagnostic}");
12565 }
12566 }
12567 }
12568 for decision in &report.promotion {
12569 println!("promotion {}: {}", decision.backend, decision.decision);
12570 println!(" gate: {}", decision.gate.status);
12571 for reason in &decision.reasons {
12572 println!(" reason: {reason}");
12573 }
12574 for check in &decision.gate.required_checks {
12575 println!(" check: {check}");
12576 }
12577 }
12578 println!("metric-digest: {}", report.metric_digest_command);
12579 println!(
12580 "repeat-samples: {}",
12581 report.performance_gate.repeated_sample_command
12582 );
12583}
12584
12585fn traversal_expand_command(root: &Path, handle: &str) -> String {
12586 format!(
12587 "tsift traverse {} --path {} --depth 1 --limit 50",
12588 shell_quote(handle),
12589 shell_quote(root.to_string_lossy().as_ref())
12590 )
12591}
12592
12593fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
12594 let display = relativize(file, root);
12595 let handle = stable_handle("gfil", &format!("file:{display}"));
12596 TraversalNode {
12597 handle: handle.clone(),
12598 kind: "file".to_string(),
12599 label: display.clone(),
12600 ref_id: Some(display.clone()),
12601 path: Some(display),
12602 line: None,
12603 detail: None,
12604 properties: BTreeMap::new(),
12605 expand: traversal_expand_command(root, &handle),
12606 }
12607}
12608
12609fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
12610 let mut node = traversal_file_node(root, file);
12611 if let Some(path) = node.path.clone() {
12612 node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
12613 node.expand = source_read_command(root, &path, 1, 80);
12614 }
12615 node
12616}
12617
12618fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
12619 let file = relativize(&symbol.file, root);
12620 let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
12621 let handle = stable_handle("gsym", &key);
12622 TraversalNode {
12623 handle: handle.clone(),
12624 kind: "symbol".to_string(),
12625 label: symbol.name.clone(),
12626 ref_id: Some(symbol.name.clone()),
12627 path: Some(file),
12628 line: Some(symbol.line),
12629 detail: Some(format!("{} {}", symbol.language, symbol.kind)),
12630 properties: BTreeMap::new(),
12631 expand: traversal_expand_command(root, &handle),
12632 }
12633}
12634
12635fn traversal_ast_span_expand_command(
12636 root: &Path,
12637 file: &str,
12638 symbol: &index::StoredSymbol,
12639 span: &AstSpanPreview,
12640) -> String {
12641 if symbol.language == "markdown" {
12642 markdown_ast_command(root, file, Some(&span.handle))
12643 } else {
12644 let line_count = span
12645 .end_line
12646 .saturating_sub(span.start_line)
12647 .saturating_add(1)
12648 .max(1);
12649 source_read_command(root, file, span.start_line, line_count)
12650 }
12651}
12652
12653fn traversal_ast_span_node(
12654 root: &Path,
12655 symbol: &index::StoredSymbol,
12656 source: &[u8],
12657 symbols: &[&index::StoredSymbol],
12658) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
12659 let span = stored_symbol_ast_span_in_file(symbol, source, symbols, usize::MAX)?;
12660 let file = relativize(&symbol.file, root);
12661 let mut properties = BTreeMap::new();
12662 properties.insert("layer".to_string(), "ast_navigation".to_string());
12663 properties.insert("language".to_string(), symbol.language.clone());
12664 properties.insert("symbol_kind".to_string(), symbol.kind.clone());
12665 properties.insert("node_kind".to_string(), span.node_kind.clone());
12666 properties.insert("start_byte".to_string(), span.start_byte.to_string());
12667 properties.insert("end_byte".to_string(), span.end_byte.to_string());
12668 properties.insert("end_line".to_string(), span.end_line.to_string());
12669 if let Some(body_start_byte) = span.body_start_byte {
12670 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12671 }
12672 if let Some(body_end_byte) = span.body_end_byte {
12673 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12674 }
12675 if let Some(body_start_line) = span.body_start_line {
12676 properties.insert("body_start_line".to_string(), body_start_line.to_string());
12677 }
12678 if let Some(body_end_line) = span.body_end_line {
12679 properties.insert("body_end_line".to_string(), body_end_line.to_string());
12680 }
12681 if let Some(parent_handle) = &span.parent_handle {
12682 properties.insert("parent_handle".to_string(), parent_handle.clone());
12683 }
12684 if !span.child_handles.is_empty() {
12685 properties.insert("child_handles".to_string(), span.child_handles.join(","));
12686 }
12687 if let Some(parent_module) = &symbol.parent_module {
12688 properties.insert("parent_module".to_string(), parent_module.clone());
12689 }
12690 if let Some(markdown) = &span.markdown {
12691 properties.insert(
12692 "markdown_block_kind".to_string(),
12693 markdown_ast_block_kind(&symbol.kind),
12694 );
12695 if let Some(heading_level) = markdown.heading_level {
12696 properties.insert("heading_level".to_string(), heading_level.to_string());
12697 }
12698 if !markdown.section_path.is_empty() {
12699 properties.insert(
12700 "section_path".to_string(),
12701 markdown.section_path.join(" > "),
12702 );
12703 }
12704 if let Some(section_handle) = &markdown.section_handle {
12705 properties.insert("section_handle".to_string(), section_handle.clone());
12706 }
12707 if let Some(list_depth) = markdown.list_depth {
12708 properties.insert("list_depth".to_string(), list_depth.to_string());
12709 }
12710 if let Some(fence_language) = &markdown.fence_language {
12711 properties.insert("fence_language".to_string(), fence_language.clone());
12712 }
12713 }
12714
12715 let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
12716 let node = TraversalNode {
12717 handle: span.handle.clone(),
12718 kind: "ast_span".to_string(),
12719 label: symbol.name.clone(),
12720 ref_id: Some(symbol.name.clone()),
12721 path: Some(file.clone()),
12722 line: Some(line),
12723 detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
12724 properties,
12725 expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
12726 };
12727 let entry = TraversalAstSpanIndexEntry {
12728 handle: span.handle,
12729 symbol_handle: String::new(),
12730 file_handle: None,
12731 file,
12732 name: symbol.name.clone(),
12733 kind: symbol.kind.clone(),
12734 language: symbol.language.clone(),
12735 node_kind: span.node_kind,
12736 start_byte: span.start_byte,
12737 end_byte: span.end_byte,
12738 parent_module: symbol.parent_module.clone(),
12739 markdown: span.markdown,
12740 };
12741 Some((node, entry))
12742}
12743
12744fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
12745 let handle = stable_handle("gsym", &format!("symbol:{name}"));
12746 TraversalNode {
12747 handle: handle.clone(),
12748 kind: "symbol".to_string(),
12749 label: name.to_string(),
12750 ref_id: Some(name.to_string()),
12751 path: None,
12752 line: None,
12753 detail: Some("unresolved call target".to_string()),
12754 properties: BTreeMap::new(),
12755 expand: traversal_expand_command(root, &handle),
12756 }
12757}
12758
12759fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12760 let file = relativize(&route.file, root);
12761 let method = route.method.as_deref().unwrap_or("any");
12762 let key = format!(
12763 "route:{file}:{}:{}:{}",
12764 route.line, method, route.route_path
12765 );
12766 let handle = stable_handle("grte", &key);
12767 TraversalNode {
12768 handle: handle.clone(),
12769 kind: "route".to_string(),
12770 label: format!("{} {}", method.to_uppercase(), route.route_path),
12771 ref_id: Some(route.route_path.clone()),
12772 path: Some(file),
12773 line: Some(route.line),
12774 detail: Some(format!(
12775 "{} route handled by {}",
12776 route.framework, route.handler_name
12777 )),
12778 properties: BTreeMap::new(),
12779 expand: traversal_expand_command(root, &handle),
12780 }
12781}
12782
12783fn traversal_cargo_workspace_node(
12784 root: &Path,
12785 workspace: &multiplicity::CargoWorkspaceInfo,
12786) -> TraversalNode {
12787 let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12788 .to_string_lossy()
12789 .replace('\\', "/");
12790 let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12791 .to_string_lossy()
12792 .replace('\\', "/");
12793 let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12794 let mut properties = BTreeMap::new();
12795 properties.insert("layer".to_string(), "cargo_workspace".to_string());
12796 properties.insert("workspace_root".to_string(), workspace_root.clone());
12797 properties.insert("members".to_string(), workspace.members.join(","));
12798 properties.insert(
12799 "default_members".to_string(),
12800 workspace.default_members.join(","),
12801 );
12802 TraversalNode {
12803 handle: handle.clone(),
12804 kind: "cargo_workspace".to_string(),
12805 label: if workspace_root.is_empty() {
12806 "root cargo workspace".to_string()
12807 } else {
12808 workspace_root
12809 },
12810 ref_id: Some(workspace.id.clone()),
12811 path: Some(manifest),
12812 line: None,
12813 detail: Some("Cargo workspace manifest".to_string()),
12814 properties,
12815 expand: traversal_expand_command(root, &handle),
12816 }
12817}
12818
12819fn traversal_cargo_package_node(
12820 root: &Path,
12821 package: &multiplicity::CargoPackageInfo,
12822) -> TraversalNode {
12823 let manifest = relativize_pathbuf(&package.manifest_path, root)
12824 .to_string_lossy()
12825 .replace('\\', "/");
12826 let package_root = relativize_pathbuf(&package.package_root, root)
12827 .to_string_lossy()
12828 .replace('\\', "/");
12829 let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12830 .to_string_lossy()
12831 .replace('\\', "/");
12832 let handle = stable_handle(
12833 "gcpk",
12834 &format!("cargo-package:{manifest}:{}", package.name),
12835 );
12836 let mut properties = BTreeMap::new();
12837 properties.insert("layer".to_string(), "cargo_package".to_string());
12838 properties.insert("package_name".to_string(), package.name.clone());
12839 properties.insert(
12840 "normalized_name".to_string(),
12841 package.normalized_name.clone(),
12842 );
12843 properties.insert("package_root".to_string(), package_root.clone());
12844 properties.insert("workspace_root".to_string(), workspace_root);
12845 properties.insert("features".to_string(), package.features.join(","));
12846 properties.insert("targets".to_string(), package.targets.join(","));
12847 properties.insert(
12848 "dependencies".to_string(),
12849 package
12850 .dependencies
12851 .iter()
12852 .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12853 .collect::<Vec<_>>()
12854 .join(","),
12855 );
12856 TraversalNode {
12857 handle: handle.clone(),
12858 kind: "cargo_package".to_string(),
12859 label: package.name.clone(),
12860 ref_id: Some(package.scope_id.clone()),
12861 path: Some(manifest),
12862 line: None,
12863 detail: Some(format!(
12864 "Cargo package in {}",
12865 if package_root.is_empty() {
12866 "."
12867 } else {
12868 package_root.as_str()
12869 }
12870 )),
12871 properties,
12872 expand: traversal_expand_command(root, &handle),
12873 }
12874}
12875
12876fn traversal_session_node(
12877 root: &Path,
12878 markdown_path: &Path,
12879 session_id: Option<&str>,
12880) -> TraversalNode {
12881 let display = relativize_pathbuf(markdown_path, root)
12882 .to_string_lossy()
12883 .replace('\\', "/");
12884 let handle = stable_handle("gses", &format!("session:{display}"));
12885 TraversalNode {
12886 handle: handle.clone(),
12887 kind: "session".to_string(),
12888 label: session_id.unwrap_or(&display).to_string(),
12889 ref_id: session_id.map(str::to_string),
12890 path: Some(display),
12891 line: None,
12892 detail: Some("agent-doc session artifact".to_string()),
12893 properties: BTreeMap::new(),
12894 expand: traversal_expand_command(root, &handle),
12895 }
12896}
12897
12898fn traversal_backlog_node(
12899 root: &Path,
12900 markdown_path: &Path,
12901 id: &str,
12902 text: &str,
12903 line: i64,
12904) -> TraversalNode {
12905 let display = relativize_pathbuf(markdown_path, root)
12906 .to_string_lossy()
12907 .replace('\\', "/");
12908 let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12909 TraversalNode {
12910 handle: handle.clone(),
12911 kind: "backlog".to_string(),
12912 label: format!("#{id}"),
12913 ref_id: Some(id.to_string()),
12914 path: Some(display),
12915 line: Some(line),
12916 detail: Some(text.to_string()),
12917 properties: BTreeMap::new(),
12918 expand: traversal_expand_command(root, &handle),
12919 }
12920}
12921
12922fn traversal_job_packet_node(
12923 root: &Path,
12924 markdown_path: &Path,
12925 label: &str,
12926 ref_id: Option<&str>,
12927 detail: &str,
12928 line: i64,
12929) -> TraversalNode {
12930 let display = relativize_pathbuf(markdown_path, root)
12931 .to_string_lossy()
12932 .replace('\\', "/");
12933 let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12934 TraversalNode {
12935 handle: handle.clone(),
12936 kind: "job_packet".to_string(),
12937 label: label.to_string(),
12938 ref_id: ref_id.map(str::to_string),
12939 path: Some(display),
12940 line: Some(line),
12941 detail: Some(detail.to_string()),
12942 properties: BTreeMap::new(),
12943 expand: traversal_expand_command(root, &handle),
12944 }
12945}
12946
12947#[derive(Clone, Debug)]
12948struct ParsedWorkerResult {
12949 id: String,
12950 status: String,
12951 touched_files: Vec<String>,
12952 tests: Vec<String>,
12953 follow_up_ids: Vec<String>,
12954}
12955
12956fn traversal_worker_result_node(
12957 root: &Path,
12958 markdown_path: &Path,
12959 parsed: &ParsedWorkerResult,
12960 line_text: &str,
12961 line: i64,
12962) -> TraversalNode {
12963 let display = relativize_pathbuf(markdown_path, root)
12964 .to_string_lossy()
12965 .replace('\\', "/");
12966 let handle = stable_handle(
12967 "wres",
12968 &format!(
12969 "worker-result:{display}:{}:{}:{}",
12970 parsed.id, parsed.status, line
12971 ),
12972 );
12973 let mut properties = BTreeMap::new();
12974 properties.insert("status".to_string(), parsed.status.clone());
12975 if !parsed.touched_files.is_empty() {
12976 properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12977 }
12978 if !parsed.tests.is_empty() {
12979 properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12980 }
12981 if !parsed.follow_up_ids.is_empty() {
12982 properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12983 }
12984 TraversalNode {
12985 handle: handle.clone(),
12986 kind: "worker_result".to_string(),
12987 label: format!("{} #{}", parsed.status, parsed.id),
12988 ref_id: Some(parsed.id.clone()),
12989 path: Some(display),
12990 line: Some(line),
12991 detail: Some(line_text.trim().to_string()),
12992 properties,
12993 expand: traversal_expand_command(root, &handle),
12994 }
12995}
12996
12997fn traversal_tokens(input: &str) -> BTreeSet<String> {
12998 input
12999 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
13000 .flat_map(|part| part.split(['_', '-']))
13001 .map(str::trim)
13002 .filter(|part| part.len() >= 3)
13003 .map(|part| part.to_ascii_lowercase())
13004 .collect()
13005}
13006
13007fn traversal_ast_span_contains(
13008 parent: &TraversalAstSpanIndexEntry,
13009 child: &TraversalAstSpanIndexEntry,
13010) -> bool {
13011 parent.handle != child.handle
13012 && parent.file == child.file
13013 && parent.start_byte <= child.start_byte
13014 && parent.end_byte >= child.end_byte
13015}
13016
13017fn traversal_ast_parent_handle<'a>(
13018 entry: &TraversalAstSpanIndexEntry,
13019 entries: &'a [TraversalAstSpanIndexEntry],
13020) -> Option<&'a str> {
13021 entries
13022 .iter()
13023 .filter(|candidate| traversal_ast_span_contains(candidate, entry))
13024 .min_by_key(|candidate| {
13025 (
13026 candidate.end_byte.saturating_sub(candidate.start_byte),
13027 candidate.start_byte,
13028 candidate.end_byte,
13029 candidate.kind.as_str(),
13030 candidate.name.as_str(),
13031 candidate.node_kind.as_str(),
13032 )
13033 })
13034 .map(|candidate| candidate.handle.as_str())
13035}
13036
13037fn traversal_ast_enclosing_module_handle<'a>(
13038 entry: &TraversalAstSpanIndexEntry,
13039 entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
13040 parent_by_handle: &BTreeMap<String, String>,
13041) -> Option<&'a str> {
13042 let mut current = parent_by_handle.get(&entry.handle);
13043 while let Some(handle) = current {
13044 let Some(parent) = entries_by_handle.get(handle) else {
13045 break;
13046 };
13047 if matches!(parent.kind.as_str(), "module" | "mod")
13048 || entry
13049 .parent_module
13050 .as_deref()
13051 .is_some_and(|module| module == parent.name)
13052 {
13053 return Some(parent.handle.as_str());
13054 }
13055 current = parent_by_handle.get(&parent.handle);
13056 }
13057 None
13058}
13059
13060fn link_ast_navigation_edges(
13061 graph: &mut TraversalGraphBuild,
13062 entries: &[TraversalAstSpanIndexEntry],
13063) {
13064 let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
13065 let entries_by_handle = entries
13066 .iter()
13067 .map(|entry| (entry.handle.clone(), entry.clone()))
13068 .collect::<BTreeMap<_, _>>();
13069 let mut parent_by_handle = BTreeMap::<String, String>::new();
13070 let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
13071
13072 for entry in entries {
13073 entries_by_file
13074 .entry(entry.file.clone())
13075 .or_default()
13076 .push(entry.clone());
13077 }
13078
13079 for file_entries in entries_by_file.values() {
13080 for entry in file_entries {
13081 let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
13082 if let Some(parent) = &parent {
13083 parent_by_handle.insert(entry.handle.clone(), parent.clone());
13084 }
13085 let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
13086 children_by_parent
13087 .entry(sibling_key)
13088 .or_default()
13089 .push(entry.clone());
13090 }
13091 }
13092
13093 for entry in entries {
13094 let parent = parent_by_handle.get(&entry.handle);
13095 if let Some(parent) = parent {
13096 graph.add_edge(
13097 parent,
13098 &entry.handle,
13099 "contains",
13100 Some("AST parent contains child span".to_string()),
13101 1,
13102 );
13103 graph.add_edge(
13104 parent,
13105 &entry.handle,
13106 "child",
13107 Some("AST child span".to_string()),
13108 1,
13109 );
13110 graph.add_edge(
13111 &entry.handle,
13112 parent,
13113 "parent",
13114 Some("AST parent span".to_string()),
13115 1,
13116 );
13117 } else if let Some(file_handle) = &entry.file_handle {
13118 graph.add_edge(
13119 file_handle,
13120 &entry.handle,
13121 "contains",
13122 Some("file contains top-level AST span".to_string()),
13123 1,
13124 );
13125 }
13126
13127 if let Some(module_handle) =
13128 traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
13129 {
13130 graph.add_edge(
13131 &entry.handle,
13132 module_handle,
13133 "enclosing_module",
13134 Some("nearest enclosing module AST span".to_string()),
13135 1,
13136 );
13137 }
13138
13139 if entry.language == "markdown"
13140 && let Some(markdown) = &entry.markdown
13141 && let Some(section_handle) = &markdown.section_handle
13142 && section_handle != &entry.handle
13143 {
13144 graph.add_edge(
13145 section_handle,
13146 &entry.handle,
13147 "contains_markdown_block",
13148 Some("Markdown section contains block".to_string()),
13149 1,
13150 );
13151 graph.add_edge(
13152 &entry.handle,
13153 section_handle,
13154 "enclosing_section",
13155 Some("Markdown enclosing section".to_string()),
13156 1,
13157 );
13158 }
13159 }
13160
13161 for siblings in children_by_parent.values_mut() {
13162 siblings.sort_by(|left, right| {
13163 left.start_byte
13164 .cmp(&right.start_byte)
13165 .then(left.end_byte.cmp(&right.end_byte))
13166 .then(left.kind.cmp(&right.kind))
13167 .then(left.name.cmp(&right.name))
13168 .then(left.node_kind.cmp(&right.node_kind))
13169 .then(left.handle.cmp(&right.handle))
13170 });
13171 for pair in siblings.windows(2) {
13172 let previous = &pair[0];
13173 let next = &pair[1];
13174 graph.add_edge(
13175 &previous.handle,
13176 &next.handle,
13177 "next_sibling",
13178 Some("next AST sibling span".to_string()),
13179 1,
13180 );
13181 graph.add_edge(
13182 &next.handle,
13183 &previous.handle,
13184 "previous_sibling",
13185 Some("previous AST sibling span".to_string()),
13186 1,
13187 );
13188 }
13189 }
13190}
13191
13192fn traversal_markdown_embedded_symbol_node(
13193 root: &Path,
13194 entry: &TraversalAstSpanIndexEntry,
13195 markdown: &MarkdownSpanMetadata,
13196 embedded: &MarkdownEmbeddedSymbol,
13197) -> TraversalNode {
13198 let mut properties = BTreeMap::new();
13199 properties.insert("layer".to_string(), "embedded_code".to_string());
13200 properties.insert("embedded".to_string(), "true".to_string());
13201 properties.insert("language".to_string(), embedded.language.clone());
13202 properties.insert("symbol_kind".to_string(), embedded.kind.clone());
13203 properties.insert("node_kind".to_string(), embedded.node_kind.clone());
13204 properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
13205 properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
13206 properties.insert("end_line".to_string(), embedded.end_line.to_string());
13207 properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
13208 properties.insert(
13209 "markdown_block_kind".to_string(),
13210 markdown_ast_block_kind(&entry.kind),
13211 );
13212 if let Some(body_start_byte) = embedded.body_start_byte {
13213 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
13214 }
13215 if let Some(body_end_byte) = embedded.body_end_byte {
13216 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
13217 }
13218 if let Some(body_start_line) = embedded.body_start_line {
13219 properties.insert("body_start_line".to_string(), body_start_line.to_string());
13220 }
13221 if let Some(body_end_line) = embedded.body_end_line {
13222 properties.insert("body_end_line".to_string(), body_end_line.to_string());
13223 }
13224 if let Some(fence_language) = &markdown.fence_language {
13225 properties.insert("fence_language".to_string(), fence_language.clone());
13226 }
13227 if !markdown.section_path.is_empty() {
13228 properties.insert(
13229 "section_path".to_string(),
13230 markdown.section_path.join(" > "),
13231 );
13232 }
13233 if let Some(section_handle) = &markdown.section_handle {
13234 properties.insert("section_handle".to_string(), section_handle.clone());
13235 }
13236 let line_count = embedded
13237 .end_line
13238 .saturating_sub(embedded.start_line)
13239 .saturating_add(1)
13240 .max(1);
13241 TraversalNode {
13242 handle: embedded.handle.clone(),
13243 kind: "ast_span".to_string(),
13244 label: embedded.name.clone(),
13245 ref_id: Some(embedded.name.clone()),
13246 path: Some(entry.file.clone()),
13247 line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
13248 detail: Some(format!(
13249 "{} {} embedded in Markdown fence",
13250 embedded.language, embedded.kind
13251 )),
13252 properties,
13253 expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
13254 }
13255}
13256
13257fn link_markdown_embedded_code_edges(
13258 graph: &mut TraversalGraphBuild,
13259 root: &Path,
13260 entries: &[TraversalAstSpanIndexEntry],
13261) {
13262 for entry in entries {
13263 let Some(markdown) = &entry.markdown else {
13264 continue;
13265 };
13266 for embedded in &markdown.embedded_symbols {
13267 let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
13268 graph.add_node(node);
13269 graph.add_edge(
13270 &entry.handle,
13271 &embedded.handle,
13272 "contains",
13273 Some("Markdown fence contains embedded AST symbol".to_string()),
13274 1,
13275 );
13276 graph.add_edge(
13277 &entry.handle,
13278 &embedded.handle,
13279 "child",
13280 Some("embedded code symbol".to_string()),
13281 1,
13282 );
13283 graph.add_edge(
13284 &entry.handle,
13285 &embedded.handle,
13286 "contains_embedded_symbol",
13287 Some("Markdown fence contains embedded code symbol".to_string()),
13288 1,
13289 );
13290 graph.add_edge(
13291 &embedded.handle,
13292 &entry.handle,
13293 "parent",
13294 Some("Markdown fence parent span".to_string()),
13295 1,
13296 );
13297 graph.add_edge(
13298 &embedded.handle,
13299 &entry.handle,
13300 "embedded_in_fence",
13301 Some("embedded code symbol belongs to Markdown fence".to_string()),
13302 1,
13303 );
13304 if let Some(section_handle) = &markdown.section_handle
13305 && section_handle != &entry.handle
13306 {
13307 graph.add_edge(
13308 section_handle,
13309 &embedded.handle,
13310 "contains_embedded_code",
13311 Some("Markdown section contains embedded code symbol".to_string()),
13312 1,
13313 );
13314 graph.add_edge(
13315 &embedded.handle,
13316 section_handle,
13317 "enclosing_section",
13318 Some("Markdown enclosing section".to_string()),
13319 1,
13320 );
13321 }
13322 }
13323 }
13324}
13325
13326fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
13327 let mut tokens = traversal_tokens(&node.label);
13328 if let Some(ref_id) = &node.ref_id {
13329 tokens.extend(traversal_tokens(ref_id));
13330 }
13331 if let Some(path) = &node.path {
13332 tokens.extend(traversal_tokens(path));
13333 }
13334 if let Some(detail) = &node.detail {
13335 tokens.extend(traversal_tokens(detail));
13336 }
13337 tokens
13338}
13339
13340fn markdown_code_spans(input: &str) -> Vec<String> {
13341 input
13342 .split('`')
13343 .enumerate()
13344 .filter(|(idx, _)| idx % 2 == 1)
13345 .map(|(_, part)| part.trim().to_string())
13346 .filter(|part| !part.is_empty())
13347 .collect()
13348}
13349
13350fn push_traversal_token_index(
13351 index: &mut HashMap<String, Vec<usize>>,
13352 tokens: &BTreeSet<String>,
13353 entry_index: usize,
13354) {
13355 for token in tokens {
13356 index.entry(token.clone()).or_default().push(entry_index);
13357 }
13358}
13359
13360impl<'a> TraversalCodeLookup<'a> {
13361 fn new(
13362 symbols: &'a [TraversalSymbolIndexEntry],
13363 files: &'a [TraversalFileIndexEntry],
13364 routes: &'a [TraversalRouteIndexEntry],
13365 multiplicities: &'a [TraversalMultiplicityIndexEntry],
13366 ) -> Self {
13367 let mut symbol_index = HashMap::new();
13368 for (idx, entry) in symbols.iter().enumerate() {
13369 push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
13370 }
13371 let mut file_index = HashMap::new();
13372 let mut file_path_index = HashMap::new();
13373 for (idx, entry) in files.iter().enumerate() {
13374 push_traversal_token_index(&mut file_index, &entry.tokens, idx);
13375 if let Some(path) = entry.node.path.as_ref() {
13376 file_path_index.insert(path.clone(), path.clone());
13377 }
13378 }
13379 let mut route_index = HashMap::new();
13380 for (idx, entry) in routes.iter().enumerate() {
13381 push_traversal_token_index(&mut route_index, &entry.tokens, idx);
13382 }
13383 let mut multiplicity_index = HashMap::new();
13384 for (idx, entry) in multiplicities.iter().enumerate() {
13385 push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
13386 }
13387 Self {
13388 symbols,
13389 files,
13390 routes,
13391 multiplicities,
13392 symbol_index,
13393 file_index,
13394 route_index,
13395 multiplicity_index,
13396 file_path_index,
13397 }
13398 }
13399
13400 fn touched_files_for_line(&self, line: &str) -> Vec<String> {
13401 let mut touched_files = BTreeSet::new();
13402 for candidate in markdown_code_spans(line)
13403 .into_iter()
13404 .chain(line.split_whitespace().map(str::to_string))
13405 {
13406 for path in traversal_path_candidates(&candidate) {
13407 if let Some(file) = self.file_path_index.get(&path) {
13408 touched_files.insert(file.clone());
13409 }
13410 }
13411 }
13412 touched_files.into_iter().collect()
13413 }
13414}
13415
13416fn traversal_path_candidates(candidate: &str) -> Vec<String> {
13417 let trimmed = candidate.trim_matches(|ch: char| {
13418 matches!(
13419 ch,
13420 '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
13421 )
13422 });
13423 if trimmed.is_empty() {
13424 return Vec::new();
13425 }
13426 let mut candidates = vec![trimmed.to_string()];
13427 if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
13428 && !path.is_empty()
13429 && line_suffix.chars().all(|ch| ch.is_ascii_digit())
13430 {
13431 candidates.push(path.to_string());
13432 }
13433 candidates
13434}
13435
13436fn parse_worker_result_line(
13437 line: &str,
13438 lookup: &TraversalCodeLookup<'_>,
13439) -> Vec<ParsedWorkerResult> {
13440 if line.trim_start().starts_with("- [") {
13441 return Vec::new();
13442 }
13443 let lower = line.to_ascii_lowercase();
13444 let status =
13445 if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
13446 {
13447 "completed"
13448 } else if lower.contains("blocked") || lower.contains("externally blocked") {
13449 "blocked"
13450 } else {
13451 return Vec::new();
13452 };
13453 let result_prefix_end = ["follow-up", "follow up", "next:"]
13454 .iter()
13455 .filter_map(|marker| lower.find(marker))
13456 .min()
13457 .unwrap_or(line.len());
13458 let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
13459 if ids.is_empty() {
13460 return Vec::new();
13461 }
13462 let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
13463 let all_ids = extract_conflict_target_refs(line);
13464
13465 let touched_files = lookup.touched_files_for_line(line);
13466 let tests = markdown_code_spans(line)
13467 .into_iter()
13468 .filter(|span| span.to_ascii_lowercase().contains("test"))
13469 .collect::<Vec<_>>();
13470
13471 ids.iter()
13472 .map(|id| ParsedWorkerResult {
13473 id: id.clone(),
13474 status: status.to_string(),
13475 touched_files: touched_files.clone(),
13476 tests: tests.clone(),
13477 follow_up_ids: all_ids
13478 .iter()
13479 .filter(|other| *other != id && !result_ids.contains(*other))
13480 .cloned()
13481 .collect(),
13482 })
13483 .collect()
13484}
13485
13486fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
13487 let hinted_path = if path_hint.is_absolute() {
13488 path_hint.to_path_buf()
13489 } else {
13490 root.join(path_hint)
13491 };
13492 if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
13493 return Some(hinted_path);
13494 }
13495 None
13496}
13497
13498fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
13499 let candidate = if path.is_absolute() {
13500 path.to_path_buf()
13501 } else {
13502 source_root.join(path)
13503 };
13504 if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
13505 return false;
13506 }
13507 if !matches!(
13508 candidate.extension().and_then(|ext| ext.to_str()),
13509 Some("md" | "mdx")
13510 ) {
13511 return false;
13512 }
13513 fs::read_to_string(&candidate)
13514 .map(|content| session_markdown::markdown_content_looks_like_agent_doc_session(&content))
13515 .unwrap_or(false)
13516}
13517
13518fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
13519 if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
13520 return Ok(vec![hinted_path]);
13521 }
13522 let mut files = Vec::new();
13523 let walker = ignore::WalkBuilder::new(root)
13524 .hidden(true)
13525 .git_ignore(true)
13526 .git_global(true)
13527 .git_exclude(true)
13528 .build();
13529 for result in walker {
13530 let entry =
13531 result.with_context(|| format!("walking markdown files under {}", root.display()))?;
13532 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
13533 continue;
13534 }
13535 if traversal_path_is_generated_artifact(root, root, entry.path()) {
13536 continue;
13537 }
13538 if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
13539 files.push(entry.path().to_path_buf());
13540 }
13541 }
13542 files.sort();
13543 Ok(files)
13544}
13545
13546fn traversal_watermark_path(root: &Path, path: &Path) -> String {
13547 path.strip_prefix(root)
13548 .unwrap_or(path)
13549 .to_string_lossy()
13550 .replace('\\', "/")
13551}
13552
13553fn push_traversal_metadata_watermark_part(
13554 root: &Path,
13555 path: &Path,
13556 label: &str,
13557 parts: &mut Vec<String>,
13558) {
13559 let display = traversal_watermark_path(root, path);
13560 match fs::metadata(path) {
13561 Ok(metadata) => {
13562 let (secs, nanos) = metadata
13563 .modified()
13564 .ok()
13565 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
13566 .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
13567 .unwrap_or((0, 0));
13568 parts.push(format!(
13569 "{label}:{display}:len={}:mtime={secs}.{nanos}",
13570 metadata.len()
13571 ));
13572 }
13573 Err(_) => parts.push(format!("{label}:{display}:missing")),
13574 }
13575}
13576
13577#[derive(Serialize)]
13578struct TraversalSummaryWatermarkRow<'a> {
13579 symbol_name: &'a str,
13580 file_path: &'a str,
13581 entities: &'a Option<Vec<summarize::Entity>>,
13582 relationships: &'a Option<Vec<summarize::Relationship>>,
13583 concept_labels: &'a Option<Vec<String>>,
13584}
13585
13586fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
13587 let summaries_db = root.join(".tsift/summaries.db");
13588 if !summaries_db.exists() {
13589 parts.push("summaries_db:absent".to_string());
13590 return Ok(());
13591 }
13592
13593 match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
13594 .and_then(|summary_db| summary_db.all())
13595 {
13596 Ok(summaries) => {
13597 let rows = summaries
13598 .iter()
13599 .map(|summary| TraversalSummaryWatermarkRow {
13600 symbol_name: &summary.symbol_name,
13601 file_path: &summary.file_path,
13602 entities: &summary.entities,
13603 relationships: &summary.relationships,
13604 concept_labels: &summary.concept_labels,
13605 })
13606 .collect::<Vec<_>>();
13607 parts.push(format!(
13608 "summaries_db:rows={}:semantic_hash={}",
13609 rows.len(),
13610 content_hash(&rows)?
13611 ));
13612 }
13613 Err(_) => {
13614 push_traversal_metadata_watermark_part(
13615 root,
13616 &summaries_db,
13617 "summaries_db_unreadable",
13618 parts,
13619 );
13620 }
13621 }
13622 Ok(())
13623}
13624
13625#[cfg(test)]
13626fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
13627 resolution::relative_path_is_generated_artifact(relative)
13628}
13629
13630fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
13631 resolution::path_is_generated_artifact(root, source_root, path)
13632}
13633
13634fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
13635 resolution::index_snapshot_part_is_generated(root, source_root, part)
13636}
13637
13638pub(crate) fn traversal_source_watermark(
13639 root: &Path,
13640 path_hint: &Path,
13641 scope: Option<&str>,
13642 session_only: bool,
13643) -> Result<Option<String>> {
13644 let mut parts = vec![
13645 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
13646 format!("scope:{}", scope.unwrap_or("root")),
13647 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
13648 format!("session_only:{session_only}"),
13649 ];
13650
13651 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13652 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13653 Ok(targets) => targets,
13654 Err(_) => return Ok(None),
13655 };
13656 let Some(target) = targets.into_iter().next() else {
13657 return Ok(None);
13658 };
13659 let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
13660 Ok(db) => db,
13661 Err(_) => return Ok(None),
13662 };
13663 parts.push(format!("index_label:{}", target.label));
13664 parts.push(format!(
13665 "index_scope:{}",
13666 target.scope_name.as_deref().unwrap_or("root")
13667 ));
13668 parts.push(format!(
13669 "index_source_root:{}",
13670 traversal_watermark_path(root, &target.source_root)
13671 ));
13672 let mut snapshot_rows = 0usize;
13673 for part in db.source_snapshot_parts()? {
13674 if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
13675 continue;
13676 }
13677 snapshot_rows += 1;
13678 parts.push(format!("index_snapshot:{part}"));
13679 }
13680 parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
13681 }
13682
13683 let markdown_files = markdown_files_for_traversal(root, path_hint)?;
13684 parts.push(format!("markdown_count:{}", markdown_files.len()));
13685 for markdown_path in markdown_files {
13686 push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
13687 }
13688
13689 push_traversal_summaries_watermark_part(root, &mut parts)?;
13690
13691 Ok(Some(content_hash(&parts)?))
13692}
13693
13694fn ranked_symbol_matches<'a>(
13695 query_tokens: &BTreeSet<String>,
13696 entries: &'a [TraversalSymbolIndexEntry],
13697 index: &HashMap<String, Vec<usize>>,
13698) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13699 let mut scores = BTreeMap::<usize, usize>::new();
13700 for token in query_tokens {
13701 if let Some(indices) = index.get(token) {
13702 for idx in indices {
13703 *scores.entry(*idx).or_default() += 1;
13704 }
13705 }
13706 }
13707 let mut matches = scores
13708 .into_iter()
13709 .map(|(idx, score)| (score, &entries[idx]))
13710 .collect::<Vec<_>>();
13711 matches.sort_by(|(left_score, left), (right_score, right)| {
13712 right_score
13713 .cmp(left_score)
13714 .then_with(|| left.node.label.cmp(&right.node.label))
13715 .then_with(|| left.handle.cmp(&right.handle))
13716 });
13717 matches
13718}
13719
13720fn ranked_file_matches<'a>(
13721 query_tokens: &BTreeSet<String>,
13722 entries: &'a [TraversalFileIndexEntry],
13723 index: &HashMap<String, Vec<usize>>,
13724) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13725 let mut scores = BTreeMap::<usize, usize>::new();
13726 for token in query_tokens {
13727 if let Some(indices) = index.get(token) {
13728 for idx in indices {
13729 *scores.entry(*idx).or_default() += 1;
13730 }
13731 }
13732 }
13733 let mut matches = scores
13734 .into_iter()
13735 .map(|(idx, score)| (score, &entries[idx]))
13736 .collect::<Vec<_>>();
13737 matches.sort_by(|(left_score, left), (right_score, right)| {
13738 right_score
13739 .cmp(left_score)
13740 .then_with(|| left.node.label.cmp(&right.node.label))
13741 .then_with(|| left.handle.cmp(&right.handle))
13742 });
13743 matches
13744}
13745
13746fn ranked_route_matches<'a>(
13747 query_tokens: &BTreeSet<String>,
13748 entries: &'a [TraversalRouteIndexEntry],
13749 index: &HashMap<String, Vec<usize>>,
13750) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13751 let mut scores = BTreeMap::<usize, usize>::new();
13752 for token in query_tokens {
13753 if let Some(indices) = index.get(token) {
13754 for idx in indices {
13755 *scores.entry(*idx).or_default() += 1;
13756 }
13757 }
13758 }
13759 let mut matches = scores
13760 .into_iter()
13761 .map(|(idx, score)| (score, &entries[idx]))
13762 .collect::<Vec<_>>();
13763 matches.sort_by(|(left_score, left), (right_score, right)| {
13764 right_score
13765 .cmp(left_score)
13766 .then_with(|| left.node.label.cmp(&right.node.label))
13767 .then_with(|| left.handle.cmp(&right.handle))
13768 });
13769 matches
13770}
13771
13772fn ranked_multiplicity_matches<'a>(
13773 query_tokens: &BTreeSet<String>,
13774 entries: &'a [TraversalMultiplicityIndexEntry],
13775 index: &HashMap<String, Vec<usize>>,
13776) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13777 let mut scores = BTreeMap::<usize, usize>::new();
13778 for token in query_tokens {
13779 if let Some(indices) = index.get(token) {
13780 for idx in indices {
13781 *scores.entry(*idx).or_default() += 1;
13782 }
13783 }
13784 }
13785 let mut matches = scores
13786 .into_iter()
13787 .map(|(idx, score)| (score, &entries[idx]))
13788 .collect::<Vec<_>>();
13789 matches.sort_by(|(left_score, left), (right_score, right)| {
13790 right_score
13791 .cmp(left_score)
13792 .then_with(|| left.node.kind.cmp(&right.node.kind))
13793 .then_with(|| left.node.label.cmp(&right.node.label))
13794 .then_with(|| left.handle.cmp(&right.handle))
13795 });
13796 matches
13797}
13798
13799fn link_backlog_to_code_nodes(
13800 graph: &mut TraversalGraphBuild,
13801 backlog: &TraversalNode,
13802 text: &str,
13803 lookup: &TraversalCodeLookup<'_>,
13804 limit: usize,
13805) {
13806 let mut query_tokens = traversal_tokens(text);
13807 if let Some(ref_id) = &backlog.ref_id {
13808 query_tokens.extend(traversal_tokens(ref_id));
13809 }
13810 if query_tokens.is_empty() {
13811 return;
13812 }
13813
13814 for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13815 .into_iter()
13816 .take(limit)
13817 {
13818 graph.add_edge(
13819 &backlog.handle,
13820 &entry.handle,
13821 "mentions",
13822 Some("backlog text matches symbol tokens".to_string()),
13823 score,
13824 );
13825 }
13826
13827 for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13828 .into_iter()
13829 .take(limit.min(5))
13830 {
13831 graph.add_edge(
13832 &backlog.handle,
13833 &entry.handle,
13834 "mentions",
13835 Some("backlog text matches file tokens".to_string()),
13836 score,
13837 );
13838 }
13839
13840 for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13841 .into_iter()
13842 .take(limit.min(5))
13843 {
13844 graph.add_edge(
13845 &backlog.handle,
13846 &entry.handle,
13847 "mentions",
13848 Some("backlog text matches route tokens".to_string()),
13849 score,
13850 );
13851 }
13852
13853 for (score, entry) in ranked_multiplicity_matches(
13854 &query_tokens,
13855 lookup.multiplicities,
13856 &lookup.multiplicity_index,
13857 )
13858 .into_iter()
13859 .take(limit.min(5))
13860 {
13861 graph.add_edge(
13862 &backlog.handle,
13863 &entry.handle,
13864 "mentions",
13865 Some("backlog text matches multiplicity tokens".to_string()),
13866 score,
13867 );
13868 }
13869}
13870
13871fn load_agent_doc_traversal_nodes(
13872 root: &Path,
13873 path_hint: &Path,
13874 graph: &mut TraversalGraphBuild,
13875 lookup: &TraversalCodeLookup<'_>,
13876) -> Result<()> {
13877 for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13878 let content = match fs::read_to_string(&markdown_path) {
13879 Ok(content) => content,
13880 Err(err) => {
13881 graph.warnings.push(format!(
13882 "session artifact unavailable: {}: {err}",
13883 markdown_path.display()
13884 ));
13885 continue;
13886 }
13887 };
13888 let Some(document) = AgentDocSessionDocument::parse_if_session(&content) else {
13889 continue;
13890 };
13891
13892 let session = traversal_session_node(root, &markdown_path, document.session_id.as_deref());
13893 graph.add_node(session.clone());
13894 let lines = content.lines().collect::<Vec<_>>();
13895 let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13896 for item in &document.backlog_items {
13897 let backlog = traversal_backlog_node(
13898 root,
13899 &markdown_path,
13900 &item.id,
13901 &item.text,
13902 item.line as i64,
13903 );
13904 graph.add_node(backlog.clone());
13905 backlog_by_id.insert(item.id.clone(), backlog.clone());
13906 graph.add_edge(
13907 &session.handle,
13908 &backlog.handle,
13909 "contains",
13910 Some("session backlog item".to_string()),
13911 1,
13912 );
13913 link_backlog_to_code_nodes(graph, &backlog, &item.text, lookup, 8);
13914 }
13915
13916 let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13917 for item in &document.queue_items {
13918 match item {
13919 AgentDocQueueItem::Dispatch { value, line }
13920 | AgentDocQueueItem::Preset { value, line } => {
13921 let dispatch_ref = value.strip_prefix('#').unwrap_or(value.as_str());
13922 let node = traversal_job_packet_node(
13923 root,
13924 &markdown_path,
13925 &format!("dispatch {value}"),
13926 Some(dispatch_ref),
13927 "agent-doc dispatch preset",
13928 *line as i64,
13929 );
13930 graph.add_node(node.clone());
13931 graph.add_edge(
13932 &session.handle,
13933 &node.handle,
13934 "contains",
13935 Some("session queued dispatch".to_string()),
13936 1,
13937 );
13938 }
13939 AgentDocQueueItem::Do { id, line } => {
13940 let detail = backlog_by_id
13941 .get(id)
13942 .and_then(|node| node.detail.clone())
13943 .unwrap_or_else(|| "queued backlog item".to_string());
13944 let node = traversal_job_packet_node(
13945 root,
13946 &markdown_path,
13947 &format!("do #{id}"),
13948 Some(id),
13949 &detail,
13950 *line as i64,
13951 );
13952 graph.add_node(node.clone());
13953 graph.add_edge(
13954 &session.handle,
13955 &node.handle,
13956 "contains",
13957 Some("session queued job packet".to_string()),
13958 1,
13959 );
13960 if let Some(backlog) = backlog_by_id.get(id) {
13961 graph.add_edge(
13962 &node.handle,
13963 &backlog.handle,
13964 "targets",
13965 Some("queued backlog item".to_string()),
13966 1,
13967 );
13968 }
13969 job_by_id.insert(id.clone(), node);
13970 }
13971 }
13972 }
13973
13974 let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13975 for (idx, line) in lines.iter().enumerate() {
13976 for parsed in parse_worker_result_line(line, lookup) {
13977 let line_no = idx as i64 + 1;
13978 if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13979 continue;
13980 }
13981 let result =
13982 traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13983 graph.add_node(result.clone());
13984 graph.add_edge(
13985 &session.handle,
13986 &result.handle,
13987 "contains",
13988 Some("session worker result".to_string()),
13989 1,
13990 );
13991 if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13992 graph.add_edge(
13993 &backlog.handle,
13994 &result.handle,
13995 "has_result",
13996 Some(format!("worker result {}", parsed.status)),
13997 1,
13998 );
13999 }
14000 if let Some(job) = job_by_id.get(&parsed.id) {
14001 graph.add_edge(
14002 &job.handle,
14003 &result.handle,
14004 "has_result",
14005 Some(format!("queued worker result {}", parsed.status)),
14006 1,
14007 );
14008 }
14009 let mut result_text = line.to_string();
14010 if !parsed.touched_files.is_empty() {
14011 result_text.push(' ');
14012 result_text.push_str(&parsed.touched_files.join(" "));
14013 }
14014 link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
14015 }
14016 }
14017 }
14018 Ok(())
14019}
14020
14021#[derive(Debug, Clone)]
14022struct AgentDocIndexGate {
14023 db_path: Option<PathBuf>,
14024 source_root: PathBuf,
14025 diagnostics: Vec<String>,
14026}
14027
14028#[derive(Clone, Hash, PartialEq, Eq)]
14029struct AgentDocIndexGateCacheKey {
14030 root: PathBuf,
14031 path_hint: PathBuf,
14032 scope: Option<String>,
14033 packet_label: String,
14034}
14035
14036fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
14037 std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
14038> {
14039 static CACHE: std::sync::OnceLock<
14040 std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
14041 > = std::sync::OnceLock::new();
14042 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
14043}
14044
14045fn prepare_agent_doc_index_gate_cached(
14046 root: &Path,
14047 path_hint: &Path,
14048 scope: Option<&str>,
14049 packet_label: &str,
14050) -> (AgentDocIndexGate, String) {
14051 let key = AgentDocIndexGateCacheKey {
14052 root: root.to_path_buf(),
14053 path_hint: path_hint.to_path_buf(),
14054 scope: scope.map(str::to_string),
14055 packet_label: packet_label.to_string(),
14056 };
14057 if let Ok(cache) = agent_doc_index_gate_cache().lock()
14058 && let Some(cached) = cache.get(&key)
14059 {
14060 return (
14061 cached.clone(),
14062 "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
14063 );
14064 }
14065 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
14066 if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
14067 cache.insert(key, gate.clone());
14068 }
14069 (
14070 gate,
14071 "fresh inspection/refresh — cache miss on this preparation key".to_string(),
14072 )
14073}
14074
14075fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
14076 match state {
14077 SearchIndexState::Fresh => None,
14078 SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
14079 SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
14080 }
14081}
14082
14083fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
14084 rebuild_search_target_detail(&RebuildSearchTarget {
14085 label: target.label.clone(),
14086 reason,
14087 reindex_cmd: target.reindex_cmd.clone(),
14088 })
14089}
14090
14091fn index_refresh_diagnostic(
14092 target: &SearchIndexTarget,
14093 reason: RebuildSearchReason,
14094 summary: &index::IndexSummary,
14095 packet_label: &str,
14096) -> String {
14097 let changed = summary.new + summary.modified + summary.deleted;
14098 format!(
14099 "index refreshed: {}; updated {} changed file{} before {}",
14100 index_reason_detail(target, reason),
14101 changed,
14102 if changed == 1 { "" } else { "s" },
14103 packet_label
14104 )
14105}
14106
14107fn index_refresh_fallback_diagnostic(
14108 target: &SearchIndexTarget,
14109 reason: RebuildSearchReason,
14110 err: &anyhow::Error,
14111 packet_label: &str,
14112) -> String {
14113 format!(
14114 "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
14115 index_reason_detail(target, reason),
14116 packet_label
14117 )
14118}
14119
14120fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
14121 if let Some(scope_name) = scope
14122 && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
14123 {
14124 return scope.source_root;
14125 }
14126 if let Some(scope_name) = scope
14127 && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
14128 {
14129 return package.package_root;
14130 }
14131 if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
14132 return scope.source_root;
14133 }
14134 if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
14135 return package.package_root;
14136 }
14137 if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
14138 return scope.source_root;
14139 }
14140 root.to_path_buf()
14141}
14142
14143fn prepare_agent_doc_index_gate(
14144 root: &Path,
14145 path_hint: &Path,
14146 scope: Option<&str>,
14147 packet_label: &str,
14148) -> AgentDocIndexGate {
14149 let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
14150 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
14151 Ok(targets) => targets,
14152 Err(err) => {
14153 return AgentDocIndexGate {
14154 db_path: None,
14155 source_root: fallback_source_root,
14156 diagnostics: vec![format!(
14157 "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14158 )],
14159 };
14160 }
14161 };
14162 let Some(target) = targets.into_iter().next() else {
14163 return AgentDocIndexGate {
14164 db_path: None,
14165 source_root: fallback_source_root,
14166 diagnostics: vec![format!(
14167 "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
14168 )],
14169 };
14170 };
14171
14172 let state = match inspect_search_index(&target) {
14173 Ok(state) => state,
14174 Err(err) => {
14175 return AgentDocIndexGate {
14176 db_path: None,
14177 source_root: target.source_root,
14178 diagnostics: vec![format!(
14179 "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14180 )],
14181 };
14182 }
14183 };
14184
14185 let Some(reason) = index_reason_for_state(state) else {
14186 return AgentDocIndexGate {
14187 db_path: Some(target.db_path),
14188 source_root: target.source_root,
14189 diagnostics: Vec::new(),
14190 };
14191 };
14192
14193 match apply_search_index_update(root, &target) {
14194 Ok(summary) => {
14195 index::inspect_scope_invalidate_all();
14201 let diagnostics = vec![index_refresh_diagnostic(
14202 &target,
14203 reason,
14204 &summary,
14205 packet_label,
14206 )];
14207 AgentDocIndexGate {
14208 db_path: Some(target.db_path),
14209 source_root: target.source_root,
14210 diagnostics,
14211 }
14212 }
14213 Err(err) => {
14214 let diagnostics = vec![index_refresh_fallback_diagnostic(
14215 &target,
14216 reason,
14217 &err,
14218 packet_label,
14219 )];
14220 AgentDocIndexGate {
14221 db_path: None,
14222 source_root: target.source_root,
14223 diagnostics,
14224 }
14225 }
14226 }
14227}
14228
14229fn add_raw_source_file_nodes(
14230 root: &Path,
14231 source_root: &Path,
14232 graph: &mut TraversalGraphBuild,
14233 file_entries: &mut Vec<TraversalFileIndexEntry>,
14234) -> Result<()> {
14235 let mut entries = walk::walk_files(source_root)?;
14236 entries.sort_by(|left, right| left.path.cmp(&right.path));
14237 for entry in entries {
14238 let file = entry.path.to_string_lossy();
14239 let node = traversal_raw_source_file_node(root, file.as_ref());
14240 let entry = TraversalFileIndexEntry {
14241 handle: node.handle.clone(),
14242 tokens: traversal_node_tokens(&node),
14243 node: node.clone(),
14244 };
14245 graph.add_node(node);
14246 file_entries.push(entry);
14247 }
14248 Ok(())
14249}
14250
14251fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
14252 if scope_root.is_empty() {
14253 return true;
14254 }
14255 path == scope_root || path.starts_with(&format!("{scope_root}/"))
14256}
14257
14258fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
14259 let path = Path::new(file);
14260 if path.is_absolute() {
14261 return path.to_path_buf();
14262 }
14263 let source_candidate = source_root.join(path);
14264 if source_candidate.exists() {
14265 source_candidate
14266 } else {
14267 root.join(path)
14268 }
14269}
14270
14271fn cargo_import_alias_from_line(line: &str) -> Option<String> {
14272 let trimmed = line.trim();
14273 let rest = trimmed
14274 .strip_prefix("pub use ")
14275 .or_else(|| trimmed.strip_prefix("use "))
14276 .or_else(|| trimmed.strip_prefix("extern crate "))?;
14277 let alias = rest
14278 .split([':', ';', ' ', '\t'])
14279 .next()
14280 .unwrap_or_default()
14281 .trim();
14282 (!alias.is_empty()).then(|| alias.to_string())
14283}
14284
14285fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
14286 let mut aliases = BTreeSet::new();
14287 for entry in walk::walk_files(&package.package_root)? {
14288 if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
14289 continue;
14290 }
14291 let content = fs::read_to_string(&entry.path)
14292 .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
14293 aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
14294 }
14295 Ok(aliases)
14296}
14297
14298fn load_multiplicity_traversal_nodes(
14299 root: &Path,
14300 source_root: &Path,
14301 graph: &mut TraversalGraphBuild,
14302 file_handle_by_path: &HashMap<String, String>,
14303 multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
14304) -> Result<()> {
14305 let inventory = multiplicity::discover_cargo_inventory(source_root)?;
14306 let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
14307 for workspace in &inventory.workspaces {
14308 let node = traversal_cargo_workspace_node(root, workspace);
14309 workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
14310 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14311 handle: node.handle.clone(),
14312 tokens: traversal_node_tokens(&node),
14313 node: node.clone(),
14314 });
14315 graph.add_node(node);
14316 }
14317
14318 let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
14319 let mut package_nodes = Vec::new();
14320 for package in &inventory.packages {
14321 let node = traversal_cargo_package_node(root, package);
14322 package_handle_by_name
14323 .entry(package.name.clone())
14324 .or_default()
14325 .push(node.handle.clone());
14326 package_handle_by_name
14327 .entry(package.normalized_name.clone())
14328 .or_default()
14329 .push(node.handle.clone());
14330 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14331 handle: node.handle.clone(),
14332 tokens: traversal_node_tokens(&node),
14333 node: node.clone(),
14334 });
14335 graph.add_node(node.clone());
14336 package_nodes.push((package, node));
14337 }
14338
14339 for (package, node) in &package_nodes {
14340 if let Some(workspace_handle) =
14341 workspace_handle_by_root.get(&package.relative_workspace_root)
14342 {
14343 graph.add_edge(
14344 workspace_handle,
14345 &node.handle,
14346 "contains_package",
14347 Some("Cargo workspace member package".to_string()),
14348 1,
14349 );
14350 }
14351 let package_root = relativize_pathbuf(&package.package_root, root)
14352 .to_string_lossy()
14353 .replace('\\', "/");
14354 for (file, handle) in file_handle_by_path {
14355 if relative_path_inside_scope(file, &package_root) {
14356 graph.add_edge(
14357 &node.handle,
14358 handle,
14359 "owns_file",
14360 Some("Cargo package owns source file".to_string()),
14361 1,
14362 );
14363 }
14364 }
14365 for dependency in &package.dependencies {
14366 if let Some(handles) = package_handle_by_name.get(&dependency.name)
14367 && handles.len() == 1
14368 {
14369 graph.add_edge(
14370 &node.handle,
14371 &handles[0],
14372 "declares_dependency",
14373 Some(format!("{} Cargo dependency", dependency.kind)),
14374 1,
14375 );
14376 }
14377 }
14378 for alias in cargo_import_aliases(package)? {
14379 if let Some(handles) = package_handle_by_name.get(&alias)
14380 && handles.len() == 1
14381 && handles[0] != node.handle
14382 {
14383 graph.add_edge(
14384 &node.handle,
14385 &handles[0],
14386 "uses_crate",
14387 Some("Rust use/extern crate reference".to_string()),
14388 1,
14389 );
14390 graph.add_edge(
14391 &node.handle,
14392 &handles[0],
14393 "imports",
14394 Some("Rust use/extern crate import".to_string()),
14395 1,
14396 );
14397 }
14398 }
14399 }
14400
14401 Ok(())
14402}
14403
14404fn build_traversal_graph_source_with_options(
14405 root: &Path,
14406 path_hint: &Path,
14407 scope: Option<&str>,
14408 session_only: bool,
14409) -> Result<TraversalGraphBuild> {
14410 let mut graph = TraversalGraphBuild::default();
14411 let mut symbol_entries = Vec::new();
14412 let mut file_entries = Vec::new();
14413 let mut route_entries = Vec::new();
14414 let mut multiplicity_entries = Vec::new();
14415 let mut file_handle_by_path = HashMap::<String, String>::new();
14416 let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
14417 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
14418 let (gate, _cache_detail) =
14419 prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
14420 graph.warnings.extend(gate.diagnostics);
14421 let gate_source_root = gate.source_root.clone();
14422
14423 match gate.db_path {
14424 Some(db_path) if db_path.exists() => {
14425 let db = index::IndexDb::open_read_only_resilient(&db_path)?;
14426 let file_paths = db.file_paths()?;
14427 for file in file_paths {
14428 if traversal_path_is_generated_artifact(
14429 root,
14430 &gate_source_root,
14431 Path::new(&file),
14432 ) {
14433 continue;
14434 }
14435 let node = traversal_file_node(root, &file);
14436 let entry = TraversalFileIndexEntry {
14437 handle: node.handle.clone(),
14438 tokens: traversal_node_tokens(&node),
14439 node: node.clone(),
14440 };
14441 if let Some(path) = entry.node.path.as_ref() {
14442 file_handle_by_path.insert(path.clone(), entry.handle.clone());
14443 }
14444 graph.add_node(node);
14445 file_entries.push(entry);
14446 }
14447
14448 let symbols = db.all_symbols()?;
14449 let mut symbols_by_file = HashMap::<String, Vec<&index::StoredSymbol>>::new();
14452 for symbol in &symbols {
14453 symbols_by_file
14454 .entry(symbol.file.clone())
14455 .or_default()
14456 .push(symbol);
14457 }
14458 let mut symbol_by_file_name_line = HashMap::new();
14459 let mut span_by_file_name_line = HashMap::new();
14460 let mut first_symbol_by_name = BTreeMap::<String, String>::new();
14461 let mut first_span_by_name = BTreeMap::<String, String>::new();
14462 let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
14463 let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
14464 for symbol in symbols.iter().filter(|symbol| {
14465 !traversal_path_is_generated_artifact(
14466 root,
14467 &gate_source_root,
14468 Path::new(&symbol.file),
14469 )
14470 }) {
14471 let node = traversal_symbol_node(root, symbol);
14472 let file = relativize(&symbol.file, root);
14473 symbol_by_file_name_line.insert(
14474 format!("{file}:{}:{}", symbol.line, symbol.name),
14475 node.handle.clone(),
14476 );
14477 first_symbol_by_name
14478 .entry(symbol.name.clone())
14479 .or_insert_with(|| node.handle.clone());
14480 let entry = TraversalSymbolIndexEntry {
14481 handle: node.handle.clone(),
14482 tokens: traversal_node_tokens(&node),
14483 node: node.clone(),
14484 };
14485 graph.add_node(node.clone());
14486 if let Some(file_handle) = file_handle_by_path.get(&file) {
14487 graph.add_edge(
14488 file_handle,
14489 &node.handle,
14490 "defines",
14491 Some("file defines symbol".to_string()),
14492 1,
14493 );
14494 }
14495 if !source_by_file.contains_key(&symbol.file) {
14496 let source_path =
14497 traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
14498 source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
14499 }
14500 if let Some(Some(source)) = source_by_file.get(&symbol.file)
14501 && let Some((ast_node, mut ast_entry)) =
14502 traversal_ast_span_node(
14503 root,
14504 symbol,
14505 source,
14506 symbols_by_file
14507 .get(&symbol.file)
14508 .map(Vec::as_slice)
14509 .unwrap_or(&[]),
14510 )
14511 {
14512 ast_entry.symbol_handle = node.handle.clone();
14513 ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
14514 span_by_file_name_line.insert(
14515 format!("{file}:{}:{}", symbol.line, symbol.name),
14516 ast_node.handle.clone(),
14517 );
14518 first_span_by_name
14519 .entry(symbol.name.clone())
14520 .or_insert_with(|| ast_node.handle.clone());
14521 graph.add_node(ast_node.clone());
14522 graph.add_edge(
14523 &node.handle,
14524 &ast_node.handle,
14525 "has_ast_span",
14526 Some("symbol projects to indexed AST span".to_string()),
14527 1,
14528 );
14529 graph.add_edge(
14530 &ast_node.handle,
14531 &node.handle,
14532 "represents_symbol",
14533 Some("AST span represents indexed symbol".to_string()),
14534 1,
14535 );
14536 ast_entries.push(ast_entry);
14537 }
14538 symbol_entries.push(entry);
14539 }
14540 link_ast_navigation_edges(&mut graph, &ast_entries);
14541 link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
14542
14543 if !bounded_session_projection {
14544 for edge in db.all_stored_edges()? {
14545 if traversal_path_is_generated_artifact(
14546 root,
14547 &gate_source_root,
14548 Path::new(&edge.caller_file),
14549 ) {
14550 continue;
14551 }
14552 let caller_file = relativize(&edge.caller_file, root);
14553 let caller_key =
14554 format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
14555 let Some(caller_handle) =
14556 symbol_by_file_name_line.get(&caller_key).cloned()
14557 else {
14558 continue;
14559 };
14560 let callee_handle = if let Some(handle) =
14561 first_symbol_by_name.get(&edge.callee_name)
14562 {
14563 handle.clone()
14564 } else {
14565 let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
14566 let handle = node.handle.clone();
14567 graph.add_node(node);
14568 handle
14569 };
14570 graph.add_edge(
14571 &caller_handle,
14572 &callee_handle,
14573 "calls",
14574 Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
14575 1,
14576 );
14577 if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
14578 && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
14579 {
14580 graph.add_edge(
14581 caller_span,
14582 callee_span,
14583 "calls",
14584 Some(format!(
14585 "AST call site {}:{}",
14586 caller_file, edge.call_site_line
14587 )),
14588 1,
14589 );
14590 }
14591 }
14592 }
14593
14594 for route in db.all_routes()? {
14595 if traversal_path_is_generated_artifact(
14596 root,
14597 &gate_source_root,
14598 Path::new(&route.file),
14599 ) {
14600 continue;
14601 }
14602 let node = traversal_route_node(root, &route);
14603 let entry = TraversalRouteIndexEntry {
14604 handle: node.handle.clone(),
14605 tokens: traversal_node_tokens(&node),
14606 node: node.clone(),
14607 };
14608 graph.add_node(node.clone());
14609 if let Some(path) = node.path.as_ref()
14610 && let Some(file_handle) = file_handle_by_path.get(path)
14611 {
14612 graph.add_edge(
14613 file_handle,
14614 &node.handle,
14615 "defines_route",
14616 Some("file declares route".to_string()),
14617 1,
14618 );
14619 }
14620 let handler_handle =
14621 if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
14622 handle.clone()
14623 } else {
14624 let node = traversal_unresolved_symbol_node(root, &route.handler_name);
14625 let handle = node.handle.clone();
14626 graph.add_node(node);
14627 handle
14628 };
14629 graph.add_edge(
14630 &entry.handle,
14631 &handler_handle,
14632 "handled_by",
14633 Some("route handler reference".to_string()),
14634 1,
14635 );
14636 if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
14637 graph.add_edge(
14638 &entry.handle,
14639 handler_span,
14640 "handled_by",
14641 Some("route handler AST span".to_string()),
14642 1,
14643 );
14644 graph.add_edge(
14645 handler_span,
14646 &entry.handle,
14647 "handles_route",
14648 Some("AST span handles route".to_string()),
14649 1,
14650 );
14651 }
14652 route_entries.push(entry);
14653 }
14654 }
14655 _ => {
14656 add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
14657 .with_context(|| {
14658 format!(
14659 "loading raw source fallback nodes from {}",
14660 gate_source_root.display()
14661 )
14662 })?;
14663 for entry in &file_entries {
14664 if let Some(path) = entry.node.path.as_ref() {
14665 file_handle_by_path.insert(path.clone(), entry.handle.clone());
14666 }
14667 }
14668 }
14669 }
14670 load_multiplicity_traversal_nodes(
14671 root,
14672 &gate_source_root,
14673 &mut graph,
14674 &file_handle_by_path,
14675 &mut multiplicity_entries,
14676 )?;
14677 }
14678
14679 let code_lookup = TraversalCodeLookup::new(
14680 &symbol_entries,
14681 &file_entries,
14682 &route_entries,
14683 &multiplicity_entries,
14684 );
14685 load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14686 Ok(graph)
14687}
14688
14689#[cfg(test)]
14690fn build_traversal_graph_source(
14691 root: &Path,
14692 path_hint: &Path,
14693 scope: Option<&str>,
14694) -> Result<TraversalGraphBuild> {
14695 build_traversal_graph_source_with_options(root, path_hint, scope, false)
14696}
14697
14698const GRAPH_DB_WRITE_LOCK_TIMEOUT: Duration = Duration::from_secs(15);
14703const GRAPH_DB_WRITE_LOCK_POLL: Duration = Duration::from_millis(50);
14704
14705pub(crate) struct GraphDbWriteLock {
14707 file: std::fs::File,
14708}
14709
14710impl Drop for GraphDbWriteLock {
14711 fn drop(&mut self) {
14712 let _ = fs4::fs_std::FileExt::unlock(&self.file);
14713 }
14714}
14715
14716pub(crate) fn graph_db_write_lock_path(graph_db: &Path) -> PathBuf {
14717 let stem = graph_db
14718 .file_stem()
14719 .and_then(|stem| stem.to_str())
14720 .unwrap_or("graph");
14721 graph_db.with_file_name(format!("{stem}.write.lock"))
14722}
14723
14724pub(crate) fn acquire_graph_db_write_lock(graph_db: &Path) -> Result<GraphDbWriteLock> {
14731 acquire_graph_db_write_lock_with_timeout(graph_db, GRAPH_DB_WRITE_LOCK_TIMEOUT)
14732}
14733
14734pub(crate) fn acquire_graph_db_write_lock_with_timeout(
14735 graph_db: &Path,
14736 timeout: Duration,
14737) -> Result<GraphDbWriteLock> {
14738 use fs4::fs_std::FileExt;
14739
14740 let lock_path = graph_db_write_lock_path(graph_db);
14741 if let Some(parent) = lock_path.parent() {
14742 fs::create_dir_all(parent)
14743 .with_context(|| format!("creating graph-db lock dir: {}", parent.display()))?;
14744 }
14745 let file = std::fs::OpenOptions::new()
14746 .read(true)
14747 .write(true)
14748 .create(true)
14749 .truncate(false)
14750 .open(&lock_path)
14751 .with_context(|| format!("opening graph-db write lock {}", lock_path.display()))?;
14752
14753 let deadline = Instant::now() + timeout;
14754 loop {
14755 match file.try_lock_exclusive() {
14756 Ok(true) => return Ok(GraphDbWriteLock { file }),
14757 Ok(false) => {
14758 if Instant::now() >= deadline {
14759 bail!(
14760 "another tsift graph-db writer is active for {} (lock: {}); \
14761 a concurrent graph-db refresh or snapshot-import is in progress, \
14762 wait for it to finish before retrying",
14763 graph_db.display(),
14764 lock_path.display()
14765 );
14766 }
14767 std::thread::sleep(GRAPH_DB_WRITE_LOCK_POLL);
14768 }
14769 Err(err) => {
14770 return Err(err).with_context(|| {
14771 format!("locking graph-db write lock {}", lock_path.display())
14772 });
14773 }
14774 }
14775 }
14776}
14777
14778pub(crate) fn write_traversal_graph_store_with_options(
14779 root: &Path,
14780 path_hint: &Path,
14781 scope: Option<&str>,
14782 session_only: bool,
14783) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14784 let source_graph =
14785 build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14786 let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14787 let graph_db = graph_substrate_db_path(root, scope);
14788 let _write_lock = acquire_graph_db_write_lock(&graph_db)?;
14790 let mut store = SqliteGraphStore::open(&graph_db)?;
14791 let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14792 .ok()
14793 .flatten()
14794 .or_else(|| graph_projection_content_hash(&projection));
14795 let refresh = store.replace_projection_with_version(
14796 scope.unwrap_or("root"),
14797 &projection,
14798 Some(GRAPH_PROJECTION_VERSION),
14799 source_watermark,
14800 )?;
14801 Ok((source_graph, refresh))
14802}
14803
14804pub(crate) fn write_traversal_graph_store(
14805 root: &Path,
14806 path_hint: &Path,
14807 scope: Option<&str>,
14808) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14809 write_traversal_graph_store_with_options(root, path_hint, scope, false)
14810}
14811
14812fn refresh_traversal_graph_store_with_options(
14813 root: &Path,
14814 path_hint: &Path,
14815 scope: Option<&str>,
14816 session_only: bool,
14817) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14818 let (source_graph, refresh) =
14819 write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14820 let graph_db = graph_substrate_db_path(root, scope);
14821 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14822 let mut graph = traversal_graph_from_store(root, &store)?;
14823 graph.warnings = source_graph.warnings;
14824 Ok((graph, refresh))
14825}
14826
14827fn refresh_traversal_graph_store(
14828 root: &Path,
14829 path_hint: &Path,
14830 scope: Option<&str>,
14831) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14832 refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14833}
14834
14835pub(crate) fn build_traversal_graph(
14836 root: &Path,
14837 path_hint: &Path,
14838 scope: Option<&str>,
14839) -> Result<TraversalGraphBuild> {
14840 let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14841 Ok(graph)
14842}
14843
14844fn traversal_query_kind_priority(kind: &str) -> usize {
14845 match kind {
14846 "backlog" => 0,
14847 "job_packet" => 1,
14848 "worker_result" => 2,
14849 "symbol" => 3,
14850 "ast_span" => 4,
14851 "file" => 5,
14852 "route" => 6,
14853 "cargo_package" => 7,
14854 "cargo_workspace" => 8,
14855 "session" => 9,
14856 "semantic_concept" => 10,
14857 "semantic_entity" => 11,
14858 _ => 12,
14859 }
14860}
14861
14862fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14863 let trimmed = query.trim();
14864 if trimmed.is_empty() {
14865 return None;
14866 }
14867 let kind_priority = traversal_query_kind_priority(&node.kind);
14868 if node.handle == trimmed {
14869 return Some((0, kind_priority, node.handle.clone()));
14870 }
14871 if node.path.as_deref() == Some(trimmed) {
14872 let path_priority = if node.kind == "file" {
14873 0
14874 } else {
14875 kind_priority.saturating_add(1)
14876 };
14877 return Some((1, path_priority, node.handle.clone()));
14878 }
14879 let normalized_backlog = trimmed.trim_start_matches('#');
14880 if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14881 {
14882 return Some((2, kind_priority, node.handle.clone()));
14883 }
14884 if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14885 return Some((3, kind_priority, node.handle.clone()));
14886 }
14887 None
14888}
14889
14890fn resolve_traversal_node<'a>(
14891 graph: &'a TraversalGraphBuild,
14892 query: &str,
14893) -> Option<&'a TraversalNode> {
14894 graph
14895 .nodes
14896 .values()
14897 .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14898 .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14899 .map(|(_, node)| node)
14900}
14901
14902fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14903 let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14904 for edge in edges {
14905 adj.entry(edge.from.clone())
14906 .or_default()
14907 .insert(edge.to.clone());
14908 adj.entry(edge.to.clone())
14909 .or_default()
14910 .insert(edge.from.clone());
14911 }
14912 adj.into_iter()
14913 .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14914 .collect()
14915}
14916
14917fn traversal_shortest_handles(
14918 edges: &[TraversalEdge],
14919 from: &str,
14920 to: &str,
14921) -> Option<Vec<String>> {
14922 if from == to {
14923 return Some(vec![from.to_string()]);
14924 }
14925 let adj = traversal_adjacency(edges);
14926 if !adj.contains_key(from) || !adj.contains_key(to) {
14927 return None;
14928 }
14929 let mut visited = BTreeSet::new();
14930 let mut queue = VecDeque::new();
14931 let mut parent = BTreeMap::<String, String>::new();
14932 visited.insert(from.to_string());
14933 queue.push_back(from.to_string());
14934 while let Some(current) = queue.pop_front() {
14935 if let Some(neighbors) = adj.get(¤t) {
14936 for neighbor in neighbors {
14937 if visited.insert(neighbor.clone()) {
14938 parent.insert(neighbor.clone(), current.clone());
14939 if neighbor == to {
14940 let mut path = vec![to.to_string()];
14941 let mut cursor = to.to_string();
14942 while let Some(prev) = parent.get(&cursor) {
14943 path.push(prev.clone());
14944 cursor = prev.clone();
14945 }
14946 path.reverse();
14947 return Some(path);
14948 }
14949 queue.push_back(neighbor.clone());
14950 }
14951 }
14952 }
14953 }
14954 None
14955}
14956
14957fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14958 let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14959 for edge in edges {
14960 let neighbor = if edge.from == current {
14961 edge.to.as_str()
14962 } else if edge.to == current {
14963 edge.from.as_str()
14964 } else {
14965 continue;
14966 };
14967 let score = traversal_relation_score(edge, current);
14968 best_score_by_neighbor
14969 .entry(neighbor.to_string())
14970 .and_modify(|best| *best = (*best).max(score))
14971 .or_insert(score);
14972 }
14973 let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14974 ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14975 right_score
14976 .cmp(left_score)
14977 .then_with(|| left_handle.cmp(right_handle))
14978 });
14979 ranked.into_iter().map(|(handle, _)| handle).collect()
14980}
14981
14982fn traversal_neighborhood_handles(
14983 edges: &[TraversalEdge],
14984 origin: &str,
14985 depth: usize,
14986 limit: usize,
14987) -> BTreeSet<String> {
14988 let mut seen = BTreeSet::new();
14989 let mut queue = VecDeque::new();
14990 seen.insert(origin.to_string());
14991 queue.push_back((origin.to_string(), 0usize));
14992 while let Some((current, current_depth)) = queue.pop_front() {
14993 if current_depth >= depth {
14994 continue;
14995 }
14996 for neighbor in traversal_scored_neighbors(edges, ¤t) {
14997 if limit > 0 && seen.len() >= limit {
14998 return seen;
14999 }
15000 if seen.insert(neighbor.clone()) {
15001 queue.push_back((neighbor, current_depth + 1));
15002 }
15003 }
15004 }
15005 seen
15006}
15007
15008fn traversal_edges_between(
15009 handles: &BTreeSet<String>,
15010 edges: &[TraversalEdge],
15011) -> Vec<TraversalEdge> {
15012 edges
15013 .iter()
15014 .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
15015 .cloned()
15016 .collect()
15017}
15018
15019fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
15020 let mut result = Vec::new();
15021 for pair in path.windows(2) {
15022 if let Some(edge) = edges.iter().find(|edge| {
15023 (edge.from == pair[0] && edge.to == pair[1])
15024 || (edge.from == pair[1] && edge.to == pair[0])
15025 }) {
15026 result.push(edge.clone());
15027 }
15028 }
15029 result
15030}
15031
15032fn sorted_traversal_nodes<'a>(
15033 nodes: impl IntoIterator<Item = &'a TraversalNode>,
15034) -> Vec<TraversalNode> {
15035 let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
15036 nodes.sort_by(|left, right| {
15037 left.kind
15038 .cmp(&right.kind)
15039 .then_with(|| left.label.cmp(&right.label))
15040 .then_with(|| left.path.cmp(&right.path))
15041 .then_with(|| left.handle.cmp(&right.handle))
15042 });
15043 nodes
15044}
15045
15046fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
15047 let base = match edge.relation.as_str() {
15048 "mentions" => 100,
15049 "contains" => 80,
15050 "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
15051 "contains_embedded_symbol" | "embedded_in_fence" => 77,
15052 "contains_markdown_block"
15053 | "contains_embedded_code"
15054 | "enclosing_module"
15055 | "enclosing_section" => 76,
15056 "calls" => {
15057 if edge.from == origin {
15058 70
15059 } else {
15060 65
15061 }
15062 }
15063 "handled_by" | "handles_route" => 68,
15064 "defines_route" => 62,
15065 "imports" => 62,
15066 "previous_sibling" | "next_sibling" => 54,
15067 "mentions_concept" | "mentions_entity" => 66,
15068 "semantic_relation" => 64,
15069 "tagged_concept" | "related_concept" => 58,
15070 "defines" => {
15071 if edge.from == origin {
15072 60
15073 } else {
15074 55
15075 }
15076 }
15077 _ => 10,
15078 };
15079 base + edge.weight
15080}
15081
15082fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
15083 match edge.relation.as_str() {
15084 "mentions" => "matched from backlog/session text".to_string(),
15085 "contains" => "contained in the selected session artifact".to_string(),
15086 "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
15087 "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
15088 "parent" => "parent AST span".to_string(),
15089 "child" => "child AST span".to_string(),
15090 "previous_sibling" => "previous AST sibling".to_string(),
15091 "next_sibling" => "next AST sibling".to_string(),
15092 "contains_markdown_block" => "Markdown section block".to_string(),
15093 "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
15094 "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
15095 "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
15096 "enclosing_module" => "nearest enclosing module".to_string(),
15097 "enclosing_section" => "nearest enclosing Markdown section".to_string(),
15098 "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
15099 "defines" => "file that defines the selected symbol".to_string(),
15100 "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
15101 "defines_route" => "file that declares the selected route".to_string(),
15102 "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
15103 "handled_by" => "route handled by the selected symbol".to_string(),
15104 "handles_route" => "route handled by the selected AST span".to_string(),
15105 "imports" => "import dependency from the selected package".to_string(),
15106 "mentions_concept" => "cached summary concept for the selected source".to_string(),
15107 "mentions_entity" => "cached summary entity for the selected source".to_string(),
15108 "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
15109 "tagged_concept" => "concept label attached to the selected entity".to_string(),
15110 "related_concept" => "co-occurring cached summary concept".to_string(),
15111 "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
15112 "calls" => "caller of the selected symbol".to_string(),
15113 other => format!("connected by {other}"),
15114 }
15115}
15116
15117fn traversal_recommendations(
15118 graph: &TraversalGraphBuild,
15119 origin: Option<&str>,
15120 shortest_path: Option<&[String]>,
15121 limit: usize,
15122) -> Vec<TraversalRecommendation> {
15123 let Some(origin) = origin else {
15124 return Vec::new();
15125 };
15126 let mut recommendations = Vec::new();
15127 let mut seen = BTreeSet::new();
15128
15129 if let Some(path) = shortest_path
15130 && path.len() > 1
15131 && path.first().is_some_and(|handle| handle == origin)
15132 && let Some(next) = graph.nodes.get(&path[1])
15133 {
15134 seen.insert(next.handle.clone());
15135 recommendations.push(TraversalRecommendation {
15136 handle: next.handle.clone(),
15137 kind: next.kind.clone(),
15138 label: next.label.clone(),
15139 reason: "next hop on shortest path".to_string(),
15140 score: 1_000,
15141 expand: next.expand.clone(),
15142 });
15143 }
15144
15145 let mut candidates = graph
15146 .edges
15147 .iter()
15148 .filter_map(|edge| {
15149 let neighbor = if edge.from == origin {
15150 edge.to.as_str()
15151 } else if edge.to == origin {
15152 edge.from.as_str()
15153 } else {
15154 return None;
15155 };
15156 let node = graph.nodes.get(neighbor)?;
15157 Some((traversal_relation_score(edge, origin), edge, node))
15158 })
15159 .collect::<Vec<_>>();
15160 candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
15161 right_score
15162 .cmp(left_score)
15163 .then_with(|| left.kind.cmp(&right.kind))
15164 .then_with(|| left.label.cmp(&right.label))
15165 .then_with(|| left.handle.cmp(&right.handle))
15166 });
15167
15168 let max = if limit == 0 { usize::MAX } else { limit };
15169 for (score, edge, node) in candidates {
15170 if recommendations.len() >= max {
15171 break;
15172 }
15173 if seen.insert(node.handle.clone()) {
15174 recommendations.push(TraversalRecommendation {
15175 handle: node.handle.clone(),
15176 kind: node.kind.clone(),
15177 label: node.label.clone(),
15178 reason: traversal_recommendation_reason(edge, origin),
15179 score,
15180 expand: node.expand.clone(),
15181 });
15182 }
15183 }
15184
15185 recommendations
15186}
15187
15188fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
15189 let scale = nodes.saturating_add(edges);
15190 if scale <= 80 {
15191 ExplorationBudget {
15192 project_size: "small".to_string(),
15193 max_source_windows: 8,
15194 lines_per_window: 96,
15195 relationship_limit: 40,
15196 }
15197 } else if scale <= 800 {
15198 ExplorationBudget {
15199 project_size: "medium".to_string(),
15200 max_source_windows: 6,
15201 lines_per_window: 80,
15202 relationship_limit: 32,
15203 }
15204 } else {
15205 ExplorationBudget {
15206 project_size: "large".to_string(),
15207 max_source_windows: 4,
15208 lines_per_window: 64,
15209 relationship_limit: 24,
15210 }
15211 }
15212}
15213
15214fn exploration_node_label(node: &TraversalNode) -> String {
15215 format!("{}:{}", node.kind, node.label)
15216}
15217
15218fn exploration_source_window_for_node(
15219 root: &Path,
15220 node: &TraversalNode,
15221 budget: &ExplorationBudget,
15222) -> Option<ExplorationSourceWindow> {
15223 let file = node.path.as_ref()?;
15224 let anchor = node
15225 .line
15226 .and_then(|line| usize::try_from(line).ok())
15227 .and_then(|line| line.checked_add(1))
15228 .unwrap_or(1);
15229 let context_before = budget.lines_per_window / 3;
15230 let start = anchor.saturating_sub(context_before).max(1);
15231 let end = start
15232 .saturating_add(budget.lines_per_window)
15233 .saturating_sub(1);
15234 let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
15235 Some(ExplorationSourceWindow {
15236 handle,
15237 file: file.clone(),
15238 start,
15239 end,
15240 reason: format!("cluster around {}", exploration_node_label(node)),
15241 expand: source_read_command(root, file, start, budget.lines_per_window),
15242 })
15243}
15244
15245fn build_exploration_packet(
15246 root: &Path,
15247 totals: &TraversalTotals,
15248 selected_nodes: &[TraversalNode],
15249 selected_edges: &[TraversalEdge],
15250) -> ExplorationPacket {
15251 let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
15252 let node_by_handle = selected_nodes
15253 .iter()
15254 .map(|node| (node.handle.as_str(), node))
15255 .collect::<BTreeMap<_, _>>();
15256 let relationship_map = selected_edges
15257 .iter()
15258 .take(budget.relationship_limit)
15259 .filter_map(|edge| {
15260 let from = node_by_handle.get(edge.from.as_str())?;
15261 let to = node_by_handle.get(edge.to.as_str())?;
15262 Some(ExplorationRelation {
15263 from: exploration_node_label(from),
15264 relation: edge.relation.clone(),
15265 to: exploration_node_label(to),
15266 label: edge.label.clone(),
15267 })
15268 })
15269 .collect::<Vec<_>>();
15270
15271 let mut seen_windows = BTreeSet::new();
15272 let mut source_windows = Vec::new();
15273 for node in selected_nodes {
15274 if source_windows.len() >= budget.max_source_windows {
15275 break;
15276 }
15277 let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
15278 continue;
15279 };
15280 let key = (window.file.clone(), window.start, window.end);
15281 if seen_windows.insert(key) {
15282 source_windows.push(window);
15283 }
15284 }
15285
15286 ExplorationPacket {
15287 budget,
15288 relationship_map,
15289 source_windows,
15290 worker_context: Vec::new(),
15291 no_reread_guidance:
15292 "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
15293 .to_string(),
15294 }
15295}
15296
15297pub(crate) fn traversal_report(
15298 root: &Path,
15299 scope: Option<&str>,
15300 graph: TraversalGraphBuild,
15301 query: Option<&str>,
15302 target: Option<&str>,
15303 depth: usize,
15304 limit: usize,
15305) -> Result<TraversalReport> {
15306 let totals = TraversalTotals {
15307 nodes: graph.nodes.len(),
15308 edges: graph.edges.len(),
15309 };
15310 let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
15311 let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
15312 if let Some(query) = query
15313 && origin_node.is_none()
15314 {
15315 bail!("traversal node not found: {}", query);
15316 }
15317 if let Some(target) = target
15318 && target_node.is_none()
15319 {
15320 bail!("traversal target not found: {}", target);
15321 }
15322
15323 let (mode, selected_nodes, selected_edges, shortest_path) =
15324 if let (Some(origin), Some(target)) = (origin_node, target_node) {
15325 if let Some(handles) =
15326 traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
15327 {
15328 let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
15329 let nodes = handles
15330 .iter()
15331 .filter_map(|handle| graph.nodes.get(handle).cloned())
15332 .collect::<Vec<_>>();
15333 let edges = traversal_path_edges(&handles, &graph.edges);
15334 let path = TraversalPathReport {
15335 from: origin.clone(),
15336 to: target.clone(),
15337 hops: handles.len().saturating_sub(1),
15338 nodes: nodes.clone(),
15339 edges: edges.clone(),
15340 };
15341 (
15342 "path".to_string(),
15343 nodes,
15344 traversal_edges_between(&handle_set, &graph.edges),
15345 Some(path),
15346 )
15347 } else {
15348 (
15349 "path".to_string(),
15350 vec![origin.clone(), target.clone()],
15351 Vec::new(),
15352 None,
15353 )
15354 }
15355 } else if let Some(origin) = origin_node {
15356 let handles =
15357 traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
15358 let nodes =
15359 sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
15360 let edges = traversal_edges_between(&handles, &graph.edges);
15361 ("neighborhood".to_string(), nodes, edges, None)
15362 } else {
15363 let mut nodes = sorted_traversal_nodes(graph.nodes.values());
15364 let truncated_nodes = limit > 0 && nodes.len() > limit;
15365 if truncated_nodes {
15366 nodes.truncate(limit);
15367 }
15368 let handles = nodes
15369 .iter()
15370 .map(|node| node.handle.clone())
15371 .collect::<BTreeSet<_>>();
15372 let mut edges = traversal_edges_between(&handles, &graph.edges);
15373 let truncated_edges = limit > 0 && edges.len() > limit;
15374 if truncated_edges {
15375 edges.truncate(limit);
15376 }
15377 ("export".to_string(), nodes, edges, None)
15378 };
15379
15380 let shortest_handles = shortest_path.as_ref().map(|path| {
15381 path.nodes
15382 .iter()
15383 .map(|node| node.handle.clone())
15384 .collect::<Vec<_>>()
15385 });
15386 let recommendations = traversal_recommendations(
15387 &graph,
15388 origin_node.map(|node| node.handle.as_str()),
15389 shortest_handles.as_deref(),
15390 if limit == 0 { 10 } else { limit.min(10) },
15391 );
15392 let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
15393 let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
15394
15395 Ok(TraversalReport {
15396 root: root.to_string_lossy().to_string(),
15397 scope: scope.map(str::to_string),
15398 mode,
15399 totals,
15400 query: query.map(str::to_string),
15401 target: target.map(str::to_string),
15402 nodes: selected_nodes,
15403 edges: selected_edges,
15404 shortest_path,
15405 recommendations,
15406 exploration,
15407 truncated,
15408 warnings: graph.warnings,
15409 })
15410}
15411
15412fn html_escape(input: &str) -> String {
15413 input
15414 .replace('&', "&")
15415 .replace('<', "<")
15416 .replace('>', ">")
15417 .replace('"', """)
15418 .replace('\'', "'")
15419}
15420
15421pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
15422 let json = serde_json::to_string(report)?.replace("</", "<\\/");
15423 let mut html = String::new();
15424 html.push_str(
15425 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
15426 );
15427 html.push_str(
15428 r#"<style>
15429:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
15430@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
15431*{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}}
15432</style>"#,
15433 );
15434 html.push_str("</head><body>");
15435 html.push_str("<div class=\"page\">");
15436 html.push_str(&format!(
15437 "<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>",
15438 html_escape(&report.mode),
15439 report.nodes.len(),
15440 report.totals.nodes,
15441 report.edges.len(),
15442 report.totals.edges
15443 ));
15444 html.push_str(
15445 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>"#,
15446 );
15447 html.push_str("<script id=\"graph-data\" type=\"application/json\">");
15448 html.push_str(&json);
15449 html.push_str(
15450 r##"</script><script>
15451const report = JSON.parse(document.getElementById("graph-data").textContent);
15452const svg = document.getElementById("graph-canvas");
15453const list = document.getElementById("node-list");
15454const selected = document.getElementById("selected");
15455const filter = document.getElementById("filter");
15456const legend = document.getElementById("legend");
15457const nodes = report.nodes.map((node, index) => ({...node, index}));
15458const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
15459const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
15460const colorByKind = new Map([
15461 ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
15462 ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
15463 ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
15464 ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
15465]);
15466function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
15467function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
15468function text(value){ return value == null ? "" : String(value); }
15469function matches(node, query){
15470 if (!query) return true;
15471 const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
15472 return haystack.includes(query);
15473}
15474function layout(){
15475 const rect = svg.getBoundingClientRect();
15476 const width = rect.width || 900;
15477 const height = rect.height || 650;
15478 const cx = width / 2;
15479 const cy = height / 2;
15480 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15481 const counts = new Map();
15482 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
15483 const offsets = new Map();
15484 for (const node of nodes) {
15485 const group = kinds.indexOf(node.kind);
15486 const index = offsets.get(node.kind) || 0;
15487 offsets.set(node.kind, index + 1);
15488 const groupCount = counts.get(node.kind) || 1;
15489 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
15490 const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
15491 node.x = cx + Math.cos(angle) * ring;
15492 node.y = cy + Math.sin(angle) * ring;
15493 }
15494}
15495function draw(){
15496 const query = filter.value.trim().toLowerCase();
15497 const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
15498 svg.innerHTML = "";
15499 for (const edge of edges) {
15500 if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
15501 const from = nodeByHandle.get(edge.from);
15502 const to = nodeByHandle.get(edge.to);
15503 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
15504 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
15505 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
15506 line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
15507 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
15508 svg.appendChild(line);
15509 }
15510 for (const node of nodes) {
15511 if (!visible.has(node.handle)) continue;
15512 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
15513 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
15514 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
15515 circle.setAttribute("fill", color(node.kind));
15516 circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
15517 circle.addEventListener("click", () => selectNode(node));
15518 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
15519 svg.appendChild(circle);
15520 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
15521 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
15522 label.setAttribute("class", "node-label");
15523 label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
15524 svg.appendChild(label);
15525 }
15526 renderList(query);
15527}
15528function renderLegend(){
15529 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15530 legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">●</b> ${kind}</span>`).join("");
15531}
15532function renderList(query){
15533 const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
15534 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("");
15535 for (const row of list.querySelectorAll(".row")) {
15536 row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
15537 }
15538}
15539function selectNode(node){
15540 const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
15541 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>`;
15542}
15543function escapeHtml(value){
15544 return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));
15545}
15546filter.addEventListener("input", draw);
15547window.addEventListener("resize", () => { layout(); draw(); });
15548renderLegend();
15549layout();
15550draw();
15551if (nodes.length) selectNode(nodes[0]);
15552</script></div></body></html>"##,
15553 );
15554 Ok(html)
15555}
15556
15557fn semantic_related_report_from_store(
15558 root: &Path,
15559 scope: Option<&str>,
15560 query: &str,
15561 limit: usize,
15562 kind: SemanticRelatedKind,
15563 store: &impl GraphStore,
15564) -> Result<SemanticRelatedReport> {
15565 if query.trim().is_empty() {
15566 bail!("semantic query cannot be empty");
15567 }
15568
15569 let query_embedding = semantic_embedding(query);
15570 let node_kinds: &[&str] = match kind {
15571 SemanticRelatedKind::Concept => &["semantic_concept"],
15572 SemanticRelatedKind::Entity => &["semantic_entity"],
15573 SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
15574 };
15575
15576 let items = store
15577 .semantic_top_candidates(&query_embedding, node_kinds, limit)?
15578 .into_iter()
15579 .map(|candidate| {
15580 let node = candidate.node;
15581 SemanticRelatedItem {
15582 handle: node
15583 .properties
15584 .get("handle")
15585 .cloned()
15586 .unwrap_or_else(|| node.id.clone()),
15587 kind: node.kind,
15588 label: node.label,
15589 score: candidate.score,
15590 file_path: node
15591 .properties
15592 .get("source_file")
15593 .or_else(|| node.properties.get("path"))
15594 .cloned(),
15595 source_symbol: node.properties.get("source_symbol").cloned(),
15596 detail: node
15597 .properties
15598 .get("description")
15599 .or_else(|| node.properties.get("detail"))
15600 .cloned(),
15601 expand: node
15602 .properties
15603 .get("expand")
15604 .cloned()
15605 .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
15606 }
15607 })
15608 .collect::<Vec<_>>();
15609
15610 let mut warnings = Vec::new();
15611 if items.is_empty() {
15612 warnings.push(
15613 "no semantic graph rows found; run `tsift summarize --extract <path>` first"
15614 .to_string(),
15615 );
15616 }
15617
15618 Ok(SemanticRelatedReport {
15619 root: root.to_string_lossy().to_string(),
15620 scope: scope.map(str::to_string),
15621 query: query.to_string(),
15622 embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
15623 count: items.len(),
15624 items,
15625 warnings,
15626 })
15627}
15628
15629fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
15630 Ok(store.nodes_by_kind("semantic_concept")?.len()
15631 + store.nodes_by_kind("semantic_entity")?.len())
15632}
15633
15634fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
15635 if limit == 0 {
15636 return 0;
15637 }
15638 limit.saturating_mul(4).clamp(
15639 GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
15640 GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
15641 )
15642}
15643
15644fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
15645 if limit == 0 {
15646 return usize::MAX;
15647 }
15648 limit.saturating_mul(3).max(limit).max(seed_count)
15649}
15650
15651fn graph_db_semantic_seeded_neighborhood(
15652 store: &impl GraphStore,
15653 seed_ids: &[String],
15654 depth: usize,
15655 limit: usize,
15656) -> Result<GraphDbSemanticSeededSubgraph> {
15657 let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
15658 let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
15659 let mut diagnostics = vec![
15660 "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
15661 "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(),
15662 format!(
15663 "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
15664 if edge_scan_cap == 0 {
15665 "unbounded".to_string()
15666 } else {
15667 edge_scan_cap.to_string()
15668 },
15669 if node_discovery_cap == usize::MAX {
15670 "unbounded".to_string()
15671 } else {
15672 node_discovery_cap.to_string()
15673 }
15674 ),
15675 ];
15676
15677 let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
15678 .with_edge_scan_cap(edge_scan_cap)
15679 .with_node_discovery_cap(node_discovery_cap);
15680 let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
15681
15682 for seed_id in &result.missing_seed_ids {
15683 diagnostics.push(format!(
15684 "semantic seed {seed_id} was not present in the graph store"
15685 ));
15686 }
15687
15688 if result.skipped_by_edge_cap > 0 {
15689 diagnostics.push(format!(
15690 "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
15691 result.skipped_by_edge_cap
15692 ));
15693 }
15694 if result.skipped_by_node_cap > 0 {
15695 diagnostics.push(format!(
15696 "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
15697 result.skipped_by_node_cap
15698 ));
15699 }
15700
15701 if result.truncated {
15702 diagnostics.push(format!(
15703 "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
15704 result.total_discovered
15705 ));
15706 }
15707
15708 Ok(GraphDbSemanticSeededSubgraph {
15709 nodes: result.nodes,
15710 edges: result.edges,
15711 truncated: result.truncated,
15712 diagnostics,
15713 })
15714}
15715
15716#[allow(clippy::too_many_arguments)]
15717fn cmd_semantic_related(
15718 query: &str,
15719 path: &Path,
15720 scope: Option<&str>,
15721 limit: usize,
15722 kind: SemanticRelatedKind,
15723 json_output: bool,
15724 compact: bool,
15725 pretty: bool,
15726 terse: bool,
15727 schema: bool,
15728 profile: Option<String>,
15729) -> Result<()> {
15730 let root = lint::resolve_project_root_or_canonical_path(path)?;
15731 write_traversal_graph_store(&root, path, scope)?;
15732 let graph_db = graph_substrate_db_path(&root, scope);
15733 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15734 let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15735 if let Some(recovery) = store.read_only_recovery() {
15736 report
15737 .warnings
15738 .push(graph_db_read_recovery_diagnostic(recovery));
15739 }
15740 if let Some(note) =
15741 profile_preference_note(profile.as_deref(), tsift_local_model::ModelRole::Embed)
15742 {
15743 report.warnings.push(note);
15744 }
15745
15746 if json_output {
15747 println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15748 } else if compact {
15749 for item in &report.items {
15750 println!(
15751 "{:.3}\t{}\t{}\t{}",
15752 item.score, item.kind, item.label, item.handle
15753 );
15754 }
15755 for warning in &report.warnings {
15756 eprintln!("warning: {warning}");
15757 }
15758 } else {
15759 println!(
15760 "Related semantic graph rows for {:?} ({})",
15761 report.query, report.embedding_model
15762 );
15763 for item in &report.items {
15764 println!(
15765 " {:.3} [{}] {} ({})",
15766 item.score, item.kind, item.label, item.handle
15767 );
15768 if let Some(detail) = &item.detail {
15769 println!(" {}", detail);
15770 }
15771 if let Some(file_path) = &item.file_path {
15772 println!(" file: {}", file_path);
15773 }
15774 println!(" expand: {}", item.expand);
15775 }
15776 for warning in &report.warnings {
15777 eprintln!("warning: {warning}");
15778 }
15779 }
15780
15781 Ok(())
15782}
15783
15784fn profile_preference_note(
15789 profile: Option<&str>,
15790 role: tsift_local_model::ModelRole,
15791) -> Option<String> {
15792 let preference = tsift_local_model::ProfilePreference::from_cli(profile);
15793 if matches!(preference, tsift_local_model::ProfilePreference::Auto) {
15794 return None;
15795 }
15796 let probe = tsift_local_model::probe_nvidia_smi();
15797 let resolution = tsift_local_model::resolve_profile_preference(&preference, role, &probe);
15798 Some(format!(
15799 "profile preference {} -> {} ({})",
15800 preference.describe(),
15801 resolution.profile.id,
15802 resolution.reason
15803 ))
15804}
15805
15806#[derive(Serialize)]
15807struct SourceLinePreview {
15808 line: usize,
15809 text: String,
15810}
15811
15812#[derive(Serialize)]
15813pub(crate) struct SourceRangePreview {
15814 start: usize,
15815 end: usize,
15816 total_lines: usize,
15817 truncated_before: bool,
15818 truncated_after: bool,
15819}
15820
15821#[derive(Serialize)]
15822struct SourceExpandCommands {
15823 #[serde(skip_serializing_if = "Option::is_none")]
15824 before: Option<String>,
15825 #[serde(skip_serializing_if = "Option::is_none")]
15826 after: Option<String>,
15827 #[serde(skip_serializing_if = "Option::is_none")]
15828 body: Option<String>,
15829 file: String,
15830 #[serde(skip_serializing_if = "Option::is_none")]
15831 markdown_ast: Option<String>,
15832}
15833
15834#[derive(Serialize)]
15835struct SourceSymbolRef {
15836 handle: String,
15837 name: String,
15838 kind: String,
15839 language: String,
15840 file: String,
15841 line: usize,
15842 #[serde(skip_serializing_if = "Option::is_none")]
15843 end_line: Option<usize>,
15844 #[serde(skip_serializing_if = "Option::is_none")]
15845 signature: Option<String>,
15846 #[serde(skip_serializing_if = "Option::is_none")]
15847 span: Option<AstSpanPreview>,
15848 expand: String,
15849}
15850
15851#[derive(Serialize)]
15852struct SourceSummaryRef {
15853 handle: String,
15854 symbol_name: String,
15855 file_path: String,
15856 summary: String,
15857 expand: String,
15858}
15859
15860#[derive(Serialize)]
15861struct SourceReadReport {
15862 handle: String,
15863 root: String,
15864 file: String,
15865 range: SourceRangePreview,
15866 preview: Vec<SourceLinePreview>,
15867 symbols: Vec<SourceSymbolRef>,
15868 summaries: Vec<SourceSummaryRef>,
15869 #[serde(skip_serializing_if = "Option::is_none")]
15870 markdown: Option<SourceReadMarkdownProjection>,
15871 expand: SourceExpandCommands,
15872 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15873 warnings: Vec<String>,
15874}
15875
15876#[derive(Serialize)]
15877struct SourceReadAstExpandCommands {
15878 window: String,
15879 file_window: String,
15880 #[serde(skip_serializing_if = "Option::is_none")]
15881 markdown_ast: Option<String>,
15882}
15883
15884#[derive(Serialize)]
15885struct SourceReadAstReport {
15886 handle: String,
15887 root: String,
15888 file: String,
15889 range: SourceRangePreview,
15890 symbols: Vec<SourceSymbolRef>,
15891 summaries: Vec<SourceSummaryRef>,
15892 #[serde(skip_serializing_if = "Option::is_none")]
15893 markdown: Option<SourceReadMarkdownProjection>,
15894 expand: SourceReadAstExpandCommands,
15895 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15896 warnings: Vec<String>,
15897}
15898
15899#[derive(Serialize)]
15900struct SymbolReadTarget {
15901 handle: String,
15902 name: String,
15903 kind: String,
15904 language: String,
15905 file: String,
15906 line: usize,
15907 #[serde(skip_serializing_if = "Option::is_none")]
15908 end_line: Option<usize>,
15909 #[serde(skip_serializing_if = "Option::is_none")]
15910 signature: Option<String>,
15911 #[serde(skip_serializing_if = "Option::is_none")]
15912 parent_module: Option<String>,
15913 #[serde(skip_serializing_if = "Option::is_none")]
15914 visibility: Option<String>,
15915 #[serde(skip_serializing_if = "Option::is_none")]
15916 span: Option<AstSpanPreview>,
15917}
15918
15919#[derive(Serialize)]
15920struct SymbolReadExpandCommands {
15921 source_window: String,
15922 #[serde(skip_serializing_if = "Option::is_none")]
15923 body: Option<String>,
15924 file: String,
15925 explain: String,
15926 callers: String,
15927 callees: String,
15928 #[serde(skip_serializing_if = "Option::is_none")]
15929 markdown_ast: Option<String>,
15930}
15931
15932#[derive(Serialize)]
15933struct SymbolReadReport {
15934 handle: String,
15935 root: String,
15936 query: String,
15937 symbol: SymbolReadTarget,
15938 range: SourceRangePreview,
15939 body: Vec<SourceLinePreview>,
15940 child_symbols: Vec<SourceSymbolRef>,
15941 summaries: Vec<SourceSummaryRef>,
15942 expand: SymbolReadExpandCommands,
15943 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15944 warnings: Vec<String>,
15945}
15946
15947#[derive(Clone)]
15948pub(crate) struct MarkdownAstRawNode {
15949 handle: String,
15950 span_handle: String,
15951 name: String,
15952 kind: String,
15953 block_kind: String,
15954 node_kind: String,
15955 start_byte: usize,
15956 end_byte: usize,
15957 body_start_byte: Option<usize>,
15958 body_end_byte: Option<usize>,
15959}
15960
15961#[derive(Clone)]
15962pub(crate) struct MarkdownAstProjection {
15963 source_hash: String,
15964 nodes: Vec<MarkdownAstRawNode>,
15965 parse_duration_micros: u128,
15966 cache_hit: bool,
15967}
15968
15969#[derive(Clone)]
15970struct MarkdownAstCacheEntry {
15971 source_hash: String,
15972 nodes: Vec<MarkdownAstRawNode>,
15973 parse_duration_micros: u128,
15974}
15975
15976static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15977 OnceLock::new();
15978
15979#[derive(Serialize, Clone)]
15980struct MarkdownAstNodeMetadata {
15981 #[serde(skip_serializing_if = "Option::is_none")]
15982 heading_level: Option<usize>,
15983 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15984 section_path: Vec<String>,
15985 #[serde(skip_serializing_if = "Option::is_none")]
15986 section_handle: Option<String>,
15987 #[serde(skip_serializing_if = "Option::is_none")]
15988 list_depth: Option<usize>,
15989 #[serde(skip_serializing_if = "Option::is_none")]
15990 list_marker: Option<String>,
15991 #[serde(skip_serializing_if = "Option::is_none")]
15992 list_order: Option<usize>,
15993 #[serde(skip_serializing_if = "Option::is_none")]
15994 fence_language: Option<String>,
15995 #[serde(skip_serializing_if = "Option::is_none")]
15996 fence_marker: Option<String>,
15997 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15998 embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15999}
16000
16001#[derive(Serialize, Clone)]
16002struct MarkdownAstNodeExpand {
16003 source_window: String,
16004 source_body: String,
16005 symbol_read: String,
16006 edit_intents: String,
16007}
16008
16009#[derive(Serialize, Clone)]
16010struct MarkdownAstCacheReport {
16011 source_hash: String,
16012 cache_hit: bool,
16013 parse_duration_micros: u128,
16014 node_count: usize,
16015 section_count: usize,
16016 list_item_count: usize,
16017 code_block_count: usize,
16018}
16019
16020#[derive(Serialize, Clone)]
16021struct MarkdownAstPhaseTiming {
16022 name: String,
16023 duration_micros: u128,
16024 detail: String,
16025}
16026
16027#[derive(Serialize, Clone)]
16028struct MarkdownAstOutlineEntry {
16029 handle: String,
16030 span_handle: String,
16031 name: String,
16032 kind: String,
16033 block_kind: String,
16034 line: usize,
16035 end_line: usize,
16036 #[serde(skip_serializing_if = "Vec::is_empty", default)]
16037 section_path: Vec<String>,
16038 child_count: usize,
16039 expand: String,
16040}
16041
16042#[derive(Serialize, Clone)]
16043struct MarkdownAstProjectionPreview {
16044 mode: String,
16045 total_nodes: usize,
16046 returned_nodes: usize,
16047 omitted_nodes: usize,
16048 selected_node: Option<String>,
16049 cache: MarkdownAstCacheReport,
16050 outline: Vec<MarkdownAstOutlineEntry>,
16051 phase_timings: Vec<MarkdownAstPhaseTiming>,
16052}
16053
16054#[derive(Serialize)]
16055struct SourceReadMarkdownProjection {
16056 handle: String,
16057 mode: String,
16058 total_nodes: usize,
16059 visible_nodes: usize,
16060 outline: Vec<MarkdownAstOutlineEntry>,
16061 expand: String,
16062}
16063
16064#[derive(Serialize, Clone)]
16065struct SourceByteRangePreview {
16066 start: usize,
16067 end: usize,
16068}
16069
16070#[derive(Serialize, Clone)]
16071struct MarkdownAstNode {
16072 handle: String,
16073 span_handle: String,
16074 name: String,
16075 kind: String,
16076 block_kind: String,
16077 node_kind: String,
16078 line: usize,
16079 end_line: usize,
16080 byte_span: SourceByteRangePreview,
16081 #[serde(skip_serializing_if = "Option::is_none")]
16082 body_byte_span: Option<SourceByteRangePreview>,
16083 parent_handle: Option<String>,
16084 #[serde(skip_serializing_if = "Vec::is_empty", default)]
16085 child_handles: Vec<String>,
16086 metadata: MarkdownAstNodeMetadata,
16087 expand: MarkdownAstNodeExpand,
16088}
16089
16090#[derive(Serialize)]
16091struct MarkdownAstExpandCommands {
16092 file: String,
16093 source_read: String,
16094 edit_intents: String,
16095}
16096
16097#[derive(Serialize)]
16098struct MarkdownAstReport {
16099 handle: String,
16100 root: String,
16101 file: String,
16102 range: SourceRangePreview,
16103 projection: MarkdownAstProjectionPreview,
16104 nodes: Vec<MarkdownAstNode>,
16105 expand: MarkdownAstExpandCommands,
16106 #[serde(skip_serializing_if = "Vec::is_empty", default)]
16107 warnings: Vec<String>,
16108}
16109
16110pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
16111 let candidate = if file.is_absolute() {
16112 file.to_path_buf()
16113 } else {
16114 root.join(file)
16115 };
16116 let canonical = candidate
16117 .canonicalize()
16118 .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
16119 if !canonical.is_file() {
16120 bail!("source file is not a regular file: {}", canonical.display());
16121 }
16122 let canonical_root = root
16123 .canonicalize()
16124 .with_context(|| format!("canonicalizing project root {}", root.display()))?;
16125 if !canonical.starts_with(&canonical_root) {
16126 bail!(
16127 "source file {} is outside project root {}",
16128 canonical.display(),
16129 canonical_root.display()
16130 );
16131 }
16132 Ok(canonical)
16133}
16134
16135pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
16136 source_read_window_command(root, file, start, lines)
16137}
16138
16139pub(crate) fn source_read_window_command(
16140 root: &Path,
16141 file: &str,
16142 start: usize,
16143 lines: usize,
16144) -> String {
16145 format!(
16146 "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
16147 shell_quote(file),
16148 shell_quote(&root.to_string_lossy()),
16149 start,
16150 lines
16151 )
16152}
16153
16154pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
16155 format!(
16156 "tsift --envelope source-read {} --path {} --budget normal",
16157 shell_quote(file),
16158 shell_quote(&root.to_string_lossy())
16159 )
16160}
16161
16162pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
16163 format!(
16164 "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
16165 shell_quote(symbol),
16166 shell_quote(&root.to_string_lossy()),
16167 shell_quote(file)
16168 )
16169}
16170
16171fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
16172 format!(
16173 "tsift --envelope explain {} --path {} --budget normal",
16174 shell_quote(symbol),
16175 shell_quote(&root.to_string_lossy())
16176 )
16177}
16178
16179fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
16180 format!(
16181 "tsift graph {} --path {} --{} --json",
16182 shell_quote(symbol),
16183 shell_quote(&root.to_string_lossy()),
16184 relation
16185 )
16186}
16187
16188fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
16189 format!(
16190 "tsift summarize {} --path {} --json",
16191 shell_quote(symbol),
16192 shell_quote(&root.to_string_lossy())
16193 )
16194}
16195
16196pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
16197 let mut command = format!(
16198 "tsift --envelope markdown-ast {} --path {} --budget normal",
16199 shell_quote(file),
16200 shell_quote(&root.to_string_lossy())
16201 );
16202 if let Some(node) = node {
16203 command.push_str(" --node ");
16204 command.push_str(&shell_quote(node));
16205 }
16206 command
16207}
16208
16209fn markdown_edit_intents_command(root: &Path) -> String {
16210 format!(
16211 "tsift --envelope edit-intents --path {} --budget normal",
16212 shell_quote(&root.to_string_lossy())
16213 )
16214}
16215
16216pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
16217 usize::try_from(symbol.line)
16218 .ok()
16219 .and_then(|line| line.checked_add(1))
16220 .unwrap_or(1)
16221}
16222
16223fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
16224 symbol
16225 .end_line
16226 .and_then(|line| usize::try_from(line).ok())
16227 .and_then(|line| line.checked_add(1))
16228}
16229
16230fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
16231 value.and_then(|byte| usize::try_from(byte).ok())
16232}
16233
16234fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
16235 let byte = byte.min(source.len());
16236 source[..byte]
16237 .iter()
16238 .filter(|value| **value == b'\n')
16239 .count()
16240 .saturating_add(1)
16241}
16242
16243fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
16244 source_line_for_byte(source, end_byte.saturating_sub(1))
16245}
16246
16247fn ast_span_handle(
16248 file: &str,
16249 name: &str,
16250 kind: &str,
16251 start_byte: usize,
16252 end_byte: usize,
16253) -> String {
16254 stable_handle(
16255 "span",
16256 &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
16257 )
16258}
16259
16260pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
16261 Some((
16262 symbol_span_byte(symbol.start_byte)?,
16263 symbol_span_byte(symbol.end_byte)?,
16264 ))
16265}
16266
16267pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
16268 Some((
16269 symbol_span_byte(symbol.start_byte)?,
16270 symbol_span_byte(symbol.end_byte)?,
16271 ))
16272}
16273
16274pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
16275 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16276 Some(ast_span_handle(
16277 &symbol.file,
16278 &symbol.name,
16279 &symbol.kind,
16280 start_byte,
16281 end_byte,
16282 ))
16283}
16284
16285fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
16286 left.file == right.file
16287 && left.name == right.name
16288 && left.kind == right.kind
16289 && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
16290}
16291
16292fn stored_symbol_parent_span_handle_in_file(
16293 symbol: &index::StoredSymbol,
16294 symbols: &[&index::StoredSymbol],
16295) -> Option<String> {
16296 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16297 symbols
16298 .iter()
16299 .copied()
16300 .filter(|candidate| {
16301 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16302 return false;
16303 }
16304 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16305 else {
16306 return false;
16307 };
16308 candidate_start <= start_byte && candidate_end >= end_byte
16309 })
16310 .min_by_key(|candidate| {
16311 stored_symbol_span_bounds(candidate)
16312 .map(|(start, end)| end.saturating_sub(start))
16313 .unwrap_or(usize::MAX)
16314 })
16315 .and_then(stored_symbol_span_handle)
16316}
16317
16318fn stored_symbol_child_span_handles_in_file(
16319 symbol: &index::StoredSymbol,
16320 symbols: &[&index::StoredSymbol],
16321 limit: usize,
16322) -> Vec<String> {
16323 let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
16324 return Vec::new();
16325 };
16326 symbols
16327 .iter()
16328 .copied()
16329 .filter(|candidate| {
16330 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16331 return false;
16332 }
16333 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16334 else {
16335 return false;
16336 };
16337 candidate_start >= start_byte && candidate_end <= end_byte
16338 })
16339 .take(limit)
16340 .filter_map(stored_symbol_span_handle)
16341 .collect()
16342}
16343
16344fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
16345 let start = start_byte.min(source.len());
16346 let line_end = source[start..]
16347 .iter()
16348 .position(|value| *value == b'\n')
16349 .map(|pos| start + pos)
16350 .unwrap_or(source.len());
16351 let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
16352 let marker = line.trim_start();
16353 let level = marker.chars().take_while(|ch| *ch == '#').count();
16354 (1..=6).contains(&level).then_some(level)
16355}
16356
16357fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
16358 let start = start_byte.min(source.len());
16359 let line_start = source[..start]
16360 .iter()
16361 .rposition(|value| *value == b'\n')
16362 .map(|pos| pos + 1)
16363 .unwrap_or(0);
16364 source[line_start..start]
16365 .iter()
16366 .map(|byte| match byte {
16367 b'\t' => 4,
16368 b' ' => 1,
16369 _ => 0,
16370 })
16371 .sum::<usize>()
16372 / 2
16373}
16374
16375fn markdown_enclosing_heading_symbols_in_file<'a>(
16376 file: &str,
16377 start_byte: usize,
16378 end_byte: usize,
16379 symbols: &[&'a index::StoredSymbol],
16380) -> Vec<&'a index::StoredSymbol> {
16381 let mut headings = symbols
16382 .iter()
16383 .copied()
16384 .filter(|candidate| candidate.file == file && candidate.kind == "heading")
16385 .filter(|candidate| {
16386 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16387 else {
16388 return false;
16389 };
16390 candidate_start <= start_byte && candidate_end >= end_byte
16391 })
16392 .collect::<Vec<_>>();
16393 headings.sort_by(|left, right| {
16394 stored_symbol_span_bounds(left)
16395 .map(|(start, _)| start)
16396 .unwrap_or(usize::MAX)
16397 .cmp(
16398 &stored_symbol_span_bounds(right)
16399 .map(|(start, _)| start)
16400 .unwrap_or(usize::MAX),
16401 )
16402 .then(left.name.cmp(&right.name))
16403 });
16404 headings
16405}
16406
16407fn markdown_stored_symbol_metadata_in_file(
16408 symbol: &index::StoredSymbol,
16409 source: &[u8],
16410 symbols: &[&index::StoredSymbol],
16411) -> Option<MarkdownSpanMetadata> {
16412 if symbol.language != "markdown" {
16413 return None;
16414 }
16415 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16416 let section_symbols =
16417 markdown_enclosing_heading_symbols_in_file(&symbol.file, start_byte, end_byte, symbols);
16418 let section_path = section_symbols
16419 .iter()
16420 .map(|heading| heading.name.clone())
16421 .collect::<Vec<_>>();
16422 let section_handle = section_symbols
16423 .last()
16424 .and_then(|heading| stored_symbol_span_handle(heading));
16425 let heading_level = (symbol.kind == "heading")
16426 .then(|| markdown_heading_level(source, start_byte))
16427 .flatten();
16428 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16429 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16430 let embedded_symbols = if symbol.kind == "code_block" {
16431 markdown_embedded_symbols(
16432 &symbol.file,
16433 source,
16434 symbol_span_byte(symbol.body_start_byte),
16435 symbol_span_byte(symbol.body_end_byte),
16436 fence_language.as_deref(),
16437 )
16438 } else {
16439 Vec::new()
16440 };
16441
16442 (heading_level.is_some()
16443 || !section_path.is_empty()
16444 || section_handle.is_some()
16445 || list_depth.is_some()
16446 || fence_language.is_some()
16447 || !embedded_symbols.is_empty())
16448 .then_some(MarkdownSpanMetadata {
16449 heading_level,
16450 section_path,
16451 section_handle,
16452 list_depth,
16453 fence_language,
16454 embedded_symbols,
16455 })
16456}
16457
16458fn markdown_symbol_hit_metadata(
16459 symbol: &index::SymbolHit,
16460 source: &[u8],
16461 start_byte: usize,
16462) -> Option<MarkdownSpanMetadata> {
16463 if symbol.language != "markdown" {
16464 return None;
16465 }
16466 let heading_level = (symbol.kind == "heading")
16467 .then(|| markdown_heading_level(source, start_byte))
16468 .flatten();
16469 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16470 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16471 let embedded_symbols = if symbol.kind == "code_block" {
16472 markdown_embedded_symbols(
16473 &symbol.file,
16474 source,
16475 symbol_span_byte(symbol.body_start_byte),
16476 symbol_span_byte(symbol.body_end_byte),
16477 fence_language.as_deref(),
16478 )
16479 } else {
16480 Vec::new()
16481 };
16482 (heading_level.is_some()
16483 || list_depth.is_some()
16484 || fence_language.is_some()
16485 || !embedded_symbols.is_empty())
16486 .then_some(MarkdownSpanMetadata {
16487 heading_level,
16488 section_path: Vec::new(),
16489 section_handle: None,
16490 list_depth,
16491 fence_language,
16492 embedded_symbols,
16493 })
16494}
16495
16496fn is_markdown_path(path: &Path) -> bool {
16497 path.extension()
16498 .and_then(|ext| ext.to_str())
16499 .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
16500 .unwrap_or(false)
16501}
16502
16503fn markdown_ast_block_kind(kind: &str) -> String {
16504 match kind {
16505 "heading" => "section",
16506 "code_block" => "fenced_code_block",
16507 "list_item" => "list_item",
16508 other => other,
16509 }
16510 .to_string()
16511}
16512
16513fn markdown_embedded_language_key(language: &str) -> Option<String> {
16514 let key = language
16515 .split_whitespace()
16516 .next()
16517 .unwrap_or("")
16518 .trim()
16519 .trim_start_matches("language-")
16520 .trim_start_matches("lang-")
16521 .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
16522 .to_ascii_lowercase();
16523 (!key.is_empty()).then_some(key)
16524}
16525
16526fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
16527 let key = markdown_embedded_language_key(language)?;
16528 let extension = match key.as_str() {
16529 "rust" => "rs",
16530 "python" => "py",
16531 "typescript" => "ts",
16532 "javascript" => "js",
16533 "kotlin" => "kt",
16534 "shell" | "sh" | "zsh" => "bash",
16535 other => other,
16536 };
16537 let lang = graph::Lang::from_extension(extension)?;
16538 (lang.name() != "markdown").then_some(lang)
16539}
16540
16541fn markdown_embedded_ast_span_handle(
16542 file: &str,
16543 language: &str,
16544 name: &str,
16545 kind: &str,
16546 start_byte: usize,
16547 end_byte: usize,
16548) -> String {
16549 stable_handle(
16550 "span",
16551 &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
16552 )
16553}
16554
16555fn markdown_embedded_symbols(
16556 file: &str,
16557 source: &[u8],
16558 body_start_byte: Option<usize>,
16559 body_end_byte: Option<usize>,
16560 fence_language: Option<&str>,
16561) -> Vec<MarkdownEmbeddedSymbol> {
16562 let Some(fence_language) = fence_language else {
16563 return Vec::new();
16564 };
16565 let Some(lang) = markdown_embedded_lang(fence_language) else {
16566 return Vec::new();
16567 };
16568 let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
16569 return Vec::new();
16570 };
16571 let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
16572 else {
16573 return Vec::new();
16574 };
16575 if body.is_empty() {
16576 return Vec::new();
16577 }
16578
16579 let Ok(symbols) = lang.extract_symbols(body) else {
16580 return Vec::new();
16581 };
16582 let language = lang.name().to_string();
16583 symbols
16584 .into_iter()
16585 .map(|symbol| {
16586 let start_byte = body_start_byte.saturating_add(symbol.start_byte);
16587 let end_byte = body_start_byte.saturating_add(symbol.end_byte);
16588 let body_start = symbol
16589 .body_start_byte
16590 .map(|byte| body_start_byte.saturating_add(byte));
16591 let body_end = symbol
16592 .body_end_byte
16593 .map(|byte| body_start_byte.saturating_add(byte));
16594 let start_line = source_line_for_byte(source, start_byte);
16595 let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
16596 MarkdownEmbeddedSymbol {
16597 handle: markdown_embedded_ast_span_handle(
16598 file,
16599 &language,
16600 &symbol.name,
16601 &symbol.kind,
16602 start_byte,
16603 end_byte,
16604 ),
16605 name: symbol.name,
16606 kind: symbol.kind,
16607 language: language.clone(),
16608 node_kind: symbol.node_kind,
16609 start_byte,
16610 end_byte,
16611 start_line,
16612 end_line,
16613 body_start_byte: body_start,
16614 body_end_byte: body_end,
16615 body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
16616 body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
16617 }
16618 })
16619 .collect()
16620}
16621
16622fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
16623 let start = start_byte.min(source.len());
16624 let line_start = source[..start]
16625 .iter()
16626 .rposition(|value| *value == b'\n')
16627 .map(|pos| pos + 1)
16628 .unwrap_or(0);
16629 let line_end = source[start..]
16630 .iter()
16631 .position(|value| *value == b'\n')
16632 .map(|pos| start + pos)
16633 .unwrap_or(source.len());
16634 std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
16635}
16636
16637fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
16638 let line = markdown_source_line(source, start_byte);
16639 let trimmed = line.trim_start();
16640 for marker in ["-", "*", "+"] {
16641 if trimmed
16642 .strip_prefix(marker)
16643 .and_then(|rest| rest.strip_prefix(' '))
16644 .is_some()
16645 {
16646 return (Some(marker.to_string()), None);
16647 }
16648 }
16649
16650 let digit_end = trimmed
16651 .find(|ch: char| !ch.is_ascii_digit())
16652 .unwrap_or(trimmed.len());
16653 let (digits, rest) = trimmed.split_at(digit_end);
16654 if !digits.is_empty() {
16655 for marker in [".", ")"] {
16656 if rest
16657 .strip_prefix(marker)
16658 .and_then(|value| value.strip_prefix(' '))
16659 .is_some()
16660 {
16661 return (
16662 Some(format!("{digits}{marker}")),
16663 digits.parse::<usize>().ok(),
16664 );
16665 }
16666 }
16667 }
16668 (None, None)
16669}
16670
16671fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
16672 let line = markdown_source_line(source, start_byte);
16673 let trimmed = line.trim_start();
16674 ["```", "~~~"]
16675 .into_iter()
16676 .find(|marker| trimmed.starts_with(marker))
16677 .map(str::to_string)
16678}
16679
16680fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16681 let mut nodes = graph::Lang::Markdown
16682 .extract_symbols(source)
16683 .context("extracting Markdown AST nodes")?
16684 .into_iter()
16685 .map(|symbol| {
16686 let body_start_byte = symbol.body_start_byte;
16687 let body_end_byte = symbol.body_end_byte;
16688 let span_handle = ast_span_handle(
16689 file,
16690 &symbol.name,
16691 &symbol.kind,
16692 symbol.start_byte,
16693 symbol.end_byte,
16694 );
16695 MarkdownAstRawNode {
16696 handle: stable_handle(
16697 "mdast",
16698 &format!(
16699 "{}:{}:{}:{}:{}",
16700 file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16701 ),
16702 ),
16703 span_handle,
16704 name: symbol.name,
16705 kind: symbol.kind.clone(),
16706 block_kind: markdown_ast_block_kind(&symbol.kind),
16707 node_kind: symbol.node_kind,
16708 start_byte: symbol.start_byte,
16709 end_byte: symbol.end_byte,
16710 body_start_byte,
16711 body_end_byte,
16712 }
16713 })
16714 .collect::<Vec<_>>();
16715 nodes.sort_by(|left, right| {
16716 left.start_byte
16717 .cmp(&right.start_byte)
16718 .then(left.end_byte.cmp(&right.end_byte))
16719 .then(left.kind.cmp(&right.kind))
16720 .then(left.name.cmp(&right.name))
16721 });
16722 Ok(nodes)
16723}
16724
16725pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16726 let source_hash = blake3::hash(source).to_hex().to_string();
16727 let cache_key = format!("{file}:{source_hash}");
16728 let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16729 if let Some(entry) = cache
16730 .lock()
16731 .expect("markdown ast cache poisoned")
16732 .get(&cache_key)
16733 {
16734 return Ok(MarkdownAstProjection {
16735 source_hash: entry.source_hash.clone(),
16736 nodes: entry.nodes.clone(),
16737 parse_duration_micros: entry.parse_duration_micros,
16738 cache_hit: true,
16739 });
16740 }
16741
16742 let started = Instant::now();
16743 let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16744 let parse_duration_micros = started.elapsed().as_micros();
16745 cache.lock().expect("markdown ast cache poisoned").insert(
16746 cache_key,
16747 MarkdownAstCacheEntry {
16748 source_hash: source_hash.clone(),
16749 nodes: nodes.clone(),
16750 parse_duration_micros,
16751 },
16752 );
16753 Ok(MarkdownAstProjection {
16754 source_hash,
16755 nodes,
16756 parse_duration_micros,
16757 cache_hit: false,
16758 })
16759}
16760
16761fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16762 MarkdownAstCacheReport {
16763 source_hash: projection.source_hash.clone(),
16764 cache_hit: projection.cache_hit,
16765 parse_duration_micros: projection.parse_duration_micros,
16766 node_count: projection.nodes.len(),
16767 section_count: projection
16768 .nodes
16769 .iter()
16770 .filter(|node| node.kind == "heading")
16771 .count(),
16772 list_item_count: projection
16773 .nodes
16774 .iter()
16775 .filter(|node| node.kind == "list_item")
16776 .count(),
16777 code_block_count: projection
16778 .nodes
16779 .iter()
16780 .filter(|node| node.kind == "code_block")
16781 .count(),
16782 }
16783}
16784
16785fn markdown_ast_node_direct_child_count(
16786 node: &MarkdownAstRawNode,
16787 nodes: &[MarkdownAstRawNode],
16788) -> usize {
16789 nodes
16790 .iter()
16791 .filter(|candidate| {
16792 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16793 })
16794 .count()
16795}
16796
16797fn markdown_ast_outline_entry(
16798 root: &Path,
16799 file: &str,
16800 source: &[u8],
16801 nodes: &[MarkdownAstRawNode],
16802 node: &MarkdownAstRawNode,
16803 max_bytes: usize,
16804) -> MarkdownAstOutlineEntry {
16805 let line = source_line_for_byte(source, node.start_byte);
16806 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16807 MarkdownAstOutlineEntry {
16808 handle: node.handle.clone(),
16809 span_handle: node.span_handle.clone(),
16810 name: truncate_for_budget(&node.name, max_bytes),
16811 kind: node.kind.clone(),
16812 block_kind: node.block_kind.clone(),
16813 line,
16814 end_line,
16815 section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16816 child_count: markdown_ast_node_direct_child_count(node, nodes),
16817 expand: markdown_ast_command(root, file, Some(&node.handle)),
16818 }
16819}
16820
16821fn markdown_ast_outline_entries(
16822 root: &Path,
16823 file: &str,
16824 source: &[u8],
16825 nodes: &[MarkdownAstRawNode],
16826 limit: usize,
16827 max_bytes: usize,
16828) -> Vec<MarkdownAstOutlineEntry> {
16829 let mut headings = nodes
16830 .iter()
16831 .filter(|node| node.kind == "heading")
16832 .collect::<Vec<_>>();
16833 let mut blocks = nodes
16834 .iter()
16835 .filter(|node| node.kind != "heading")
16836 .collect::<Vec<_>>();
16837 headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16838 blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16839 headings
16840 .into_iter()
16841 .chain(blocks)
16842 .take(limit)
16843 .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16844 .collect()
16845}
16846
16847fn markdown_ast_node_intersects_lines(
16848 source: &[u8],
16849 node: &MarkdownAstRawNode,
16850 start: usize,
16851 end: usize,
16852) -> bool {
16853 let line = source_line_for_byte(source, node.start_byte);
16854 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16855 line <= end && end_line >= start
16856}
16857
16858fn source_read_markdown_projection(
16859 root: &Path,
16860 file: &str,
16861 source: &[u8],
16862 start: usize,
16863 end: usize,
16864 budget: ResponseBudget,
16865) -> Result<SourceReadMarkdownProjection> {
16866 let projection = markdown_ast_projection(file, source)?;
16867 let visible_nodes = projection
16868 .nodes
16869 .iter()
16870 .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16871 .collect::<Vec<_>>();
16872 let mut outline_nodes = visible_nodes.clone();
16873 outline_nodes.sort_by_key(|node| {
16874 (
16875 node.kind != "heading",
16876 node.start_byte,
16877 node.end_byte,
16878 node.name.as_str(),
16879 )
16880 });
16881 let outline = outline_nodes
16882 .into_iter()
16883 .take(budget.preview_items())
16884 .map(|node| {
16885 markdown_ast_outline_entry(
16886 root,
16887 file,
16888 source,
16889 &projection.nodes,
16890 node,
16891 budget.preview_bytes(),
16892 )
16893 })
16894 .collect::<Vec<_>>();
16895 Ok(SourceReadMarkdownProjection {
16896 handle: stable_handle(
16897 "mdproj",
16898 &format!("{file}:{start}:{end}:{}", projection.source_hash),
16899 ),
16900 mode: "window_outline".to_string(),
16901 total_nodes: projection.nodes.len(),
16902 visible_nodes: visible_nodes.len(),
16903 outline,
16904 expand: markdown_ast_command(root, file, None),
16905 })
16906}
16907
16908fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16909 if parent.handle == child.handle {
16910 return false;
16911 }
16912 parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16913}
16914
16915fn markdown_ast_parent_handle(
16916 node: &MarkdownAstRawNode,
16917 nodes: &[MarkdownAstRawNode],
16918) -> Option<String> {
16919 nodes
16920 .iter()
16921 .filter(|candidate| markdown_ast_contains(candidate, node))
16922 .min_by_key(|candidate| {
16923 (
16924 candidate.end_byte.saturating_sub(candidate.start_byte),
16925 candidate.start_byte,
16926 )
16927 })
16928 .map(|candidate| candidate.handle.clone())
16929}
16930
16931fn markdown_ast_child_handles(
16932 node: &MarkdownAstRawNode,
16933 nodes: &[MarkdownAstRawNode],
16934 limit: usize,
16935) -> Vec<String> {
16936 nodes
16937 .iter()
16938 .filter(|candidate| {
16939 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16940 })
16941 .take(limit)
16942 .map(|candidate| candidate.handle.clone())
16943 .collect()
16944}
16945
16946fn markdown_ast_section_nodes<'a>(
16947 node: &MarkdownAstRawNode,
16948 nodes: &'a [MarkdownAstRawNode],
16949) -> Vec<&'a MarkdownAstRawNode> {
16950 let mut headings = nodes
16951 .iter()
16952 .filter(|candidate| candidate.kind == "heading")
16953 .filter(|candidate| {
16954 candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16955 })
16956 .collect::<Vec<_>>();
16957 headings.sort_by(|left, right| {
16958 left.start_byte
16959 .cmp(&right.start_byte)
16960 .then(left.end_byte.cmp(&right.end_byte))
16961 .then(left.name.cmp(&right.name))
16962 });
16963 headings
16964}
16965
16966fn markdown_ast_node_metadata(
16967 file: &str,
16968 node: &MarkdownAstRawNode,
16969 source: &[u8],
16970 nodes: &[MarkdownAstRawNode],
16971) -> MarkdownAstNodeMetadata {
16972 let section_nodes = markdown_ast_section_nodes(node, nodes);
16973 let section_path = section_nodes
16974 .iter()
16975 .map(|heading| heading.name.clone())
16976 .collect::<Vec<_>>();
16977 let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16978 let heading_level = (node.kind == "heading")
16979 .then(|| markdown_heading_level(source, node.start_byte))
16980 .flatten();
16981 let (list_marker, list_order) = if node.kind == "list_item" {
16982 markdown_list_attributes(source, node.start_byte)
16983 } else {
16984 (None, None)
16985 };
16986 let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16987 let embedded_symbols = if node.kind == "code_block" {
16988 markdown_embedded_symbols(
16989 file,
16990 source,
16991 node.body_start_byte,
16992 node.body_end_byte,
16993 fence_language.as_deref(),
16994 )
16995 } else {
16996 Vec::new()
16997 };
16998 MarkdownAstNodeMetadata {
16999 heading_level,
17000 section_path,
17001 section_handle,
17002 list_depth: (node.kind == "list_item")
17003 .then(|| markdown_list_depth(source, node.start_byte)),
17004 list_marker,
17005 list_order,
17006 fence_language,
17007 fence_marker: (node.kind == "code_block")
17008 .then(|| markdown_fence_marker(source, node.start_byte))
17009 .flatten(),
17010 embedded_symbols,
17011 }
17012}
17013
17014fn markdown_ast_node_expand(
17015 root: &Path,
17016 file: &str,
17017 node: &MarkdownAstRawNode,
17018 source: &[u8],
17019) -> MarkdownAstNodeExpand {
17020 let start_line = source_line_for_byte(source, node.start_byte);
17021 let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
17022 let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
17023 let body_start_line = node
17024 .body_start_byte
17025 .map(|byte| source_line_for_byte(source, byte))
17026 .unwrap_or(start_line);
17027 let body_end_line = node
17028 .body_end_byte
17029 .map(|byte| source_line_for_end_byte(source, byte))
17030 .unwrap_or(end_line)
17031 .max(body_start_line);
17032 let body_line_count = body_end_line
17033 .saturating_sub(body_start_line)
17034 .saturating_add(1)
17035 .max(1);
17036 MarkdownAstNodeExpand {
17037 source_window: source_read_command(root, file, start_line, line_count),
17038 source_body: source_read_command(root, file, body_start_line, body_line_count),
17039 symbol_read: source_symbol_read_command(root, &node.name, file),
17040 edit_intents: markdown_edit_intents_command(root),
17041 }
17042}
17043
17044fn markdown_ast_node(
17045 root: &Path,
17046 file: &str,
17047 node: &MarkdownAstRawNode,
17048 source: &[u8],
17049 nodes: &[MarkdownAstRawNode],
17050 child_limit: usize,
17051) -> MarkdownAstNode {
17052 let line = source_line_for_byte(source, node.start_byte);
17053 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
17054 let body_byte_span = node
17055 .body_start_byte
17056 .zip(node.body_end_byte)
17057 .map(|(start, end)| SourceByteRangePreview { start, end });
17058 MarkdownAstNode {
17059 handle: node.handle.clone(),
17060 span_handle: node.span_handle.clone(),
17061 name: node.name.clone(),
17062 kind: node.kind.clone(),
17063 block_kind: node.block_kind.clone(),
17064 node_kind: node.node_kind.clone(),
17065 line,
17066 end_line,
17067 byte_span: SourceByteRangePreview {
17068 start: node.start_byte,
17069 end: node.end_byte,
17070 },
17071 body_byte_span,
17072 parent_handle: markdown_ast_parent_handle(node, nodes),
17073 child_handles: markdown_ast_child_handles(node, nodes, child_limit),
17074 metadata: markdown_ast_node_metadata(file, node, source, nodes),
17075 expand: markdown_ast_node_expand(root, file, node, source),
17076 }
17077}
17078
17079pub(crate) fn stored_symbol_ast_span(
17080 symbol: &index::StoredSymbol,
17081 source: &[u8],
17082 symbols: &[index::StoredSymbol],
17083 child_limit: usize,
17084) -> Option<AstSpanPreview> {
17085 let file_symbols = symbols.iter().collect::<Vec<_>>();
17086 stored_symbol_ast_span_in_file(symbol, source, &file_symbols, child_limit)
17087}
17088
17089fn stored_symbol_ast_span_in_file(
17090 symbol: &index::StoredSymbol,
17091 source: &[u8],
17092 symbols: &[&index::StoredSymbol],
17093 child_limit: usize,
17094) -> Option<AstSpanPreview> {
17095 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
17096 let node_kind = symbol.node_kind.clone()?;
17097 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17098 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17099 Some(AstSpanPreview {
17100 handle: ast_span_handle(
17101 &symbol.file,
17102 &symbol.name,
17103 &symbol.kind,
17104 start_byte,
17105 end_byte,
17106 ),
17107 node_kind,
17108 start_byte,
17109 end_byte,
17110 start_line: source_line_for_byte(source, start_byte),
17111 end_line: source_line_for_end_byte(source, end_byte),
17112 body_start_byte,
17113 body_end_byte,
17114 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17115 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17116 parent_handle: stored_symbol_parent_span_handle_in_file(symbol, symbols),
17117 child_handles: stored_symbol_child_span_handles_in_file(symbol, symbols, child_limit),
17118 markdown: markdown_stored_symbol_metadata_in_file(symbol, source, symbols),
17119 })
17120}
17121
17122pub(crate) fn symbol_hit_ast_span(
17123 symbol: &index::SymbolHit,
17124 source: &[u8],
17125) -> Option<AstSpanPreview> {
17126 let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
17127 let node_kind = symbol.node_kind.clone()?;
17128 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17129 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17130 Some(AstSpanPreview {
17131 handle: ast_span_handle(
17132 &symbol.file,
17133 &symbol.name,
17134 &symbol.kind,
17135 start_byte,
17136 end_byte,
17137 ),
17138 node_kind,
17139 start_byte,
17140 end_byte,
17141 start_line: source_line_for_byte(source, start_byte),
17142 end_line: source_line_for_end_byte(source, end_byte),
17143 body_start_byte,
17144 body_end_byte,
17145 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17146 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17147 parent_handle: None,
17148 child_handles: Vec::new(),
17149 markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
17150 })
17151}
17152
17153pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
17154 usize::try_from(symbol.line)
17155 .ok()
17156 .and_then(|line| line.checked_add(1))
17157 .unwrap_or(1)
17158}
17159
17160pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
17161 symbol
17162 .end_line
17163 .and_then(|line| usize::try_from(line).ok())
17164 .and_then(|line| line.checked_add(1))
17165}
17166
17167fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
17168 if end == 0 {
17169 return false;
17170 }
17171 let symbol_start = source_symbol_line(symbol);
17172 let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
17173 symbol_start <= end && symbol_end >= start
17174}
17175
17176#[allow(clippy::too_many_arguments)]
17177fn load_source_symbols(
17178 root: &Path,
17179 file_abs: &Path,
17180 file_display: &str,
17181 source: &[u8],
17182 scope: Option<&str>,
17183 start: usize,
17184 end: usize,
17185 limit: usize,
17186 max_bytes: usize,
17187 warnings: &mut Vec<String>,
17188) -> Vec<SourceSymbolRef> {
17189 let target = match resolve_query_index_target(root, file_abs, scope) {
17190 Ok(target) => target,
17191 Err(err) => {
17192 warnings.push(format!("index refs unavailable: {err:#}"));
17193 return Vec::new();
17194 }
17195 };
17196 if let Err(err) = ensure_query_index_current(root, &target) {
17205 warnings.push(format!("index refs unavailable: {err:#}"));
17206 return Vec::new();
17207 }
17208 let db_path = target.db_path;
17209 if !db_path.exists() {
17210 warnings.push(format!(
17211 "index refs unavailable: no index found at {}",
17212 db_path.display()
17213 ));
17214 return Vec::new();
17215 }
17216
17217 let db = match index::IndexDb::open_read_only_resilient(&db_path) {
17218 Ok(db) => db,
17219 Err(err) => {
17220 warnings.push(format!("index refs unavailable: {err:#}"));
17221 return Vec::new();
17222 }
17223 };
17224
17225 let file_key = file_abs.to_string_lossy().to_string();
17226 let symbols = match db.symbols_for_file(&file_key) {
17227 Ok(symbols) => symbols,
17228 Err(err) => {
17229 warnings.push(format!("symbol refs unavailable: {err:#}"));
17230 return Vec::new();
17231 }
17232 };
17233
17234 symbols
17235 .iter()
17236 .filter(|symbol| source_symbol_intersects(symbol, start, end))
17237 .take(limit)
17238 .map(|symbol| {
17239 let line = source_symbol_line(symbol);
17240 let end_line = source_symbol_end_line(symbol);
17241 let handle = stable_handle(
17242 "ssym",
17243 &format!("{}:{}:{}", file_display, symbol.name, line),
17244 );
17245 SourceSymbolRef {
17246 handle,
17247 name: truncate_for_budget(&symbol.name, max_bytes),
17248 kind: symbol.kind.clone(),
17249 language: symbol.language.clone(),
17250 file: file_display.to_string(),
17251 line,
17252 end_line,
17253 signature: symbol
17254 .signature
17255 .clone()
17256 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17257 span: stored_symbol_ast_span(symbol, source, &symbols, limit),
17258 expand: source_symbol_read_command(root, &symbol.name, file_display),
17259 }
17260 })
17261 .collect()
17262}
17263
17264fn load_source_summaries(
17265 root: &Path,
17266 file_display: &str,
17267 limit: usize,
17268 max_bytes: usize,
17269 warnings: &mut Vec<String>,
17270) -> Vec<SourceSummaryRef> {
17271 let db_path = root.join(".tsift/summaries.db");
17272 if !db_path.exists() {
17273 return Vec::new();
17274 }
17275 let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
17276 Ok(db) => db,
17277 Err(err) => {
17278 warnings.push(format!("summary refs unavailable: {err:#}"));
17279 return Vec::new();
17280 }
17281 };
17282 let summaries = match db.get_by_file(file_display) {
17283 Ok(summaries) => summaries,
17284 Err(err) => {
17285 warnings.push(format!("summary refs unavailable: {err:#}"));
17286 return Vec::new();
17287 }
17288 };
17289
17290 summaries
17291 .into_iter()
17292 .take(limit)
17293 .map(|summary| SourceSummaryRef {
17294 handle: stable_handle(
17295 "sum",
17296 &format!(
17297 "{}:{}:{}",
17298 summary.file_path, summary.symbol_name, summary.id
17299 ),
17300 ),
17301 symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
17302 file_path: summary.file_path,
17303 summary: truncate_for_budget(&summary.summary, max_bytes),
17304 expand: source_summary_expand_command(root, &summary.symbol_name),
17305 })
17306 .collect()
17307}
17308
17309fn cmd_markdown_ast(
17310 file: &Path,
17311 path: &Path,
17312 node: Option<&str>,
17313 format: OutputFormat,
17314 absolute: bool,
17315 budget: ResponseBudget,
17316) -> Result<()> {
17317 let root = lint::resolve_project_root_or_canonical_path(path)?;
17318 let file_abs = resolve_source_file(&root, file)?;
17319 if !is_markdown_path(&file_abs) {
17320 bail!(
17321 "markdown-ast only supports Markdown files (.md/.mdx): {}",
17322 file_abs.display()
17323 );
17324 }
17325 let file_display = if absolute {
17326 file_abs.to_string_lossy().to_string()
17327 } else {
17328 relativize_pathbuf(&file_abs, &root)
17329 .to_string_lossy()
17330 .to_string()
17331 };
17332 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17333 let text = String::from_utf8_lossy(&source);
17334 let total_lines = text.lines().count();
17335 let projection = markdown_ast_projection(&file_display, &source)?;
17336 let raw_nodes = &projection.nodes;
17337 let max_items = budget.preview_items();
17338 let max_bytes = budget.preview_bytes();
17339
17340 let selected_nodes = if let Some(handle) = node {
17341 let matches = raw_nodes
17342 .iter()
17343 .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
17344 .collect::<Vec<_>>();
17345 if matches.is_empty() {
17346 bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
17347 }
17348 matches
17349 } else {
17350 raw_nodes.iter().take(max_items).collect::<Vec<_>>()
17351 };
17352 let nodes = selected_nodes
17353 .into_iter()
17354 .map(|raw| {
17355 let mut node =
17356 markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
17357 node.name = truncate_for_budget(&node.name, max_bytes);
17358 node
17359 })
17360 .collect::<Vec<_>>();
17361 let outline_started = Instant::now();
17362 let outline = markdown_ast_outline_entries(
17363 &root,
17364 &file_display,
17365 &source,
17366 raw_nodes,
17367 max_items,
17368 max_bytes,
17369 );
17370 let outline_duration_micros = outline_started.elapsed().as_micros();
17371 let projection_preview = MarkdownAstProjectionPreview {
17372 mode: if node.is_some() {
17373 "selected_node".to_string()
17374 } else {
17375 "outline_first".to_string()
17376 },
17377 total_nodes: raw_nodes.len(),
17378 returned_nodes: nodes.len(),
17379 omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
17380 selected_node: node.map(str::to_string),
17381 cache: markdown_ast_cache_report(&projection),
17382 outline,
17383 phase_timings: vec![
17384 MarkdownAstPhaseTiming {
17385 name: "parse_extract".to_string(),
17386 duration_micros: projection.parse_duration_micros,
17387 detail: if projection.cache_hit {
17388 "reused cached tree-sitter Markdown symbol extraction".to_string()
17389 } else {
17390 "tree-sitter Markdown symbol extraction".to_string()
17391 },
17392 },
17393 MarkdownAstPhaseTiming {
17394 name: "outline_projection".to_string(),
17395 duration_micros: outline_duration_micros,
17396 detail: "outline-first section/block preview construction".to_string(),
17397 },
17398 ],
17399 };
17400 let report = MarkdownAstReport {
17401 handle: stable_handle("mdastrep", &file_display),
17402 root: root.to_string_lossy().to_string(),
17403 file: file_display.clone(),
17404 range: SourceRangePreview {
17405 start: 1,
17406 end: total_lines,
17407 total_lines,
17408 truncated_before: false,
17409 truncated_after: false,
17410 },
17411 projection: projection_preview,
17412 nodes,
17413 expand: MarkdownAstExpandCommands {
17414 file: markdown_ast_command(&root, &file_display, None),
17415 source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
17416 edit_intents: markdown_edit_intents_command(&root),
17417 },
17418 warnings: Vec::new(),
17419 };
17420
17421 if format.json_output {
17422 let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
17423 let mut follow_up = vec![
17424 report.expand.file.clone(),
17425 report.expand.source_read.clone(),
17426 report.expand.edit_intents.clone(),
17427 ];
17428 follow_up.extend(
17429 report
17430 .nodes
17431 .iter()
17432 .map(|node| node.expand.source_window.clone()),
17433 );
17434 print_json_or_envelope(
17435 &report,
17436 &format,
17437 "markdown-ast",
17438 "ast",
17439 ToolEnvelopeSummary {
17440 text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
17441 metrics: vec![
17442 envelope_metric("nodes", report.nodes.len()),
17443 envelope_metric("total_nodes", report.projection.total_nodes),
17444 envelope_metric(
17445 "parse_duration_micros",
17446 report.projection.cache.parse_duration_micros,
17447 ),
17448 envelope_metric("total_lines", report.range.total_lines),
17449 ],
17450 },
17451 truncated,
17452 follow_up,
17453 )?;
17454 } else if format.compact {
17455 println!(
17456 "markdown-ast {} nodes:{} handle:{}",
17457 report.file,
17458 report.nodes.len(),
17459 report.handle
17460 );
17461 for node in &report.nodes {
17462 println!(
17463 " {} {} {}:{}-{}",
17464 node.handle, node.kind, node.name, node.line, node.end_line
17465 );
17466 }
17467 if node.is_none() && raw_nodes.len() > report.nodes.len() {
17468 println!("expand: {}", report.expand.file);
17469 }
17470 } else {
17471 println!(
17472 "Markdown AST `{}` nodes {} of {} ({})",
17473 report.file,
17474 report.nodes.len(),
17475 raw_nodes.len(),
17476 report.handle
17477 );
17478 for node in &report.nodes {
17479 println!(
17480 " {} `{}` {}:{}-{} — {}",
17481 node.handle,
17482 node.name,
17483 node.kind,
17484 node.line,
17485 node.end_line,
17486 node.expand.source_window
17487 );
17488 }
17489 if node.is_none() && raw_nodes.len() > report.nodes.len() {
17490 println!();
17491 println!("Expand:");
17492 println!(" file: {}", report.expand.file);
17493 }
17494 }
17495
17496 Ok(())
17497}
17498
17499#[allow(clippy::too_many_arguments)]
17500fn cmd_source_read(
17501 file: &Path,
17502 path: &Path,
17503 style: SourceReadStyle,
17504 start: usize,
17505 lines: usize,
17506 end: Option<usize>,
17507 scope: Option<&str>,
17508 format: OutputFormat,
17509 absolute: bool,
17510 budget: ResponseBudget,
17511) -> Result<()> {
17512 if start == 0 {
17513 bail!("--start is 1-based and must be greater than zero");
17514 }
17515 if lines == 0 {
17516 bail!("--lines must be greater than zero");
17517 }
17518 if let Some(end) = end
17519 && end < start
17520 {
17521 bail!("--end must be greater than or equal to --start");
17522 }
17523
17524 let root = lint::resolve_project_root_or_canonical_path(path)?;
17525 let file_abs = resolve_source_file(&root, file)?;
17526 let file_display = if absolute {
17527 file_abs.to_string_lossy().to_string()
17528 } else {
17529 relativize_pathbuf(&file_abs, &root)
17530 .to_string_lossy()
17531 .to_string()
17532 };
17533
17534 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17535 let text = String::from_utf8_lossy(&source);
17536 let all_lines: Vec<&str> = text.lines().collect();
17537 let total_lines = all_lines.len();
17538 if total_lines > 0 && start > total_lines {
17539 bail!(
17540 "--start {} is beyond end of {} ({} lines)",
17541 start,
17542 file_display,
17543 total_lines
17544 );
17545 }
17546 let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
17547 let end_line = requested_end.min(total_lines);
17548 let mut warnings = Vec::new();
17549 let max_items = budget.preview_items();
17550 let max_bytes = budget.preview_bytes();
17551 if style == SourceReadStyle::Ast {
17552 let symbols = load_source_symbols(
17553 &root,
17554 &file_abs,
17555 &file_display,
17556 &source,
17557 scope,
17558 start,
17559 end_line,
17560 max_items,
17561 max_bytes,
17562 &mut warnings,
17563 );
17564 let summaries =
17565 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17566 let markdown = if is_markdown_path(&file_abs) {
17567 match source_read_markdown_projection(
17568 &root,
17569 &file_display,
17570 &source,
17571 start,
17572 end_line,
17573 budget,
17574 ) {
17575 Ok(markdown) => Some(markdown),
17576 Err(err) => {
17577 warnings.push(format!("markdown projection unavailable: {err:#}"));
17578 None
17579 }
17580 }
17581 } else {
17582 None
17583 };
17584 let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
17585 let report = SourceReadAstReport {
17586 handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
17587 root: root.to_string_lossy().to_string(),
17588 file: file_display.clone(),
17589 range: SourceRangePreview {
17590 start,
17591 end: end_line,
17592 total_lines,
17593 truncated_before: start > 1,
17594 truncated_after: end_line < total_lines,
17595 },
17596 symbols,
17597 summaries,
17598 markdown,
17599 expand: SourceReadAstExpandCommands {
17600 window: source_read_window_command(&root, &file_display, start, window_lines),
17601 file_window: source_read_window_command(
17602 &root,
17603 &file_display,
17604 1,
17605 total_lines.max(window_lines),
17606 ),
17607 markdown_ast: is_markdown_path(&file_abs)
17608 .then(|| markdown_ast_command(&root, &file_display, None)),
17609 },
17610 warnings,
17611 };
17612
17613 if format.json_output {
17614 let truncated = report.range.truncated_before
17615 || report.range.truncated_after
17616 || report.symbols.len() >= max_items
17617 || report.summaries.len() >= max_items;
17618 let follow_up = [
17619 Some(report.expand.window.clone()),
17620 Some(report.expand.file_window.clone()),
17621 report.expand.markdown_ast.clone(),
17622 ]
17623 .into_iter()
17624 .flatten()
17625 .collect::<Vec<_>>();
17626 print_json_or_envelope(
17627 &report,
17628 &format,
17629 "source-read",
17630 "ast",
17631 ToolEnvelopeSummary {
17632 text: format!(
17633 "source ast {}:{}-{}",
17634 report.file, report.range.start, report.range.end
17635 ),
17636 metrics: vec![
17637 envelope_metric("symbols", report.symbols.len()),
17638 envelope_metric("summaries", report.summaries.len()),
17639 envelope_metric(
17640 "markdown_nodes",
17641 report
17642 .markdown
17643 .as_ref()
17644 .map_or(0, |markdown| markdown.visible_nodes),
17645 ),
17646 ],
17647 },
17648 truncated,
17649 follow_up,
17650 )?;
17651 } else if format.compact {
17652 println!(
17653 "source-ast {}:{}-{} / {} handle:{}",
17654 report.file,
17655 report.range.start,
17656 report.range.end,
17657 report.range.total_lines,
17658 report.handle
17659 );
17660 for symbol in &report.symbols {
17661 println!(
17662 " {} {}:{} {}",
17663 symbol.name, symbol.file, symbol.line, symbol.expand
17664 );
17665 }
17666 if !report.summaries.is_empty() {
17667 println!("summaries[{}]", report.summaries.len());
17668 }
17669 for warning in &report.warnings {
17670 eprintln!("warning: {warning}");
17671 }
17672 } else {
17673 println!(
17674 "Source AST `{}` lines {}-{} of {} ({})",
17675 report.file,
17676 report.range.start,
17677 report.range.end,
17678 report.range.total_lines,
17679 report.handle
17680 );
17681 if !report.symbols.is_empty() {
17682 println!();
17683 println!("Symbol refs:");
17684 for symbol in &report.symbols {
17685 println!(
17686 " {} `{}` {}:{} — {}",
17687 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17688 );
17689 }
17690 }
17691 if !report.summaries.is_empty() {
17692 println!();
17693 println!("Summary refs:");
17694 for summary in &report.summaries {
17695 println!(
17696 " {} `{}` — {}",
17697 summary.handle, summary.symbol_name, summary.expand
17698 );
17699 }
17700 }
17701 println!();
17702 println!("Expand:");
17703 println!(" window: {}", report.expand.window);
17704 println!(" file window: {}", report.expand.file_window);
17705 if let Some(markdown_ast) = &report.expand.markdown_ast {
17706 println!(" markdown: {}", markdown_ast);
17707 }
17708 for warning in &report.warnings {
17709 eprintln!("warning: {warning}");
17710 }
17711 }
17712
17713 return Ok(());
17714 }
17715 let max_bytes = budget.preview_bytes();
17716 let token_cap = budget.body_token_cap();
17717 let (preview, preview_end, body_truncated) = if total_lines == 0 {
17718 (Vec::new(), end_line, false)
17719 } else {
17720 let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17721 (capped.preview, capped.capped_end, capped.was_capped)
17722 };
17723 let effective_end = if body_truncated {
17724 preview_end
17725 } else {
17726 end_line
17727 };
17728
17729 if body_truncated {
17730 warnings.push(format!(
17731 "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17732 ));
17733 }
17734 let symbols = load_source_symbols(
17735 &root,
17736 &file_abs,
17737 &file_display,
17738 &source,
17739 scope,
17740 start,
17741 effective_end,
17742 max_items,
17743 max_bytes,
17744 &mut warnings,
17745 );
17746 let summaries =
17747 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17748 let markdown = if is_markdown_path(&file_abs) {
17749 match source_read_markdown_projection(
17750 &root,
17751 &file_display,
17752 &source,
17753 start,
17754 effective_end,
17755 budget,
17756 ) {
17757 Ok(markdown) => Some(markdown),
17758 Err(err) => {
17759 warnings.push(format!("markdown projection unavailable: {err:#}"));
17760 None
17761 }
17762 }
17763 } else {
17764 None
17765 };
17766
17767 let expand = SourceExpandCommands {
17768 before: (start > 1).then(|| {
17769 let before_start = start.saturating_sub(lines).max(1);
17770 source_read_window_command(&root, &file_display, before_start, start - before_start)
17771 }),
17772 after: (effective_end < total_lines)
17773 .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17774 body: body_truncated.then(|| {
17775 let remaining = end_line.saturating_sub(effective_end);
17776 source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17777 }),
17778 file: source_read_ast_command(&root, &file_display),
17779 markdown_ast: is_markdown_path(&file_abs)
17780 .then(|| markdown_ast_command(&root, &file_display, None)),
17781 };
17782
17783 let report = SourceReadReport {
17784 handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17785 root: root.to_string_lossy().to_string(),
17786 file: file_display,
17787 range: SourceRangePreview {
17788 start,
17789 end: effective_end,
17790 total_lines,
17791 truncated_before: start > 1,
17792 truncated_after: effective_end < total_lines,
17793 },
17794 preview,
17795 symbols,
17796 summaries,
17797 markdown,
17798 expand,
17799 warnings,
17800 };
17801
17802 if format.json_output {
17803 let truncated = report.range.truncated_before || report.range.truncated_after;
17804 let follow_up = [
17805 report.expand.before.clone(),
17806 report.expand.after.clone(),
17807 report.expand.body.clone(),
17808 Some(report.expand.file.clone()),
17809 report.expand.markdown_ast.clone(),
17810 ]
17811 .into_iter()
17812 .flatten()
17813 .collect::<Vec<_>>();
17814 print_json_or_envelope(
17815 &report,
17816 &format,
17817 "source-read",
17818 "window",
17819 ToolEnvelopeSummary {
17820 text: format!(
17821 "source window {}:{}-{}",
17822 report.file, report.range.start, report.range.end
17823 ),
17824 metrics: vec![
17825 envelope_metric("lines", report.preview.len()),
17826 envelope_metric("symbols", report.symbols.len()),
17827 envelope_metric("summaries", report.summaries.len()),
17828 envelope_metric(
17829 "markdown_nodes",
17830 report
17831 .markdown
17832 .as_ref()
17833 .map_or(0, |markdown| markdown.visible_nodes),
17834 ),
17835 ],
17836 },
17837 truncated,
17838 follow_up,
17839 )?;
17840 } else if format.compact {
17841 println!(
17842 "source {}:{}-{} / {} handle:{}",
17843 report.file,
17844 report.range.start,
17845 report.range.end,
17846 report.range.total_lines,
17847 report.handle
17848 );
17849 for line in &report.preview {
17850 println!("{:>5} {}", line.line, line.text);
17851 }
17852 if !report.symbols.is_empty() {
17853 println!("syms[{}]:", report.symbols.len());
17854 for symbol in &report.symbols {
17855 println!(" {} {}:{}", symbol.name, symbol.file, symbol.line);
17856 }
17857 }
17858 if report.range.truncated_before || report.range.truncated_after {
17859 println!("expand: {}", report.expand.file);
17860 }
17861 } else {
17862 println!(
17863 "Source window `{}` lines {}-{} of {} ({})",
17864 report.file,
17865 report.range.start,
17866 report.range.end,
17867 report.range.total_lines,
17868 report.handle
17869 );
17870 for line in &report.preview {
17871 println!("{:>5} | {}", line.line, line.text);
17872 }
17873 if !report.symbols.is_empty() {
17874 println!();
17875 println!("Symbol refs:");
17876 for symbol in &report.symbols {
17877 println!(
17878 " {} `{}` {}:{} — {}",
17879 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17880 );
17881 }
17882 }
17883 if !report.summaries.is_empty() {
17884 println!();
17885 println!("Summary refs:");
17886 for summary in &report.summaries {
17887 println!(
17888 " {} `{}` — {}",
17889 summary.handle, summary.symbol_name, summary.expand
17890 );
17891 }
17892 }
17893 if report.range.truncated_before || report.range.truncated_after {
17894 println!();
17895 println!("Expand:");
17896 if let Some(before) = &report.expand.before {
17897 println!(" before: {}", before);
17898 }
17899 if let Some(after) = &report.expand.after {
17900 println!(" after: {}", after);
17901 }
17902 println!(" file: {}", report.expand.file);
17903 }
17904 for warning in &report.warnings {
17905 eprintln!("warning: {warning}");
17906 }
17907 }
17908
17909 Ok(())
17910}
17911
17912#[allow(clippy::too_many_arguments)]
17913fn cmd_symbol_read(
17914 symbol: &str,
17915 file_hint: Option<&Path>,
17916 path: &Path,
17917 scope: Option<&str>,
17918 format: OutputFormat,
17919 absolute: bool,
17920 budget: ResponseBudget,
17921) -> Result<()> {
17922 let root = lint::resolve_project_root_or_canonical_path(path)?;
17923 let hinted_file_abs = file_hint
17924 .map(|file| resolve_source_file(&root, file))
17925 .transpose()?;
17926 let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17927 let target = resolve_query_index_target(&root, path_hint, scope)?;
17933 ensure_query_index_current(&root, &target)?;
17934 let db_path = target.db_path;
17935 if !db_path.exists() {
17936 bail!(
17937 "index refs unavailable: no index found at {}",
17938 db_path.display()
17939 );
17940 }
17941 let db = index::IndexDb::open_read_only_resilient(&db_path)
17942 .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17943 let search_limit = budget.follow_up_items().max(10);
17944 let hits = db
17945 .symbol_search(symbol, search_limit)
17946 .with_context(|| format!("searching symbols for {symbol:?}"))?;
17947 let selected = hits
17948 .into_iter()
17949 .find(|hit| {
17950 let Some(hinted_file_abs) = &hinted_file_abs else {
17951 return true;
17952 };
17953 resolve_source_file(&root, Path::new(&hit.file))
17954 .map(|hit_file| hit_file == *hinted_file_abs)
17955 .unwrap_or(false)
17956 })
17957 .with_context(|| {
17958 let hint = file_hint
17959 .map(|file| format!(" in {}", file.display()))
17960 .unwrap_or_default();
17961 format!("no indexed symbol matched {symbol:?}{hint}")
17962 })?;
17963
17964 let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17965 let file_display = if absolute {
17966 file_abs.to_string_lossy().to_string()
17967 } else {
17968 relativize_pathbuf(&file_abs, &root)
17969 .to_string_lossy()
17970 .to_string()
17971 };
17972 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17973 let content_hash = blake3::hash(&source).to_hex().to_string();
17974 let text = String::from_utf8_lossy(&source);
17975 let all_lines: Vec<&str> = text.lines().collect();
17976 let total_lines = all_lines.len();
17977 let file_symbols = db
17978 .symbols_for_file(&file_abs.to_string_lossy())
17979 .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17980 let max_items = budget.preview_items();
17981 let max_bytes = budget.preview_bytes();
17982 let selected_start = symbol_hit_line(&selected);
17983 let selected_end = symbol_hit_end_line(&selected)
17984 .unwrap_or(selected_start)
17985 .max(selected_start);
17986 let stored_target = file_symbols.iter().find(|candidate| {
17987 candidate.name == selected.name
17988 && candidate.kind == selected.kind
17989 && source_symbol_line(candidate) == selected_start
17990 });
17991 let target_span = stored_target
17992 .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17993 .or_else(|| symbol_hit_ast_span(&selected, &source));
17994 let target_start = target_span
17995 .as_ref()
17996 .map(|span| span.start_line)
17997 .unwrap_or(selected_start);
17998 let target_end = target_span
17999 .as_ref()
18000 .map(|span| span.end_line)
18001 .or_else(|| stored_target.and_then(source_symbol_end_line))
18002 .unwrap_or(selected_end)
18003 .max(target_start);
18004 let target_bounds = stored_target
18005 .and_then(stored_symbol_span_bounds)
18006 .or_else(|| symbol_hit_span_bounds(&selected));
18007 let target_end = stored_target
18008 .and_then(source_symbol_end_line)
18009 .unwrap_or(target_end)
18010 .max(target_start);
18011 let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
18012 let line_capped_end = target_start
18013 .saturating_add(body_line_budget)
18014 .saturating_sub(1)
18015 .min(target_end)
18016 .min(total_lines.max(target_start));
18017 let token_cap = budget.body_token_cap();
18018 let (body, effective_preview_end, body_truncated) =
18019 if total_lines == 0 || target_start > total_lines {
18020 (Vec::new(), line_capped_end, false)
18021 } else {
18022 let capped = build_token_capped_preview(
18023 &all_lines,
18024 target_start,
18025 line_capped_end,
18026 max_bytes,
18027 token_cap,
18028 );
18029 (capped.preview, capped.capped_end, capped.was_capped)
18030 };
18031 let preview_end = if body_truncated {
18032 effective_preview_end
18033 } else {
18034 line_capped_end
18035 };
18036 let child_symbols = file_symbols
18037 .iter()
18038 .filter(|candidate| {
18039 if let Some((target_start_byte, target_end_byte)) = target_bounds {
18040 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
18041 else {
18042 return false;
18043 };
18044 return candidate_start >= target_start_byte
18045 && candidate_end <= target_end_byte
18046 && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
18047 }
18048 let line = source_symbol_line(candidate);
18049 line > target_start && line <= target_end
18050 })
18051 .take(max_items)
18052 .map(|symbol| {
18053 let line = source_symbol_line(symbol);
18054 let end_line = source_symbol_end_line(symbol);
18055 SourceSymbolRef {
18056 handle: stable_handle(
18057 "ssym",
18058 &format!("{}:{}:{}", file_display, symbol.name, line),
18059 ),
18060 name: truncate_for_budget(&symbol.name, max_bytes),
18061 kind: symbol.kind.clone(),
18062 language: symbol.language.clone(),
18063 file: file_display.clone(),
18064 line,
18065 end_line,
18066 signature: symbol
18067 .signature
18068 .clone()
18069 .map(|signature| truncate_for_budget(&signature, max_bytes)),
18070 span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
18071 expand: source_symbol_read_command(&root, &symbol.name, &file_display),
18072 }
18073 })
18074 .collect::<Vec<_>>();
18075 let mut warnings = Vec::new();
18076 if body_truncated {
18077 warnings.push(format!(
18078 "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
18079 ));
18080 }
18081 let summaries =
18082 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
18083 let symbol_handle = stable_handle(
18084 "sread",
18085 &format!("{}:{}:{}", file_display, selected.name, target_start),
18086 );
18087 let source_lines = preview_end
18088 .saturating_sub(target_start)
18089 .saturating_add(1)
18090 .max(1);
18091 let expand = SymbolReadExpandCommands {
18092 source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
18093 body: body_truncated.then(|| {
18094 let remaining = target_end.saturating_sub(preview_end);
18095 source_read_window_command(&root, &file_display, preview_end + 1, remaining)
18096 }),
18097 file: source_read_ast_command(&root, &file_display),
18098 explain: source_symbol_expand_command(&root, &selected.name),
18099 callers: source_symbol_graph_command(&root, &selected.name, "callers"),
18100 callees: source_symbol_graph_command(&root, &selected.name, "callees"),
18101 markdown_ast: (selected.language == "markdown").then(|| {
18102 markdown_ast_command(
18103 &root,
18104 &file_display,
18105 target_span.as_ref().map(|span| span.handle.as_str()),
18106 )
18107 }),
18108 };
18109 let report = SymbolReadReport {
18110 handle: symbol_handle.clone(),
18111 root: root.to_string_lossy().to_string(),
18112 query: symbol.to_string(),
18113 symbol: SymbolReadTarget {
18114 handle: symbol_handle,
18115 name: selected.name.clone(),
18116 kind: selected.kind.clone(),
18117 language: selected.language.clone(),
18118 file: file_display.clone(),
18119 line: target_start,
18120 end_line: Some(target_end),
18121 signature: stored_target
18122 .and_then(|stored| stored.signature.clone())
18123 .map(|signature| truncate_for_budget(&signature, max_bytes)),
18124 parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
18125 visibility: stored_target.and_then(|stored| stored.visibility.clone()),
18126 span: target_span,
18127 },
18128 range: SourceRangePreview {
18129 start: target_start,
18130 end: preview_end,
18131 total_lines,
18132 truncated_before: false,
18133 truncated_after: preview_end < target_end,
18134 },
18135 body,
18136 child_symbols,
18137 summaries,
18138 expand,
18139 warnings,
18140 };
18141
18142 if format.json_output {
18143 let truncated = report.range.truncated_after
18144 || report.body.iter().any(|line| line.text.len() >= max_bytes)
18145 || report.child_symbols.len() >= max_items;
18146 let follow_up = [
18147 Some(report.expand.source_window.clone()),
18148 report.expand.body.clone(),
18149 Some(report.expand.file.clone()),
18150 Some(report.expand.explain.clone()),
18151 Some(report.expand.callers.clone()),
18152 Some(report.expand.callees.clone()),
18153 ]
18154 .into_iter()
18155 .flatten()
18156 .chain(report.expand.markdown_ast.clone())
18157 .collect::<Vec<_>>();
18158 print_json_or_envelope(
18159 &report,
18160 &format,
18161 "symbol-read",
18162 "symbol",
18163 ToolEnvelopeSummary {
18164 text: format!(
18165 "symbol {} {}:{}-{}",
18166 report.symbol.name, report.symbol.file, report.range.start, report.range.end
18167 ),
18168 metrics: vec![
18169 envelope_metric("body_lines", report.body.len()),
18170 envelope_metric("child_symbols", report.child_symbols.len()),
18171 envelope_metric("summaries", report.summaries.len()),
18172 ],
18173 },
18174 truncated,
18175 follow_up,
18176 )?;
18177 } else if format.compact {
18178 println!(
18179 "symbol {} {}:{}-{} handle:{} hash:{}",
18180 report.symbol.name,
18181 report.symbol.file,
18182 report.range.start,
18183 report.range.end,
18184 report.handle,
18185 content_hash
18186 );
18187 for line in &report.body {
18188 println!("{:>5} {}", line.line, line.text);
18189 }
18190 if !report.child_symbols.is_empty() {
18191 println!("children[{}]:", report.child_symbols.len());
18192 for child in &report.child_symbols {
18193 println!(" {} {}:{}", child.name, child.file, child.line);
18194 }
18195 }
18196 } else {
18197 println!(
18198 "Symbol `{}` in `{}` lines {}-{} ({})",
18199 report.symbol.name,
18200 report.symbol.file,
18201 report.range.start,
18202 report.range.end,
18203 report.handle
18204 );
18205 for line in &report.body {
18206 println!("{:>5} | {}", line.line, line.text);
18207 }
18208 if !report.child_symbols.is_empty() {
18209 println!();
18210 println!("Child symbols:");
18211 for child in &report.child_symbols {
18212 println!(
18213 " {} `{}` {}:{} — {}",
18214 child.handle, child.name, child.file, child.line, child.expand
18215 );
18216 }
18217 }
18218 println!();
18219 println!("Expand:");
18220 println!(" source: {}", report.expand.source_window);
18221 println!(" file: {}", report.expand.file);
18222 println!(" explain: {}", report.expand.explain);
18223 println!(" callers: {}", report.expand.callers);
18224 println!(" callees: {}", report.expand.callees);
18225 for warning in &report.warnings {
18226 eprintln!("warning: {warning}");
18227 }
18228 }
18229
18230 Ok(())
18231}
18232
18233#[allow(clippy::too_many_arguments)]
18234#[derive(Serialize)]
18235struct ExplainBudgetDefinitionPreview {
18236 handle: String,
18237 #[serde(skip_serializing_if = "Option::is_none")]
18238 tag_alias: Option<String>,
18239 kind: String,
18240 name: String,
18241 file: String,
18242 line: i64,
18243 expand: String,
18244}
18245
18246#[derive(Serialize)]
18247struct ExplainBudgetEdgePreview {
18248 handle: String,
18249 #[serde(skip_serializing_if = "Option::is_none")]
18250 tag_alias: Option<String>,
18251 name: String,
18252 file: String,
18253 line: i64,
18254 expand: String,
18255}
18256
18257#[derive(Serialize)]
18258struct ExplainBudgetCommunityPreview {
18259 size: usize,
18260 members: Vec<String>,
18261}
18262
18263#[derive(Serialize)]
18264struct ExplainBudgetReport {
18265 symbol: String,
18266 max_items: usize,
18267 max_bytes: usize,
18268 definition_total: usize,
18269 callers_total: usize,
18270 callers_truncated_by_limit: bool,
18271 callees_total: usize,
18272 callees_truncated_by_limit: bool,
18273 truncated: bool,
18274 definitions: Vec<ExplainBudgetDefinitionPreview>,
18275 callers: Vec<ExplainBudgetEdgePreview>,
18276 callees: Vec<ExplainBudgetEdgePreview>,
18277 #[serde(skip_serializing_if = "Option::is_none")]
18278 community: Option<ExplainBudgetCommunityPreview>,
18279}
18280
18281#[allow(clippy::too_many_arguments)]
18282pub(crate) fn build_explain_budget_report(
18283 symbol: &str,
18284 _root: &Path,
18285 symbols: &[index::StoredSymbol],
18286 callers: &[index::StoredEdge],
18287 callers_total: usize,
18288 callers_truncated_by_limit: bool,
18289 callees: &[index::StoredEdge],
18290 callees_total: usize,
18291 callees_truncated_by_limit: bool,
18292 community: Option<&graph::Community>,
18293 budget: ResponseBudget,
18294) -> ExplainBudgetReport {
18295 let max_items = budget.preview_items();
18296 let max_bytes = budget.preview_bytes();
18297 let definitions = symbols
18298 .iter()
18299 .take(max_items)
18300 .map(|entry| {
18301 let symbol_ref = build_compact_symbol_ref(
18302 "edef",
18303 &format!(
18304 "{}:{}:{}:{}",
18305 entry.kind, entry.name, entry.file, entry.line
18306 ),
18307 &entry.name,
18308 entry.tags.as_deref(),
18309 max_bytes,
18310 );
18311 ExplainBudgetDefinitionPreview {
18312 handle: symbol_ref.handle,
18313 tag_alias: symbol_ref.tag_alias,
18314 kind: entry.kind.clone(),
18315 name: symbol_ref.name,
18316 file: truncate_for_budget(&entry.file, max_bytes),
18317 line: entry.line,
18318 expand: format!(
18319 "tsift search {} --exact --path {} --limit 20",
18320 shell_quote(&entry.name),
18321 shell_quote(&entry.file)
18322 ),
18323 }
18324 })
18325 .collect();
18326 let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
18327 .iter()
18328 .take(max_items)
18329 .map(|entry| {
18330 let symbol_ref = build_compact_symbol_ref(
18331 "ecall",
18332 &format!(
18333 "{}:{}:{}:{}",
18334 entry.caller_name, entry.caller_file, entry.call_site_line, symbol
18335 ),
18336 &entry.caller_name,
18337 None,
18338 max_bytes,
18339 );
18340 ExplainBudgetEdgePreview {
18341 handle: symbol_ref.handle,
18342 tag_alias: symbol_ref.tag_alias,
18343 name: symbol_ref.name,
18344 file: truncate_for_budget(&entry.caller_file, max_bytes),
18345 line: entry.call_site_line,
18346 expand: format!(
18347 "tsift explain {} --path {} --limit 0",
18348 shell_quote(&entry.caller_name),
18349 shell_quote(&entry.caller_file)
18350 ),
18351 }
18352 })
18353 .collect();
18354 let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
18355 .iter()
18356 .take(max_items)
18357 .map(|entry| {
18358 let symbol_ref = build_compact_symbol_ref(
18359 "eces",
18360 &format!(
18361 "{}:{}:{}:{}",
18362 entry.callee_name, entry.caller_file, entry.call_site_line, symbol
18363 ),
18364 &entry.callee_name,
18365 None,
18366 max_bytes,
18367 );
18368 ExplainBudgetEdgePreview {
18369 handle: symbol_ref.handle,
18370 tag_alias: symbol_ref.tag_alias,
18371 name: symbol_ref.name,
18372 file: truncate_for_budget(&entry.caller_file, max_bytes),
18373 line: entry.call_site_line,
18374 expand: format!(
18375 "tsift explain {} --path {} --limit 0",
18376 shell_quote(&entry.callee_name),
18377 shell_quote(&entry.caller_file)
18378 ),
18379 }
18380 })
18381 .collect();
18382 let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
18383 size: entry.members.len(),
18384 members: entry
18385 .members
18386 .iter()
18387 .take(max_items)
18388 .map(|member| truncate_for_budget(&member.name, max_bytes))
18389 .collect(),
18390 });
18391
18392 ExplainBudgetReport {
18393 symbol: symbol.to_string(),
18394 max_items,
18395 max_bytes,
18396 definition_total: symbols.len(),
18397 callers_total,
18398 callers_truncated_by_limit,
18399 callees_total,
18400 callees_truncated_by_limit,
18401 truncated: symbols.len() > max_items
18402 || callers_total > callers_preview.len()
18403 || callees_total > callees_preview.len()
18404 || community
18405 .map(|entry| entry.members.len() > max_items)
18406 .unwrap_or(false),
18407 definitions,
18408 callers: callers_preview,
18409 callees: callees_preview,
18410 community: community_preview,
18411 }
18412}
18413
18414pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
18415 println!(
18416 "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
18417 shell_quote(&report.symbol),
18418 report.definitions.len(),
18419 report.definition_total,
18420 report.callers.len(),
18421 report.callers_total,
18422 report.callees.len(),
18423 report.callees_total
18424 );
18425 for entry in &report.definitions {
18426 println!(
18427 "def {} {} {}:{} expand:{}",
18428 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18429 entry.kind,
18430 entry.file,
18431 entry.line,
18432 entry.expand
18433 );
18434 }
18435 for entry in &report.callers {
18436 println!(
18437 "caller {} {}:{} expand:{}",
18438 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18439 entry.file,
18440 entry.line,
18441 entry.expand
18442 );
18443 }
18444 for entry in &report.callees {
18445 println!(
18446 "callee {} {}:{} expand:{}",
18447 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18448 entry.file,
18449 entry.line,
18450 entry.expand
18451 );
18452 }
18453 if let Some(community) = &report.community {
18454 println!(
18455 "community size:{} members:{}",
18456 community.size,
18457 community.members.join(", ")
18458 );
18459 }
18460 if report.truncated {
18461 println!(
18462 "budget truncated items:{} bytes:{}",
18463 report.max_items, report.max_bytes
18464 );
18465 }
18466}
18467
18468const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
18478 ".git",
18479 "node_modules",
18480 "target",
18481 "__pycache__",
18482 ".venv",
18483 "vendor",
18484];
18485
18486const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
18487 "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
18488 "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
18489 "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
18490 "sh", "bash", "zsh", "sql", "css", "tsx",
18491];
18492
18493pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
18494 let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
18495 .iter()
18496 .map(|ext| (*ext).to_string())
18497 .collect::<BTreeSet<_>>();
18498
18499 let config_path = root.join(".naming.toml");
18500 if !config_path.exists() {
18501 return extensions;
18502 }
18503
18504 match tagpath::config::resolve(&config_path) {
18505 Ok(config) => {
18506 if let Some(grammars) = config.grammars {
18507 for grammar in grammars.languages.values() {
18508 for ext in &grammar.extensions {
18509 if let Some(normalized) = normalize_extension(ext) {
18510 extensions.insert(normalized);
18511 }
18512 }
18513 }
18514 }
18515 }
18516 Err(err) => {
18517 eprintln!("tagpath_policy_hint_config_unreadable: {err}");
18518 }
18519 }
18520 extensions
18521}
18522
18523pub(crate) fn tagpath_audit_policy_hints(
18524 rel_path: &str,
18525 supported_extensions: &BTreeSet<String>,
18526) -> Vec<String> {
18527 let path = Path::new(rel_path);
18528 let mut hints = BTreeSet::new();
18529 if let Some(parent) = path.parent() {
18530 for component in parent.components() {
18531 if let std::path::Component::Normal(name) = component {
18532 let name = name.to_string_lossy();
18533 if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
18534 hints.insert(format!("skip_dir:{name}"));
18535 }
18536 }
18537 }
18538 }
18539 if path
18540 .extension()
18541 .and_then(|ext| ext.to_str())
18542 .and_then(normalize_extension)
18543 .is_some_and(|ext| !supported_extensions.contains(&ext))
18544 {
18545 hints.insert("extension_unsupported".to_string());
18546 }
18547 hints.into_iter().collect()
18548}
18549
18550fn normalize_extension(ext: &str) -> Option<String> {
18551 let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
18552 if normalized.is_empty() {
18553 None
18554 } else {
18555 Some(normalized)
18556 }
18557}
18558
18559pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
18560 match status {
18561 diff_digest::DiffDigestFileStatus::Added => "added",
18562 diff_digest::DiffDigestFileStatus::Modified => "modified",
18563 diff_digest::DiffDigestFileStatus::Deleted => "deleted",
18564 }
18565}
18566
18567pub(crate) fn diff_digest_summary_label(
18568 state: diff_digest::DiffDigestSummaryState,
18569) -> &'static str {
18570 match state {
18571 diff_digest::DiffDigestSummaryState::Current => "current",
18572 diff_digest::DiffDigestSummaryState::Stale => "stale",
18573 diff_digest::DiffDigestSummaryState::Missing => "missing",
18574 diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
18575 }
18576}
18577
18578fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
18579 match state {
18580 test_digest::TestDigestSummaryState::Current => "current",
18581 test_digest::TestDigestSummaryState::Stale => "stale",
18582 test_digest::TestDigestSummaryState::Missing => "missing",
18583 test_digest::TestDigestSummaryState::Unavailable => "unavailable",
18584 }
18585}
18586
18587fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
18588 match state {
18589 log_digest::LogDigestSummaryState::Current => "current",
18590 log_digest::LogDigestSummaryState::Stale => "stale",
18591 log_digest::LogDigestSummaryState::Missing => "missing",
18592 log_digest::LogDigestSummaryState::Unavailable => "unavailable",
18593 }
18594}
18595
18596pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
18597 match mode {
18598 diff_digest::DiffDigestMode::WorkingTree => "worktree",
18599 diff_digest::DiffDigestMode::Cached => "cached",
18600 diff_digest::DiffDigestMode::Revision => "revision",
18601 }
18602}
18603
18604pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
18605 match (&report.mode, &report.revision) {
18606 (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
18607 (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
18608 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18609 format!("revision {revision}")
18610 }
18611 (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
18612 }
18613}
18614
18615pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
18616 match (&report.mode, &report.revision) {
18617 (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
18618 (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
18619 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18620 format!("No diff found for revision {revision}.")
18621 }
18622 (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
18623 }
18624}
18625
18626fn cmd_impact(
18627 path: &Path,
18628 cached: bool,
18629 revision: Option<&str>,
18630 scope: Option<&str>,
18631 limit: usize,
18632 format: OutputFormat,
18633) -> Result<()> {
18634 let report = impact::compute(
18635 path,
18636 impact::ImpactOptions {
18637 cached,
18638 revision,
18639 scope,
18640 limit,
18641 },
18642 )?;
18643 if format.json_output {
18644 println!(
18645 "{}",
18646 to_json_schema(
18647 &report,
18648 format.pretty,
18649 format.terse,
18650 format.ultra_terse,
18651 format.schema
18652 )?
18653 );
18654 return Ok(());
18655 }
18656
18657 if format.compact {
18658 println!(
18659 "impact mode:{} changed:{} symbols:{} tests:{}/{}",
18660 diff_digest_mode_label(report.mode),
18661 report.changed_files.len(),
18662 report.changed_symbols.len(),
18663 report.affected_tests.len(),
18664 report.affected_tests_total
18665 );
18666 for target in &report.affected_tests {
18667 println!(
18668 "{} reasons:{} command:{}",
18669 target.path,
18670 target.reasons.len(),
18671 target.commands.join(" && ")
18672 );
18673 }
18674 for warning in &report.warnings {
18675 println!("warning {warning}");
18676 }
18677 return Ok(());
18678 }
18679
18680 println!("Impact ({})", diff_digest_mode_label(report.mode));
18681 println!(" changed files: {}", report.changed_files.len());
18682 println!(" changed symbols: {}", report.changed_symbols.len());
18683 println!(
18684 " affected tests: {}/{}",
18685 report.affected_tests.len(),
18686 report.affected_tests_total
18687 );
18688 for target in &report.affected_tests {
18689 println!();
18690 println!("{}", target.path);
18691 for reason in &target.reasons {
18692 println!(" - {reason}");
18693 }
18694 if !target.symbols.is_empty() {
18695 println!(" symbols: {}", target.symbols.join(", "));
18696 }
18697 for command in &target.commands {
18698 println!(" run: {}", command);
18699 }
18700 }
18701 for warning in &report.warnings {
18702 println!("warning: {warning}");
18703 }
18704 Ok(())
18705}
18706
18707pub(crate) fn render_test_digest_from_input(
18708 path: &Path,
18709 input: &str,
18710 runner: Option<&str>,
18711 format: OutputFormat,
18712) -> Result<()> {
18713 let report = test_digest::compute(path, input, runner)?;
18714 if format.json_output {
18715 println!(
18716 "{}",
18717 to_json_schema(
18718 &report,
18719 format.pretty,
18720 format.terse,
18721 format.ultra_terse,
18722 format.schema
18723 )?
18724 );
18725 return Ok(());
18726 }
18727
18728 if report.failure_groups.is_empty() {
18729 println!("No failures detected (runner: {}).", report.runner);
18730 for warning in &report.warnings {
18731 println!("warning: {warning}");
18732 }
18733 return Ok(());
18734 }
18735
18736 if format.compact {
18737 println!(
18738 "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18739 report.runner,
18740 report.failures,
18741 report.grouped_failures,
18742 report.counts.passed.unwrap_or(0),
18743 report.counts.failed.unwrap_or(report.grouped_failures),
18744 report.counts.skipped.unwrap_or(0),
18745 );
18746 for failure in &report.failure_groups {
18747 let tests = truncate_for_compact(&failure.tests.join(","), 60);
18748 let location = match (&failure.path, failure.line) {
18749 (Some(path), Some(line)) => format!("{path}:{line}"),
18750 (Some(path), None) => path.clone(),
18751 _ => "-".to_string(),
18752 };
18753 println!(
18754 "{} tests:{} count:{} summaries:{} msg:{}",
18755 location,
18756 tests,
18757 failure.occurrences,
18758 test_digest_summary_label(failure.summary_state),
18759 truncate_for_compact(&failure.message, 80)
18760 );
18761 }
18762 for warning in &report.warnings {
18763 println!("warning: {warning}");
18764 }
18765 return Ok(());
18766 }
18767
18768 println!("Test digest ({})", report.runner);
18769 println!(" failures: {}", report.failures);
18770 println!(" failure groups: {}", report.grouped_failures);
18771 if let Some(passed) = report.counts.passed {
18772 println!(" passed: {}", passed);
18773 }
18774 if let Some(failed) = report.counts.failed {
18775 println!(" failed: {}", failed);
18776 }
18777 if let Some(skipped) = report.counts.skipped {
18778 println!(" skipped: {}", skipped);
18779 }
18780
18781 for failure in &report.failure_groups {
18782 println!();
18783 match (&failure.path, failure.line, failure.column) {
18784 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18785 (Some(path), Some(line), None) => println!("{path}:{line}"),
18786 (Some(path), None, _) => println!("{path}"),
18787 (None, _, _) => println!("(no file anchor)"),
18788 }
18789 println!(" tests: {}", failure.tests.join(", "));
18790 println!(" occurrences: {}", failure.occurrences);
18791 println!(" message: {}", failure.message);
18792 println!(
18793 " cached summaries: {}",
18794 test_digest_summary_label(failure.summary_state)
18795 );
18796 for summary in &failure.current_summaries {
18797 println!(
18798 " - {}: {}",
18799 summary.symbol,
18800 truncate_for_compact(&summary.summary, 160)
18801 );
18802 }
18803 }
18804 for warning in &report.warnings {
18805 println!("warning: {warning}");
18806 }
18807 Ok(())
18808}
18809
18810#[derive(Clone, Serialize, Deserialize)]
18811struct DispatchTraceSummary {
18812 backlog: usize,
18813 job_packet: usize,
18814 worker_result: usize,
18815 worker_context: usize,
18816 source_handle: usize,
18817 semantic_rows: usize,
18818}
18819
18820#[derive(Clone, Serialize, Deserialize)]
18821struct DispatchTraceReport {
18822 contract_version: String,
18823 root: String,
18824 #[serde(skip_serializing_if = "Option::is_none")]
18825 scope: Option<String>,
18826 targets: Vec<String>,
18827 projection_freshness: GraphDbFreshnessReport,
18828 projection_hashes: Vec<String>,
18829 evidence_packet_ids: Vec<String>,
18830 shared_preparation: ConflictMatrixSharedPreparationSummary,
18831 worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18832 worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18833 summary: DispatchTraceSummary,
18834 nodes: Vec<SubstrateTerseGraphNode>,
18835 edges: Vec<SubstrateTerseGraphEdge>,
18836 conflict_matrix_decisions: Vec<String>,
18837 replay_commands: Vec<String>,
18838 repair_commands: Vec<String>,
18839 truncated: bool,
18840 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18841 warnings: Vec<String>,
18842}
18843
18844fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18845 matches!(
18846 kind,
18847 "session"
18848 | "backlog"
18849 | "job_packet"
18850 | "worker_result"
18851 | "worker_context"
18852 | "source_handle"
18853 | "semantic_concept"
18854 | "semantic_entity"
18855 | "file"
18856 | "symbol"
18857 | "route"
18858 )
18859}
18860
18861fn dispatch_trace_kind_rank(kind: &str) -> usize {
18862 match kind {
18863 "backlog" => 0,
18864 "job_packet" => 1,
18865 "worker_result" => 2,
18866 "worker_context" => 3,
18867 "source_handle" => 4,
18868 "file" => 5,
18869 "symbol" => 6,
18870 "route" => 7,
18871 "semantic_concept" => 8,
18872 "semantic_entity" => 9,
18873 "session" => 10,
18874 _ => 99,
18875 }
18876}
18877
18878fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18879 DispatchTraceSummary {
18880 backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18881 job_packet: nodes
18882 .iter()
18883 .filter(|node| node.kind == "job_packet")
18884 .count(),
18885 worker_result: nodes
18886 .iter()
18887 .filter(|node| node.kind == "worker_result")
18888 .count(),
18889 worker_context: nodes
18890 .iter()
18891 .filter(|node| node.kind == "worker_context")
18892 .count(),
18893 source_handle: nodes
18894 .iter()
18895 .filter(|node| node.kind == "source_handle")
18896 .count(),
18897 semantic_rows: nodes
18898 .iter()
18899 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18900 .count(),
18901 }
18902}
18903
18904fn dispatch_trace_shared_preparation_summary(
18905 graph_nodes: &[SubstrateGraphNode],
18906 graph_edges: &[SubstrateGraphEdge],
18907 conflict: &ConflictMatrixReport,
18908) -> ConflictMatrixSharedPreparationSummary {
18909 ConflictMatrixSharedPreparationSummary {
18910 evidence_cache_status: conflict
18911 .inputs
18912 .shared_preparation
18913 .evidence_cache_status
18914 .clone(),
18915 graph_nodes: graph_nodes.len(),
18916 graph_edges: graph_edges.len(),
18917 evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18918 source_handles: conflict
18919 .candidates
18920 .iter()
18921 .map(|candidate| candidate.source_handles.len())
18922 .sum(),
18923 worker_context: conflict
18924 .candidates
18925 .iter()
18926 .map(|candidate| candidate.worker_context_handles.len())
18927 .sum(),
18928 worker_results: conflict
18929 .candidates
18930 .iter()
18931 .map(|candidate| candidate.worker_feedback.total)
18932 .sum(),
18933 semantic_rows: conflict
18934 .candidates
18935 .iter()
18936 .map(|candidate| candidate.semantic_related.len())
18937 .sum(),
18938 dispatch_trace_snapshot_nodes: graph_nodes.len(),
18939 dispatch_trace_snapshot_edges: graph_edges.len(),
18940 }
18941}
18942
18943fn dispatch_trace_collect_ids(
18944 targets: &[String],
18945 candidates: &[ConflictMatrixCandidate],
18946 graph_nodes: &[SubstrateGraphNode],
18947 graph_edges: &[SubstrateGraphEdge],
18948 depth: usize,
18949 limit: usize,
18950) -> (BTreeSet<String>, bool) {
18951 let target_refs = targets
18952 .iter()
18953 .map(|target| target.trim_start_matches('#').to_string())
18954 .collect::<BTreeSet<_>>();
18955 let mut ids = BTreeSet::new();
18956 for candidate in candidates {
18957 ids.insert(candidate.target_node_id.clone());
18958 for source in &candidate.source_handles {
18959 ids.insert(source.handle.clone());
18960 }
18961 for handle in &candidate.worker_context_handles {
18962 ids.insert(handle.clone());
18963 }
18964 for semantic in &candidate.semantic_related {
18965 ids.insert(semantic.handle.clone());
18966 }
18967 }
18968 for node in graph_nodes {
18969 if !dispatch_trace_allowed_node_kind(&node.kind) {
18970 continue;
18971 }
18972 if node
18973 .properties
18974 .get("ref_id")
18975 .is_some_and(|ref_id| target_refs.contains(ref_id))
18976 {
18977 ids.insert(node.id.clone());
18978 }
18979 }
18980
18981 let node_by_id = graph_nodes
18982 .iter()
18983 .map(|node| (node.id.as_str(), node))
18984 .collect::<BTreeMap<_, _>>();
18985 let max_nodes = if limit == 0 {
18986 usize::MAX
18987 } else {
18988 limit
18989 .saturating_mul(targets.len().max(1))
18990 .saturating_mul(12)
18991 .max(64)
18992 };
18993 let mut truncated = false;
18994 for _ in 0..depth.max(1) {
18995 let before = ids.len();
18996 let current_ids = ids.clone();
18997 for edge in graph_edges {
18998 if ids.len() >= max_nodes {
18999 truncated = true;
19000 break;
19001 }
19002 let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
19003 if !touches {
19004 continue;
19005 }
19006 for endpoint in [&edge.from_id, &edge.to_id] {
19007 let Some(node) = node_by_id.get(endpoint.as_str()) else {
19008 continue;
19009 };
19010 if dispatch_trace_allowed_node_kind(&node.kind) {
19011 ids.insert(endpoint.clone());
19012 }
19013 }
19014 }
19015 if ids.len() == before || truncated {
19016 break;
19017 }
19018 }
19019 (ids, truncated)
19020}
19021
19022#[allow(clippy::too_many_arguments)]
19023fn build_dispatch_trace_report_from_conflict_snapshot(
19024 root: &Path,
19025 scope: Option<&str>,
19026 conflict: ConflictMatrixReport,
19027 graph_nodes: Vec<SubstrateGraphNode>,
19028 graph_edges: Vec<SubstrateGraphEdge>,
19029 depth: usize,
19030 limit: usize,
19031 extra_warnings: Vec<String>,
19032) -> Result<DispatchTraceReport> {
19033 let shared_preparation =
19034 dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
19035 let (ids, truncated) = dispatch_trace_collect_ids(
19036 &conflict.targets,
19037 &conflict.candidates,
19038 &graph_nodes,
19039 &graph_edges,
19040 depth,
19041 limit,
19042 );
19043 let mut nodes = graph_nodes
19044 .into_iter()
19045 .filter(|node| ids.contains(&node.id))
19046 .collect::<Vec<_>>();
19047 nodes.sort_by(|left, right| {
19048 dispatch_trace_kind_rank(&left.kind)
19049 .cmp(&dispatch_trace_kind_rank(&right.kind))
19050 .then(left.id.cmp(&right.id))
19051 });
19052 let node_ids = nodes
19053 .iter()
19054 .map(|node| node.id.as_str())
19055 .collect::<BTreeSet<_>>();
19056 let mut edges = graph_edges
19057 .into_iter()
19058 .filter(|edge| {
19059 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
19060 })
19061 .collect::<Vec<_>>();
19062 edges.sort_by(|left, right| {
19063 left.from_id
19064 .cmp(&right.from_id)
19065 .then(left.kind.cmp(&right.kind))
19066 .then(left.to_id.cmp(&right.to_id))
19067 });
19068 let mut warnings = conflict.warnings;
19069 warnings.extend(extra_warnings);
19070
19071 Ok(DispatchTraceReport {
19072 contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
19073 root: conflict.root,
19074 scope: conflict.scope,
19075 targets: conflict.targets,
19076 projection_freshness: conflict.orchestration.projection_freshness,
19077 projection_hashes: conflict.orchestration.projection_hashes,
19078 evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
19079 shared_preparation,
19080 worker_prompt_packets: conflict.worker_prompt_packets,
19081 worker_feedback: conflict
19082 .candidates
19083 .iter()
19084 .map(|candidate| candidate.worker_feedback.clone())
19085 .collect(),
19086 summary: dispatch_trace_summary(&nodes),
19087 nodes: nodes.into_iter().map(Into::into).collect(),
19088 edges: edges.into_iter().map(Into::into).collect(),
19089 conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
19090 replay_commands: conflict.next_commands,
19091 repair_commands: graph_db_repair_commands(root, scope),
19092 truncated,
19093 warnings,
19094 })
19095}
19096
19097fn build_dispatch_trace_report(
19098 path: &Path,
19099 scope: Option<&str>,
19100 raw_targets: &[String],
19101 depth: usize,
19102 limit: usize,
19103 impact_limit: usize,
19104) -> Result<DispatchTraceReport> {
19105 let root = lint::resolve_project_root_or_canonical_path(path)?;
19106 let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
19107 if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
19108 write_traversal_graph_store(&root, path, scope)
19109 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19110 }
19111 let graph_db = graph_substrate_db_path(&root, scope);
19112 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19113 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19114 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19115 let extra_warnings = store
19116 .read_only_recovery()
19117 .map(graph_db_read_recovery_diagnostic)
19118 .into_iter()
19119 .collect::<Vec<_>>();
19120 let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
19121 let graph_prepared = prepare_conflict_matrix_graph_orchestration(
19122 &root,
19123 scope,
19124 "sqlite",
19125 raw_targets,
19126 &prepared,
19127 depth,
19128 limit,
19129 &store,
19130 freshness.clone(),
19131 )?;
19132 let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
19133 &prepared.preparation_cache.source_watermark,
19134 &prepared.preparation_cache.document_watermark,
19135 &prepared.preparation_cache.staged_diff_watermark,
19136 &[
19137 &format!("targets:{}", raw_targets.join(",")),
19138 &format!("depth:{depth}"),
19139 &format!("limit:{limit}"),
19140 ],
19141 );
19142 if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
19143 &root,
19144 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19145 &dt_cache_key,
19146 ) {
19147 return Ok(cached_report);
19148 }
19149 let conflict = build_conflict_matrix_report_from_prepared_graph(
19150 &root,
19151 path,
19152 scope,
19153 depth,
19154 limit,
19155 impact_limit,
19156 freshness,
19157 extra_warnings.clone(),
19158 &prepared,
19159 &graph_prepared,
19160 )?;
19161 let report = build_dispatch_trace_report_from_conflict_snapshot(
19162 &root,
19163 scope,
19164 conflict,
19165 graph_prepared.graph.nodes,
19166 graph_prepared.graph.edges,
19167 depth,
19168 limit,
19169 extra_warnings,
19170 )?;
19171 cycle_packet_cache::cycle_packet_write_cache(
19172 &root,
19173 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19174 &dt_cache_key,
19175 &report,
19176 );
19177 Ok(report)
19178}
19179
19180fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
19181 let json = serde_json::to_string(report)?.replace("</", "<\\/");
19182 let mut html = String::new();
19183 html.push_str(
19184 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
19185 );
19186 html.push_str(
19187 r#"<style>
19188:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
19189@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
19190*{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}}
19191</style>"#,
19192 );
19193 html.push_str("</head><body><div class=\"page\">");
19194 html.push_str(&format!(
19195 "<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>",
19196 html_escape(&report.targets.join(", ")),
19197 report.evidence_packet_ids.len(),
19198 report.nodes.len(),
19199 report.worker_prompt_packets.len(),
19200 html_escape(&report.contract_version)
19201 ));
19202 html.push_str(
19203 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>"#,
19204 );
19205 html.push_str("<script id=\"trace-data\" type=\"application/json\">");
19206 html.push_str(&json);
19207 html.push_str(
19208 r##"</script><script>
19209const report = JSON.parse(document.getElementById("trace-data").textContent);
19210const svg = document.getElementById("graph-canvas");
19211const nodeList = document.getElementById("nodes");
19212const packets = document.getElementById("packets");
19213const feedback = document.getElementById("feedback");
19214const nodes = report.nodes.map((node, index) => ({...node, index}));
19215const nodeById = new Map(nodes.map(node => [node.id, node]));
19216const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
19217const 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"]]);
19218function color(kind){return colorByKind.get(kind)||"#6b7280";}
19219function text(value){return value == null ? "" : String(value);}
19220function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));}
19221function layout(){
19222 const rect = svg.getBoundingClientRect();
19223 const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
19224 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
19225 const counts = new Map();
19226 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
19227 const offsets = new Map();
19228 for (const node of nodes) {
19229 const group = kinds.indexOf(node.kind);
19230 const index = offsets.get(node.kind) || 0;
19231 offsets.set(node.kind, index + 1);
19232 const total = counts.get(node.kind) || 1;
19233 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
19234 const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
19235 node.x = cx + Math.cos(angle) * ring;
19236 node.y = cy + Math.sin(angle) * ring;
19237 }
19238}
19239function draw(){
19240 svg.innerHTML = "";
19241 for (const edge of edges) {
19242 const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
19243 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
19244 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
19245 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
19246 line.setAttribute("class", "edge");
19247 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
19248 svg.appendChild(line);
19249 }
19250 for (const node of nodes) {
19251 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
19252 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
19253 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
19254 circle.setAttribute("fill", color(node.kind));
19255 circle.setAttribute("class", "node");
19256 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
19257 svg.appendChild(circle);
19258 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
19259 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
19260 label.setAttribute("class", "node-label");
19261 label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
19262 svg.appendChild(label);
19263 }
19264}
19265packets.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>";
19266feedback.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>";
19267nodeList.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("");
19268window.addEventListener("resize", () => { layout(); draw(); });
19269layout(); draw();
19270</script></div></body></html>"##,
19271 );
19272 Ok(html)
19273}
19274
19275struct DispatchTraceOptions<'a> {
19276 path: &'a Path,
19277 scope: Option<&'a str>,
19278 raw_targets: &'a [String],
19279 depth: usize,
19280 limit: usize,
19281 impact_limit: usize,
19282 trace_format: DispatchTraceFormat,
19283}
19284
19285fn cmd_dispatch_trace(
19286 options: DispatchTraceOptions<'_>,
19287 output_format: OutputFormat,
19288) -> Result<()> {
19289 let report = build_dispatch_trace_report(
19290 options.path,
19291 options.scope,
19292 options.raw_targets,
19293 options.depth,
19294 options.limit,
19295 options.impact_limit,
19296 )?;
19297 match options.trace_format {
19298 DispatchTraceFormat::Json => {
19299 if output_format.envelope {
19300 print_json_or_envelope(
19301 &report,
19302 &output_format,
19303 "dispatch-trace",
19304 "operator-review",
19305 ToolEnvelopeSummary {
19306 text: format!(
19307 "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
19308 report.targets.len(),
19309 report.nodes.len(),
19310 report.worker_prompt_packets.len()
19311 ),
19312 metrics: vec![
19313 envelope_metric("targets", report.targets.len()),
19314 envelope_metric("nodes", report.nodes.len()),
19315 envelope_metric("edges", report.edges.len()),
19316 envelope_metric(
19317 "worker_prompt_packets",
19318 report.worker_prompt_packets.len(),
19319 ),
19320 ],
19321 },
19322 report.truncated,
19323 report.replay_commands.clone(),
19324 )
19325 } else {
19326 println!(
19327 "{}",
19328 to_json_schema(
19329 &report,
19330 output_format.pretty,
19331 output_format.terse,
19332 output_format.ultra_terse,
19333 output_format.schema
19334 )?
19335 );
19336 Ok(())
19337 }
19338 }
19339 DispatchTraceFormat::Html => {
19340 println!("{}", dispatch_trace_html(&report)?);
19341 Ok(())
19342 }
19343 }
19344}
19345
19346#[derive(Clone, Debug)]
19347struct DependencyDagProfile {
19348 id: String,
19349 graph_node_id: String,
19350 label: String,
19351 path: Option<String>,
19352 line: Option<i64>,
19353 detail: Option<String>,
19354 source_files: BTreeSet<String>,
19355 source_symbols: BTreeSet<String>,
19356 config_files: BTreeSet<String>,
19357 expected_tests: BTreeSet<String>,
19358 semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
19359 worker_feedback: ConflictMatrixWorkerFeedback,
19360}
19361
19362#[derive(Clone, Debug, Serialize)]
19363struct DependencyDagNode {
19364 id: String,
19365 graph_node_id: String,
19366 label: String,
19367 #[serde(skip_serializing_if = "Option::is_none")]
19368 path: Option<String>,
19369 #[serde(skip_serializing_if = "Option::is_none")]
19370 line: Option<i64>,
19371 #[serde(skip_serializing_if = "Option::is_none")]
19372 detail: Option<String>,
19373 source_files: Vec<String>,
19374 source_symbols: Vec<String>,
19375 config_files: Vec<String>,
19376 expected_tests: Vec<String>,
19377 semantic_refs: Vec<ConflictMatrixSemanticRef>,
19378 worker_feedback: ConflictMatrixWorkerFeedback,
19379}
19380
19381#[derive(Clone, Debug, Serialize)]
19382struct DependencyDagEdge {
19383 from: String,
19384 to: String,
19385 kind: String,
19386 weight: usize,
19387 reasons: Vec<String>,
19388 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19389 shared_files: Vec<String>,
19390 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19391 shared_symbols: Vec<String>,
19392 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19393 shared_tests: Vec<String>,
19394 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19395 shared_config_files: Vec<String>,
19396 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19397 shared_semantic_refs: Vec<String>,
19398}
19399
19400#[derive(Clone, Debug, Serialize)]
19401struct DependencyDagTopoBatch {
19402 batch: usize,
19403 targets: Vec<String>,
19404}
19405
19406#[derive(Clone, Debug, Serialize)]
19407struct DependencyDagCycleDiagnostics {
19408 has_cycles: bool,
19409 blocked_nodes: Vec<String>,
19410 cycle_edges: Vec<DependencyDagEdge>,
19411}
19412
19413#[derive(Serialize)]
19414struct DependencyDagSummary {
19415 nodes: usize,
19416 edges: usize,
19417 topo_batches: usize,
19418 has_cycles: bool,
19419}
19420
19421#[derive(Serialize)]
19422struct DependencyDagReport {
19423 contract_version: &'static str,
19424 root: String,
19425 #[serde(skip_serializing_if = "Option::is_none")]
19426 scope: Option<String>,
19427 path: String,
19428 targets: Vec<String>,
19429 projection_freshness: GraphDbFreshnessReport,
19430 projection_hashes: Vec<String>,
19431 nodes: Vec<DependencyDagNode>,
19432 edges: Vec<DependencyDagEdge>,
19433 topo_batches: Vec<DependencyDagTopoBatch>,
19434 cycle_diagnostics: DependencyDagCycleDiagnostics,
19435 summary: DependencyDagSummary,
19436 replay_commands: Vec<String>,
19437 repair_commands: Vec<String>,
19438 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19439 warnings: Vec<String>,
19440}
19441
19442fn dependency_dag_backlog_node_for_target(
19443 store: &impl GraphStore,
19444 target: &str,
19445) -> Result<SubstrateGraphNode> {
19446 let resolved = graph_db_resolve_evidence_target(store, target)?
19447 .with_context(|| format!("dependency-dag target not found: {target}"))?;
19448 if resolved.kind == "backlog" {
19449 return Ok(resolved);
19450 }
19451 let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
19452 bail!(
19453 "dependency-dag target {} resolved to {} without a backlog ref_id",
19454 target,
19455 resolved.kind
19456 );
19457 };
19458 store
19459 .nodes_by_kind("backlog")?
19460 .into_iter()
19461 .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
19462 .min_by(|left, right| {
19463 left.properties
19464 .get("line")
19465 .and_then(|value| value.parse::<i64>().ok())
19466 .cmp(
19467 &right
19468 .properties
19469 .get("line")
19470 .and_then(|value| value.parse::<i64>().ok()),
19471 )
19472 .then(left.id.cmp(&right.id))
19473 })
19474 .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
19475}
19476
19477fn dependency_dag_resolve_backlog_nodes(
19478 root: &Path,
19479 path: &Path,
19480 store: &impl GraphStore,
19481 raw_targets: &[String],
19482) -> Result<Vec<SubstrateGraphNode>> {
19483 let mut nodes = Vec::new();
19484 let mut seen = BTreeSet::new();
19485 if raw_targets.is_empty() {
19486 let hinted_path = if path.is_absolute() {
19487 path.to_path_buf()
19488 } else {
19489 root.join(path)
19490 };
19491 let hinted_markdown = hinted_path
19492 .extension()
19493 .and_then(|ext| ext.to_str())
19494 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
19495 let hinted_rel = hinted_markdown.then(|| {
19496 relativize_pathbuf(&hinted_path, root)
19497 .to_string_lossy()
19498 .replace('\\', "/")
19499 });
19500 for node in store.nodes_by_kind("backlog")? {
19501 if let Some(expected_path) = &hinted_rel
19502 && node.properties.get("path") != Some(expected_path)
19503 {
19504 continue;
19505 }
19506 if seen.insert(node.id.clone()) {
19507 nodes.push(node);
19508 }
19509 }
19510 if nodes.is_empty() && hinted_rel.is_some() {
19511 for node in store.nodes_by_kind("backlog")? {
19512 if seen.insert(node.id.clone()) {
19513 nodes.push(node);
19514 }
19515 }
19516 }
19517 } else {
19518 for target in raw_targets {
19519 let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
19520 let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
19521 if seen.insert(node.id.clone()) {
19522 nodes.push(node);
19523 }
19524 }
19525 }
19526 if nodes.is_empty() {
19527 bail!("dependency-dag needs at least one resolvable backlog id");
19528 }
19529 nodes.sort_by(|left, right| {
19530 left.properties
19531 .get("line")
19532 .and_then(|value| value.parse::<i64>().ok())
19533 .cmp(
19534 &right
19535 .properties
19536 .get("line")
19537 .and_then(|value| value.parse::<i64>().ok()),
19538 )
19539 .then(left.id.cmp(&right.id))
19540 });
19541 Ok(nodes)
19542}
19543
19544fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
19545 node.properties
19546 .get("ref_id")
19547 .cloned()
19548 .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
19549}
19550
19551fn dependency_dag_node_profile(
19552 root: &Path,
19553 store: &impl GraphStore,
19554 node: &SubstrateGraphNode,
19555 graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
19556 graph_edges: &[SubstrateGraphEdge],
19557 depth: usize,
19558 limit: usize,
19559) -> Result<DependencyDagProfile> {
19560 let id = dependency_dag_node_id(node);
19561 let mut source_files = BTreeSet::new();
19562 let mut source_symbols = BTreeSet::new();
19563 for edge in graph_edges
19564 .iter()
19565 .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
19566 {
19567 let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
19568 continue;
19569 };
19570 match target.kind.as_str() {
19571 "file" | "route" => {
19572 if let Some(path) = target.properties.get("path") {
19573 source_files.insert(path.clone());
19574 }
19575 }
19576 "symbol" => {
19577 source_symbols.insert(target.label.clone());
19578 if let Some(path) = target.properties.get("path") {
19579 source_files.insert(path.clone());
19580 }
19581 }
19582 _ => {}
19583 }
19584 }
19585
19586 let max_rows = if limit == 0 { usize::MAX } else { limit };
19587 for (source, _) in
19588 graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
19589 {
19590 let terse: SubstrateTerseGraphNode = (&source).into();
19591 if let Some(handle) = conflict_matrix_source_handle(&terse) {
19592 source_files.insert(handle.file);
19593 }
19594 }
19595
19596 let worker_results = graph_nodes_by_id
19597 .values()
19598 .filter(|candidate| {
19599 candidate.kind == "worker_result"
19600 && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
19601 })
19602 .map(SubstrateTerseGraphNode::from)
19603 .collect::<Vec<_>>();
19604 let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
19605 let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
19606 let config_files = source_files
19607 .iter()
19608 .filter(|file| is_planner_config_path(file))
19609 .cloned()
19610 .collect();
19611
19612 let mut semantic_refs = BTreeMap::new();
19613 for kind in ["semantic_concept", "semantic_entity"] {
19614 for (semantic, _) in
19615 graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
19616 {
19617 let terse: SubstrateTerseGraphNode = (&semantic).into();
19618 let item = conflict_matrix_semantic_ref(root, &terse);
19619 semantic_refs
19620 .entry(format!("{}:{}", item.kind, item.label))
19621 .or_insert(item);
19622 }
19623 }
19624
19625 Ok(DependencyDagProfile {
19626 id,
19627 graph_node_id: node.id.clone(),
19628 label: node.label.clone(),
19629 path: node.properties.get("path").cloned(),
19630 line: node
19631 .properties
19632 .get("line")
19633 .and_then(|value| value.parse::<i64>().ok()),
19634 detail: node.properties.get("detail").cloned(),
19635 source_files,
19636 source_symbols,
19637 config_files,
19638 expected_tests,
19639 semantic_refs,
19640 worker_feedback,
19641 })
19642}
19643
19644fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
19645 let lower = text.to_ascii_lowercase();
19646 let mut refs = Vec::new();
19647 for marker in markers {
19648 let mut offset = 0usize;
19649 while let Some(pos) = lower[offset..].find(marker) {
19650 let start = offset + pos + marker.len();
19651 let segment = text[start..]
19652 .split(['\n', '.'])
19653 .next()
19654 .unwrap_or(&text[start..]);
19655 refs.extend(extract_conflict_target_refs(segment));
19656 offset = start;
19657 }
19658 }
19659 dedupe_preserve_order(refs)
19660}
19661
19662fn dependency_dag_push_edge(
19663 edges: &mut Vec<DependencyDagEdge>,
19664 seen: &mut BTreeSet<(String, String, String)>,
19665 edge: DependencyDagEdge,
19666) {
19667 if edge.from == edge.to {
19668 return;
19669 }
19670 if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
19671 edges.push(edge);
19672 }
19673}
19674
19675fn dependency_dag_explicit_edges(
19676 profiles: &[DependencyDagProfile],
19677 target_ids: &BTreeSet<String>,
19678 edges: &mut Vec<DependencyDagEdge>,
19679 seen: &mut BTreeSet<(String, String, String)>,
19680) {
19681 for profile in profiles {
19682 let detail = profile.detail.as_deref().unwrap_or_default();
19683 for dep in dependency_dag_marker_refs(
19684 detail,
19685 &[
19686 "depends on",
19687 "depends-on",
19688 "deps:",
19689 "after",
19690 "blocked by",
19691 "requires",
19692 ],
19693 ) {
19694 if target_ids.contains(&dep) {
19695 dependency_dag_push_edge(
19696 edges,
19697 seen,
19698 DependencyDagEdge {
19699 from: dep.clone(),
19700 to: profile.id.clone(),
19701 kind: "explicit_depends_on".to_string(),
19702 weight: 1000,
19703 reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
19704 shared_files: Vec::new(),
19705 shared_symbols: Vec::new(),
19706 shared_tests: Vec::new(),
19707 shared_config_files: Vec::new(),
19708 shared_semantic_refs: Vec::new(),
19709 },
19710 );
19711 }
19712 }
19713 for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
19714 if target_ids.contains(&downstream) {
19715 dependency_dag_push_edge(
19716 edges,
19717 seen,
19718 DependencyDagEdge {
19719 from: profile.id.clone(),
19720 to: downstream.clone(),
19721 kind: "explicit_before".to_string(),
19722 weight: 900,
19723 reasons: vec![format!(
19724 "{} declares it should run before #{downstream}",
19725 profile.id
19726 )],
19727 shared_files: Vec::new(),
19728 shared_symbols: Vec::new(),
19729 shared_tests: Vec::new(),
19730 shared_config_files: Vec::new(),
19731 shared_semantic_refs: Vec::new(),
19732 },
19733 );
19734 }
19735 }
19736 }
19737}
19738
19739fn dependency_dag_worker_follow_up_edges(
19740 profiles: &[DependencyDagProfile],
19741 target_ids: &BTreeSet<String>,
19742 edges: &mut Vec<DependencyDagEdge>,
19743 seen: &mut BTreeSet<(String, String, String)>,
19744) {
19745 for profile in profiles {
19746 for follow_up in &profile.worker_feedback.follow_up_ids {
19747 if target_ids.contains(follow_up) {
19748 dependency_dag_push_edge(
19749 edges,
19750 seen,
19751 DependencyDagEdge {
19752 from: profile.id.clone(),
19753 to: follow_up.clone(),
19754 kind: "worker_result_follow_up".to_string(),
19755 weight: 700,
19756 reasons: vec![format!(
19757 "worker_result for #{} references follow-up #{}",
19758 profile.id, follow_up
19759 )],
19760 shared_files: Vec::new(),
19761 shared_symbols: Vec::new(),
19762 shared_tests: Vec::new(),
19763 shared_config_files: Vec::new(),
19764 shared_semantic_refs: Vec::new(),
19765 },
19766 );
19767 }
19768 }
19769 }
19770}
19771
19772fn dependency_dag_overlap_edges(
19773 profiles: &[DependencyDagProfile],
19774 edges: &mut Vec<DependencyDagEdge>,
19775 seen: &mut BTreeSet<(String, String, String)>,
19776) {
19777 for left_idx in 0..profiles.len() {
19778 for right_idx in (left_idx + 1)..profiles.len() {
19779 let left = &profiles[left_idx];
19780 let right = &profiles[right_idx];
19781 let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19782 let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19783 let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19784 let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19785 let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19786 let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19787 let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19788 if shared_files.is_empty()
19789 && shared_symbols.is_empty()
19790 && shared_tests.is_empty()
19791 && shared_config_files.is_empty()
19792 && shared_semantic_refs.is_empty()
19793 {
19794 continue;
19795 }
19796 let kind = if shared_files.is_empty()
19797 && shared_symbols.is_empty()
19798 && shared_tests.is_empty()
19799 && shared_config_files.is_empty()
19800 {
19801 "semantic_relation"
19802 } else {
19803 "shared_resource"
19804 };
19805 let mut reasons = Vec::new();
19806 if !shared_files.is_empty() {
19807 reasons.push(format!("shared files: {}", shared_files.join(", ")));
19808 }
19809 if !shared_symbols.is_empty() {
19810 reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19811 }
19812 if !shared_tests.is_empty() {
19813 reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19814 }
19815 if !shared_config_files.is_empty() {
19816 reasons.push(format!(
19817 "shared config files: {}",
19818 shared_config_files.join(", ")
19819 ));
19820 }
19821 if !shared_semantic_refs.is_empty() {
19822 reasons.push(format!(
19823 "shared semantic refs: {}",
19824 shared_semantic_refs.join(", ")
19825 ));
19826 }
19827 let weight = shared_files.len() * 100
19828 + shared_config_files.len() * 100
19829 + shared_symbols.len() * 40
19830 + shared_tests.len() * 10
19831 + shared_semantic_refs.len() * 5;
19832 dependency_dag_push_edge(
19833 edges,
19834 seen,
19835 DependencyDagEdge {
19836 from: left.id.clone(),
19837 to: right.id.clone(),
19838 kind: kind.to_string(),
19839 weight,
19840 reasons,
19841 shared_files,
19842 shared_symbols,
19843 shared_tests,
19844 shared_config_files,
19845 shared_semantic_refs,
19846 },
19847 );
19848 }
19849 }
19850}
19851
19852fn dependency_dag_topo_batches(
19853 targets: &[String],
19854 edges: &[DependencyDagEdge],
19855) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19856 let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19857 let order = targets
19858 .iter()
19859 .enumerate()
19860 .map(|(idx, id)| (id.clone(), idx))
19861 .collect::<BTreeMap<_, _>>();
19862 let mut indegree = targets
19863 .iter()
19864 .map(|id| (id.clone(), 0usize))
19865 .collect::<BTreeMap<_, _>>();
19866 let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19867 let mut seen_pairs = BTreeSet::<(String, String)>::new();
19868 for edge in edges {
19869 if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19870 continue;
19871 }
19872 if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19873 continue;
19874 }
19875 *indegree.entry(edge.to.clone()).or_default() += 1;
19876 outgoing
19877 .entry(edge.from.clone())
19878 .or_default()
19879 .push(edge.to.clone());
19880 }
19881 for values in outgoing.values_mut() {
19882 values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19883 values.dedup();
19884 }
19885
19886 let mut processed = BTreeSet::new();
19887 let mut batches = Vec::new();
19888 loop {
19889 let mut ready = targets
19890 .iter()
19891 .filter(|id| !processed.contains(*id))
19892 .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19893 .cloned()
19894 .collect::<Vec<_>>();
19895 ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19896 if ready.is_empty() {
19897 break;
19898 }
19899 for id in &ready {
19900 processed.insert(id.clone());
19901 for next in outgoing.get(id).into_iter().flatten() {
19902 if let Some(value) = indegree.get_mut(next) {
19903 *value = value.saturating_sub(1);
19904 }
19905 }
19906 }
19907 batches.push(DependencyDagTopoBatch {
19908 batch: batches.len() + 1,
19909 targets: ready,
19910 });
19911 }
19912
19913 let blocked_nodes = targets
19914 .iter()
19915 .filter(|id| !processed.contains(*id))
19916 .cloned()
19917 .collect::<Vec<_>>();
19918 let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19919 let cycle_edges = edges
19920 .iter()
19921 .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19922 .cloned()
19923 .collect::<Vec<_>>();
19924 (
19925 batches,
19926 DependencyDagCycleDiagnostics {
19927 has_cycles: !blocked_nodes.is_empty(),
19928 blocked_nodes,
19929 cycle_edges,
19930 },
19931 )
19932}
19933
19934fn dependency_dag_replay_commands(
19935 path: &Path,
19936 scope: Option<&str>,
19937 targets: &[String],
19938 depth: usize,
19939 limit: usize,
19940) -> Vec<String> {
19941 let target_args = targets
19942 .iter()
19943 .map(|target| shell_quote(target))
19944 .collect::<Vec<_>>()
19945 .join(" ");
19946 let mut command = format!(
19947 "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19948 shell_quote(path.to_string_lossy().as_ref()),
19949 scope
19950 .map(|scope| format!(" --scope {}", shell_quote(scope)))
19951 .unwrap_or_default(),
19952 depth,
19953 limit
19954 );
19955 if !target_args.is_empty() {
19956 command.push(' ');
19957 command.push_str(&target_args);
19958 }
19959 vec![command]
19960}
19961
19962fn build_dependency_dag_report(
19963 path: &Path,
19964 scope: Option<&str>,
19965 raw_targets: &[String],
19966 depth: usize,
19967 limit: usize,
19968) -> Result<DependencyDagReport> {
19969 let root = lint::resolve_project_root_or_canonical_path(path)?;
19970 write_traversal_graph_store(&root, path, scope)
19971 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19972 let graph_db = graph_substrate_db_path(&root, scope);
19973 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19974 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19975 let mut warnings = Vec::new();
19976 if let Some(recovery) = store.read_only_recovery() {
19977 warnings.push(graph_db_read_recovery_diagnostic(recovery));
19978 }
19979 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19980 if freshness.fail_closed {
19981 bail!(
19982 "dependency-dag graph projection failed closed: {}; repair: {}",
19983 freshness.diagnostics.join("; "),
19984 graph_db_repair_commands(&root, scope).join("; ")
19985 );
19986 }
19987
19988 let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19989 let graph_nodes = store.all_nodes()?;
19990 let graph_edges = store.all_edges()?;
19991 let graph_nodes_by_id = graph_nodes
19992 .into_iter()
19993 .map(|node| (node.id.clone(), node))
19994 .collect::<BTreeMap<_, _>>();
19995 let profiles = target_nodes
19996 .iter()
19997 .map(|node| {
19998 dependency_dag_node_profile(
19999 &root,
20000 &store,
20001 node,
20002 &graph_nodes_by_id,
20003 &graph_edges,
20004 depth,
20005 limit,
20006 )
20007 })
20008 .collect::<Result<Vec<_>>>()?;
20009 let targets = profiles
20010 .iter()
20011 .map(|profile| profile.id.clone())
20012 .collect::<Vec<_>>();
20013 let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
20014
20015 let mut edges = Vec::new();
20016 let mut seen_edges = BTreeSet::new();
20017 dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
20018 dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
20019 dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
20020 edges.sort_by(|left, right| {
20021 left.from
20022 .cmp(&right.from)
20023 .then(left.to.cmp(&right.to))
20024 .then(left.kind.cmp(&right.kind))
20025 });
20026 let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
20027
20028 let nodes = profiles
20029 .into_iter()
20030 .map(|profile| DependencyDagNode {
20031 id: profile.id,
20032 graph_node_id: profile.graph_node_id,
20033 label: profile.label,
20034 path: profile.path,
20035 line: profile.line,
20036 detail: profile.detail,
20037 source_files: sorted_set(&profile.source_files),
20038 source_symbols: sorted_set(&profile.source_symbols),
20039 config_files: sorted_set(&profile.config_files),
20040 expected_tests: sorted_set(&profile.expected_tests),
20041 semantic_refs: profile.semantic_refs.into_values().collect(),
20042 worker_feedback: profile.worker_feedback,
20043 })
20044 .collect::<Vec<_>>();
20045 let projection_hashes = freshness
20046 .content_hash
20047 .clone()
20048 .into_iter()
20049 .collect::<Vec<_>>();
20050 let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
20051 let repair_commands = graph_db_repair_commands(&root, scope);
20052 let summary = DependencyDagSummary {
20053 nodes: nodes.len(),
20054 edges: edges.len(),
20055 topo_batches: topo_batches.len(),
20056 has_cycles: cycle_diagnostics.has_cycles,
20057 };
20058
20059 Ok(DependencyDagReport {
20060 contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
20061 root: root.to_string_lossy().to_string(),
20062 scope: scope.map(str::to_string),
20063 path: path.to_string_lossy().to_string(),
20064 targets,
20065 projection_freshness: freshness,
20066 projection_hashes,
20067 nodes,
20068 edges,
20069 topo_batches,
20070 cycle_diagnostics,
20071 summary,
20072 replay_commands,
20073 repair_commands,
20074 warnings,
20075 })
20076}
20077
20078fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
20079 if compact {
20080 println!(
20081 "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
20082 report.targets.len(),
20083 report.edges.len(),
20084 report.topo_batches.len(),
20085 report.cycle_diagnostics.has_cycles
20086 );
20087 } else {
20088 println!("Dependency DAG");
20089 println!(" targets: {}", report.targets.join(", "));
20090 println!(" edges: {}", report.edges.len());
20091 println!(" cycles: {}", report.cycle_diagnostics.has_cycles);
20092 }
20093 for batch in &report.topo_batches {
20094 println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
20095 }
20096 for edge in &report.edges {
20097 println!(
20098 "edge {} -> {} kind:{} weight:{}",
20099 edge.from, edge.to, edge.kind, edge.weight
20100 );
20101 for reason in &edge.reasons {
20102 println!(" reason: {reason}");
20103 }
20104 }
20105 if report.cycle_diagnostics.has_cycles {
20106 println!(
20107 "cycle blocked nodes: {}",
20108 report.cycle_diagnostics.blocked_nodes.join(", ")
20109 );
20110 }
20111 for command in &report.replay_commands {
20112 println!("replay: {command}");
20113 }
20114 for command in &report.repair_commands {
20115 println!("repair: {command}");
20116 }
20117 for warning in &report.warnings {
20118 println!("warning: {warning}");
20119 }
20120}
20121
20122fn cmd_dependency_dag(
20123 path: &Path,
20124 scope: Option<&str>,
20125 raw_targets: &[String],
20126 depth: usize,
20127 limit: usize,
20128 format: OutputFormat,
20129) -> Result<()> {
20130 let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
20131 if format.json_output {
20132 print_json_or_envelope(
20133 &report,
20134 &format,
20135 "dependency-dag",
20136 "topological-planning",
20137 ToolEnvelopeSummary {
20138 text: format!(
20139 "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
20140 report.targets.len(),
20141 report.edges.len(),
20142 report.topo_batches.len(),
20143 report.cycle_diagnostics.has_cycles
20144 ),
20145 metrics: vec![
20146 envelope_metric("targets", report.targets.len()),
20147 envelope_metric("edges", report.edges.len()),
20148 envelope_metric("topo_batches", report.topo_batches.len()),
20149 envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
20150 ],
20151 },
20152 report.cycle_diagnostics.has_cycles,
20153 report.replay_commands.clone(),
20154 )
20155 } else {
20156 print_dependency_dag_human(&report, format.compact);
20157 Ok(())
20158 }
20159}
20160
20161fn maybe_attach_log_digest_raw_artifact(
20166 root: &Path,
20167 report: &mut log_digest::LogDigestReport,
20168 input: &str,
20169) -> Result<()> {
20170 if input.trim().is_empty() || !log_digest::raw_log_artifact_recommended(report, input.len()) {
20171 return Ok(());
20172 }
20173 let key = format!("logdigest:{}:{}", report.total_lines, input.len());
20174 let artifact_path = root
20175 .join(".tsift/artifacts")
20176 .join(format!("{}.log", stable_handle("logdg", &key)));
20177 let expand = format!(
20178 "tsift log-digest --path {} --input {} --json",
20179 shell_quote(root.to_string_lossy().as_ref()),
20180 shell_quote(artifact_path.to_string_lossy().as_ref())
20181 );
20182 let artifact = persist_transcript_artifact(root, "logdg", "log", &key, input, expand)?;
20183 report.raw_log_artifact = Some(log_digest::LogDigestArtifactRef {
20184 handle: artifact.handle,
20185 path: artifact.path,
20186 bytes: artifact.bytes,
20187 lines: artifact.lines,
20188 expand: artifact.expand,
20189 });
20190 Ok(())
20191}
20192
20193pub(crate) fn render_log_digest_fixture(
20197 path: &Path,
20198 fixture_path: &Path,
20199 fail_under: bool,
20200 format: OutputFormat,
20201) -> Result<()> {
20202 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20203 let fixture_body = fs::read_to_string(fixture_path)
20204 .with_context(|| format!("reading log-digest fixture: {}", fixture_path.display()))?;
20205 let fixture: log_digest::LogDigestFixture = serde_json::from_str(&fixture_body)
20206 .with_context(|| format!("parsing log-digest fixture: {}", fixture_path.display()))?;
20207 let report = log_digest::evaluate_fixture(&root, &fixture)?;
20208
20209 if format.json_output {
20210 print_json_or_envelope(
20211 &report,
20212 &format,
20213 "log-digest-fixture",
20214 "report",
20215 ToolEnvelopeSummary {
20216 text: if report.passed {
20217 format!("log-digest gate passed for {} case(s)", report.total_cases)
20218 } else {
20219 format!("log-digest gate failed {} case(s)", report.failed_cases)
20220 },
20221 metrics: vec![
20222 envelope_metric("cases", report.total_cases),
20223 envelope_metric("failed", report.failed_cases),
20224 envelope_metric("passed", report.passed),
20225 ],
20226 },
20227 false,
20228 vec![],
20229 )?;
20230 } else {
20231 println!("Log digest fixture gate");
20232 println!(" cases: {}", report.total_cases);
20233 println!(" failed: {}", report.failed_cases);
20234 println!(" status: {}", if report.passed { "pass" } else { "fail" });
20235 for case in &report.cases {
20236 println!(
20237 " [{}] {} ({}): savings {:.1}% (min {:.1}%) raw_tok {} digest_tok {}",
20238 if case.passed { "pass" } else { "FAIL" },
20239 case.name,
20240 case.ecosystem,
20241 case.savings_percent,
20242 case.minimum_savings_percent,
20243 case.raw_tokens,
20244 case.digest_tokens
20245 );
20246 if !case.missing_required_signals.is_empty() {
20247 println!(
20248 " missing required signals: {}",
20249 case.missing_required_signals.join(", ")
20250 );
20251 }
20252 if !case.present_forbidden_signals.is_empty() {
20253 println!(
20254 " present forbidden signals: {}",
20255 case.present_forbidden_signals.join(", ")
20256 );
20257 }
20258 }
20259 }
20260
20261 if fail_under && !report.passed {
20262 bail!("log-digest fixture gate failed");
20263 }
20264 Ok(())
20265}
20266
20267pub(crate) fn render_log_digest_from_input(
20268 path: &Path,
20269 input: &str,
20270 format: OutputFormat,
20271) -> Result<()> {
20272 let mut report = log_digest::compute(path, input)?;
20273 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20274 maybe_attach_log_digest_raw_artifact(&root, &mut report, input)?;
20275 if format.json_output {
20276 println!(
20277 "{}",
20278 to_json_schema(
20279 &report,
20280 format.pretty,
20281 format.terse,
20282 format.ultra_terse,
20283 format.schema
20284 )?
20285 );
20286 return Ok(());
20287 }
20288
20289 if format.compact {
20290 println!(
20291 "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
20292 report.non_empty_lines,
20293 report.signal_groups,
20294 report.repeated_line_groups,
20295 report.file_ref_groups,
20296 report.symbol_ref_groups,
20297 report.stack_groups
20298 );
20299 for signal in &report.signals {
20300 let location = match (&signal.path, signal.line) {
20301 (Some(path), Some(line)) => format!("{path}:{line}"),
20302 (Some(path), None) => path.clone(),
20303 _ => "-".to_string(),
20304 };
20305 println!(
20306 "{} sev:{} count:{} sums:{} msg:{}",
20307 location,
20308 signal.severity,
20309 signal.occurrences,
20310 log_digest_summary_label(signal.summary_state),
20311 truncate_for_compact(&signal.message, 80)
20312 );
20313 }
20314 for repeated in &report.repeated_lines {
20315 println!(
20316 "repeat count:{} line:{}",
20317 repeated.occurrences,
20318 truncate_for_compact(&repeated.line, 80)
20319 );
20320 }
20321 for family in &report.line_families {
20322 println!(
20323 "family count:{} variants:{} template:{}",
20324 family.occurrences,
20325 family.variants,
20326 truncate_for_compact(&family.template, 80)
20327 );
20328 }
20329 for symbol in &report.symbol_refs {
20330 println!(
20331 "sym:{} count:{} sums:{}",
20332 symbol.symbol,
20333 symbol.occurrences,
20334 log_digest_summary_label(symbol.summary_state)
20335 );
20336 }
20337 if let Some(artifact) = &report.raw_log_artifact {
20338 println!(
20339 "raw-artifact handle:{} lines:{} bytes:{} expand:{}",
20340 artifact.handle, artifact.lines, artifact.bytes, artifact.expand
20341 );
20342 }
20343 for warning in &report.warnings {
20344 println!("warning: {warning}");
20345 }
20346 return Ok(());
20347 }
20348
20349 println!("Log digest");
20350 println!(" lines: {}", report.total_lines);
20351 println!(" non-empty lines: {}", report.non_empty_lines);
20352 println!(" signal groups: {}", report.signal_groups);
20353 println!(
20354 " repeated lines: {}",
20355 report.repeated_line_groups
20356 );
20357 println!(
20358 " repeated line instances: {}",
20359 report.repeated_line_occurrences
20360 );
20361 println!(" line families: {}", report.line_family_groups);
20362 println!(" file refs: {}", report.file_ref_groups);
20363 println!(" symbol refs: {}", report.symbol_ref_groups);
20364 println!(" stack groups: {}", report.stack_groups);
20365
20366 if !report.signals.is_empty() {
20367 println!();
20368 println!("Signals:");
20369 for signal in &report.signals {
20370 match (&signal.path, signal.line, signal.column) {
20371 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
20372 (Some(path), Some(line), None) => println!("{path}:{line}"),
20373 (Some(path), None, _) => println!("{path}"),
20374 (None, _, _) => println!("(no file anchor)"),
20375 }
20376 println!(" severity: {}", signal.severity);
20377 println!(" occurrences: {}", signal.occurrences);
20378 println!(" message: {}", signal.message);
20379 println!(
20380 " cached summaries: {}",
20381 log_digest_summary_label(signal.summary_state)
20382 );
20383 for summary in &signal.current_summaries {
20384 println!(
20385 " - {}: {}",
20386 summary.symbol,
20387 truncate_for_compact(&summary.summary, 160)
20388 );
20389 }
20390 }
20391 }
20392
20393 if !report.repeated_lines.is_empty() {
20394 println!();
20395 println!("Repeated lines:");
20396 for repeated in &report.repeated_lines {
20397 println!(
20398 " {}x {}",
20399 repeated.occurrences,
20400 truncate_for_compact(&repeated.line, 180)
20401 );
20402 }
20403 }
20404
20405 if !report.line_families.is_empty() {
20406 println!();
20407 println!("Line families (near-duplicate folds):");
20408 for family in &report.line_families {
20409 println!(
20410 " {}x ({} variants) {}",
20411 family.occurrences,
20412 family.variants,
20413 truncate_for_compact(&family.template, 180)
20414 );
20415 println!(
20416 " first: {}",
20417 truncate_for_compact(&family.first_sample, 180)
20418 );
20419 println!(
20420 " last: {}",
20421 truncate_for_compact(&family.last_sample, 180)
20422 );
20423 }
20424 }
20425
20426 if !report.file_refs.is_empty() {
20427 println!();
20428 println!("Anchored files:");
20429 for file_ref in &report.file_refs {
20430 match (file_ref.line, file_ref.column) {
20431 (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
20432 (Some(line), None) => println!("{}:{}", file_ref.path, line),
20433 (None, _) => println!("{}", file_ref.path),
20434 }
20435 println!(" occurrences: {}", file_ref.occurrences);
20436 println!(
20437 " cached summaries: {}",
20438 log_digest_summary_label(file_ref.summary_state)
20439 );
20440 for summary in &file_ref.current_summaries {
20441 println!(
20442 " - {}: {}",
20443 summary.symbol,
20444 truncate_for_compact(&summary.summary, 160)
20445 );
20446 }
20447 }
20448 }
20449
20450 if !report.symbol_refs.is_empty() {
20451 println!();
20452 println!("Symbol candidates:");
20453 for symbol in &report.symbol_refs {
20454 println!("{}", symbol.symbol);
20455 println!(" occurrences: {}", symbol.occurrences);
20456 println!(
20457 " cached summaries: {}",
20458 log_digest_summary_label(symbol.summary_state)
20459 );
20460 for summary in &symbol.current_summaries {
20461 println!(
20462 " - {}: {}",
20463 summary.symbol,
20464 truncate_for_compact(&summary.summary, 160)
20465 );
20466 }
20467 }
20468 }
20469
20470 if !report.stack_traces.is_empty() {
20471 println!();
20472 println!("Stack groups:");
20473 for stack in &report.stack_traces {
20474 println!(" occurrences: {}", stack.occurrences);
20475 for frame in &stack.frames {
20476 println!(" - {}", frame);
20477 }
20478 }
20479 }
20480
20481 if let Some(artifact) = &report.raw_log_artifact {
20482 println!();
20483 println!("Raw log artifact:");
20484 println!(" handle: {}", artifact.handle);
20485 println!(" path: {}", artifact.path);
20486 println!(" lines: {}", artifact.lines);
20487 println!(" bytes: {}", artifact.bytes);
20488 println!(" expand: {}", artifact.expand);
20489 }
20490
20491 for warning in &report.warnings {
20492 println!("warning: {warning}");
20493 }
20494 Ok(())
20495}
20496
20497pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
20498 match trend {
20499 metric_digest::MetricDigestTrend::Improved => "improved",
20500 metric_digest::MetricDigestTrend::Regressed => "regressed",
20501 metric_digest::MetricDigestTrend::Flat => "flat",
20502 metric_digest::MetricDigestTrend::Unknown => "changed",
20503 }
20504}
20505
20506pub(crate) fn metric_digest_gate_label(
20507 decision: metric_digest::CommunitySearchGateDecision,
20508) -> &'static str {
20509 match decision {
20510 metric_digest::CommunitySearchGateDecision::Pass => "pass",
20511 metric_digest::CommunitySearchGateDecision::Block => "block",
20512 }
20513}
20514
20515pub(crate) fn memgraphrag_metric_digest_gate_label(
20516 decision: metric_digest::MemGraphRagPerformanceGateDecision,
20517) -> &'static str {
20518 match decision {
20519 metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
20520 metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
20521 }
20522}
20523
20524fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
20525 let input = fs::read_to_string(fixture_path)
20526 .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
20527 let report = dci_benchmark::compute(&input)?;
20528
20529 if format.json_output {
20530 println!(
20531 "{}",
20532 to_json_schema(
20533 &report,
20534 format.pretty,
20535 format.terse,
20536 format.ultra_terse,
20537 format.schema
20538 )?
20539 );
20540 return Ok(());
20541 }
20542
20543 if format.compact {
20544 println!(
20545 "dci tasks:{} strategies:{} warnings:{}",
20546 report.tasks_loaded,
20547 report.strategies_compared,
20548 report.warnings.len()
20549 );
20550 for summary in &report.strategy_summaries {
20551 println!(
20552 "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
20553 summary.strategy,
20554 summary.rank,
20555 summary.localized,
20556 summary.task_runs,
20557 dci_benchmark::format_number(summary.localization_rate * 100.0),
20558 dci_benchmark::format_number(summary.avg_useful_hits),
20559 dci_benchmark::format_number(summary.zero_output_rate * 100.0),
20560 dci_benchmark::format_number(summary.avg_tool_calls),
20561 dci_benchmark::format_number(summary.avg_latency_ms),
20562 dci_benchmark::format_number(summary.avg_estimated_tokens),
20563 dci_benchmark::format_number(summary.avg_output_tokens)
20564 );
20565 }
20566 if let Some(gate) = &report.memory_retrieval_gate {
20567 println!(
20568 "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
20569 gate.decision,
20570 gate.baseline_strategy,
20571 dci_benchmark::format_number(gate.min_avg_useful_hits),
20572 gate.max_zero_output_failures,
20573 gate.diagnostics.len()
20574 );
20575 }
20576 for warning in &report.warnings {
20577 println!("warning: {warning}");
20578 }
20579 return Ok(());
20580 }
20581
20582 println!("DCI benchmark");
20583 if let Some(description) = &report.description {
20584 println!(" description: {}", description);
20585 }
20586 println!(" tasks loaded: {}", report.tasks_loaded);
20587 println!(" strategies compared: {}", report.strategies_compared);
20588
20589 println!();
20590 println!("Strategy summary:");
20591 for summary in &report.strategy_summaries {
20592 println!(
20593 " #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
20594 summary.rank,
20595 summary.strategy,
20596 summary.localized,
20597 summary.task_runs,
20598 summary.localization_rate * 100.0,
20599 dci_benchmark::format_number(summary.avg_useful_hits),
20600 summary.zero_output_rate * 100.0,
20601 dci_benchmark::format_number(summary.avg_tool_calls),
20602 dci_benchmark::format_number(summary.avg_latency_ms),
20603 dci_benchmark::format_number(summary.avg_estimated_tokens),
20604 dci_benchmark::format_number(summary.avg_output_tokens)
20605 );
20606 }
20607
20608 if let Some(gate) = &report.memory_retrieval_gate {
20609 println!();
20610 println!("Memory retrieval gate:");
20611 println!(" decision: {}", gate.decision);
20612 println!(
20613 " baseline: {}, min avg useful hits {}, max zero-output failures {}",
20614 gate.baseline_strategy,
20615 dci_benchmark::format_number(gate.min_avg_useful_hits),
20616 gate.max_zero_output_failures
20617 );
20618 for row in &gate.rows {
20619 println!(
20620 " {}: status {}, avg useful hits {}, zero-output failures {}",
20621 row.strategy,
20622 row.status,
20623 dci_benchmark::format_number(row.avg_useful_hits),
20624 row.zero_output_failures
20625 );
20626 }
20627 for diagnostic in &gate.diagnostics {
20628 println!(" diagnostic: {diagnostic}");
20629 }
20630 }
20631
20632 println!();
20633 println!("Task winners:");
20634 for row in &report.task_rows {
20635 let label = row
20636 .label
20637 .as_ref()
20638 .map(|value| format!(" ({value})"))
20639 .unwrap_or_default();
20640 println!(" {}{}", row.task_id, label);
20641 println!(" localized: {}", row.best_localization.join(", "));
20642 println!(" most useful hits: {}", row.most_useful_hits.join(", "));
20643 println!(
20644 " lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
20645 row.lowest_tool_calls.as_deref().unwrap_or("-"),
20646 row.lowest_latency.as_deref().unwrap_or("-"),
20647 row.lowest_token_budget.as_deref().unwrap_or("-"),
20648 row.lowest_output_tokens.as_deref().unwrap_or("-")
20649 );
20650 if !row.zero_output_failures.is_empty() {
20651 println!(" zero output: {}", row.zero_output_failures.join(", "));
20652 }
20653 }
20654
20655 for warning in &report.warnings {
20656 println!("warning: {warning}");
20657 }
20658 Ok(())
20659}
20660
20661pub(crate) fn format_compact_count(value: u64) -> String {
20662 if value >= 1_000_000 {
20663 format!("{:.1}M", value as f64 / 1_000_000.0)
20664 } else if value >= 1_000 {
20665 format!("{:.1}K", value as f64 / 1_000.0)
20666 } else {
20667 value.to_string()
20668 }
20669}
20670
20671fn cmd_digest_runner(
20672 kind: &str,
20673 path: &Path,
20674 runner: Option<&str>,
20675 shell_command: &str,
20676 format: OutputFormat,
20677) -> Result<()> {
20678 let digest_kind = DigestRunnerKind::parse(kind)?;
20679 let root = transcript_artifact_root(path)?;
20680 let execution = run_digest_runner_command(shell_command)?;
20681 let output = &execution.output;
20682 let captured = String::from_utf8_lossy(&output.stdout).into_owned();
20683 let exit_code = output.status.code().unwrap_or(-1);
20684 if format.json_output && format.envelope {
20685 let artifact_key = format!(
20686 "{}:{}:{}:{}",
20687 digest_kind.as_str(),
20688 shell_command,
20689 execution.executed_command,
20690 captured
20691 );
20692 let artifact = if captured.trim().is_empty() {
20693 None
20694 } else {
20695 let (suffix, expand) = match digest_kind {
20696 DigestRunnerKind::Test => (
20697 "test.log",
20698 format!(
20699 "tsift test-digest --path {} --input {}{} --json",
20700 shell_quote(root.to_string_lossy().as_ref()),
20701 shell_quote(
20702 root.join(".tsift/artifacts")
20703 .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
20704 .to_string_lossy()
20705 .as_ref()
20706 ),
20707 runner
20708 .map(|value| format!(" --runner {}", shell_quote(value)))
20709 .unwrap_or_default()
20710 ),
20711 ),
20712 DigestRunnerKind::Log => (
20713 "log",
20714 format!(
20715 "tsift log-digest --path {} --input {} --json",
20716 shell_quote(root.to_string_lossy().as_ref()),
20717 shell_quote(
20718 root.join(".tsift/artifacts")
20719 .join(format!("{}.log", stable_handle("tart", &artifact_key)))
20720 .to_string_lossy()
20721 .as_ref()
20722 )
20723 ),
20724 ),
20725 };
20726 Some(persist_transcript_artifact(
20727 &root,
20728 "tart",
20729 suffix,
20730 &artifact_key,
20731 &captured,
20732 expand,
20733 )?)
20734 };
20735 let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
20736
20737 match digest_kind {
20738 DigestRunnerKind::Test => {
20739 let digest_report = test_digest::compute(path, &captured, runner)?;
20740 let report = serde_json::json!({
20741 "kind": digest_kind.as_str(),
20742 "command": shell_command,
20743 "executed_command": execution.executed_command,
20744 "exit_code": exit_code,
20745 "success": output.status.success(),
20746 "filter": filter_report,
20747 "artifact": artifact,
20748 "digest": digest_report,
20749 });
20750 let mut follow_up = artifact
20751 .as_ref()
20752 .map(|entry| vec![entry.expand.clone()])
20753 .unwrap_or_default();
20754 follow_up.push(format!(
20755 "tsift rewrite --run {}",
20756 shell_quote(shell_command)
20757 ));
20758 let summary_text = if output.status.success() && digest_report.failures == 0 {
20759 format!("test run passed for {}", runner.unwrap_or("auto"))
20760 } else {
20761 format!("test run captured {} failure(s)", digest_report.failures)
20762 };
20763 print_json_or_envelope(
20764 &report,
20765 &format,
20766 "digest-runner",
20767 "test-run",
20768 ToolEnvelopeSummary {
20769 text: summary_text,
20770 metrics: vec![
20771 envelope_metric("runner", &digest_report.runner),
20772 envelope_metric("exit_code", exit_code),
20773 envelope_metric("filter", execution.filter_label()),
20774 envelope_metric("failures", digest_report.failures),
20775 envelope_metric("groups", digest_report.grouped_failures),
20776 envelope_metric(
20777 "artifact",
20778 artifact
20779 .as_ref()
20780 .map(|entry| entry.handle.as_str())
20781 .unwrap_or("-"),
20782 ),
20783 ],
20784 },
20785 false,
20786 follow_up,
20787 )?;
20788 }
20789 DigestRunnerKind::Log => {
20790 let digest_report = log_digest::compute(path, &captured)?;
20791 let report = serde_json::json!({
20792 "kind": digest_kind.as_str(),
20793 "command": shell_command,
20794 "executed_command": execution.executed_command,
20795 "exit_code": exit_code,
20796 "success": output.status.success(),
20797 "filter": filter_report,
20798 "artifact": artifact,
20799 "digest": digest_report,
20800 });
20801 let mut follow_up = artifact
20802 .as_ref()
20803 .map(|entry| vec![entry.expand.clone()])
20804 .unwrap_or_default();
20805 follow_up.push(format!(
20806 "tsift rewrite --run {}",
20807 shell_quote(shell_command)
20808 ));
20809 let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
20810 "command finished without log signals".to_string()
20811 } else {
20812 format!(
20813 "command emitted {} log signal group(s)",
20814 digest_report.signal_groups
20815 )
20816 };
20817 print_json_or_envelope(
20818 &report,
20819 &format,
20820 "digest-runner",
20821 "command-run",
20822 ToolEnvelopeSummary {
20823 text: summary_text,
20824 metrics: vec![
20825 envelope_metric("exit_code", exit_code),
20826 envelope_metric("filter", execution.filter_label()),
20827 envelope_metric("signals", digest_report.signal_groups),
20828 envelope_metric("file_refs", digest_report.file_ref_groups),
20829 envelope_metric(
20830 "artifact",
20831 artifact
20832 .as_ref()
20833 .map(|entry| entry.handle.as_str())
20834 .unwrap_or("-"),
20835 ),
20836 ],
20837 },
20838 false,
20839 follow_up,
20840 )?;
20841 }
20842 }
20843
20844 if output.status.success() {
20845 return Ok(());
20846 }
20847 if let Some(code) = output.status.code() {
20848 std::process::exit(code);
20849 }
20850 bail!("digest-wrapped command terminated by signal: {shell_command}");
20851 }
20852
20853 if captured.trim().is_empty() {
20854 let label = match digest_kind {
20855 DigestRunnerKind::Test => "test",
20856 DigestRunnerKind::Log => "log",
20857 };
20858 println!("No {label} output captured.");
20859 } else {
20860 match digest_kind {
20861 DigestRunnerKind::Test => {
20862 render_test_digest_from_input(path, &captured, runner, format)?
20863 }
20864 DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20865 }
20866 }
20867
20868 if output.status.success() {
20869 return Ok(());
20870 }
20871 if let Some(code) = output.status.code() {
20872 std::process::exit(code);
20873 }
20874 bail!("digest-wrapped command terminated by signal: {shell_command}");
20875}
20876
20877struct DigestRunnerExecution {
20878 output: std::process::Output,
20879 executed_command: String,
20880 filter: Option<DigestRunnerFilter>,
20881}
20882
20883impl DigestRunnerExecution {
20884 fn filter_label(&self) -> &'static str {
20885 self.filter
20886 .as_ref()
20887 .map(|filter| filter.tool)
20888 .unwrap_or("none")
20889 }
20890}
20891
20892struct DigestRunnerFilter {
20893 tool: &'static str,
20894 command: String,
20895}
20896
20897impl DigestRunnerFilter {
20898 fn to_json(&self) -> serde_json::Value {
20899 serde_json::json!({
20900 "tool": self.tool,
20901 "command": self.command,
20902 })
20903 }
20904}
20905
20906fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20907 let filter = rtk_rewrite_for_digest_runner(shell_command);
20908 let executed_command = filter
20909 .as_ref()
20910 .map(|filter| filter.command.as_str())
20911 .unwrap_or(shell_command);
20912 let output = Command::new("sh")
20913 .arg("-lc")
20914 .arg(format!("({executed_command}) 2>&1"))
20915 .stdout(Stdio::piped())
20916 .output()
20917 .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20918
20919 Ok(DigestRunnerExecution {
20920 output,
20921 executed_command: executed_command.to_string(),
20922 filter,
20923 })
20924}
20925
20926fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20927 if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20928 return None;
20929 }
20930 let output = Command::new("rtk")
20931 .arg("rewrite")
20932 .arg(shell_command)
20933 .output()
20934 .ok()?;
20935 if !output.status.success() {
20936 return None;
20937 }
20938 let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20939 if rewritten.is_empty() || rewritten == shell_command {
20940 return None;
20941 }
20942 Some(DigestRunnerFilter {
20943 tool: "rtk",
20944 command: rewritten,
20945 })
20946}
20947
20948fn find_command_on_path(command: &str) -> Option<PathBuf> {
20949 let path_var = std::env::var_os("PATH")?;
20950 std::env::split_paths(&path_var)
20951 .map(|dir| dir.join(command))
20952 .find(|candidate| candidate.is_file())
20953}
20954
20955pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20956 if !db_path.exists() {
20957 bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20958 }
20959 summarize::SummaryDb::open_read_only_resilient(db_path)
20960}
20961
20962fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20963 !matches!(report.index, status::IndexStatus::Fresh { .. })
20964}
20965
20966fn status_workspace_scope_ids_needing_fix(
20967 report: &status::StatusReport,
20968) -> std::collections::HashSet<&str> {
20969 let (workspace_scopes, missing_scopes) = match &report.index {
20970 status::IndexStatus::Fresh {
20971 workspace_scopes,
20972 missing_scopes,
20973 ..
20974 }
20975 | status::IndexStatus::Stale {
20976 workspace_scopes,
20977 missing_scopes,
20978 ..
20979 } => (workspace_scopes.as_slice(), missing_scopes.as_slice()),
20980 status::IndexStatus::Missing { missing_scopes } => (&[][..], missing_scopes.as_slice()),
20981 };
20982
20983 workspace_scopes
20984 .iter()
20985 .filter(|scope| scope.stale_files > 0)
20986 .map(|scope| scope.scope.as_str())
20987 .chain(missing_scopes.iter().map(|scope| scope.scope.as_str()))
20988 .collect()
20989}
20990
20991fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20992 !matches!(report.instructions, init::InstructionStatus::Current { .. })
20993}
20994
20995pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20996 if status_instructions_need_fix(report) {
20997 eprintln!("status fix: refreshing tsift instructions");
20998 init::init(root, false, false)?;
20999 }
21000
21001 let eviction = cycle_packet_cache::cycle_packet_cache_evict(
21002 root,
21003 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
21004 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
21005 );
21006 if eviction.evicted_entries > 0 {
21007 eprintln!(
21008 "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
21009 eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
21010 );
21011 }
21012
21013 if !status_index_needs_fix(report) {
21014 return Ok(());
21015 }
21016
21017 let scopes = config::Config::submodule_dirs(root)?;
21018 if scopes.is_empty() {
21019 eprintln!("status fix: refreshing index");
21020 run_index_update(
21021 &root.join(".tsift/index.db"),
21022 root,
21023 "status --fix refreshing index".to_string(),
21024 root,
21025 None,
21026 false,
21027 false,
21028 )?;
21029 return Ok(());
21030 }
21031
21032 let cfg = config::Config::load(root)?;
21033 let scope_ids_needing_fix = status_workspace_scope_ids_needing_fix(report);
21034 for scope in scopes {
21035 if !scope_ids_needing_fix.contains(scope.id.as_str()) {
21036 continue;
21037 }
21038 if !scope.source_root.exists() {
21039 eprintln!(
21040 "status fix: skipping missing submodule `{}` ({})",
21041 scope.id,
21042 scope.source_root.display()
21043 );
21044 continue;
21045 }
21046 eprintln!("status fix: refreshing submodule `{}` index", scope.id);
21047 run_index_update(
21048 &cfg.db_path_for(root, &scope.id),
21049 &scope.source_root,
21050 format!("status --fix refreshing submodule `{}` index", scope.id),
21051 root,
21052 Some(scope.id.as_str()),
21053 false,
21054 false,
21055 )?;
21056 }
21057
21058 Ok(())
21059}
21060
21061pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
21062 match &report.index {
21063 status::IndexStatus::Fresh { missing_scopes, .. }
21064 | status::IndexStatus::Stale { missing_scopes, .. }
21065 | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
21066 }
21067}
21068
21069pub(crate) fn autoindex_missing_workspace_scopes(
21070 root: &Path,
21071 report: &status::StatusReport,
21072) -> Result<()> {
21073 let missing_scopes = match &report.index {
21074 status::IndexStatus::Fresh { missing_scopes, .. }
21075 | status::IndexStatus::Stale { missing_scopes, .. }
21076 | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
21077 };
21078 if missing_scopes.is_empty() {
21079 return Ok(());
21080 }
21081
21082 let missing_scope_ids = missing_scopes
21083 .iter()
21084 .map(|scope| scope.scope.as_str())
21085 .collect::<std::collections::HashSet<_>>();
21086 let cfg = config::Config::load(root)?;
21087 for scope in config::Config::submodule_dirs(root)? {
21088 if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
21089 continue;
21090 }
21091 let db_path = cfg.db_path_for(root, &scope.id);
21092 run_index_update(
21093 &db_path,
21094 &scope.source_root,
21095 format!(
21096 "autoindexing missing submodule `{}` during status",
21097 scope.id
21098 ),
21099 root,
21100 Some(scope.id.as_str()),
21101 false,
21102 false,
21103 )?;
21104 }
21105 Ok(())
21106}
21107
21108pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
21109 for warning in &stats.warnings {
21110 let rel_path = relativize_pathbuf(&warning.path, root);
21111 eprintln!(
21112 "warning: summarize stats {}: {}",
21113 rel_path.display(),
21114 warning.message
21115 );
21116 }
21117}
21118
21119fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
21120 Result::<(), anyhow::Error>::Err(err)
21121 .context(context)
21122 .unwrap_err()
21123}
21124
21125fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
21126 let message = err.to_string();
21127 message.contains("another tsift index writer is already active")
21128 || substrate::error_mentions_locked_db(err)
21129}
21130
21131fn add_write_lock_context(
21132 err: anyhow::Error,
21133 action: String,
21134 root: &std::path::Path,
21135 scope: Option<&str>,
21136) -> anyhow::Error {
21137 if !should_attach_lock_diagnostics(&err) {
21138 return contextualize_error(err, action);
21139 }
21140
21141 let Ok(report) = status::check_locks(root, None, scope) else {
21142 return contextualize_error(err, action);
21143 };
21144
21145 contextualize_error(
21146 err,
21147 format!(
21148 "{}\n\nlock diagnostics:\n{}",
21149 action,
21150 status::format_locks_human(&report, false).trim_end()
21151 ),
21152 )
21153}
21154
21155pub(crate) fn run_index_update(
21156 db_path: &std::path::Path,
21157 source_root: &std::path::Path,
21158 action: String,
21159 root: &std::path::Path,
21160 scope: Option<&str>,
21161 rebuild: bool,
21162 prune: bool,
21163) -> Result<index::IndexSummary> {
21164 let result = (|| {
21165 let db = index::IndexDb::open(db_path)?;
21166 if rebuild {
21167 db.rebuild(source_root)
21168 } else if prune {
21169 db.apply_changes_pruned(source_root)
21170 } else {
21171 db.apply_changes(source_root)
21172 }
21173 })();
21174
21175 let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
21176 emit_index_warnings(&summary, source_root, scope);
21177 Ok(summary)
21178}
21179
21180pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
21181 for change in &mut summary.changes {
21182 change.path = relativize_pathbuf(&change.path, root);
21183 }
21184 for warning in &mut summary.warnings {
21185 warning.path = relativize_pathbuf(&warning.path, root);
21186 }
21187}
21188
21189fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
21190 for warning in &summary.warnings {
21191 let rel_path = relativize_pathbuf(&warning.path, root);
21192 let stage = match warning.stage {
21193 index::IndexWarningStage::ReadSource => "read failed",
21194 index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
21195 index::IndexWarningStage::ExtractCallSites => "call extraction failed",
21196 index::IndexWarningStage::ExtractRoutes => "route extraction failed",
21197 };
21198 let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
21199 let lang_suffix = warning
21200 .language
21201 .as_deref()
21202 .map(|lang| format!(" [{}]", lang))
21203 .unwrap_or_default();
21204 eprintln!(
21205 "warning: {}{}{}: {}: {}",
21206 scope_prefix,
21207 rel_path.display(),
21208 lang_suffix,
21209 stage,
21210 warning.message
21211 );
21212 }
21213}
21214
21215pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
21216 let config_path = root.join(".tsift/config.toml");
21217 if !config_path.exists() {
21218 return summarize::SummarizeConfig::default();
21219 }
21220 #[derive(serde::Deserialize, Default)]
21221 struct RawConfig {
21222 #[serde(default)]
21223 summarize: Option<RawSummarize>,
21224 }
21225 #[derive(serde::Deserialize)]
21226 struct RawSummarize {
21227 model: Option<String>,
21228 max_file_tokens: Option<usize>,
21229 api_key_env: Option<String>,
21230 }
21231 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
21232 let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
21233 let defaults = summarize::SummarizeConfig::default();
21234 match raw.summarize {
21235 Some(s) => summarize::SummarizeConfig {
21236 model: s.model.unwrap_or(defaults.model),
21237 max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
21238 api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
21239 },
21240 None => defaults,
21241 }
21242}
21243
21244#[derive(Debug, Clone, PartialEq, Eq)]
21245struct ExtractSymbolContext {
21246 db_path: PathBuf,
21247 source_root: PathBuf,
21248}
21249
21250pub(crate) fn find_symbols_db_for_file(
21251 root: &Path,
21252 file_path: &Path,
21253) -> Result<Option<ExtractSymbolContext>> {
21254 let cfg = config::Config::load(root)?;
21255 let mut submodules = config::Config::submodule_dirs(root)?;
21256 submodules.sort_by(|left, right| {
21257 right
21258 .source_root
21259 .components()
21260 .count()
21261 .cmp(&left.source_root.components().count())
21262 });
21263
21264 for scope in submodules {
21265 if !file_path.starts_with(&scope.source_root) {
21266 continue;
21267 }
21268 let db_path = cfg.db_path_for(root, &scope.id);
21269 if db_path.exists() {
21270 return Ok(Some(ExtractSymbolContext {
21271 db_path,
21272 source_root: scope.source_root,
21273 }));
21274 }
21275 }
21276
21277 let single = root.join(".tsift/index.db");
21278 if single.exists() && file_path.starts_with(root) {
21279 return Ok(Some(ExtractSymbolContext {
21280 db_path: single,
21281 source_root: root.to_path_buf(),
21282 }));
21283 }
21284
21285 Ok(None)
21286}
21287
21288pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
21289 let canonical = path
21290 .canonicalize()
21291 .with_context(|| format!("canonicalizing {}", path.display()))?;
21292
21293 Ok(if canonical.is_dir() {
21294 canonical
21295 } else {
21296 canonical
21297 .parent()
21298 .map(Path::to_path_buf)
21299 .unwrap_or(canonical)
21300 })
21301}
21302
21303fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
21304 if path.exists() {
21305 return path
21306 .canonicalize()
21307 .with_context(|| format!("canonicalizing extract scope {}", path.display()));
21308 }
21309
21310 Ok(summarize::normalize_lexical_path(path))
21311}
21312
21313pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
21314 let scope = if extract_path.is_absolute() {
21315 extract_path.to_path_buf()
21316 } else {
21317 root.join(extract_path)
21318 };
21319 normalize_extract_scope_path(&scope)
21320}
21321
21322pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
21323 normalize_extract_scope_path(changed_path)
21324 .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
21325 .starts_with(extract_scope)
21326}
21327
21328pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
21329 summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
21330}
21331
21332pub(crate) fn summarize_full_extract_deleted_summary_paths(
21333 summary_db: &summarize::SummaryDb,
21334 root: &Path,
21335 extract_scope: &Path,
21336 files_to_extract: &[PathBuf],
21337) -> Result<BTreeSet<String>> {
21338 let live_paths = files_to_extract
21339 .iter()
21340 .map(|file_path| summarize_relative_file_path(root, file_path))
21341 .collect::<BTreeSet<_>>();
21342 let mut deleted = BTreeSet::new();
21343
21344 for cached_path in summary_db.cached_file_paths()? {
21345 if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
21346 continue;
21347 }
21348 if !live_paths.contains(&cached_path) {
21349 deleted.insert(cached_path);
21350 }
21351 }
21352
21353 Ok(deleted)
21354}
21355
21356#[derive(Debug, Clone)]
21357struct SearchIndexTarget {
21358 label: String,
21359 db_path: PathBuf,
21360 source_root: PathBuf,
21361 scope_name: Option<String>,
21362 reindex_cmd: String,
21363}
21364
21365fn cargo_package_index_target(
21366 root: &Path,
21367 package: multiplicity::CargoPackageInfo,
21368) -> SearchIndexTarget {
21369 SearchIndexTarget {
21370 label: format!("cargo package `{}` index", package.scope_id),
21371 db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
21372 source_root: package.package_root.clone(),
21373 scope_name: Some(package.scope_id.clone()),
21374 reindex_cmd: format!(
21375 "tsift index --submodule {} {}",
21376 package.scope_id,
21377 root.display()
21378 ),
21379 }
21380}
21381
21382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21383enum SearchIndexState {
21384 Missing,
21385 Fresh,
21386 Stale { stale_files: usize },
21387}
21388
21389fn resolve_search_index_targets(
21390 root: &Path,
21391 path_hint: &Path,
21392 scope: Option<&str>,
21393 federated: bool,
21394) -> Result<Vec<SearchIndexTarget>> {
21395 if let Some(scope_name) = scope {
21396 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
21397 let cfg = config::Config::load(root)?;
21398 return Ok(vec![SearchIndexTarget {
21399 label: format!("submodule `{}` index", scope.id),
21400 db_path: cfg.db_path_for(root, &scope.id),
21401 source_root: scope.source_root.clone(),
21402 scope_name: Some(scope.id.clone()),
21403 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21404 }]);
21405 }
21406 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
21407 return Ok(vec![cargo_package_index_target(root, package)]);
21408 }
21409 config::Config::resolve_submodule(root, scope_name)?;
21410 }
21411
21412 if federated {
21413 let cfg = config::Config::load(root)?;
21414 let mut targets = Vec::new();
21415 for scope in config::Config::submodule_dirs(root)? {
21416 if !cfg.federation_for_scope(&scope) {
21417 continue;
21418 }
21419 targets.push(SearchIndexTarget {
21420 label: format!("submodule `{}` index", scope.id),
21421 db_path: cfg.db_path_for(root, &scope.id),
21422 source_root: scope.source_root.clone(),
21423 scope_name: Some(scope.id.clone()),
21424 reindex_cmd: format!("tsift index --workspace {}", root.display()),
21425 });
21426 }
21427 return Ok(targets);
21428 }
21429
21430 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
21431 let cfg = config::Config::load(root)?;
21432 return Ok(vec![SearchIndexTarget {
21433 label: format!("submodule `{}` index", scope.id),
21434 db_path: cfg.db_path_for(root, &scope.id),
21435 source_root: scope.source_root.clone(),
21436 scope_name: Some(scope.id.clone()),
21437 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21438 }]);
21439 }
21440
21441 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
21442 return Ok(vec![cargo_package_index_target(root, package)]);
21443 }
21444
21445 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
21446 let cfg = config::Config::load(root)?;
21447 return Ok(vec![SearchIndexTarget {
21448 label: format!("submodule `{}` index", scope.id),
21449 db_path: cfg.db_path_for(root, &scope.id),
21450 source_root: scope.source_root.clone(),
21451 scope_name: Some(scope.id.clone()),
21452 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21453 }]);
21454 }
21455
21456 let scopes = config::Config::submodule_dirs(root)?;
21457 if !scopes.is_empty() {
21458 let root_db = root.join(".tsift/index.db");
21459 if !root_db.exists() {
21460 let available_scopes = scopes
21461 .iter()
21462 .map(|scope| scope.id.as_str())
21463 .collect::<Vec<_>>()
21464 .join(", ");
21465 let cfg = config::Config::load(root)?;
21466 let indexed_scopes = scopes
21467 .iter()
21468 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
21469 .map(|scope| scope.id.as_str())
21470 .collect::<Vec<_>>();
21471 let indexed_label = if indexed_scopes.is_empty() {
21472 "none".to_string()
21473 } else {
21474 indexed_scopes.join(", ")
21475 };
21476 bail!(
21477 "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: {}.",
21478 root.display(),
21479 root_db.display(),
21480 available_scopes,
21481 indexed_label,
21482 );
21483 }
21484 }
21485
21486 Ok(vec![SearchIndexTarget {
21487 label: "index".to_string(),
21488 db_path: root.join(".tsift/index.db"),
21489 source_root: root.to_path_buf(),
21490 scope_name: None,
21491 reindex_cmd: format!("tsift index {}", root.display()),
21492 }])
21493}
21494
21495fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
21496 if !target.source_root.exists() || !target.db_path.exists() {
21497 return Ok(SearchIndexState::Missing);
21498 }
21499
21500 let inspection =
21501 index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
21502 let stale_files =
21503 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
21504 if stale_files == 0 {
21505 Ok(SearchIndexState::Fresh)
21506 } else {
21507 Ok(SearchIndexState::Stale { stale_files })
21508 }
21509}
21510
21511#[derive(Debug, Clone, PartialEq, Eq)]
21512struct RebuildSearchTarget {
21513 label: String,
21514 reason: RebuildSearchReason,
21515 reindex_cmd: String,
21516}
21517
21518#[derive(Debug, Clone, PartialEq, Eq)]
21519enum RebuildSearchReason {
21520 Missing,
21521 Stale { stale_files: usize },
21522}
21523
21524#[derive(Debug, Clone, PartialEq, Eq)]
21525struct DegradedSearchTarget {
21526 label: String,
21527 reason: RebuildSearchReason,
21528 reindex_cmd: String,
21529}
21530
21531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21532pub(crate) enum DegradedSearchMode {
21533 ReadOnly,
21534 Exact,
21535}
21536
21537#[derive(Debug)]
21538struct SearchPrecheck {
21539 targets: Vec<SearchIndexTarget>,
21540 degraded_targets: Vec<DegradedSearchTarget>,
21541}
21542
21543fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
21544 err.chain().any(|cause| {
21545 cause
21546 .to_string()
21547 .contains("another tsift index writer is already active")
21548 })
21549}
21550
21551fn infer_agent_doc_task_submodule(
21552 root: &Path,
21553 path_hint: &Path,
21554) -> Result<Option<config::WorkspaceScope>> {
21555 let hinted_path = if path_hint.is_absolute() {
21556 path_hint.to_path_buf()
21557 } else {
21558 root.join(path_hint)
21559 };
21560 let Ok(relative) = hinted_path.strip_prefix(root) else {
21561 return Ok(None);
21562 };
21563 let mut components = relative.components();
21564 let Some(std::path::Component::Normal(first)) = components.next() else {
21565 return Ok(None);
21566 };
21567 if first != "tasks" {
21568 return Ok(None);
21569 }
21570 let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
21571 return Ok(None);
21572 };
21573 config::Config::find_submodule(root, file_stem)
21574}
21575
21576fn degraded_search_target(
21577 target: &SearchIndexTarget,
21578 reason: RebuildSearchReason,
21579) -> DegradedSearchTarget {
21580 DegradedSearchTarget {
21581 label: target.label.clone(),
21582 reason,
21583 reindex_cmd: target.reindex_cmd.clone(),
21584 }
21585}
21586
21587fn apply_search_index_update(
21588 root: &Path,
21589 target: &SearchIndexTarget,
21590) -> Result<index::IndexSummary> {
21591 run_index_update(
21592 &target.db_path,
21593 &target.source_root,
21594 format!("autoindexing {}", target.label),
21595 root,
21596 target.scope_name.as_deref(),
21597 false,
21598 false,
21599 )
21600}
21601
21602fn collect_rebuild_search_targets(
21603 targets: &[SearchIndexTarget],
21604) -> Result<Vec<RebuildSearchTarget>> {
21605 let mut rebuild_targets = Vec::new();
21606 for target in targets {
21607 let reason = match inspect_search_index(target)? {
21608 SearchIndexState::Missing => RebuildSearchReason::Missing,
21609 SearchIndexState::Fresh => continue,
21610 SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
21611 };
21612 rebuild_targets.push(RebuildSearchTarget {
21613 label: target.label.clone(),
21614 reason,
21615 reindex_cmd: target.reindex_cmd.clone(),
21616 });
21617 }
21618 Ok(rebuild_targets)
21619}
21620
21621fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
21622 match target.reason {
21623 RebuildSearchReason::Missing => format!("{} is missing", target.label),
21624 RebuildSearchReason::Stale { stale_files } => {
21625 let file_suffix = if stale_files == 1 { "" } else { "s" };
21626 format!(
21627 "{} is stale ({} file{})",
21628 target.label, stale_files, file_suffix
21629 )
21630 }
21631 }
21632}
21633
21634fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
21635 if rebuild_targets.len() == 1 {
21636 let target = &rebuild_targets[0];
21637 return format!(
21638 "{}. Run `{}` to rebuild before retrying.",
21639 rebuild_search_target_detail(target),
21640 target.reindex_cmd
21641 );
21642 }
21643
21644 let summary: Vec<String> = rebuild_targets
21645 .iter()
21646 .take(3)
21647 .map(rebuild_search_target_detail)
21648 .collect();
21649 let overflow = rebuild_targets.len().saturating_sub(summary.len());
21650 let mut details = summary.join(", ");
21651 if overflow > 0 {
21652 details.push_str(&format!(", +{} more", overflow));
21653 }
21654 let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
21655 format!(
21656 "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
21657 rebuild_targets.len(),
21658 details,
21659 reindex_cmd
21660 )
21661}
21662
21663pub(crate) fn precheck_search_indexes(
21664 root: &Path,
21665 path_hint: &Path,
21666 scope: Option<&str>,
21667 federated: bool,
21668 autoindex: bool,
21669) -> Result<SearchPrecheck> {
21670 let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
21671 let mut stale_targets = Vec::new();
21672 let mut degraded_targets = Vec::new();
21673
21674 for target in &targets {
21675 match inspect_search_index(target)? {
21676 SearchIndexState::Missing => {
21677 if autoindex && let Err(err) = apply_search_index_update(root, target) {
21678 if is_active_writer_lock_error(&err) {
21679 degraded_targets
21680 .push(degraded_search_target(target, RebuildSearchReason::Missing));
21681 } else {
21682 return Err(err);
21683 }
21684 }
21685 }
21686 SearchIndexState::Fresh => {}
21687 SearchIndexState::Stale { stale_files } => {
21688 if autoindex {
21689 if let Err(err) = apply_search_index_update(root, target) {
21690 if is_active_writer_lock_error(&err) {
21691 degraded_targets.push(degraded_search_target(
21692 target,
21693 RebuildSearchReason::Stale { stale_files },
21694 ));
21695 } else {
21696 return Err(err);
21697 }
21698 }
21699 } else {
21700 stale_targets.push(RebuildSearchTarget {
21701 label: target.label.clone(),
21702 reason: RebuildSearchReason::Stale { stale_files },
21703 reindex_cmd: target.reindex_cmd.clone(),
21704 });
21705 }
21706 }
21707 }
21708 }
21709
21710 if stale_targets.is_empty() {
21711 return Ok(SearchPrecheck {
21712 targets,
21713 degraded_targets,
21714 });
21715 }
21716
21717 bail!(
21718 "tsift search aborted: {} \
21719 or re-run without `--no-autoindex`.",
21720 rebuild_search_targets_message(&stale_targets),
21721 );
21722}
21723
21724pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
21725 if targets.is_empty() {
21726 return None;
21727 }
21728
21729 if targets
21730 .iter()
21731 .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
21732 {
21733 Some(DegradedSearchMode::Exact)
21734 } else {
21735 Some(DegradedSearchMode::ReadOnly)
21736 }
21737}
21738
21739fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
21740 if targets.len() == 1 {
21741 let target = &targets[0];
21742 return match target.reason {
21743 RebuildSearchReason::Missing => format!("{} is missing", target.label),
21744 RebuildSearchReason::Stale { stale_files } => {
21745 let file_suffix = if stale_files == 1 { "" } else { "s" };
21746 format!(
21747 "{} is stale ({} file{})",
21748 target.label, stale_files, file_suffix
21749 )
21750 }
21751 };
21752 }
21753
21754 let missing = targets
21755 .iter()
21756 .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
21757 .count();
21758 let stale = targets.len().saturating_sub(missing);
21759 let mut parts = Vec::new();
21760 if stale > 0 {
21761 let suffix = if stale == 1 { "" } else { "es" };
21762 parts.push(format!("{stale} stale index{suffix}"));
21763 }
21764 if missing > 0 {
21765 let suffix = if missing == 1 { "" } else { "es" };
21766 parts.push(format!("{missing} missing index{suffix}"));
21767 }
21768 parts.join(", ")
21769}
21770
21771pub(crate) fn emit_degraded_search_note(
21772 targets: &[DegradedSearchTarget],
21773 mode: DegradedSearchMode,
21774) {
21775 let summary = degraded_search_targets_summary(targets);
21776 let reindex_cmd = &targets[0].reindex_cmd;
21777 match mode {
21778 DegradedSearchMode::ReadOnly => eprintln!(
21779 "note: active tsift writer detected; skipping autoindex because {}. \
21780 Continuing with read-only search and the current index snapshot; symbol hits may lag. \
21781 Retry `{}` after the active writer finishes for fresh index results.",
21782 summary, reindex_cmd
21783 ),
21784 DegradedSearchMode::Exact => eprintln!(
21785 "note: active tsift writer detected; skipping autoindex because {}. \
21786 Continuing with exact live-file search. Retry `{}` after the active writer finishes \
21787 for indexed symbol hits.",
21788 summary, reindex_cmd
21789 ),
21790 }
21791}
21792
21793fn search_timeout_message(
21794 timeout_secs: u64,
21795 strategy: &str,
21796 targets: &[SearchIndexTarget],
21797) -> Result<String> {
21798 let rebuild_targets = collect_rebuild_search_targets(targets)?;
21799 if rebuild_targets.is_empty() {
21800 return Ok(format!(
21801 "tsift search timed out after {}s (strategy: {}). \
21802 The search root looks fresh, so reindexing is unlikely to help. \
21803 Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
21804 or try a different strategy.",
21805 timeout_secs, strategy,
21806 ));
21807 }
21808
21809 Ok(format!(
21810 "tsift search timed out after {}s (strategy: {}). {}",
21811 timeout_secs,
21812 strategy,
21813 rebuild_search_targets_message(&rebuild_targets),
21814 ))
21815}
21816
21817fn is_exact_preferring_query_char(ch: char) -> bool {
21818 matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
21819}
21820
21821fn query_prefers_exact_search(query: &str) -> bool {
21822 let trimmed = query.trim();
21823 !trimmed.is_empty()
21824 && !trimmed.chars().any(char::is_whitespace)
21825 && trimmed.chars().any(|ch| ch.is_alphanumeric())
21826 && trimmed.chars().any(is_exact_preferring_query_char)
21827 && trimmed
21828 .chars()
21829 .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
21830}
21831
21832pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
21833 strategy.unwrap_or_else(|| {
21834 if query_prefers_exact_search(query) {
21835 "exact".to_string()
21836 } else {
21837 "lexical".to_string()
21838 }
21839 })
21840}
21841
21842pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21843 let mut files = Vec::new();
21844 if path.is_file() {
21845 files.push(path.to_path_buf());
21846 return Ok(files);
21847 }
21848 let walker = ignore::WalkBuilder::new(path)
21849 .hidden(true)
21850 .git_ignore(true)
21851 .build();
21852 for entry in walker {
21853 let entry = entry?;
21854 if entry.file_type().is_some_and(|ft| ft.is_file()) {
21855 let p = entry.path();
21856 if let Some(ext) = p.extension() {
21857 let ext = ext.to_string_lossy();
21858 if matches!(
21859 ext.as_ref(),
21860 "rs" | "py"
21861 | "ts"
21862 | "tsx"
21863 | "js"
21864 | "jsx"
21865 | "kt"
21866 | "kts"
21867 | "zig"
21868 | "sh"
21869 | "bash"
21870 | "zsh"
21871 ) {
21872 files.push(p.to_path_buf());
21873 }
21874 }
21875 }
21876 }
21877 Ok(files)
21878}
21879
21880#[cfg(test)]
21881mod tests {
21882 use super::semantic_edit::{
21883 EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21884 markdown_section_spans,
21885 };
21886 use super::*;
21887 use tsift_memory::{MemoryEventKind, MemoryStore};
21888
21889 use std::cell::RefCell;
21890 use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21891
21892 #[test]
21893 fn graph_db_write_lock_serializes_concurrent_writers() {
21894 let dir = tempfile::tempdir().unwrap();
21895 let graph_db = dir.path().join(".tsift/graph.db");
21896 let short = Duration::from_millis(150);
21897
21898 let first = acquire_graph_db_write_lock_with_timeout(&graph_db, short)
21899 .expect("first writer acquires the lock");
21900 let second = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21903 assert!(
21904 second.is_err(),
21905 "a second writer must not acquire the graph-db write lock while it is held"
21906 );
21907 drop(first);
21908 let third = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21910 assert!(
21911 third.is_ok(),
21912 "graph-db write lock must be re-acquirable after release"
21913 );
21914 }
21915
21916 #[test]
21922 fn graph_db_compact_apply_blocks_on_held_write_lock() {
21923 let dir = setup_traversal_project();
21924 let session = dir.path().join("tasks/software/tsift.md");
21925 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
21926 let graph_db = graph_substrate_db_path(dir.path(), None);
21927
21928 let held = acquire_graph_db_write_lock(&graph_db).expect("hold writer lock");
21929
21930 let root = dir.path().to_path_buf();
21931 let handle = std::thread::Builder::new()
21932 .name("compact-apply".to_string())
21933 .stack_size(16 * 1024 * 1024)
21934 .spawn(move || {
21935 crate::commands::infra::cmd_graph_db_compact(
21936 &root,
21937 None,
21938 true,
21939 false,
21940 false,
21941 OutputFormat {
21942 json_output: true,
21943 compact: true,
21944 pretty: false,
21945 terse: false,
21946 ultra_terse: false,
21947 schema: false,
21948 envelope: false,
21949 },
21950 )
21951 })
21952 .unwrap();
21953
21954 std::thread::sleep(Duration::from_millis(300));
21958 assert!(
21959 !handle.is_finished(),
21960 "compact --apply must block on the held graph-db write lock, not run unguarded"
21961 );
21962
21963 drop(held);
21964 let result = handle.join().expect("compact thread joins");
21965 assert!(
21966 result.is_ok(),
21967 "compact --apply must succeed after the lock is released: {result:?}"
21968 );
21969 }
21970
21971 fn parse_cli<I, T>(itr: I) -> Cli
21972 where
21973 I: IntoIterator<Item = T> + Send + 'static,
21974 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21975 {
21976 std::thread::Builder::new()
21977 .name("cli-parse".to_string())
21978 .stack_size(16 * 1024 * 1024)
21979 .spawn(move || Cli::parse_from(itr))
21980 .unwrap()
21981 .join()
21982 .unwrap()
21983 }
21984
21985 fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21986 where
21987 I: IntoIterator<Item = T> + Send + 'static,
21988 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21989 {
21990 std::thread::Builder::new()
21991 .name("cli-try-parse".to_string())
21992 .stack_size(16 * 1024 * 1024)
21993 .spawn(move || Cli::try_parse_from(itr))
21994 .unwrap()
21995 .join()
21996 .unwrap()
21997 }
21998
21999 fn build_relative_search_budget_report(
22000 query: &str,
22001 strategy: &str,
22002 root: &Path,
22003 response: &sift::SearchResponse,
22004 symbol_hits: &[index::SymbolHit],
22005 budget: ResponseBudget,
22006 filters: &SearchFacetFilters,
22007 ) -> SearchBudgetReport {
22008 build_search_budget_report(SearchBudgetReportInput {
22009 query,
22010 strategy,
22011 root,
22012 response,
22013 symbol_hits,
22014 absolute: false,
22015 budget,
22016 filters,
22017 })
22018 }
22019
22020 #[derive(Default)]
22021 struct MemoryConvexGraphClient {
22022 nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
22023 edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
22024 }
22025
22026 impl ConvexGraphClient for MemoryConvexGraphClient {
22027 fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
22028 self.nodes
22029 .borrow_mut()
22030 .insert(row.external_id.clone(), row.clone());
22031 Ok(())
22032 }
22033
22034 fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
22035 self.edges
22036 .borrow_mut()
22037 .insert(row.edge_key.clone(), row.clone());
22038 Ok(())
22039 }
22040
22041 fn delete_node_row(&self, external_id: &str) -> Result<usize> {
22042 Ok(usize::from(
22043 self.nodes.borrow_mut().remove(external_id).is_some(),
22044 ))
22045 }
22046
22047 fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
22048 Ok(usize::from(
22049 self.edges.borrow_mut().remove(edge_key).is_some(),
22050 ))
22051 }
22052
22053 fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
22054 Ok(self.nodes.borrow().get(external_id).cloned())
22055 }
22056
22057 fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
22058 Ok(self.nodes.borrow().values().cloned().collect())
22059 }
22060
22061 fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
22062 Ok(self.edges.borrow().values().cloned().collect())
22063 }
22064
22065 fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
22066 Ok(self
22067 .nodes
22068 .borrow()
22069 .values()
22070 .filter(|row| row.kind == kind)
22071 .cloned()
22072 .collect())
22073 }
22074
22075 fn outgoing_edge_rows(
22076 &self,
22077 from_external_id: &str,
22078 kind: Option<&str>,
22079 ) -> Result<Vec<ConvexEdgeRow>> {
22080 Ok(self
22081 .edges
22082 .borrow()
22083 .values()
22084 .filter(|row| row.from_external_id == from_external_id)
22085 .filter(|row| kind.is_none_or(|kind| row.kind == kind))
22086 .cloned()
22087 .collect())
22088 }
22089 }
22090
22091 fn init_git_repo(path: &Path) {
22092 let status = std::process::Command::new("git")
22093 .args(["init"])
22094 .current_dir(path)
22095 .status()
22096 .unwrap();
22097 assert!(status.success(), "git init failed");
22098
22099 let status = std::process::Command::new("git")
22100 .args(["add", "."])
22101 .current_dir(path)
22102 .status()
22103 .unwrap();
22104 assert!(status.success(), "git add failed");
22105
22106 let status = std::process::Command::new("git")
22107 .args([
22108 "-c",
22109 "user.name=tsift-tests",
22110 "-c",
22111 "user.email=tsift-tests@example.com",
22112 "commit",
22113 "--quiet",
22114 "-m",
22115 "init",
22116 ])
22117 .current_dir(path)
22118 .status()
22119 .unwrap();
22120 assert!(status.success(), "git commit failed");
22121 }
22122
22123 fn write_empty_root_index(root: &Path) {
22124 let index_dir = root.join(".tsift");
22125 fs::create_dir_all(&index_dir).unwrap();
22126 fs::write(index_dir.join("index.db"), "").unwrap();
22127 }
22128
22129 fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
22130 if let Some(parent) = path.parent() {
22131 fs::create_dir_all(parent).unwrap();
22132 }
22133 let body = std::iter::repeat_n(line, lines)
22134 .collect::<Vec<_>>()
22135 .join("\n");
22136 fs::write(path, format!("{body}\n")).unwrap();
22137 path.to_path_buf()
22138 }
22139
22140 #[test]
22143 fn token_capped_preview_returns_all_lines_when_under_cap() {
22144 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 1", "}"];
22145 let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
22146 assert!(!result.was_capped);
22147 assert_eq!(result.preview.len(), 3);
22148 assert_eq!(result.capped_end, 3);
22149 }
22150
22151 #[test]
22152 fn token_capped_preview_truncates_when_over_cap() {
22153 let lines: Vec<&str> = (0..200)
22154 .map(|_| " let x = some_very_long_expression_here();")
22155 .collect();
22156 let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
22157 assert!(result.was_capped);
22158 assert!(result.preview.len() < 200);
22159 assert!(result.capped_end < 200);
22160 }
22161
22162 #[test]
22163 fn token_capped_preview_keeps_at_least_one_line() {
22164 let long_line: String = "x".repeat(8000);
22165 let lines: Vec<&str> = vec![&long_line];
22166 let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
22167 assert!(!result.was_capped);
22168 assert_eq!(result.preview.len(), 1);
22169 }
22170
22171 #[test]
22172 fn token_capped_preview_cap_at_boundary() {
22173 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22174 let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
22175 assert!(!result.was_capped);
22176 assert_eq!(result.preview.len(), 4);
22177 }
22178
22179 #[test]
22180 fn token_capped_preview_cap_just_over_boundary() {
22181 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22182 let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
22183 assert!(result.was_capped);
22184 assert_eq!(result.preview.len(), 3);
22185 assert_eq!(result.capped_end, 3);
22186 }
22187
22188 #[test]
22189 fn token_capped_preview_empty_lines() {
22190 let lines: Vec<&str> = vec![];
22191 let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
22192 assert!(!result.was_capped);
22193 assert!(result.preview.is_empty());
22194 }
22195
22196 #[test]
22197 fn token_capped_preview_per_line_truncation_applied() {
22198 let long_line = "x".repeat(500);
22199 let lines: Vec<&str> = vec![&long_line, "short"];
22200 let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
22201 assert!(!result.was_capped);
22202 assert_eq!(result.preview.len(), 2);
22203 assert!(result.preview[0].text.len() <= 23);
22204 assert!(result.preview[0].text.ends_with("..."));
22205 }
22206
22207 #[test]
22210 fn route_search_defaults_to_haiku() {
22211 let (tier, model) = classify_task("find all uses of authenticate");
22212 assert_eq!(tier, "haiku");
22213 assert!(
22214 model.contains("haiku"),
22215 "expected haiku model, got {}",
22216 model
22217 );
22218 }
22219
22220 #[test]
22221 fn route_edit_keywords_to_sonnet() {
22222 for kw in &[
22223 "edit the file",
22224 "fix the bug",
22225 "update the config",
22226 "remove dead code",
22227 "create a new module",
22228 ] {
22229 let (tier, _) = classify_task(kw);
22230 assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
22231 }
22232 }
22233
22234 #[test]
22235 fn route_architecture_keywords_to_opus() {
22236 for kw in &[
22237 "design the API",
22238 "architecture review",
22239 "plan the migration",
22240 "analyze the system",
22241 "evaluate trade-offs",
22242 ] {
22243 let (tier, _) = classify_task(kw);
22244 assert_eq!(tier, "opus", "expected opus for {:?}", kw);
22245 }
22246 }
22247
22248 #[test]
22249 fn route_architecture_beats_edit() {
22250 let (tier, _) = classify_task("design and implement the new auth service");
22252 assert_eq!(tier, "opus");
22253 }
22254
22255 #[test]
22256 fn cli_accepts_global_compact_flag() {
22257 let cli = parse_cli(["tsift", "--compact", "status"]);
22258 assert!(cli.compact);
22259 assert!(matches!(cli.command, Some(Commands::Status { .. })));
22260 }
22261
22262 #[test]
22263 fn summarize_diff_scope_matches_relative_directory() {
22264 let root = Path::new("/repo");
22265 let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
22266
22267 assert!(summarize_diff_matches_scope(
22268 Path::new("/repo/src/feature/main.rs"),
22269 &extract_scope
22270 ));
22271 assert!(!summarize_diff_matches_scope(
22272 Path::new("/repo/src/other/main.rs"),
22273 &extract_scope
22274 ));
22275 }
22276
22277 #[test]
22278 fn summarize_diff_scope_matches_relative_file() {
22279 let root = Path::new("/repo");
22280 let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
22281
22282 assert!(summarize_diff_matches_scope(
22283 Path::new("/repo/src/feature/main.rs"),
22284 &extract_scope
22285 ));
22286 assert!(!summarize_diff_matches_scope(
22287 Path::new("/repo/src/feature/lib.rs"),
22288 &extract_scope
22289 ));
22290 }
22291
22292 #[test]
22293 fn summarize_extract_scope_walks_relative_paths_from_root() {
22294 let dir = tempfile::tempdir().unwrap();
22295 let source_dir = dir.path().join("src");
22296 std::fs::create_dir_all(&source_dir).unwrap();
22297 let main_rs = source_dir.join("main.rs");
22298 std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
22299
22300 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
22301 let files = collect_source_files(&extract_scope).unwrap();
22302
22303 assert_eq!(files, vec![main_rs]);
22304 }
22305
22306 #[test]
22307 fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
22308 let dir = tempfile::tempdir().unwrap();
22309 let nested = dir.path().join("src/nested");
22310 std::fs::create_dir_all(&nested).unwrap();
22311 std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
22312 let nested_file = nested.join("main.rs");
22313 std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
22314
22315 let extract_base = resolve_extract_base(&nested).unwrap();
22316 let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
22317 let files = collect_source_files(&extract_scope).unwrap();
22318
22319 assert_eq!(extract_scope, nested);
22320 assert_eq!(files, vec![nested_file]);
22321 }
22322
22323 #[test]
22324 fn summarize_extract_base_uses_parent_of_file_path() {
22325 let dir = tempfile::tempdir().unwrap();
22326 let nested = dir.path().join("src/nested");
22327 std::fs::create_dir_all(&nested).unwrap();
22328 let file_path = nested.join("main.rs");
22329 std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
22330
22331 let extract_base = resolve_extract_base(&file_path).unwrap();
22332
22333 assert_eq!(extract_base, nested);
22334 }
22335
22336 #[test]
22337 fn summarize_extract_scope_normalizes_dotdot_segments() {
22338 let dir = tempfile::tempdir().unwrap();
22339 let source_dir = dir.path().join("src");
22340 std::fs::create_dir_all(&source_dir).unwrap();
22341
22342 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
22343
22344 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22345 assert!(summarize_diff_matches_scope(
22346 &source_dir.join("main.rs"),
22347 &extract_scope
22348 ));
22349 }
22350
22351 #[cfg(unix)]
22352 #[test]
22353 fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
22354 use std::os::unix::fs::symlink;
22355
22356 let dir = tempfile::tempdir().unwrap();
22357 let real_root = dir.path().join("real");
22358 let source_dir = real_root.join("src");
22359 std::fs::create_dir_all(&source_dir).unwrap();
22360 let symlink_scope = dir.path().join("scope-link");
22361 symlink(&source_dir, &symlink_scope).unwrap();
22362
22363 let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
22364
22365 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22366 assert!(summarize_diff_matches_scope(
22367 &source_dir.join("lib.rs"),
22368 &extract_scope
22369 ));
22370 }
22371
22372 #[test]
22373 fn summarize_diff_extract_includes_untracked_files() {
22374 let dir = tempfile::tempdir().unwrap();
22375 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22376 init_git_repo(dir.path());
22377
22378 let source_dir = dir.path().join("src");
22379 std::fs::create_dir_all(&source_dir).unwrap();
22380 let new_file = source_dir.join("new.rs");
22381 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22382
22383 let files = summarize::git_changed_files(dir.path()).unwrap();
22384
22385 assert_eq!(files.existing, vec![new_file]);
22386 assert!(files.deleted.is_empty());
22387 }
22388
22389 #[test]
22390 fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
22391 let dir = tempfile::tempdir().unwrap();
22392 let status = std::process::Command::new("git")
22393 .args(["init"])
22394 .current_dir(dir.path())
22395 .status()
22396 .unwrap();
22397 assert!(status.success(), "git init failed");
22398
22399 let source_dir = dir.path().join("src");
22400 std::fs::create_dir_all(&source_dir).unwrap();
22401 let new_file = source_dir.join("new.rs");
22402 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22403
22404 let files = summarize::git_changed_files(dir.path()).unwrap();
22405
22406 assert_eq!(files.existing, vec![new_file]);
22407 assert!(files.deleted.is_empty());
22408 }
22409
22410 #[test]
22411 fn summarize_diff_extract_tracks_deleted_files() {
22412 let dir = tempfile::tempdir().unwrap();
22413 let source_dir = dir.path().join("src");
22414 std::fs::create_dir_all(&source_dir).unwrap();
22415 let deleted_file = source_dir.join("gone.rs");
22416 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22417 init_git_repo(dir.path());
22418
22419 std::fs::remove_file(&deleted_file).unwrap();
22420
22421 let files = summarize::git_changed_files(dir.path()).unwrap();
22422
22423 assert!(files.existing.is_empty());
22424 assert_eq!(files.deleted, vec![deleted_file]);
22425 }
22426
22427 #[test]
22428 fn summarize_diff_extract_tracks_git_renames() {
22429 let dir = tempfile::tempdir().unwrap();
22430 let source_dir = dir.path().join("src");
22431 std::fs::create_dir_all(&source_dir).unwrap();
22432 let old_file = source_dir.join("old.rs");
22433 let new_file = source_dir.join("new.rs");
22434 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22435 init_git_repo(dir.path());
22436
22437 let status = std::process::Command::new("git")
22438 .args(["mv", "src/old.rs", "src/new.rs"])
22439 .current_dir(dir.path())
22440 .status()
22441 .unwrap();
22442 assert!(status.success(), "git mv failed");
22443
22444 let files = summarize::git_changed_files(dir.path()).unwrap();
22445
22446 assert_eq!(files.existing, vec![new_file]);
22447 assert_eq!(files.deleted, vec![old_file]);
22448 }
22449
22450 #[test]
22451 fn summarize_diff_extract_deletes_removed_summary_rows() {
22452 let dir = tempfile::tempdir().unwrap();
22453 let source_dir = dir.path().join("src");
22454 std::fs::create_dir_all(&source_dir).unwrap();
22455 let deleted_file = source_dir.join("gone.rs");
22456 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22457 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22458 init_git_repo(dir.path());
22459
22460 let summary_db =
22461 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22462 summary_db
22463 .insert(&summarize::Summary {
22464 id: 0,
22465 symbol_name: "stale".to_string(),
22466 file_path: "src/gone.rs".to_string(),
22467 content_hash: "hash1".to_string(),
22468 summary: "stale summary".to_string(),
22469 entities: None,
22470 relationships: None,
22471 concept_labels: None,
22472 extracted_at: "1700000000".to_string(),
22473 model: "test".to_string(),
22474 tokens_input: Some(100),
22475 tokens_output: Some(50),
22476 })
22477 .unwrap();
22478
22479 std::fs::remove_file(&deleted_file).unwrap();
22480
22481 cmd_summarize(
22482 None,
22483 None,
22484 Some(PathBuf::from("src")),
22485 true,
22486 false,
22487 dir.path(),
22488 false,
22489 true,
22490 false,
22491 false,
22492 false,
22493 None,
22494 )
22495 .unwrap();
22496
22497 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22498 }
22499
22500 #[test]
22501 fn summarize_diff_extract_deletes_renamed_summary_rows() {
22502 let dir = tempfile::tempdir().unwrap();
22503 let source_dir = dir.path().join("src");
22504 std::fs::create_dir_all(&source_dir).unwrap();
22505 let old_file = source_dir.join("old.rs");
22506 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22507 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22508 init_git_repo(dir.path());
22509
22510 let summary_db =
22511 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22512 summary_db
22513 .insert(&summarize::Summary {
22514 id: 0,
22515 symbol_name: "stale".to_string(),
22516 file_path: "src/old.rs".to_string(),
22517 content_hash: "hash1".to_string(),
22518 summary: "stale summary".to_string(),
22519 entities: None,
22520 relationships: None,
22521 concept_labels: None,
22522 extracted_at: "1700000000".to_string(),
22523 model: "test".to_string(),
22524 tokens_input: Some(100),
22525 tokens_output: Some(50),
22526 })
22527 .unwrap();
22528
22529 let status = std::process::Command::new("git")
22530 .args(["mv", "src/old.rs", "src/new.rs"])
22531 .current_dir(dir.path())
22532 .status()
22533 .unwrap();
22534 assert!(status.success(), "git mv failed");
22535
22536 cmd_summarize(
22537 None,
22538 None,
22539 Some(PathBuf::from("src")),
22540 true,
22541 false,
22542 dir.path(),
22543 false,
22544 true,
22545 false,
22546 false,
22547 false,
22548 None,
22549 )
22550 .unwrap();
22551
22552 assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
22553 }
22554
22555 #[test]
22556 fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
22557 let dir = tempfile::tempdir().unwrap();
22558 let source_dir = dir.path().join("src");
22559 std::fs::create_dir_all(&source_dir).unwrap();
22560 let deleted_file = source_dir.join("gone.rs");
22561 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22562
22563 let summary_db =
22564 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22565 summary_db
22566 .insert(&summarize::Summary {
22567 id: 0,
22568 symbol_name: "stale".to_string(),
22569 file_path: "src/gone.rs".to_string(),
22570 content_hash: "hash1".to_string(),
22571 summary: "stale summary".to_string(),
22572 entities: None,
22573 relationships: None,
22574 concept_labels: None,
22575 extracted_at: "1700000000".to_string(),
22576 model: "test".to_string(),
22577 tokens_input: Some(100),
22578 tokens_output: Some(50),
22579 })
22580 .unwrap();
22581
22582 std::fs::remove_file(&deleted_file).unwrap();
22583
22584 cmd_summarize(
22585 None,
22586 None,
22587 Some(PathBuf::from("src")),
22588 false,
22589 false,
22590 dir.path(),
22591 false,
22592 true,
22593 false,
22594 false,
22595 false,
22596 None,
22597 )
22598 .unwrap();
22599
22600 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22601 }
22602
22603 #[test]
22604 fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
22605 let dir = tempfile::tempdir().unwrap();
22606 let source_dir = dir.path().join("src");
22607 std::fs::create_dir_all(&source_dir).unwrap();
22608 let file = source_dir.join("lib.rs");
22609 std::fs::write(&file, "fn helper() {}\n").unwrap();
22610
22611 let content = std::fs::read(&file).unwrap();
22612 let summary_db =
22613 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22614 summary_db
22615 .insert(&summarize::Summary {
22616 id: 0,
22617 symbol_name: "lib.rs".to_string(),
22618 file_path: "src/lib.rs".to_string(),
22619 content_hash: summarize::content_hash(&content),
22620 summary: "cached summary".to_string(),
22621 entities: None,
22622 relationships: None,
22623 concept_labels: None,
22624 extracted_at: "1700000000".to_string(),
22625 model: "test".to_string(),
22626 tokens_input: Some(100),
22627 tokens_output: Some(50),
22628 })
22629 .unwrap();
22630 drop(summary_db);
22631
22632 let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
22633 let _lock = hold_writer_lock(&lock_path);
22634
22635 let err = cmd_summarize(
22636 None,
22637 None,
22638 Some(PathBuf::from("src")),
22639 false,
22640 false,
22641 dir.path(),
22642 false,
22643 true,
22644 false,
22645 false,
22646 false,
22647 None,
22648 )
22649 .unwrap_err();
22650 let message = err.to_string();
22651
22652 assert!(message.contains("another tsift summarize extractor is already active"));
22653 assert!(message.contains("tsift summarize --extract"));
22654 }
22655
22656 #[test]
22657 fn summarize_stats_fails_closed_when_cache_missing() {
22658 let dir = tempfile::tempdir().unwrap();
22659 let err = cmd_summarize(
22660 None,
22661 None,
22662 None,
22663 false,
22664 true,
22665 dir.path(),
22666 false,
22667 false,
22668 false,
22669 false,
22670 false,
22671 None,
22672 )
22673 .unwrap_err();
22674
22675 assert!(
22676 err.to_string().contains("no summaries.db found"),
22677 "got: {err}"
22678 );
22679 assert!(!dir.path().join(".tsift/summaries.db").exists());
22680 }
22681
22682 #[test]
22683 fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22684 let dir = tempfile::tempdir().unwrap();
22685 let summary_db =
22686 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22687 summary_db
22688 .insert(&summarize::Summary {
22689 id: 0,
22690 symbol_name: "alpha_helper".to_string(),
22691 file_path: "src/lib.rs".to_string(),
22692 content_hash: "hash1".to_string(),
22693 summary: "cached summary".to_string(),
22694 entities: None,
22695 relationships: None,
22696 concept_labels: None,
22697 extracted_at: "1700000000".to_string(),
22698 model: "claude-haiku-4-5-20251001".to_string(),
22699 tokens_input: Some(100),
22700 tokens_output: Some(40),
22701 })
22702 .unwrap();
22703 drop(summary_db);
22704 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22705
22706 let result = cmd_summarize(
22707 None,
22708 None,
22709 None,
22710 false,
22711 true,
22712 dir.path(),
22713 false,
22714 false,
22715 false,
22716 false,
22717 false,
22718 None,
22719 );
22720
22721 assert!(result.is_ok());
22722 }
22723
22724 #[test]
22725 fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22726 let dir = tempfile::tempdir().unwrap();
22727 let summary_db =
22728 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22729 summary_db
22730 .insert(&summarize::Summary {
22731 id: 0,
22732 symbol_name: "alpha_helper".to_string(),
22733 file_path: "src/lib.rs".to_string(),
22734 content_hash: "hash1".to_string(),
22735 summary: "cached summary".to_string(),
22736 entities: None,
22737 relationships: None,
22738 concept_labels: None,
22739 extracted_at: "1700000000".to_string(),
22740 model: "claude-haiku-4-5-20251001".to_string(),
22741 tokens_input: Some(100),
22742 tokens_output: Some(40),
22743 })
22744 .unwrap();
22745 drop(summary_db);
22746 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22747
22748 let result = cmd_summarize(
22749 Some("alpha_helper".to_string()),
22750 None,
22751 None,
22752 false,
22753 false,
22754 dir.path(),
22755 false,
22756 true,
22757 false,
22758 false,
22759 false,
22760 None,
22761 );
22762
22763 assert!(result.is_ok());
22764 }
22765
22766 #[test]
22767 fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
22768 let dir = tempfile::tempdir().unwrap();
22769 let nested = dir.path().join("src/nested");
22770 std::fs::create_dir_all(&nested).unwrap();
22771
22772 let summary_db =
22773 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22774 summary_db
22775 .insert(&summarize::Summary {
22776 id: 0,
22777 symbol_name: "alpha_helper".to_string(),
22778 file_path: "src/lib.rs".to_string(),
22779 content_hash: "hash1".to_string(),
22780 summary: "cached summary".to_string(),
22781 entities: None,
22782 relationships: None,
22783 concept_labels: None,
22784 extracted_at: "1700000000".to_string(),
22785 model: "claude-haiku-4-5-20251001".to_string(),
22786 tokens_input: Some(100),
22787 tokens_output: Some(40),
22788 })
22789 .unwrap();
22790
22791 let result = cmd_summarize(
22792 Some("alpha_helper".to_string()),
22793 None,
22794 None,
22795 false,
22796 false,
22797 &nested,
22798 false,
22799 true,
22800 false,
22801 false,
22802 false,
22803 None,
22804 );
22805
22806 assert!(result.is_ok());
22807 assert!(!nested.join(".tsift/summaries.db").exists());
22808 }
22809
22810 #[test]
22811 fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
22812 let dir = tempfile::tempdir().unwrap();
22813 std::fs::write(
22814 dir.path().join(".gitmodules"),
22815 r#"[submodule "src/alpha"]
22816 path = src/alpha
22817 url = https://example.com/alpha
22818[submodule "src/beta"]
22819 path = src/beta
22820 url = https://example.com/beta
22821"#,
22822 )
22823 .unwrap();
22824
22825 let alpha_root = dir.path().join("src/alpha");
22826 let beta_root = dir.path().join("src/beta");
22827 std::fs::create_dir_all(alpha_root.join("src")).unwrap();
22828 std::fs::create_dir_all(beta_root.join("src")).unwrap();
22829 std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
22830 std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
22831 std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
22832 let beta_file = beta_root.join("src/lib.rs");
22833 std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
22834 std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
22835 std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
22836
22837 let context = find_symbols_db_for_file(dir.path(), &beta_file)
22838 .unwrap()
22839 .expect("expected matching scoped index");
22840
22841 assert_eq!(
22842 context.db_path,
22843 dir.path().join(".tsift/indexes/beta/index.db")
22844 );
22845 assert_eq!(context.source_root, beta_root);
22846 }
22847
22848 fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
22851 EditOp {
22852 file: PathBuf::from("dummy.txt"),
22853 old: old.to_string(),
22854 new: new.to_string(),
22855 replace_all,
22856 }
22857 }
22858
22859 #[test]
22860 fn edit_replaces_single_occurrence() {
22861 let content = "hello world";
22862 let op = make_op("world", "rust", false);
22863 let (result, count) = apply_edit_op(content, &op).unwrap();
22864 assert_eq!(result, "hello rust");
22865 assert_eq!(count, 1);
22866 }
22867
22868 #[test]
22869 fn edit_replace_all_replaces_every_occurrence() {
22870 let content = "foo foo foo";
22871 let op = make_op("foo", "bar", true);
22872 let (result, count) = apply_edit_op(content, &op).unwrap();
22873 assert_eq!(result, "bar bar bar");
22874 assert_eq!(count, 3);
22875 }
22876
22877 #[test]
22878 fn edit_fails_when_old_not_found() {
22879 let content = "hello world";
22880 let op = make_op("missing", "x", false);
22881 assert!(apply_edit_op(content, &op).is_err());
22882 }
22883
22884 #[test]
22885 fn edit_fails_when_ambiguous_without_replace_all() {
22886 let content = "foo foo";
22887 let op = make_op("foo", "bar", false);
22888 let err = apply_edit_op(content, &op).unwrap_err();
22889 assert!(err.to_string().contains("2 times"), "got: {}", err);
22890 }
22891
22892 #[test]
22893 fn edit_fails_when_old_equals_new() {
22894 let content = "hello";
22895 let op = make_op("hello", "hello", false);
22896 assert!(apply_edit_op(content, &op).is_err());
22897 }
22898
22899 #[test]
22900 fn edit_batch_rolls_back_when_later_swap_fails() {
22901 let dir = tempfile::tempdir().unwrap();
22902 let alpha = dir.path().join("alpha.txt");
22903 let beta = dir.path().join("beta.txt");
22904 fs::write(&alpha, "alpha old\n").unwrap();
22905 fs::write(&beta, "beta old\n").unwrap();
22906
22907 let batch = EditBatch {
22908 edits: vec![
22909 EditOp {
22910 file: alpha.clone(),
22911 old: "old".to_string(),
22912 new: "new".to_string(),
22913 replace_all: false,
22914 },
22915 EditOp {
22916 file: beta.clone(),
22917 old: "old".to_string(),
22918 new: "new".to_string(),
22919 replace_all: false,
22920 },
22921 ],
22922 };
22923
22924 let plan = build_edit_plan(&batch).unwrap();
22925 let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
22926 if commit_index == 1 {
22927 bail!("simulated swap failure");
22928 }
22929 Ok(())
22930 }) {
22931 Ok(_) => panic!("expected simulated swap failure"),
22932 Err(err) => err,
22933 };
22934
22935 assert!(err.to_string().contains("simulated swap failure"));
22936 assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22937 assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22938 }
22939
22940 fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22943 let tmp = tempfile::NamedTempFile::new().unwrap();
22944 let conn = Connection::open(tmp.path()).unwrap();
22945 conn.execute_batch(
22946 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22947 INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22948 INSERT INTO users VALUES (2, 'Bob', NULL);
22949 CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22950 FOREIGN KEY(user_id) REFERENCES users(id));
22951 INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22952 INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22953 INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22954 ).unwrap();
22955 (tmp, conn)
22956 }
22957
22958 #[test]
22961 fn rewrite_rg_simple_pattern() {
22962 let result = rewrite_command("rg authenticate");
22963 assert_eq!(
22964 result,
22965 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22966 );
22967 }
22968
22969 #[test]
22970 fn rewrite_rg_with_path() {
22971 let result = rewrite_command("rg authenticate src/");
22972 assert_eq!(
22973 result,
22974 Some(
22975 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22976 .to_string()
22977 )
22978 );
22979 }
22980
22981 #[test]
22982 fn rewrite_rg_with_flags_ignored() {
22983 let result = rewrite_command("rg -i authenticate src/");
22984 assert_eq!(
22985 result,
22986 Some(
22987 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22988 .to_string()
22989 )
22990 );
22991 }
22992
22993 #[test]
22994 fn rewrite_rg_with_type_flag() {
22995 let result = rewrite_command("rg -t rs authenticate");
22997 assert_eq!(
22998 result,
22999 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
23000 );
23001 }
23002
23003 #[test]
23004 fn rewrite_rg_pipe_passthrough() {
23005 let result = rewrite_command("rg authenticate | head -5");
23007 assert_eq!(result, None);
23008 }
23009
23010 #[test]
23011 fn rewrite_rg_files_passthrough() {
23012 let result = rewrite_command("rg --files src/tsift .agent-doc logs");
23013 assert_eq!(result, None);
23014 }
23015
23016 #[test]
23017 fn rewrite_find_passthrough() {
23018 let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
23019 assert_eq!(result, None);
23020 }
23021
23022 #[test]
23023 fn rewrite_grep_recursive() {
23024 let result = rewrite_command("grep -r authenticate src/");
23025 assert_eq!(
23026 result,
23027 Some(
23028 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
23029 .to_string()
23030 )
23031 );
23032 }
23033
23034 #[test]
23035 fn rewrite_grep_non_recursive_passthrough() {
23036 let result = rewrite_command("grep authenticate file.txt");
23037 assert_eq!(result, None);
23038 }
23039
23040 #[test]
23041 fn rewrite_tsift_passthrough() {
23042 let result = rewrite_command("tsift search \"foo\"");
23043 assert_eq!(result, Some("tsift search \"foo\"".to_string()));
23044 }
23045
23046 #[test]
23047 fn rewrite_run_tsift_search_disables_timeout_by_default() {
23048 let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
23049 assert_eq!(
23050 result,
23051 "tsift search hookcaps --exact --path /tmp/x --timeout 0"
23052 );
23053 }
23054
23055 #[test]
23056 fn rewrite_run_preserves_explicit_search_timeout() {
23057 let result = effective_rewrite_run_command(
23058 "tsift search hookcaps --exact --path /tmp/x --timeout 5",
23059 );
23060 assert_eq!(
23061 result,
23062 "tsift search hookcaps --exact --path /tmp/x --timeout 5"
23063 );
23064 }
23065
23066 #[test]
23067 fn rewrite_unrelated_passthrough() {
23068 let result = rewrite_command("echo cargo build");
23069 assert_eq!(result, None);
23070 }
23071
23072 #[test]
23073 fn rewrite_rg_quoted_pattern() {
23074 let result = rewrite_command("rg \"fn main\"");
23075 assert_eq!(
23076 result,
23077 Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
23078 );
23079 }
23080
23081 #[test]
23082 fn rewrite_git_diff_to_diff_digest() {
23083 let result = rewrite_command("git diff");
23084 assert_eq!(result, Some("tsift diff-digest .".to_string()));
23085 }
23086
23087 #[test]
23088 fn rewrite_git_diff_cached_to_diff_digest() {
23089 let result = rewrite_command("git diff --cached");
23090 assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
23091 }
23092
23093 #[test]
23094 fn rewrite_git_diff_with_path_to_diff_digest() {
23095 let result = rewrite_command("git diff -- src/");
23096 assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
23097 }
23098
23099 #[test]
23100 fn rewrite_git_diff_with_revision_passthrough() {
23101 let result = rewrite_command("git diff HEAD~1");
23102 assert_eq!(result, None);
23103 }
23104
23105 #[test]
23106 fn rewrite_git_show_to_revision_diff_digest() {
23107 let result = rewrite_command("git show HEAD~1");
23108 assert_eq!(
23109 result,
23110 Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
23111 );
23112 }
23113
23114 #[test]
23115 fn rewrite_git_log_patch_history_to_revision_diff_digest() {
23116 let result = rewrite_command("git log -p -1 HEAD~2");
23117 assert_eq!(
23118 result,
23119 Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
23120 );
23121 }
23122
23123 #[test]
23124 fn rewrite_cat_long_agent_doc_session_to_session_digest() {
23125 let dir = tempfile::tempdir().unwrap();
23126 let session = dir.path().join("tsift.md");
23127 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23128 for index in 0..90 {
23129 body.push_str(&format!("❯ prompt {index}?\n"));
23130 }
23131 fs::write(&session, body).unwrap();
23132
23133 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23134 assert_eq!(
23135 result,
23136 Some(format!(
23137 "tsift session-digest --path {} --input {} --source markdown",
23138 shell_quote(&resolve_digest_context_path(&session)),
23139 shell_quote(session.to_str().unwrap())
23140 ))
23141 );
23142 }
23143
23144 #[test]
23145 fn rewrite_head_long_claude_jsonl_to_session_digest() {
23146 let dir = tempfile::tempdir().unwrap();
23147 let session = dir.path().join("session.jsonl");
23148 let line =
23149 r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
23150 let body = std::iter::repeat_n(line, 120)
23151 .collect::<Vec<_>>()
23152 .join("\n");
23153 fs::write(&session, format!("{body}\n")).unwrap();
23154
23155 let result = rewrite_command(&format!(
23156 "head -n 120 {}",
23157 shell_quote(session.to_str().unwrap())
23158 ));
23159 assert_eq!(
23160 result,
23161 Some(format!(
23162 "tsift session-digest --path {} --input {} --source claude-jsonl",
23163 shell_quote(&resolve_digest_context_path(&session)),
23164 shell_quote(session.to_str().unwrap())
23165 ))
23166 );
23167 }
23168
23169 #[test]
23170 fn rewrite_head_long_codex_jsonl_to_session_digest() {
23171 let dir = tempfile::tempdir().unwrap();
23172 let session = dir.path().join("codex.jsonl");
23173 let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
23174 let body = std::iter::repeat_n(line, 120)
23175 .collect::<Vec<_>>()
23176 .join("\n");
23177 fs::write(&session, format!("{body}\n")).unwrap();
23178
23179 let result = rewrite_command(&format!(
23180 "head -n 120 {}",
23181 shell_quote(session.to_str().unwrap())
23182 ));
23183 assert_eq!(
23184 result,
23185 Some(format!(
23186 "tsift session-digest --path {} --input {} --source codex-jsonl",
23187 shell_quote(&resolve_digest_context_path(&session)),
23188 shell_quote(session.to_str().unwrap())
23189 ))
23190 );
23191 }
23192
23193 #[test]
23194 fn rewrite_small_transcript_window_passthrough() {
23195 let dir = tempfile::tempdir().unwrap();
23196 let session = dir.path().join("session.jsonl");
23197 let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
23198 let body = std::iter::repeat_n(line, 120)
23199 .collect::<Vec<_>>()
23200 .join("\n");
23201 fs::write(&session, format!("{body}\n")).unwrap();
23202
23203 let result = rewrite_command(&format!(
23204 "tail -n 20 {}",
23205 shell_quote(session.to_str().unwrap())
23206 ));
23207 assert_eq!(result, None);
23208 }
23209
23210 #[test]
23211 fn rewrite_sed_large_agent_doc_range_to_session_digest() {
23212 let dir = tempfile::tempdir().unwrap();
23213 let session = dir.path().join("tsift.md");
23214 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23215 for index in 0..120 {
23216 body.push_str(&format!("### Re: topic {index}\n"));
23217 }
23218 fs::write(&session, body).unwrap();
23219
23220 let result = rewrite_command(&format!(
23221 "sed -n '1,120p' {}",
23222 shell_quote(session.to_str().unwrap())
23223 ));
23224 assert_eq!(
23225 result,
23226 Some(format!(
23227 "tsift session-digest --path {} --input {} --source markdown",
23228 shell_quote(&resolve_digest_context_path(&session)),
23229 shell_quote(session.to_str().unwrap())
23230 ))
23231 );
23232 }
23233
23234 #[test]
23235 fn rewrite_cat_large_agent_doc_log_to_session_digest() {
23236 let dir = tempfile::tempdir().unwrap();
23237 let session = dir.path().join("tsift.log");
23238 let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
23239 let body = std::iter::repeat_n(line, 120)
23240 .collect::<Vec<_>>()
23241 .join("\n");
23242 fs::write(&session, format!("{body}\n")).unwrap();
23243
23244 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23245 assert_eq!(
23246 result,
23247 Some(format!(
23248 "tsift session-digest --path {} --input {} --source agent-doc-log",
23249 shell_quote(&resolve_digest_context_path(&session)),
23250 shell_quote(session.to_str().unwrap())
23251 ))
23252 );
23253 }
23254
23255 #[test]
23256 fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
23257 let dir = tempfile::tempdir().unwrap();
23258 fs::write(
23259 dir.path().join(".gitmodules"),
23260 r#"[submodule "src/tsift"]
23261 path = src/tsift
23262 url = https://example.com/tsift
23263"#,
23264 )
23265 .unwrap();
23266 let submodule = dir.path().join("src/tsift");
23267 fs::create_dir_all(submodule.join("tasks")).unwrap();
23268 fs::write(
23269 submodule.join(".git"),
23270 "gitdir: ../../.git/modules/src/tsift\n",
23271 )
23272 .unwrap();
23273 let session = submodule.join("tasks/plan.md");
23274 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23275 for index in 0..90 {
23276 body.push_str(&format!("❯ prompt {index}?\n"));
23277 }
23278 fs::write(&session, body).unwrap();
23279
23280 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23281
23282 assert_eq!(
23283 result,
23284 Some(format!(
23285 "tsift session-digest --path {} --input {} --source markdown",
23286 shell_quote(submodule.to_str().unwrap()),
23287 shell_quote(session.to_str().unwrap())
23288 ))
23289 );
23290 }
23291
23292 #[test]
23293 fn rewrite_regular_markdown_read_passthrough() {
23294 let dir = tempfile::tempdir().unwrap();
23295 let readme = dir.path().join("README.md");
23296 let body = std::iter::repeat_n("plain markdown", 120)
23297 .collect::<Vec<_>>()
23298 .join("\n");
23299 fs::write(&readme, format!("{body}\n")).unwrap();
23300
23301 let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
23302 assert_eq!(result, None);
23303 }
23304
23305 #[test]
23306 fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
23307 let dir = tempfile::tempdir().unwrap();
23308 write_empty_root_index(dir.path());
23309 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23310
23311 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23312
23313 assert_eq!(
23314 result,
23315 Some(format!(
23316 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
23317 shell_quote(&dir.path().to_string_lossy())
23318 ))
23319 );
23320 }
23321
23322 #[test]
23323 fn rewrite_head_small_source_window_passthrough() {
23324 let dir = tempfile::tempdir().unwrap();
23325 write_empty_root_index(dir.path());
23326 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23327
23328 let result = rewrite_command(&format!(
23329 "head -n 20 {}",
23330 shell_quote(source.to_str().unwrap())
23331 ));
23332
23333 assert_eq!(result, None);
23334 }
23335
23336 #[test]
23337 fn rewrite_sed_large_source_range_to_source_read() {
23338 let dir = tempfile::tempdir().unwrap();
23339 write_empty_root_index(dir.path());
23340 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23341
23342 let result = rewrite_command(&format!(
23343 "sed -n '40,160p' {}",
23344 shell_quote(source.to_str().unwrap())
23345 ));
23346
23347 assert_eq!(
23348 result,
23349 Some(format!(
23350 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
23351 shell_quote(&dir.path().to_string_lossy())
23352 ))
23353 );
23354 }
23355
23356 #[test]
23357 fn rewrite_tail_large_source_window_preserves_tail_anchor() {
23358 let dir = tempfile::tempdir().unwrap();
23359 write_empty_root_index(dir.path());
23360 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23361
23362 let result = rewrite_command(&format!(
23363 "tail -n 120 {}",
23364 shell_quote(source.to_str().unwrap())
23365 ));
23366
23367 assert_eq!(
23368 result,
23369 Some(format!(
23370 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
23371 shell_quote(&dir.path().to_string_lossy())
23372 ))
23373 );
23374 }
23375
23376 #[test]
23377 fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
23378 let dir = tempfile::tempdir().unwrap();
23379 write_empty_root_index(dir.path());
23380 let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
23381
23382 let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
23383
23384 assert_eq!(result, None);
23385 }
23386
23387 #[test]
23388 fn rewrite_large_source_read_passthrough_without_index() {
23389 let dir = tempfile::tempdir().unwrap();
23390 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23391
23392 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23393
23394 assert_eq!(result, None);
23395 }
23396
23397 #[test]
23398 fn rewrite_cargo_test_to_digest_runner() {
23399 let result = rewrite_command("cargo test --lib");
23400 assert_eq!(
23401 result,
23402 Some(
23403 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
23404 )
23405 );
23406 }
23407
23408 #[test]
23409 fn rewrite_pytest_to_digest_runner() {
23410 let result = rewrite_command("pytest -q tests/test_cli.py");
23411 assert_eq!(
23412 result,
23413 Some(
23414 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
23415 )
23416 );
23417 }
23418
23419 #[test]
23420 fn rewrite_python_m_pytest_to_digest_runner() {
23421 let result = rewrite_command("python -m pytest tests/test_cli.py");
23422 assert_eq!(
23423 result,
23424 Some(
23425 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
23426 )
23427 );
23428 }
23429
23430 #[test]
23431 fn rewrite_cargo_build_to_log_digest_runner() {
23432 let result = rewrite_command("cargo build --release");
23433 assert_eq!(
23434 result,
23435 Some(
23436 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
23437 )
23438 );
23439 }
23440
23441 #[test]
23442 fn rewrite_cargo_install_to_log_digest_runner() {
23443 let result = rewrite_command("cargo install --path . --force");
23444 assert_eq!(
23445 result,
23446 Some(
23447 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
23448 )
23449 );
23450 }
23451
23452 #[test]
23453 fn rewrite_metacharacter_command_passthrough() {
23454 let result = rewrite_command("cargo test | head");
23455 assert_eq!(result, None);
23456 }
23457
23458 #[test]
23459 fn rewrite_output_cap_detects_search_even_with_global_flag() {
23460 let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
23461 assert_eq!(cap.max_lines, 50);
23462 assert_eq!(cap.strip_prefix, Some("Strategy:"));
23463 }
23464
23465 #[test]
23466 fn rewrite_output_cap_skips_structured_output() {
23467 assert!(rewrite_output_cap("tsift search foo --json").is_none());
23468 assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
23469 assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
23470 }
23471
23472 #[test]
23473 fn rewrite_output_format_forwards_envelope_to_digest_runner() {
23474 let command = rewrite_command("cargo test --lib").expect("rewrite");
23475 let forwarded = apply_rewrite_output_format(
23476 &command,
23477 OutputFormat {
23478 json_output: true,
23479 compact: false,
23480 pretty: false,
23481 terse: false,
23482 ultra_terse: false,
23483 schema: false,
23484 envelope: true,
23485 },
23486 );
23487 assert_eq!(
23488 forwarded,
23489 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
23490 );
23491 }
23492
23493 #[test]
23494 fn rewrite_output_format_forwards_json_when_requested() {
23495 let command = rewrite_command("cargo build --release").expect("rewrite");
23496 let forwarded = apply_rewrite_output_format(
23497 &command,
23498 OutputFormat {
23499 json_output: true,
23500 compact: false,
23501 pretty: true,
23502 terse: false,
23503 ultra_terse: false,
23504 schema: false,
23505 envelope: false,
23506 },
23507 );
23508 assert_eq!(
23509 forwarded,
23510 "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
23511 );
23512 }
23513
23514 #[test]
23515 fn output_cap_strips_search_header_and_truncates() {
23516 let capped = apply_output_cap(
23517 b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
23518 OutputCap {
23519 max_lines: 2,
23520 strip_prefix: Some("Strategy:"),
23521 },
23522 );
23523 assert_eq!(
23524 capped,
23525 "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
23526 );
23527 }
23528
23529 #[test]
23530 fn sql_schema_overview_lists_tables() {
23531 let (_tmp, conn) = setup_test_db();
23532 let tables = schema_overview(&conn).unwrap();
23533 let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
23534 assert_eq!(names, &["posts", "users"]);
23535 }
23536
23537 #[test]
23538 fn sql_schema_overview_row_counts() {
23539 let (_tmp, conn) = setup_test_db();
23540 let tables = schema_overview(&conn).unwrap();
23541 let users = tables.iter().find(|t| t.name == "users").unwrap();
23542 let posts = tables.iter().find(|t| t.name == "posts").unwrap();
23543 assert_eq!(users.row_count, 2);
23544 assert_eq!(posts.row_count, 3);
23545 }
23546
23547 #[test]
23548 fn sql_table_columns_metadata() {
23549 let (_tmp, conn) = setup_test_db();
23550 let cols = table_columns(&conn, "users").unwrap();
23551 assert_eq!(cols.len(), 3);
23552 assert_eq!(cols[0].name, "id");
23553 assert!(cols[0].pk);
23554 assert_eq!(cols[1].name, "name");
23555 assert!(cols[1].notnull);
23556 assert_eq!(cols[2].name, "email");
23557 assert!(!cols[2].notnull);
23558 }
23559
23560 #[test]
23561 fn sql_execute_query_returns_rows() {
23562 let (_tmp, conn) = setup_test_db();
23563 let (columns, rows) =
23564 execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
23565 assert_eq!(columns, &["name", "email"]);
23566 assert_eq!(rows.len(), 2);
23567 assert_eq!(rows[0][0], serde_json::json!("Alice"));
23568 assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
23569 assert_eq!(rows[1][1], serde_json::Value::Null);
23570 }
23571
23572 #[test]
23573 fn sql_execute_query_aggregate() {
23574 let (_tmp, conn) = setup_test_db();
23575 let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
23576 assert_eq!(columns, &["cnt"]);
23577 assert_eq!(rows[0][0], serde_json::json!(3));
23578 }
23579
23580 #[test]
23581 fn sql_execute_query_join() {
23582 let (_tmp, conn) = setup_test_db();
23583 let (_cols, rows) = execute_query(
23584 &conn,
23585 "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
23586 )
23587 .unwrap();
23588 assert_eq!(rows.len(), 3);
23589 assert_eq!(rows[0][0], serde_json::json!("Alice"));
23590 assert_eq!(rows[2][0], serde_json::json!("Bob"));
23591 }
23592
23593 #[test]
23594 fn sql_open_db_read_only() {
23595 let (tmp, _conn) = setup_test_db();
23596 drop(_conn);
23597 let ro_conn = open_db(tmp.path()).unwrap();
23598 let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
23599 assert!(result.is_err(), "read-only connection should reject writes");
23600 }
23601
23602 #[test]
23603 fn sql_empty_table_schema() {
23604 let tmp = tempfile::NamedTempFile::new().unwrap();
23605 let conn = Connection::open(tmp.path()).unwrap();
23606 conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
23607 .unwrap();
23608 let tables = schema_overview(&conn).unwrap();
23609 assert_eq!(tables[0].row_count, 0);
23610 assert_eq!(tables[0].columns.len(), 2);
23611 }
23612
23613 fn setup_graph_index() -> tempfile::TempDir {
23616 let dir = tempfile::tempdir().unwrap();
23617 std::fs::write(
23618 dir.path().join("main.rs"),
23619 "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
23620 )
23621 .unwrap();
23622 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23623 db.apply_changes(dir.path()).unwrap();
23624 dir
23625 }
23626
23627 fn setup_traversal_project() -> tempfile::TempDir {
23628 let dir = setup_graph_index();
23629 let task_dir = dir.path().join("tasks/software");
23630 std::fs::create_dir_all(&task_dir).unwrap();
23631 std::fs::write(
23632 task_dir.join("tsift.md"),
23633 r#"---
23634agent_doc_session: tsift-v0.1
23635agent_doc_format: template
23636---
23637
23638## Exchange
23639
23640<!-- agent:exchange patch=append -->
23641❯ do [#kgnv]
23642Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
23643<!-- /agent:exchange -->
23644
23645<!-- agent:queue -->
23646dispatch #spec-test-build-install-commit-push
23647- do [#kgnv]
23648<!-- /agent:queue -->
23649
23650## Backlog
23651
23652<!-- agent:backlog -->
23653- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
23654<!-- /agent:backlog -->
23655"#,
23656 )
23657 .unwrap();
23658 dir
23659 }
23660
23661 fn resolve_ast_span_node<'a>(
23662 graph: &'a TraversalGraphBuild,
23663 label: &str,
23664 symbol_kind: &str,
23665 ) -> &'a TraversalNode {
23666 graph
23667 .nodes
23668 .values()
23669 .find(|node| {
23670 node.kind == "ast_span"
23671 && node.label == label
23672 && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
23673 })
23674 .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
23675 }
23676
23677 fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
23678 let dir = tempfile::tempdir().unwrap();
23679 std::fs::write(
23680 dir.path().join("rust.rs"),
23681 r#"mod fixture_nav_rust_mod {
23682 pub fn fixture_nav_rust_helper() {}
23683 pub fn fixture_nav_rust_entry() {
23684 fixture_nav_rust_helper();
23685 }
23686}
23687"#,
23688 )
23689 .unwrap();
23690 std::fs::write(
23691 dir.path().join("python.py"),
23692 r#"def fixture_nav_python_helper():
23693 return 1
23694
23695def fixture_nav_python_entry():
23696 return fixture_nav_python_helper()
23697"#,
23698 )
23699 .unwrap();
23700 std::fs::write(
23701 dir.path().join("typescript.ts"),
23702 r#"export function fixture_nav_typescript_entry(): number {
23703 return fixtureNavTsHelper();
23704}
23705
23706function fixtureNavTsHelper(): number {
23707 return 1;
23708}
23709"#,
23710 )
23711 .unwrap();
23712 std::fs::write(
23713 dir.path().join("javascript.js"),
23714 r#"function fixture_nav_javascript_entry() {
23715 return fixtureNavJsHelper();
23716}
23717
23718function fixtureNavJsHelper() {
23719 return 1;
23720}
23721"#,
23722 )
23723 .unwrap();
23724 std::fs::write(
23725 dir.path().join("kotlin.kt"),
23726 r#"fun fixture_nav_kotlin_entry(): Int {
23727 return fixtureNavKotlinHelper()
23728}
23729
23730fun fixtureNavKotlinHelper(): Int = 1
23731"#,
23732 )
23733 .unwrap();
23734 std::fs::write(
23735 dir.path().join("zig.zig"),
23736 r#"pub fn fixture_nav_zig_entry() i32 {
23737 return fixtureNavZigHelper();
23738}
23739
23740fn fixtureNavZigHelper() i32 {
23741 return 1;
23742}
23743"#,
23744 )
23745 .unwrap();
23746 std::fs::write(
23747 dir.path().join("bash.sh"),
23748 r#"#!/usr/bin/env bash
23749fixture_nav_bash_entry() {
23750 fixture_nav_bash_helper
23751}
23752
23753fixture_nav_bash_helper() {
23754 echo ok
23755}
23756
23757alias fixture_nav_bash_alias='echo alias'
23758"#,
23759 )
23760 .unwrap();
23761 std::fs::write(
23762 dir.path().join("README.md"),
23763 r#"# Fixture Guide
23764
23765## Fixture Section
23766
23767- Fixture step
23768 - Nested fixture step
23769
23770```python
23771def fixture_nav_markdown_embedded():
23772 return 1
23773```
23774"#,
23775 )
23776 .unwrap();
23777
23778 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23779 db.apply_changes(dir.path()).unwrap();
23780 dir
23781 }
23782
23783 fn assert_cli_expand_command_parses(command: &str) {
23784 let args = shell_split(command)
23785 .into_iter()
23786 .map(str::to_string)
23787 .collect::<Vec<_>>();
23788 assert!(
23789 try_parse_cli(args).is_ok(),
23790 "expand command should parse as a tsift CLI command: {command}"
23791 );
23792 }
23793
23794 fn setup_multiplicity_project() -> tempfile::TempDir {
23795 let dir = tempfile::tempdir().unwrap();
23796 std::fs::write(
23797 dir.path().join("Cargo.toml"),
23798 r#"[workspace]
23799members = ["crates/core-lib", "crates/cli-app"]
23800"#,
23801 )
23802 .unwrap();
23803 std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
23804 std::fs::write(
23805 dir.path().join("crates/core-lib/Cargo.toml"),
23806 r#"[package]
23807name = "core-lib"
23808
23809[lib]
23810name = "core_lib"
23811
23812[features]
23813default = []
23814"#,
23815 )
23816 .unwrap();
23817 std::fs::write(
23818 dir.path().join("crates/core-lib/src/lib.rs"),
23819 "pub fn run() {}\n",
23820 )
23821 .unwrap();
23822 std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
23823 std::fs::write(
23824 dir.path().join("crates/cli-app/Cargo.toml"),
23825 r#"[package]
23826name = "cli-app"
23827
23828[[bin]]
23829name = "cli-app"
23830
23831[dependencies]
23832core-lib = { path = "../core-lib" }
23833"#,
23834 )
23835 .unwrap();
23836 std::fs::write(
23837 dir.path().join("crates/cli-app/src/main.rs"),
23838 "use core_lib::run;\nfn main() { run(); }\n",
23839 )
23840 .unwrap();
23841 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23842 db.apply_changes(dir.path()).unwrap();
23843
23844 let task_dir = dir.path().join("tasks/software");
23845 std::fs::create_dir_all(&task_dir).unwrap();
23846 std::fs::write(
23847 task_dir.join("tsift.md"),
23848 r#"---
23849agent_doc_session: tsift-multiplicity
23850agent_doc_format: template
23851---
23852
23853## Backlog
23854
23855<!-- agent:backlog -->
23856- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
23857<!-- /agent:backlog -->
23858"#,
23859 )
23860 .unwrap();
23861 init_git_repo(dir.path());
23862 dir
23863 }
23864
23865 fn setup_dependency_dag_project() -> tempfile::TempDir {
23866 let dir = tempfile::tempdir().unwrap();
23867 std::fs::write(
23868 dir.path().join("main.rs"),
23869 "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
23870 )
23871 .unwrap();
23872 std::fs::write(
23873 dir.path().join("Cargo.toml"),
23874 "[package]\nname = \"dag-fixture\"\n",
23875 )
23876 .unwrap();
23877 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23878 db.apply_changes(dir.path()).unwrap();
23879
23880 let task_dir = dir.path().join("tasks/software");
23881 std::fs::create_dir_all(&task_dir).unwrap();
23882 std::fs::write(
23883 task_dir.join("tsift.md"),
23884 r#"---
23885agent_doc_session: tsift-dag
23886agent_doc_format: template
23887---
23888
23889## Exchange
23890
23891<!-- agent:exchange patch=append -->
23892Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
23893<!-- /agent:exchange -->
23894
23895## Backlog
23896
23897<!-- agent:backlog -->
23898- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
23899- [ ] [#alpha] Update shared_helper in main.rs after #prep.
23900- [ ] [#beta] Refactor shared_helper tests in main.rs.
23901- [ ] [#gamma] Follow-up review for graph navigation.
23902<!-- /agent:backlog -->
23903"#,
23904 )
23905 .unwrap();
23906 dir
23907 }
23908
23909 fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
23910 let dir = setup_graph_index();
23911 let task_dir = dir.path().join("tasks/software");
23912 std::fs::create_dir_all(&task_dir).unwrap();
23913 std::fs::write(
23914 task_dir.join("tsift.md"),
23915 r#"---
23916agent_doc_session: tsift-dag-cycle
23917agent_doc_format: template
23918---
23919
23920## Backlog
23921
23922<!-- agent:backlog -->
23923- [ ] [#left] Left side depends on #right.
23924- [ ] [#right] Right side depends on #left.
23925<!-- /agent:backlog -->
23926"#,
23927 )
23928 .unwrap();
23929 dir
23930 }
23931
23932 fn seed_traversal_semantic_summaries(dir: &Path) {
23933 let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23934 summary_db
23935 .insert(&summarize::Summary {
23936 id: 0,
23937 symbol_name: "helper".to_string(),
23938 file_path: "main.rs".to_string(),
23939 content_hash: "hash-main".to_string(),
23940 summary: "helper builds graph navigation handles for traversal.".to_string(),
23941 entities: Some(vec![
23942 summarize::Entity {
23943 name: "helper".to_string(),
23944 kind: "function".to_string(),
23945 description: "Builds graph navigation handles.".to_string(),
23946 },
23947 summarize::Entity {
23948 name: "TraversalGraph".to_string(),
23949 kind: "type".to_string(),
23950 description: "Carries GraphStore-backed traversal rows.".to_string(),
23951 },
23952 ]),
23953 relationships: Some(vec![summarize::Relationship {
23954 from: "helper".to_string(),
23955 to: "TraversalGraph".to_string(),
23956 kind: "uses".to_string(),
23957 }]),
23958 concept_labels: Some(vec![
23959 "graph navigation".to_string(),
23960 "semantic extraction".to_string(),
23961 ]),
23962 extracted_at: "1700000000".to_string(),
23963 model: "test-model".to_string(),
23964 tokens_input: Some(10),
23965 tokens_output: Some(5),
23966 })
23967 .unwrap();
23968 }
23969
23970 fn seed_tsift_memory_graph_db(dir: &Path) {
23971 let db = dir.join(".tsift").join("memory.db");
23972 let store = MemoryStore::open_or_create(&db).unwrap();
23973 let project = dir.to_string_lossy().to_string();
23974 let observation = MemoryEvent::new(
23975 MemoryEventKind::ImportedObservation,
23976 "claude-mem:observations:1",
23977 [
23978 "Graph memory adapter",
23979 "read-only projection",
23980 "graph-db should retrieve tsift memory observations",
23981 "Project memory is queried from .tsift/memory.db",
23982 "graph memory, tsift memory, semantic query",
23983 ]
23984 .join("\n\n"),
23985 )
23986 .with_session_id("claude-session-a")
23987 .with_observed_at_unix(1_700_000_000)
23988 .with_import("claude-mem", "observations:1")
23989 .with_metadata("project", project.clone())
23990 .with_metadata("observation_type", "fact")
23991 .with_metadata("prompt_number", "7")
23992 .with_metadata("discovery_tokens", "42")
23993 .with_metadata("content_hash", "hash-observation-1");
23994 store.insert_event(&observation).unwrap();
23995
23996 let summary = MemoryEvent::new(
23997 MemoryEventKind::ImportedSessionSummary,
23998 "claude-mem:session_summaries:2",
23999 [
24000 "Query old memory from graph-db",
24001 "Read-only tsift memory SQLite projection",
24002 "Semantic graph rows can point at existing memory",
24003 "Projected source and session nodes",
24004 "Keep capture ownership inside tsift-memory",
24005 "summary note",
24006 ]
24007 .join("\n\n"),
24008 )
24009 .with_session_id("claude-session-a")
24010 .with_observed_at_unix(1_700_000_010)
24011 .with_import("claude-mem", "session_summaries:2")
24012 .with_metadata("project", project)
24013 .with_metadata("prompt_number", "8")
24014 .with_metadata("discovery_tokens", "36");
24015 store.insert_event(&summary).unwrap();
24016
24017 let prompt = MemoryEvent::new(
24018 MemoryEventKind::ImportedUserPrompt,
24019 "claude-mem:user_prompts:3",
24020 "How can graph-db query tsift memory semantic history?",
24021 )
24022 .with_session_id("claude-session-a")
24023 .with_observed_at_unix(1_700_000_020)
24024 .with_import("claude-mem", "user_prompts:3")
24025 .with_metadata("prompt_number", "9");
24026 store.insert_event(&prompt).unwrap();
24027 }
24028
24029 #[test]
24030 fn graph_callers_query() {
24031 let dir = setup_graph_index();
24032 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24033 let callers = db.callers_of("helper").unwrap();
24034 assert_eq!(callers.len(), 1);
24035 assert_eq!(callers[0].caller_name, "main");
24036 }
24037
24038 #[test]
24039 fn graph_callees_query() {
24040 let dir = setup_graph_index();
24041 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24042 let callees = db.callees_of("main").unwrap();
24043 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
24044 assert!(names.contains(&"helper"));
24045 assert!(names.contains(&"new"));
24046 }
24047
24048 #[test]
24049 fn graph_no_callers_returns_empty() {
24050 let dir = setup_graph_index();
24051 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24052 let callers = db.callers_of("nonexistent").unwrap();
24053 assert!(callers.is_empty());
24054 }
24055
24056 #[test]
24057 fn graph_cmd_autoindexes_missing_index_by_default() {
24058 let dir = tempfile::tempdir().unwrap();
24059 std::fs::write(
24060 dir.path().join("main.rs"),
24061 "fn helper() {}\nfn main() { helper(); }\n",
24062 )
24063 .unwrap();
24064 let result = cmd_graph(
24065 "helper",
24066 dir.path(),
24067 true,
24068 false,
24069 None,
24070 20,
24071 false,
24072 true,
24073 false,
24074 false,
24075 false,
24076 false,
24077 false,
24078 TagpathSearchOpts::default(),
24079 );
24080
24081 assert!(result.is_ok());
24082 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
24083 let summary = db.compute_changes(dir.path()).unwrap();
24084 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
24085 }
24086
24087 #[test]
24088 fn traversal_graph_has_stable_typed_handles() {
24089 let dir = setup_traversal_project();
24090 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24091 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24092
24093 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
24094 let symbol = resolve_traversal_node(&graph, "helper").unwrap();
24095 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24096 let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
24097
24098 assert!(file.handle.starts_with("gfil-"));
24099 assert!(symbol.handle.starts_with("gsym-"));
24100 assert!(backlog.handle.starts_with("gbak-"));
24101 assert!(session.handle.starts_with("gses-"));
24102
24103 assert_eq!(
24104 symbol.handle,
24105 resolve_traversal_node(&graph_again, "helper")
24106 .unwrap()
24107 .handle
24108 );
24109 assert_eq!(
24110 backlog.handle,
24111 resolve_traversal_node(&graph_again, "#kgnv")
24112 .unwrap()
24113 .handle
24114 );
24115 }
24116
24117 #[test]
24118 fn traversal_graph_links_backlog_items_to_code_tokens() {
24119 let dir = setup_traversal_project();
24120 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24121 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24122 let helper = resolve_traversal_node(&graph, "helper").unwrap();
24123
24124 assert!(graph.edges.iter().any(|edge| {
24125 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24126 }));
24127 }
24128
24129 #[test]
24130 fn session_hinted_traversal_skips_global_call_edges() {
24131 let dir = setup_traversal_project();
24132 let session = dir.path().join("tasks/software/tsift.md");
24133 let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
24134 let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
24135 let helper = resolve_traversal_node(&bounded, "helper").unwrap();
24136
24137 assert!(bounded.edges.iter().any(|edge| {
24138 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24139 }));
24140 assert!(
24141 !bounded.edges.iter().any(|edge| edge.relation == "calls"),
24142 "session-hinted graph-db projections should not materialize unrelated global call edges"
24143 );
24144
24145 let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24146 assert!(
24147 full.edges.iter().any(|edge| edge.relation == "calls"),
24148 "root/full projections still carry the complete indexed call graph"
24149 );
24150 }
24151
24152 #[test]
24153 fn agent_doc_task_path_infers_matching_workspace_scope() {
24154 let dir = tempfile::tempdir().unwrap();
24155 std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
24156 std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
24157 std::fs::write(
24158 dir.path().join(".gitmodules"),
24159 "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
24160 )
24161 .unwrap();
24162 let task = dir.path().join("tasks/software/tsift.md");
24163 std::fs::write(&task, "# tsift\n").unwrap();
24164
24165 let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
24166 let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
24167 let cfg = config::Config::load(dir.path()).unwrap();
24168
24169 assert_eq!(targets.len(), 1);
24170 assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
24171 assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
24172 assert!(
24173 targets[0]
24174 .db_path
24175 .ends_with(".tsift/indexes/tsift/index.db")
24176 );
24177 assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
24178 }
24179
24180 #[test]
24181 fn cargo_package_scope_selector_indexes_package_db() {
24182 let dir = setup_multiplicity_project();
24183 let targets =
24184 resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
24185
24186 assert_eq!(targets.len(), 1);
24187 assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
24188 assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
24189 assert!(
24190 targets[0]
24191 .db_path
24192 .ends_with(".tsift/indexes/cargo/core-lib/index.db")
24193 );
24194
24195 cmd_index(
24196 dir.path(),
24197 false,
24198 false,
24199 false,
24200 false,
24201 true,
24202 false,
24203 Some("core_lib"),
24204 false,
24205 true,
24206 false,
24207 false,
24208 false,
24209 false,
24210 )
24211 .unwrap();
24212 assert!(targets[0].db_path.exists());
24213 }
24214
24215 #[test]
24216 fn source_read_symbols_build_cargo_package_index_on_demand() {
24217 let dir = setup_multiplicity_project();
24223 let cargo_index = dir.path().join(".tsift/indexes/cargo/core-lib/index.db");
24224 assert!(
24225 !cargo_index.exists(),
24226 "core-lib cargo index should not exist before the first source-read"
24227 );
24228
24229 let file_abs = dir.path().join("crates/core-lib/src/lib.rs");
24230 let source = std::fs::read(&file_abs).unwrap();
24231 let mut warnings = Vec::new();
24232 let symbols = load_source_symbols(
24233 dir.path(),
24234 &file_abs,
24235 "crates/core-lib/src/lib.rs",
24236 &source,
24237 None,
24238 1,
24239 usize::MAX,
24240 10,
24241 4096,
24242 &mut warnings,
24243 );
24244
24245 assert!(
24246 warnings.is_empty(),
24247 "source-read must build the index on demand instead of warning: {warnings:?}"
24248 );
24249 let symbol_names = symbols
24250 .iter()
24251 .map(|symbol| symbol.name.as_str())
24252 .collect::<Vec<_>>();
24253 assert!(
24254 symbol_names.contains(&"run"),
24255 "source-read should resolve `run` from the on-demand-built cargo index: {symbol_names:?}"
24256 );
24257 assert!(
24258 cargo_index.exists(),
24259 "source-read should have built the core-lib cargo index on demand"
24260 );
24261 }
24262
24263 #[test]
24264 fn path_inference_prefers_nested_cargo_package_without_submodule() {
24265 let dir = setup_multiplicity_project();
24266 let source = dir.path().join("crates/cli-app/src/main.rs");
24267 let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
24268
24269 assert_eq!(targets.len(), 1);
24270 assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
24271 assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
24272 }
24273
24274 #[test]
24275 fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
24276 let dir = setup_multiplicity_project();
24277 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24278 let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
24279 let core = resolve_traversal_node(&graph, "core-lib").unwrap();
24280 let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
24281 let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
24282
24283 assert_eq!(workspace.kind, "cargo_workspace");
24284 assert_eq!(core.kind, "cargo_package");
24285 assert_eq!(
24286 core.properties.get("features"),
24287 Some(&"default".to_string())
24288 );
24289 assert!(graph.edges.iter().any(|edge| {
24290 edge.from == workspace.handle
24291 && edge.to == core.handle
24292 && edge.relation == "contains_package"
24293 }));
24294 assert!(graph.edges.iter().any(|edge| {
24295 edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
24296 }));
24297 assert!(graph.edges.iter().any(|edge| {
24298 edge.from == cli.handle
24299 && edge.to == core.handle
24300 && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
24301 }));
24302 }
24303
24304 #[test]
24305 fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
24306 let dir = setup_multiplicity_project();
24307 let session = dir.path().join("tasks/software/tsift.md");
24308 let report =
24309 build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
24310 .unwrap();
24311
24312 assert!(report.per_target_fail_closed.is_empty());
24313 let candidate = report
24314 .candidates
24315 .iter()
24316 .find(|candidate| candidate.target == "corepkg")
24317 .unwrap();
24318 assert!(
24319 candidate
24320 .owned_files
24321 .iter()
24322 .any(|file| file == "crates/core-lib/Cargo.toml"),
24323 "{:?}",
24324 candidate.owned_files
24325 );
24326 }
24327
24328 #[test]
24329 fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
24330 let dir = setup_traversal_project();
24331 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24332 let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
24333 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24334
24335 assert_eq!(job.kind, "job_packet");
24336 assert!(job.handle.starts_with("gjob-"));
24337 assert!(graph.edges.iter().any(|edge| {
24338 edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
24339 }));
24340
24341 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24342 let jobs = store.nodes_by_kind("job_packet").unwrap();
24343 assert!(
24344 jobs.iter()
24345 .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24346 "expected queued job packet in graph store, got {jobs:?}"
24347 );
24348 }
24349
24350 #[test]
24351 fn traversal_graph_includes_routes_and_handler_edges() {
24352 let dir = tempfile::tempdir().unwrap();
24353 std::fs::write(
24354 dir.path().join("api.py"),
24355 r#"@router.get("/items")
24356def list_items():
24357 return []
24358"#,
24359 )
24360 .unwrap();
24361 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24362 db.apply_changes(dir.path()).unwrap();
24363
24364 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24365 let route = resolve_traversal_node(&graph, "/items").unwrap();
24366 let handler = resolve_traversal_node(&graph, "list_items").unwrap();
24367
24368 assert_eq!(route.kind, "route");
24369 assert!(graph.edges.iter().any(|edge| {
24370 edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
24371 }));
24372 }
24373
24374 #[test]
24375 fn traversal_graph_projects_rust_ast_navigation_edges() {
24376 let dir = tempfile::tempdir().unwrap();
24377 std::fs::write(
24378 dir.path().join("main.rs"),
24379 r#"mod api {
24380 pub fn helper() {}
24381 pub fn handler() { helper(); }
24382}
24383
24384fn main() { api::handler(); }
24385"#,
24386 )
24387 .unwrap();
24388 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24389 db.apply_changes(dir.path()).unwrap();
24390
24391 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24392 let api = resolve_ast_span_node(&graph, "api", "mod");
24393 let helper = resolve_ast_span_node(&graph, "helper", "function");
24394 let handler = resolve_ast_span_node(&graph, "handler", "function");
24395
24396 assert_eq!(helper.kind, "ast_span");
24397 assert!(helper.handle.starts_with("span-"));
24398 assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
24399 assert!(graph.edges.iter().any(|edge| {
24400 edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
24401 }));
24402 assert!(graph.edges.iter().any(|edge| {
24403 edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
24404 }));
24405 assert!(graph.edges.iter().any(|edge| {
24406 edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
24407 }));
24408 assert!(graph.edges.iter().any(|edge| {
24409 edge.from == helper.handle
24410 && edge.to == handler.handle
24411 && edge.relation == "next_sibling"
24412 }));
24413 assert!(graph.edges.iter().any(|edge| {
24414 edge.from == handler.handle
24415 && edge.to == helper.handle
24416 && edge.relation == "previous_sibling"
24417 }));
24418 assert!(graph.edges.iter().any(|edge| {
24419 edge.from == helper.handle
24420 && edge.to == api.handle
24421 && edge.relation == "enclosing_module"
24422 }));
24423 assert!(graph.edges.iter().any(|edge| {
24424 edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
24425 }));
24426
24427 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24428 let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
24429 assert!(
24430 ast_nodes.iter().any(|node| node.id == helper.handle
24431 && node.properties.get("symbol_kind") == Some(&"function".to_string())),
24432 "expected helper AST span in graph store, got {ast_nodes:?}"
24433 );
24434 assert!(
24435 store
24436 .outgoing_edges(&helper.handle, Some("parent"))
24437 .unwrap()
24438 .iter()
24439 .any(|edge| edge.to_id == api.handle),
24440 "expected persisted AST parent edge"
24441 );
24442 }
24443
24444 #[test]
24445 fn traversal_graph_projects_markdown_section_block_edges() {
24446 let dir = tempfile::tempdir().unwrap();
24447 std::fs::write(
24448 dir.path().join("README.md"),
24449 "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
24450 )
24451 .unwrap();
24452 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24453 db.apply_changes(dir.path()).unwrap();
24454
24455 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24456 let guide = resolve_ast_span_node(&graph, "Guide", "heading");
24457 let code = resolve_ast_span_node(&graph, "rust", "code_block");
24458 let embedded = resolve_ast_span_node(&graph, "demo", "function");
24459 let list_item = graph
24460 .nodes
24461 .values()
24462 .find(|node| {
24463 node.kind == "ast_span"
24464 && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
24465 && node.properties.get("section_handle") == Some(&guide.handle)
24466 })
24467 .expect("missing Markdown list item AST span");
24468
24469 assert_eq!(
24470 code.properties.get("markdown_block_kind"),
24471 Some(&"fenced_code_block".to_string())
24472 );
24473 assert_eq!(
24474 guide.properties.get("heading_level"),
24475 Some(&"1".to_string())
24476 );
24477 assert_eq!(
24478 embedded.properties.get("embedded"),
24479 Some(&"true".to_string())
24480 );
24481 assert_eq!(
24482 embedded.properties.get("language"),
24483 Some(&"rust".to_string())
24484 );
24485 assert_eq!(
24486 embedded.properties.get("markdown_block_handle"),
24487 Some(&code.handle)
24488 );
24489 assert!(graph.edges.iter().any(|edge| {
24490 edge.from == guide.handle
24491 && edge.to == code.handle
24492 && edge.relation == "contains_markdown_block"
24493 }));
24494 assert!(graph.edges.iter().any(|edge| {
24495 edge.from == code.handle
24496 && edge.to == guide.handle
24497 && edge.relation == "enclosing_section"
24498 }));
24499 assert!(graph.edges.iter().any(|edge| {
24500 edge.from == guide.handle
24501 && edge.to == list_item.handle
24502 && edge.relation == "contains_markdown_block"
24503 }));
24504 assert!(graph.edges.iter().any(|edge| {
24505 edge.from == code.handle
24506 && edge.to == embedded.handle
24507 && edge.relation == "contains_embedded_symbol"
24508 }));
24509 assert!(graph.edges.iter().any(|edge| {
24510 edge.from == embedded.handle
24511 && edge.to == code.handle
24512 && edge.relation == "embedded_in_fence"
24513 }));
24514 assert!(graph.edges.iter().any(|edge| {
24515 edge.from == guide.handle
24516 && edge.to == embedded.handle
24517 && edge.relation == "contains_embedded_code"
24518 }));
24519
24520 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24521 assert!(
24522 store
24523 .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
24524 .unwrap()
24525 .iter()
24526 .any(|edge| edge.to_id == code.handle),
24527 "expected persisted Markdown section/block edge"
24528 );
24529 assert!(
24530 store
24531 .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
24532 .unwrap()
24533 .iter()
24534 .any(|edge| edge.to_id == embedded.handle),
24535 "expected persisted Markdown fence/embedded symbol edge"
24536 );
24537 }
24538
24539 #[test]
24540 fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
24541 let dir = setup_multilingual_ast_navigation_project();
24542 let db =
24543 index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
24544 let symbols = db.all_symbols().unwrap();
24545 let expected_symbols = [
24546 ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
24547 (
24548 "python",
24549 "fixture_nav_python_entry",
24550 "function",
24551 "python.py",
24552 ),
24553 (
24554 "typescript",
24555 "fixture_nav_typescript_entry",
24556 "function",
24557 "typescript.ts",
24558 ),
24559 (
24560 "javascript",
24561 "fixture_nav_javascript_entry",
24562 "function",
24563 "javascript.js",
24564 ),
24565 (
24566 "kotlin",
24567 "fixture_nav_kotlin_entry",
24568 "function",
24569 "kotlin.kt",
24570 ),
24571 ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
24572 ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
24573 ("markdown", "Fixture Section", "heading", "README.md"),
24574 ("markdown", "Fixture step", "list_item", "README.md"),
24575 ("markdown", "python", "code_block", "README.md"),
24576 ];
24577
24578 for (language, name, kind, file) in expected_symbols {
24579 let symbol = symbols
24580 .iter()
24581 .find(|symbol| {
24582 symbol.language == language
24583 && symbol.name == name
24584 && symbol.kind == kind
24585 && symbol.file.ends_with(file)
24586 })
24587 .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
24588 assert!(
24589 symbol.start_byte.is_some() && symbol.end_byte.is_some(),
24590 "{language} {name} should carry AST byte spans"
24591 );
24592 }
24593
24594 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24595 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24596 let expected_ast_nodes = [
24597 ("fixture_nav_rust_entry", "function", "rust"),
24598 ("fixture_nav_python_entry", "function", "python"),
24599 ("fixture_nav_typescript_entry", "function", "typescript"),
24600 ("fixture_nav_javascript_entry", "function", "javascript"),
24601 ("fixture_nav_kotlin_entry", "function", "kotlin"),
24602 ("fixture_nav_zig_entry", "function", "zig"),
24603 ("fixture_nav_bash_entry", "function", "bash"),
24604 ("Fixture Section", "heading", "markdown"),
24605 ("Fixture step", "list_item", "markdown"),
24606 ("python", "code_block", "markdown"),
24607 ("fixture_nav_markdown_embedded", "function", "python"),
24608 ];
24609
24610 for (name, kind, language) in expected_ast_nodes {
24611 let node = resolve_ast_span_node(&graph, name, kind);
24612 let repeated = resolve_ast_span_node(&graph_again, name, kind);
24613 assert!(
24614 node.handle.starts_with("span-"),
24615 "{name} handle: {}",
24616 node.handle
24617 );
24618 assert_eq!(
24619 node.handle, repeated.handle,
24620 "{language} {name} handle drifted"
24621 );
24622 assert_eq!(
24623 node.properties.get("language"),
24624 Some(&language.to_string()),
24625 "{name} should keep its language label"
24626 );
24627 }
24628
24629 let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
24630 let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
24631 let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
24632 assert!(graph.edges.iter().any(|edge| {
24633 edge.from == markdown_section.handle
24634 && edge.to == markdown_code.handle
24635 && edge.relation == "contains_markdown_block"
24636 }));
24637 assert!(graph.edges.iter().any(|edge| {
24638 edge.from == markdown_code.handle
24639 && edge.to == embedded.handle
24640 && edge.relation == "contains_embedded_symbol"
24641 }));
24642 assert!(
24643 graph.nodes.len() <= 80,
24644 "multilingual AST fixture should stay bounded, got {} nodes",
24645 graph.nodes.len()
24646 );
24647 assert!(
24648 graph.edges.len() <= 180,
24649 "multilingual AST fixture should stay bounded, got {} edges",
24650 graph.edges.len()
24651 );
24652
24653 let response = empty_search_response(dir.path(), "lexical");
24654 let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
24655 let report = build_relative_search_budget_report(
24656 "fixture_nav_python_entry",
24657 "lexical",
24658 dir.path(),
24659 &response,
24660 &symbol_hits,
24661 ResponseBudget::new(Some(8), Some(120)),
24662 &SearchFacetFilters::default(),
24663 );
24664 let report_again = build_relative_search_budget_report(
24665 "fixture_nav_python_entry",
24666 "lexical",
24667 dir.path(),
24668 &response,
24669 &symbol_hits,
24670 ResponseBudget::new(Some(8), Some(120)),
24671 &SearchFacetFilters::default(),
24672 );
24673
24674 let top = report
24675 .ranked
24676 .first()
24677 .expect("ranked preview should not be empty");
24678 assert_eq!(top.source, "symbol_span");
24679 assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
24680 assert!(top.handle.starts_with("srnk-"));
24681 assert_eq!(top.handle, report_again.ranked[0].handle);
24682 assert!(
24683 top.reasons.iter().any(|reason| reason == "ast_span"),
24684 "expected AST span ranking reason, got {:?}",
24685 top.reasons
24686 );
24687 assert!(report.ranked.len() <= 8);
24688 assert!(report.symbols.len() <= 8);
24689
24690 let symbol = report
24691 .symbols
24692 .iter()
24693 .find(|symbol| symbol.name == "fixture_nav_python_entry")
24694 .expect("missing search preview symbol");
24695 assert_cli_expand_command_parses(&symbol.expand);
24696 let ast = symbol
24697 .ast
24698 .as_ref()
24699 .expect("search symbol should expose AST");
24700 assert_cli_expand_command_parses(&ast.expand.source_window);
24701 assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
24702 assert_cli_expand_command_parses(&ast.expand.symbol_read);
24703
24704 let markdown_hits = db.symbol_search("python", 20).unwrap();
24705 let markdown_report = build_relative_search_budget_report(
24706 "python",
24707 "lexical",
24708 dir.path(),
24709 &response,
24710 &markdown_hits,
24711 ResponseBudget::new(Some(8), Some(120)),
24712 &SearchFacetFilters::default(),
24713 );
24714 let markdown_symbol = markdown_report
24715 .symbols
24716 .iter()
24717 .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
24718 .expect("missing Markdown code-block symbol");
24719 let markdown_ast = markdown_symbol
24720 .ast
24721 .as_ref()
24722 .expect("Markdown code block should expose AST");
24723 assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
24724 assert_eq!(
24725 markdown_ast
24726 .span
24727 .markdown
24728 .as_ref()
24729 .unwrap()
24730 .embedded_symbols[0]
24731 .name,
24732 "fixture_nav_markdown_embedded"
24733 );
24734 }
24735
24736 #[test]
24737 fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
24738 let edges = vec![
24739 TraversalEdge {
24740 from: "origin".to_string(),
24741 to: "aaa_low".to_string(),
24742 relation: "unknown".to_string(),
24743 label: None,
24744 weight: 1,
24745 },
24746 TraversalEdge {
24747 from: "origin".to_string(),
24748 to: "zzz_high".to_string(),
24749 relation: "mentions".to_string(),
24750 label: None,
24751 weight: 1,
24752 },
24753 ];
24754
24755 let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
24756
24757 assert!(handles.contains("origin"));
24758 assert!(handles.contains("zzz_high"), "{handles:?}");
24759 assert!(!handles.contains("aaa_low"), "{handles:?}");
24760 }
24761
24762 #[test]
24763 fn traversal_materializes_provider_neutral_sqlite_graph() {
24764 let dir = setup_traversal_project();
24765 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24766 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24767
24768 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24769 let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
24770 assert!(
24771 backlog_nodes.iter().any(|node| node.id == backlog.handle
24772 && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24773 "expected materialized backlog node, got {backlog_nodes:?}"
24774 );
24775 assert!(
24776 store
24777 .all_nodes()
24778 .unwrap()
24779 .iter()
24780 .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
24781 && node.properties.get("projection_version")
24782 == Some(&GRAPH_PROJECTION_VERSION.to_string())),
24783 "expected projection metadata node"
24784 );
24785 let source_handles = store.nodes_by_kind("source_handle").unwrap();
24786 assert!(
24787 source_handles
24788 .iter()
24789 .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
24790 "expected bounded source_handle rows, got {source_handles:?}"
24791 );
24792 let worker_context = store.nodes_by_kind("worker_context").unwrap();
24793 assert!(
24794 worker_context
24795 .iter()
24796 .any(|node| node.properties.get("target")
24797 == Some(&"tasks/software/tsift.md".to_string())),
24798 "expected bounded worker_context rows, got {worker_context:?}"
24799 );
24800 let worker_results = store.nodes_by_kind("worker_result").unwrap();
24801 assert!(
24802 worker_results.iter().any(|node| {
24803 node.properties.get("ref_id") == Some(&"kgnv".to_string())
24804 && node.properties.get("status") == Some(&"completed".to_string())
24805 && node.properties.get("touched_files") == Some(&"main.rs".to_string())
24806 && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
24807 }),
24808 "expected worker_result rows, got {worker_results:?}"
24809 );
24810 }
24811
24812 #[test]
24813 fn traversal_projection_materializes_cached_semantic_rows() {
24814 let dir = setup_traversal_project();
24815 seed_traversal_semantic_summaries(dir.path());
24816 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24817 let helper = resolve_traversal_node(&graph, "helper").unwrap();
24818 let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
24819 let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
24820
24821 assert_eq!(concept.kind, "semantic_concept");
24822 assert_eq!(entity.kind, "semantic_entity");
24823 assert!(concept.handle.starts_with("gcon-"));
24824 assert!(entity.handle.starts_with("gent-"));
24825
24826 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24827 assert!(
24828 store
24829 .nodes_by_kind("semantic_concept")
24830 .unwrap()
24831 .iter()
24832 .any(|node| node.label == "semantic extraction"
24833 && node.properties.contains_key("embedding")),
24834 "expected persisted concept embeddings"
24835 );
24836 assert!(
24837 store
24838 .outgoing_edges(&helper.handle, Some("mentions_concept"))
24839 .unwrap()
24840 .iter()
24841 .any(|edge| edge.to_id == concept.handle),
24842 "expected helper symbol to link to cached summary concept"
24843 );
24844 assert!(
24845 store
24846 .outgoing_edges(
24847 &semantic_entity_handle("helper", "function"),
24848 Some("semantic_relation")
24849 )
24850 .unwrap()
24851 .iter()
24852 .any(|edge| edge.to_id == entity.handle
24853 && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
24854 "expected LLM relationship rows projected into GraphStore"
24855 );
24856 }
24857
24858 #[test]
24859 fn traversal_projection_materializes_tsift_memory_rows() {
24860 let dir = setup_traversal_project();
24861 seed_tsift_memory_graph_db(dir.path());
24862 let memory_db = dir.path().join(".tsift").join("memory.db");
24863 let store = MemoryStore::open_or_create(&memory_db).unwrap();
24864 for summary in ["first closeout", "second closeout"] {
24865 let event = MemoryEvent::new(
24866 MemoryEventKind::ResponseSummary,
24867 "tasks/software/tsift.md",
24868 summary,
24869 )
24870 .with_session_id("tasks/software/tsift.md")
24871 .with_observed_at_unix(1_700_000_100);
24872 store.insert_event(&event).unwrap();
24873 }
24874 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24875 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24876
24877 let native_sources = store
24878 .nodes_by_kind("source_handle")
24879 .unwrap()
24880 .into_iter()
24881 .filter(|node| {
24882 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24883 && node.properties.get("source_ref")
24884 == Some(&"tasks/software/tsift.md".to_string())
24885 })
24886 .collect::<Vec<_>>();
24887 assert_eq!(
24888 native_sources.len(),
24889 2,
24890 "same-source native memory events must get distinct source handles"
24891 );
24892
24893 let source = store
24894 .nodes_by_kind("source_handle")
24895 .unwrap()
24896 .into_iter()
24897 .find(|node| {
24898 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24899 })
24900 .expect("expected tsift-memory source handle");
24901 let session = store
24902 .nodes_by_kind("memory_session")
24903 .unwrap()
24904 .into_iter()
24905 .find(|node| {
24906 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24907 && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
24908 })
24909 .expect("expected tsift-memory session node");
24910 let event = store
24911 .nodes_by_kind("memory_event")
24912 .unwrap()
24913 .into_iter()
24914 .find(|node| {
24915 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24916 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24917 && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
24918 })
24919 .expect("expected tsift-memory event node");
24920 let concept = store
24921 .nodes_by_kind("semantic_concept")
24922 .unwrap()
24923 .into_iter()
24924 .find(|node| {
24925 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24926 && node.label.contains("Graph memory adapter")
24927 && node.properties.contains_key("embedding")
24928 })
24929 .expect("expected tsift-memory semantic concept");
24930
24931 assert!(
24932 store
24933 .outgoing_edges(&session.id, Some("records_memory_source"))
24934 .unwrap()
24935 .iter()
24936 .any(|edge| edge.to_id == source.id),
24937 "expected session to link to source handle"
24938 );
24939 assert!(
24940 store
24941 .outgoing_edges(&session.id, Some("records_memory_event"))
24942 .unwrap()
24943 .iter()
24944 .any(|edge| edge.to_id == event.id),
24945 "expected session to link to memory event"
24946 );
24947 assert!(
24948 store
24949 .outgoing_edges(&event.id, Some("projects_source"))
24950 .unwrap()
24951 .iter()
24952 .any(|edge| edge.to_id == source.id),
24953 "expected memory event to project source handle"
24954 );
24955 assert!(
24956 store
24957 .outgoing_edges(&source.id, Some("mentions_concept"))
24958 .unwrap()
24959 .iter()
24960 .any(|edge| edge.to_id == concept.id),
24961 "expected source handle to seed semantic concept"
24962 );
24963
24964 let related = semantic_related_report_from_store(
24965 dir.path(),
24966 None,
24967 "tsift memory graph adapter",
24968 5,
24969 SemanticRelatedKind::Concept,
24970 &store,
24971 )
24972 .unwrap();
24973 assert!(
24974 related
24975 .items
24976 .iter()
24977 .any(|item| item.handle == concept.id && item.score > 0.0),
24978 "expected semantic query to retrieve tsift-memory concept, got {:?}",
24979 related.items
24980 );
24981
24982 let graph_related = graph_db_report_from_store(
24983 dir.path(),
24984 None,
24985 "sqlite",
24986 GraphDbQuery::Related {
24987 query: "tsift memory graph adapter".to_string(),
24988 kind: SemanticRelatedKind::Concept,
24989 depth: 1,
24990 seed_limit: 5,
24991 limit: 20,
24992 },
24993 &store,
24994 sqlite_graph_freshness(&store, "root").unwrap(),
24995 Vec::new(),
24996 )
24997 .unwrap();
24998 assert_eq!(
24999 graph_related
25000 .readiness
25001 .as_ref()
25002 .map(|readiness| readiness.status.as_str()),
25003 Some("ready"),
25004 "tsift-memory semantic rows should satisfy graph-db related readiness"
25005 );
25006 assert!(
25007 graph_related.nodes.iter().any(|node| {
25008 node.kind == "semantic_concept"
25009 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
25010 }),
25011 "expected related graph output to include tsift-memory semantic rows"
25012 );
25013 }
25014
25015 #[test]
25016 fn semantic_related_query_uses_persisted_graph_embeddings() {
25017 let dir = setup_traversal_project();
25018 seed_traversal_semantic_summaries(dir.path());
25019 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25020 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25021 let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
25022 .unwrap()
25023 .query_row(
25024 "SELECT COUNT(*) FROM graph_node_semantic_vectors",
25025 [],
25026 |row| row_usize(row, 0),
25027 )
25028 .unwrap();
25029 assert!(semantic_vector_rows > 0);
25030
25031 let report = semantic_related_report_from_store(
25032 dir.path(),
25033 None,
25034 "graph navigation",
25035 5,
25036 SemanticRelatedKind::Concept,
25037 &store,
25038 )
25039 .unwrap();
25040
25041 assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
25042 assert!(
25043 report
25044 .items
25045 .iter()
25046 .any(|item| item.label == "graph navigation"
25047 && item.kind == "semantic_concept"
25048 && item.score > 0.9),
25049 "expected nearest concept match from graph embeddings, got {:?}",
25050 report.items
25051 );
25052 }
25053
25054 #[test]
25055 fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
25056 let dir = setup_traversal_project();
25057 seed_traversal_semantic_summaries(dir.path());
25058 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25059 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25060
25061 let report = graph_db_report_from_store(
25062 dir.path(),
25063 None,
25064 "sqlite",
25065 GraphDbQuery::Related {
25066 query: "graph navigation".to_string(),
25067 kind: SemanticRelatedKind::All,
25068 depth: 1,
25069 seed_limit: 2,
25070 limit: 20,
25071 },
25072 &store,
25073 sqlite_graph_freshness(&store, "root").unwrap(),
25074 Vec::new(),
25075 )
25076 .unwrap();
25077
25078 let knowledge = report.knowledge_retrieval.as_ref().unwrap();
25079 assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
25080 assert_eq!(knowledge.seed_kind, "all");
25081 assert_eq!(knowledge.depth, 1);
25082 assert_eq!(
25083 report
25084 .readiness
25085 .as_ref()
25086 .map(|readiness| readiness.status.as_str()),
25087 Some("ready")
25088 );
25089 assert!(
25090 knowledge
25091 .diagnostics
25092 .iter()
25093 .any(|diagnostic| diagnostic.contains("incident"))
25094 );
25095 assert!(
25096 report
25097 .semantic_related
25098 .iter()
25099 .any(|item| item.label == "graph navigation"
25100 && item.kind == "semantic_concept"
25101 && item.score > 0.9),
25102 "expected natural-language query to seed the graph navigation concept, got {:?}",
25103 report.semantic_related
25104 );
25105 assert!(
25106 report
25107 .nodes
25108 .iter()
25109 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
25110 );
25111 assert!(
25112 report
25113 .nodes
25114 .iter()
25115 .any(|node| node.kind == "symbol" && node.label == "helper"),
25116 "incident expansion from semantic seed should recover source symbols, got {:?}",
25117 report
25118 .nodes
25119 .iter()
25120 .map(|node| (&node.kind, &node.label))
25121 .collect::<Vec<_>>()
25122 );
25123 assert!(
25124 report
25125 .edges
25126 .iter()
25127 .any(|edge| edge.kind == "mentions_concept")
25128 );
25129 assert!(
25130 report.output_budget.as_ref().is_some_and(|budget| budget
25131 .diagnostics
25132 .iter()
25133 .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
25134 "expected related output budget diagnostics, got {:?}",
25135 report.output_budget
25136 );
25137 }
25138
25139 #[test]
25140 fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
25141 let dir = setup_graph_index();
25142 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25143 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25144
25145 let report = graph_db_report_from_store(
25146 dir.path(),
25147 None,
25148 "sqlite",
25149 GraphDbQuery::Related {
25150 query: "graph navigation".to_string(),
25151 kind: SemanticRelatedKind::All,
25152 depth: 1,
25153 seed_limit: 2,
25154 limit: 20,
25155 },
25156 &store,
25157 sqlite_graph_freshness(&store, "root").unwrap(),
25158 Vec::new(),
25159 )
25160 .unwrap();
25161
25162 let readiness = report.readiness.as_ref().unwrap();
25163 assert_eq!(readiness.status, "blocked");
25164 assert_eq!(readiness.reason, "summary_cache_empty");
25165 assert!(readiness.fail_closed);
25166 assert_eq!(
25167 readiness.next_commands,
25168 vec![
25169 "tsift summarize --extract .".to_string(),
25170 graph_db_refresh_command(dir.path(), None)
25171 ]
25172 );
25173 assert!(
25174 report
25175 .knowledge_retrieval
25176 .as_ref()
25177 .unwrap()
25178 .diagnostics
25179 .iter()
25180 .any(|diagnostic| diagnostic.contains("summary cache empty")
25181 && diagnostic.contains("graph-db materialized code/session rows")),
25182 "expected related diagnostics to carry readiness gate, got {:?}",
25183 report.knowledge_retrieval.as_ref().unwrap().diagnostics
25184 );
25185 }
25186
25187 #[test]
25188 fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
25189 let mut nodes = vec![
25190 SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
25191 SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
25192 ];
25193 let mut edges = vec![SubstrateGraphEdge::new(
25194 "zzz_high",
25195 "seed",
25196 "mentions_concept",
25197 )];
25198 for idx in 0..24 {
25199 let id = format!("aaa_low_{idx:02}");
25200 nodes.push(SubstrateGraphNode::new(
25201 id.clone(),
25202 "note",
25203 format!("low {idx}"),
25204 ));
25205 edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
25206 }
25207 let mut store = SqliteGraphStore::in_memory().unwrap();
25208 store
25209 .replace_projection(&GraphProjection { nodes, edges })
25210 .unwrap();
25211
25212 let subgraph =
25213 graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
25214
25215 assert_eq!(subgraph.nodes.len(), 3);
25216 assert_eq!(subgraph.nodes[0].id, "seed");
25217 assert_eq!(
25218 subgraph.nodes[1].id, "zzz_high",
25219 "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
25220 subgraph.nodes
25221 );
25222 assert!(subgraph.truncated);
25223 assert!(
25224 subgraph
25225 .diagnostics
25226 .iter()
25227 .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
25228 "{:?}",
25229 subgraph.diagnostics
25230 );
25231 assert!(
25232 subgraph
25233 .diagnostics
25234 .iter()
25235 .any(|diagnostic| diagnostic.contains("skipped")),
25236 "{:?}",
25237 subgraph.diagnostics
25238 );
25239 }
25240
25241 #[test]
25242 fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
25243 let dir = setup_traversal_project();
25244 seed_traversal_semantic_summaries(dir.path());
25245 init_git_repo(dir.path());
25246 let session = dir.path().join("tasks/software/tsift.md");
25247 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25248 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25249 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25250 let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25251 root: dir.path(),
25252 scope: None,
25253 backend: "sqlite",
25254 target: "kgnv",
25255 preferred_path: None,
25256 depth: 4,
25257 limit: 8,
25258 cursor: None,
25259 store: &store,
25260 freshness,
25261 warnings: Vec::new(),
25262 })
25263 .unwrap();
25264 assert!(
25265 evidence
25266 .semantic_related
25267 .iter()
25268 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
25269 "expected semantic evidence rows, got {:?}",
25270 evidence
25271 .semantic_related
25272 .iter()
25273 .map(|node| (&node.kind, &node.label))
25274 .collect::<Vec<_>>()
25275 );
25276 assert!(
25277 evidence
25278 .output_budget
25279 .as_ref()
25280 .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
25281 diagnostic.contains("semantic_match")
25282 && diagnostic.contains("source_handle_coverage")
25283 })),
25284 "expected evidence output budget diagnostics, got {:?}",
25285 evidence.output_budget
25286 );
25287
25288 let cached_diff = diff_digest::compute(
25289 dir.path(),
25290 diff_digest::DiffDigestOptions {
25291 cached: true,
25292 revision: None,
25293 max_parsed_files: None,
25294 },
25295 )
25296 .unwrap();
25297 let impact_report = impact::compute(
25298 dir.path(),
25299 impact::ImpactOptions {
25300 cached: true,
25301 revision: None,
25302 scope: None,
25303 limit: 10,
25304 },
25305 )
25306 .unwrap();
25307 let graph_nodes = store.all_nodes().unwrap();
25308 let graph_index = conflict_matrix_graph_index(&graph_nodes);
25309 let semantic_candidate = conflict_matrix_candidate_from_evidence(
25310 dir.path(),
25311 &evidence,
25312 &graph_index,
25313 &cached_diff,
25314 &impact_report,
25315 );
25316 assert!(semantic_candidate.semantic_dispatch_score > 0);
25317 assert!(
25318 semantic_candidate
25319 .semantic_dispatch_reasons
25320 .iter()
25321 .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
25322 "expected semantic ranking explanations, got {:?}",
25323 semantic_candidate.semantic_dispatch_reasons
25324 );
25325 assert!(
25326 semantic_candidate
25327 .semantic_related
25328 .iter()
25329 .any(|item| item.label == "graph navigation")
25330 );
25331
25332 let mut plain_candidate = semantic_candidate.clone();
25333 plain_candidate.target = "plain".to_string();
25334 plain_candidate.semantic_related.clear();
25335 plain_candidate.semantic_dispatch_score = 0;
25336 plain_candidate.semantic_dispatch_reasons.clear();
25337 let mut ranked = [plain_candidate, semantic_candidate];
25338 ranked.sort_by(|left, right| {
25339 left.risk
25340 .cmp(&right.risk)
25341 .then_with(|| left.risk_score.cmp(&right.risk_score))
25342 .then_with(|| {
25343 right
25344 .semantic_dispatch_score
25345 .cmp(&left.semantic_dispatch_score)
25346 })
25347 .then_with(|| left.target.cmp(&right.target))
25348 });
25349 assert_eq!(ranked[0].target, "kgnv");
25350 }
25351
25352 #[test]
25353 fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
25354 let dir = setup_dependency_dag_project();
25355 let session = dir.path().join("tasks/software/tsift.md");
25356 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25357
25358 assert_eq!(report.contract_version, "dependency-dag-v1");
25359 assert_eq!(
25360 report.targets,
25361 vec![
25362 "prep".to_string(),
25363 "alpha".to_string(),
25364 "beta".to_string(),
25365 "gamma".to_string()
25366 ]
25367 );
25368 assert!(report.edges.iter().any(|edge| {
25369 edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
25370 }));
25371 assert!(report.edges.iter().any(|edge| {
25372 edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
25373 }));
25374 assert!(report.edges.iter().any(|edge| {
25375 edge.from == "alpha"
25376 && edge.to == "beta"
25377 && edge.kind == "shared_resource"
25378 && edge.shared_files.contains(&"main.rs".to_string())
25379 && edge.shared_symbols.contains(&"shared_helper".to_string())
25380 }));
25381 assert!(
25382 !report.cycle_diagnostics.has_cycles,
25383 "{:?}",
25384 report.cycle_diagnostics
25385 );
25386 assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
25387 assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
25388 assert!(
25389 report.replay_commands[0].contains("dependency-dag"),
25390 "{:?}",
25391 report.replay_commands
25392 );
25393
25394 cmd_dependency_dag(
25395 &session,
25396 None,
25397 &["alpha".to_string(), "beta".to_string()],
25398 4,
25399 12,
25400 OutputFormat {
25401 json_output: true,
25402 compact: false,
25403 pretty: false,
25404 terse: false,
25405 ultra_terse: false,
25406 schema: false,
25407 envelope: false,
25408 },
25409 )
25410 .unwrap();
25411 }
25412
25413 #[test]
25414 fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
25415 let dir = setup_dependency_dag_cycle_project();
25416 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25417
25418 assert!(report.cycle_diagnostics.has_cycles);
25419 assert_eq!(
25420 report.cycle_diagnostics.blocked_nodes,
25421 vec!["left".to_string(), "right".to_string()]
25422 );
25423 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25424 edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
25425 }));
25426 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25427 edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
25428 }));
25429 }
25430
25431 #[test]
25432 fn traversal_projection_queries_match_sqlite_and_convex_stores() {
25433 let dir = setup_traversal_project();
25434 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
25435 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
25436
25437 let mut sqlite = SqliteGraphStore::in_memory().unwrap();
25438 sqlite.replace_projection(&projection).unwrap();
25439 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25440 projection.upsert_into(&convex).unwrap();
25441
25442 let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
25443 let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
25444 assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
25445 assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
25446
25447 let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
25448 let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
25449 assert!(convex_graph.edges.iter().any(|edge| {
25450 edge.from == sqlite_backlog.handle
25451 && edge.to == convex_helper.handle
25452 && edge.relation == "mentions"
25453 }));
25454 }
25455
25456 #[test]
25457 fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
25458 let dir = setup_traversal_project();
25459 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25460 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25461 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25462 assert_eq!(freshness.status, "current");
25463
25464 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25465 let report = graph_db_report_from_store(
25466 dir.path(),
25467 None,
25468 "sqlite",
25469 GraphDbQuery::Neighborhood {
25470 id: backlog.handle.clone(),
25471 depth: 1,
25472 edge_kind: Some("mentions".to_string()),
25473 cursor: None,
25474 limit: None,
25475 property_filters: Vec::new(),
25476 },
25477 &store,
25478 freshness,
25479 Vec::new(),
25480 )
25481 .unwrap();
25482 assert!(
25483 report
25484 .edges
25485 .iter()
25486 .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
25487 "expected backlog mention edge, got {:?}",
25488 report.edges
25489 );
25490 assert!(
25491 report.ranked_neighbors.iter().any(|neighbor| {
25492 neighbor.depth == Some(1)
25493 && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
25494 && neighbor.node_id != backlog.handle
25495 && neighbor.handle_coverage_pct >= 95.0
25496 && neighbor.duplicate_name_precision >= 0.99
25497 }),
25498 "expected ranked neighborhood neighbors with quality scores, got {:?}",
25499 report.ranked_neighbors
25500 );
25501 assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
25502 let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
25503 assert!(!ranking_gate.ranked_output_default);
25504 assert_eq!(ranking_gate.default_order, "stable_node_id");
25505 assert!(
25506 ranking_gate
25507 .diagnostics
25508 .iter()
25509 .any(|diagnostic| diagnostic.contains("score-capped")),
25510 "{ranking_gate:?}"
25511 );
25512 assert!(
25513 ranking_gate
25514 .required_metrics
25515 .iter()
25516 .any(|metric| metric == "handle_coverage_pct")
25517 );
25518 assert!(
25519 ranking_gate
25520 .required_metrics
25521 .iter()
25522 .any(|metric| metric == "duplicate_name_precision")
25523 );
25524 assert!(
25525 report
25526 .page
25527 .as_ref()
25528 .unwrap()
25529 .diagnostics
25530 .iter()
25531 .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
25532 "expected SQLite neighborhood query plan diagnostics, got {:?}",
25533 report.page.as_ref().unwrap().diagnostics
25534 );
25535 let edges_report = graph_db_report_from_store(
25536 dir.path(),
25537 None,
25538 "sqlite",
25539 GraphDbQuery::Edges {
25540 edge_kind: Some("mentions".to_string()),
25541 cursor: None,
25542 limit: Some(2),
25543 property_filters: Vec::new(),
25544 },
25545 &store,
25546 sqlite_graph_freshness(&store, "root").unwrap(),
25547 Vec::new(),
25548 )
25549 .unwrap();
25550 let edge_id = edges_report
25551 .edges
25552 .first()
25553 .map(|edge| edge.id.clone())
25554 .expect("expected at least one paged mentions edge");
25555 assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
25556 assert_eq!(
25557 edges_report.page.as_ref().unwrap().returned_edges,
25558 edges_report.edges.len()
25559 );
25560
25561 let edge_report = graph_db_report_from_store(
25562 dir.path(),
25563 None,
25564 "sqlite",
25565 GraphDbQuery::Edge {
25566 id: edge_id.clone(),
25567 },
25568 &store,
25569 sqlite_graph_freshness(&store, "root").unwrap(),
25570 Vec::new(),
25571 )
25572 .unwrap();
25573 assert_eq!(
25574 edge_report
25575 .edge
25576 .as_ref()
25577 .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
25578 Some(edge_id.clone())
25579 );
25580
25581 let incident_report = graph_db_report_from_store(
25582 dir.path(),
25583 None,
25584 "sqlite",
25585 GraphDbQuery::Incident {
25586 id: backlog.handle.clone(),
25587 edge_kind: Some("mentions".to_string()),
25588 cursor: None,
25589 limit: Some(1),
25590 property_filters: Vec::new(),
25591 },
25592 &store,
25593 sqlite_graph_freshness(&store, "root").unwrap(),
25594 Vec::new(),
25595 )
25596 .unwrap();
25597 assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
25598 assert!(
25599 incident_report
25600 .edges
25601 .iter()
25602 .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
25603 "{:?}",
25604 incident_report.edges
25605 );
25606
25607 let schema_report = graph_db_report_from_store(
25608 dir.path(),
25609 None,
25610 "sqlite",
25611 GraphDbQuery::Schema,
25612 &store,
25613 sqlite_graph_freshness(&store, "root").unwrap(),
25614 Vec::new(),
25615 )
25616 .unwrap();
25617 assert!(
25618 schema_report
25619 .schema
25620 .unwrap()
25621 .operations
25622 .iter()
25623 .any(|operation| operation.command.starts_with("neighborhood"))
25624 );
25625 }
25626
25627 #[test]
25628 fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
25629 let mut nodes = vec![SubstrateGraphNode::new(
25630 "origin",
25631 "backlog",
25632 "#budgeted-neighborhood",
25633 )];
25634 let mut edges = Vec::new();
25635 for idx in 0..32 {
25636 let id = format!("src-{idx:02}");
25637 nodes.push(
25638 SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
25639 .with_property("source_ref", format!("fixture:{idx}"))
25640 .with_property("detail", "x".repeat(600)),
25641 );
25642 edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
25643 }
25644 let store = SqliteGraphStore::in_memory().unwrap();
25645 GraphProjection { nodes, edges }
25646 .upsert_into(&store)
25647 .unwrap();
25648
25649 let report = graph_db_report_from_store(
25650 Path::new("."),
25651 None,
25652 "fixture",
25653 GraphDbQuery::Neighborhood {
25654 id: "origin".to_string(),
25655 depth: 1,
25656 edge_kind: None,
25657 cursor: None,
25658 limit: None,
25659 property_filters: Vec::new(),
25660 },
25661 &store,
25662 current_graph_db_freshness(),
25663 Vec::new(),
25664 )
25665 .unwrap();
25666 let budget = report.output_budget.as_ref().unwrap();
25667 assert!(budget.selected_nodes < budget.candidate_nodes);
25668 assert!(
25669 budget.dropped_by_budget.iter().any(|drop| {
25670 drop.item == "node"
25671 && drop.kind == "source_handle"
25672 && drop.reason == "per_kind_quota"
25673 }),
25674 "expected source_handle budget drops, got {:?}",
25675 budget.dropped_by_budget
25676 );
25677 assert!(report.page.as_ref().unwrap().truncated);
25678 assert!(
25679 report
25680 .page
25681 .as_ref()
25682 .unwrap()
25683 .diagnostics
25684 .iter()
25685 .any(|diagnostic| diagnostic.contains("budget ranking signals")),
25686 "{:?}",
25687 report.page
25688 );
25689 }
25690
25691 #[test]
25692 fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
25693 let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
25694 let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
25695 for idx in 0..8 {
25696 let id = format!("far-{idx:02}");
25697 nodes.push(SubstrateGraphNode::new(
25698 id.clone(),
25699 "note",
25700 format!("aaa deeper row {idx}"),
25701 ));
25702 depth_by_id.insert(id, 6);
25703 }
25704
25705 let origin_ids = vec!["target".to_string()];
25706 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
25707 &origin_ids,
25708 &BTreeMap::new(),
25709 nodes,
25710 Vec::new(),
25711 Some(3),
25712 Some(&depth_by_id),
25713 None,
25714 );
25715
25716 assert!(
25717 budgeted.nodes.iter().any(|node| node.id == "near"),
25718 "expected the shallow evidence row to outrank deeper rows, got {:?}",
25719 budgeted
25720 .nodes
25721 .iter()
25722 .map(|node| (&node.id, &node.label))
25723 .collect::<Vec<_>>()
25724 );
25725 assert!(
25726 budgeted.report.dropped_by_budget.iter().any(|drop| {
25727 drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
25728 }),
25729 "expected node quota drops, got {:?}",
25730 budgeted.report.dropped_by_budget
25731 );
25732 assert!(
25733 budgeted
25734 .report
25735 .diagnostics
25736 .iter()
25737 .any(|diagnostic| diagnostic.contains("depth")),
25738 "{:?}",
25739 budgeted.report.diagnostics
25740 );
25741 }
25742
25743 #[test]
25744 fn evidence_pagination_returns_next_cursor_when_truncated() {
25745 let mut nodes = vec![SubstrateGraphNode::new(
25746 "target".to_string(),
25747 "backlog_item",
25748 "target item".to_string(),
25749 )];
25750 let mut depth_by_id = BTreeMap::new();
25751 depth_by_id.insert("target".to_string(), 0);
25752 for idx in 0..20 {
25753 let id = format!("ev-{idx}");
25754 nodes.push(
25755 SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
25756 .with_property("detail", "x".repeat(400)),
25757 );
25758 depth_by_id.insert(id, 1);
25759 }
25760 let origin_ids = vec!["target".to_string()];
25761 let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
25762 &origin_ids,
25763 &BTreeMap::new(),
25764 nodes.clone(),
25765 Vec::new(),
25766 Some(3),
25767 Some(&depth_by_id),
25768 None,
25769 );
25770 assert!(
25771 first_page.truncated,
25772 "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
25773 first_page.nodes.len(),
25774 first_page.report.candidate_nodes
25775 );
25776 assert!(
25777 first_page.next_cursor.is_some(),
25778 "expected next_cursor when truncated"
25779 );
25780 let cursor = first_page.next_cursor.unwrap();
25781 assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
25782 let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
25783 let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
25784 &origin_ids,
25785 &BTreeMap::new(),
25786 nodes.clone(),
25787 Vec::new(),
25788 Some(3),
25789 Some(&depth_by_id),
25790 Some(&cursor),
25791 );
25792 let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
25793 let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
25794 assert!(
25795 overlap.is_empty(),
25796 "pages should not overlap, but found shared ids: {overlap:?}"
25797 );
25798 assert!(
25799 second_page
25800 .report
25801 .diagnostics
25802 .iter()
25803 .any(|d| d.contains("cursor skipped")),
25804 "expected cursor skip diagnostic, got {:?}",
25805 second_page.report.diagnostics
25806 );
25807 }
25808
25809 #[test]
25810 fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
25811 let mut nodes = vec![SubstrateGraphNode::new(
25812 "target".to_string(),
25813 "backlog_item",
25814 "target item".to_string(),
25815 )];
25816 let mut depth_by_id = BTreeMap::new();
25817 depth_by_id.insert("target".to_string(), 0);
25818 for idx in 0..3 {
25819 let id = format!("ev-{idx}");
25820 nodes.push(SubstrateGraphNode::new(
25821 id.clone(),
25822 "source_handle",
25823 format!("evidence row {idx}"),
25824 ));
25825 depth_by_id.insert(id, 1);
25826 }
25827 let origin_ids = vec!["target".to_string()];
25828 let result = graph_db_apply_output_budget_with_depths_and_cursor(
25829 &origin_ids,
25830 &BTreeMap::new(),
25831 nodes,
25832 Vec::new(),
25833 None,
25834 Some(&depth_by_id),
25835 None,
25836 );
25837 assert!(
25838 !result.truncated,
25839 "expected no truncation with small candidate set and default budget"
25840 );
25841 assert!(
25842 result.next_cursor.is_none(),
25843 "expected no next_cursor when not truncated"
25844 );
25845 }
25846
25847 #[test]
25848 fn evidence_pagination_invalid_cursor_returns_first_page() {
25849 let mut nodes = vec![SubstrateGraphNode::new(
25850 "target".to_string(),
25851 "backlog_item",
25852 "target item".to_string(),
25853 )];
25854 let mut depth_by_id = BTreeMap::new();
25855 depth_by_id.insert("target".to_string(), 0);
25856 for idx in 0..5 {
25857 let id = format!("ev-{idx}");
25858 nodes.push(SubstrateGraphNode::new(
25859 id.clone(),
25860 "source_handle",
25861 format!("evidence row {idx}"),
25862 ));
25863 depth_by_id.insert(id, 1);
25864 }
25865 let origin_ids = vec!["target".to_string()];
25866 let result = graph_db_apply_output_budget_with_depths_and_cursor(
25867 &origin_ids,
25868 &BTreeMap::new(),
25869 nodes.clone(),
25870 Vec::new(),
25871 None,
25872 Some(&depth_by_id),
25873 Some("nonexistent-id"),
25874 );
25875 assert!(
25876 result
25877 .report
25878 .diagnostics
25879 .iter()
25880 .any(|d| d.contains("cursor skipped 0")),
25881 "invalid cursor should skip 0 candidates, got {:?}",
25882 result.report.diagnostics
25883 );
25884 }
25885
25886 #[test]
25887 fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
25888 let dir = setup_traversal_project();
25889 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25890 let graph_db = dir.path().join(".tsift/graph.db");
25891 let _lock = hold_rollback_journal_lock(&graph_db);
25892
25893 let report =
25894 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25895 .unwrap();
25896
25897 assert_eq!(report.status, "current");
25898 assert_eq!(
25899 report.recovery,
25900 Some(index::ReadOnlyRecovery::SnapshotFallback)
25901 );
25902 assert!(
25903 report
25904 .warnings
25905 .iter()
25906 .any(|warning| warning.contains("rollback-journal lock")),
25907 "expected rollback-journal recovery warning, got {:?}",
25908 report.warnings
25909 );
25910 }
25911
25912 #[test]
25913 fn graph_db_status_copies_wal_sidecars_when_locked() {
25914 let dir = setup_traversal_project();
25915 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25916 let graph_db = dir.path().join(".tsift/graph.db");
25917 let _lock = hold_wal_database_lock(&graph_db);
25918
25919 let report =
25920 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25921 .unwrap();
25922
25923 assert_eq!(report.status, "current");
25924 assert_eq!(
25925 report.recovery,
25926 Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
25927 );
25928 assert!(
25929 report
25930 .warnings
25931 .iter()
25932 .any(|warning| warning.contains("WAL-aware snapshot fallback")),
25933 "expected WAL recovery warning, got {:?}",
25934 report.warnings
25935 );
25936 }
25937
25938 #[test]
25939 fn graph_db_doctor_reports_snapshot_fallback_when_rollback_journal_is_locked() {
25940 let dir = setup_traversal_project();
25941 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25942 let graph_db = dir.path().join(".tsift/graph.db");
25943 let _lock = hold_rollback_journal_lock(&graph_db);
25944
25945 let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25946 append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25947 report.finalize();
25948
25949 assert_eq!(report.status, "ok");
25950 assert!(!report.fail_closed);
25951 let recovery_check = report
25952 .checks
25953 .iter()
25954 .find(|check| check.name == "sqlite_graph_db_read_recovery")
25955 .expect("doctor should include read recovery diagnostic");
25956 assert_eq!(recovery_check.status, "recovered");
25957 assert!(
25958 recovery_check
25959 .diagnostics
25960 .iter()
25961 .any(|diagnostic| diagnostic.contains("rollback-journal lock")),
25962 "expected rollback-journal recovery diagnostic, got {:?}",
25963 recovery_check.diagnostics
25964 );
25965 }
25966
25967 #[test]
25968 fn graph_db_doctor_reports_wal_snapshot_fallback_when_locked() {
25969 let dir = setup_traversal_project();
25970 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25971 let graph_db = dir.path().join(".tsift/graph.db");
25972 let _lock = hold_wal_database_lock(&graph_db);
25973
25974 let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25975 append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25976 report.finalize();
25977
25978 assert_eq!(report.status, "ok");
25979 assert!(!report.fail_closed);
25980 let recovery_check = report
25981 .checks
25982 .iter()
25983 .find(|check| check.name == "sqlite_graph_db_read_recovery")
25984 .expect("doctor should include WAL read recovery diagnostic");
25985 assert_eq!(recovery_check.status, "recovered");
25986 assert!(
25987 recovery_check
25988 .diagnostics
25989 .iter()
25990 .any(|diagnostic| diagnostic.contains("WAL-aware snapshot fallback")),
25991 "expected WAL recovery diagnostic, got {:?}",
25992 recovery_check.diagnostics
25993 );
25994 }
25995
25996 #[test]
25997 fn graph_db_snapshot_export_import_round_trip_preserves_projection_metadata() {
25998 let dir = setup_traversal_project();
25999 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26000 let artifact = dir.path().join("graph.db.gz");
26001
26002 let exported =
26003 commands::infra::graph_db_snapshot_export_report(dir.path(), None, &artifact, false)
26004 .unwrap();
26005 let exported_projection_version = exported.freshness.projection_version.clone();
26006 let exported_content_hash = exported.freshness.content_hash.clone();
26007 let exported_source_watermark = exported.freshness.source_watermark.clone();
26008 let exported_nodes = exported.counts.nodes;
26009 let exported_edges = exported.counts.edges;
26010 assert_eq!(exported.operation, "snapshot-export");
26011 assert!(exported.status.starts_with("exported"));
26012 assert!(artifact.exists());
26013 assert!(exported.artifact_bytes > 0);
26014 assert_eq!(exported.compression, "gzip");
26015
26016 fs::remove_file(dir.path().join(".tsift/graph.db")).unwrap();
26017
26018 let imported =
26019 commands::infra::graph_db_snapshot_import_report(dir.path(), None, &artifact, false)
26020 .unwrap();
26021 assert_eq!(imported.operation, "snapshot-import");
26022 assert!(imported.status.starts_with("imported"));
26023 assert_eq!(
26024 imported.freshness.projection_version,
26025 exported_projection_version
26026 );
26027 assert_eq!(imported.freshness.content_hash, exported_content_hash);
26028 assert_eq!(
26029 imported.freshness.source_watermark,
26030 exported_source_watermark
26031 );
26032 assert_eq!(imported.counts.nodes, exported_nodes);
26033 assert_eq!(imported.counts.edges, exported_edges);
26034 assert!(dir.path().join(".tsift/graph.db").exists());
26035 }
26036
26037 #[test]
26038 fn graph_db_snapshot_export_fails_closed_when_wal_lock_requires_recovery() {
26039 let dir = setup_traversal_project();
26040 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26041 let graph_db = dir.path().join(".tsift/graph.db");
26042 let _lock = hold_wal_database_lock(&graph_db);
26043
26044 let err = match commands::infra::graph_db_snapshot_export_report(
26045 dir.path(),
26046 None,
26047 &dir.path().join("graph.db.gz"),
26048 false,
26049 ) {
26050 Ok(report) => panic!("expected snapshot export to fail, got {}", report.status),
26051 Err(err) => err,
26052 };
26053
26054 let message = err.to_string();
26060 assert!(
26061 message.contains("recovered path")
26062 && message.contains("database is locked")
26063 && message.contains("wait for it to finish before retrying the export"),
26064 "expected unified live-lock recovery diagnostic, got {err:#}"
26065 );
26066 }
26067
26068 #[test]
26069 fn graph_db_snapshot_clean_export_maps_database_locked_to_live_lock_diagnostic() {
26070 let dir = setup_traversal_project();
26071 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26072 let graph_db = dir.path().join(".tsift/graph.db");
26073
26074 let blocker = Connection::open(&graph_db).unwrap();
26078 blocker
26079 .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26080 .unwrap();
26081 assert!(!substrate::rollback_journal_path(&graph_db).exists());
26082
26083 let clean_path = dir.path().join("graph-clean-export.db");
26084 let err = match commands::infra::graph_db_snapshot_clean_export_copy(&graph_db, &clean_path)
26085 {
26086 Ok(bytes) => panic!("expected export to fail under live lock, got {bytes} bytes"),
26087 Err(err) => err,
26088 };
26089
26090 let message = err.to_string();
26091 assert!(
26092 message.contains("concurrent graph-db refresh or snapshot-import is in progress"),
26093 "expected actionable live-lock diagnostic, got {err:#}"
26094 );
26095 assert!(
26096 message.contains("wait for it to finish before retrying"),
26097 "expected retry guidance, got {err:#}"
26098 );
26099 assert!(
26101 !message.contains("creating clean graph-db export copy"),
26102 "live-lock case must not surface the generic VACUUM context, got {err:#}"
26103 );
26104
26105 drop(blocker);
26106 }
26107
26108 #[test]
26109 fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
26110 let dir = setup_traversal_project();
26111 let session = dir.path().join("tasks/software/tsift.md");
26112 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
26113 let graph_db = dir.path().join(".tsift/graph.db");
26114 let _lock = hold_rollback_journal_lock(&graph_db);
26115
26116 let result = cmd_graph_db(
26117 &session,
26118 None,
26119 GraphDbBackend::Sqlite,
26120 None,
26121 GraphDbQuery::Evidence {
26122 target: "kgnv".to_string(),
26123 depth: 3,
26124 limit: 8,
26125 cursor: None,
26126 },
26127 OutputFormat {
26128 json_output: false,
26129 compact: true,
26130 pretty: false,
26131 terse: false,
26132 ultra_terse: false,
26133 schema: false,
26134 envelope: false,
26135 },
26136 );
26137
26138 assert!(result.is_ok());
26139 }
26140
26141 fn current_graph_db_freshness() -> GraphDbFreshnessReport {
26142 GraphDbFreshnessReport {
26143 status: "current".to_string(),
26144 fail_closed: false,
26145 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
26146 content_hash: Some("fixture".to_string()),
26147 source_watermark: None,
26148 diagnostics: Vec::new(),
26149 }
26150 }
26151
26152 #[test]
26153 fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
26154 let dir = setup_traversal_project();
26155 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26156 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
26157 let stale = GraphDbFreshnessReport {
26158 status: "stale".to_string(),
26159 fail_closed: true,
26160 projection_version: Some("old-v0".to_string()),
26161 content_hash: None,
26162 source_watermark: None,
26163 diagnostics: vec!["projection content hash is missing".to_string()],
26164 };
26165
26166 let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
26167 root: dir.path(),
26168 scope: None,
26169 backend: "sqlite",
26170 target: "kgnv",
26171 preferred_path: None,
26172 depth: 3,
26173 limit: 8,
26174 cursor: None,
26175 store: &store,
26176 freshness: stale,
26177 warnings: Vec::new(),
26178 }) {
26179 Ok(_) => panic!("stale graph freshness should fail closed"),
26180 Err(err) => err,
26181 };
26182 let message = err.to_string();
26183 assert!(message.contains("failed closed"), "{message}");
26184 assert!(message.contains("graph-db --path"), "{message}");
26185 assert!(message.contains("refresh --json"), "{message}");
26186 }
26187
26188 fn paged_graph_ids(
26189 store: &impl GraphStore,
26190 cursor: Option<&str>,
26191 ) -> (Vec<String>, GraphDbPageReport) {
26192 let report = graph_db_report_from_store(
26193 Path::new("."),
26194 None,
26195 "fixture",
26196 GraphDbQuery::Kind {
26197 kind: "backlog".to_string(),
26198 cursor: cursor.map(str::to_string),
26199 limit: Some(2),
26200 property_filters: vec!["phase=open".to_string()],
26201 },
26202 store,
26203 current_graph_db_freshness(),
26204 Vec::new(),
26205 )
26206 .unwrap();
26207 (
26208 report.nodes.iter().map(|node| node.id.clone()).collect(),
26209 report.page.unwrap(),
26210 )
26211 }
26212
26213 #[test]
26214 fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
26215 let nodes = (0..5)
26216 .map(|idx| {
26217 let phase = if idx == 1 { "closed" } else { "open" };
26218 SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
26219 .with_property("phase", phase)
26220 })
26221 .collect::<Vec<_>>();
26222 let projection = GraphProjection {
26223 nodes,
26224 edges: Vec::new(),
26225 };
26226 let sqlite = SqliteGraphStore::in_memory().unwrap();
26227 projection.upsert_into(&sqlite).unwrap();
26228 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
26229 projection.upsert_into(&convex).unwrap();
26230
26231 let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
26232 let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
26233 assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
26234 assert_eq!(sqlite_first_ids, convex_first_ids);
26235 assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
26236 assert!(sqlite_first_page.truncated);
26237 assert_eq!(
26238 sqlite_first_page.returned_nodes,
26239 convex_first_page.returned_nodes
26240 );
26241 assert_eq!(
26242 sqlite_first_page.property_filters,
26243 convex_first_page.property_filters
26244 );
26245 assert!(
26246 sqlite_first_page
26247 .diagnostics
26248 .iter()
26249 .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
26250 "expected SQLite kind query plan diagnostics, got {:?}",
26251 sqlite_first_page.diagnostics
26252 );
26253
26254 let cursor = sqlite_first_page.next_cursor.as_deref();
26255 let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
26256 let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
26257 assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
26258 assert_eq!(sqlite_next_ids, convex_next_ids);
26259 assert_eq!(sqlite_next_page.next_cursor, None);
26260 assert!(!sqlite_next_page.truncated);
26261 assert_eq!(
26262 sqlite_next_page.returned_nodes,
26263 convex_next_page.returned_nodes
26264 );
26265 assert_eq!(
26266 sqlite_next_page.property_filters,
26267 convex_next_page.property_filters
26268 );
26269 }
26270
26271 #[test]
26272 fn traversal_shortest_path_crosses_artifacts_and_symbols() {
26273 let dir = setup_traversal_project();
26274 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26275 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
26276 let main = resolve_traversal_node(&graph, "main").unwrap();
26277
26278 let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
26279 assert_eq!(path.first(), Some(&backlog.handle));
26280 assert_eq!(path.last(), Some(&main.handle));
26281 assert!(
26282 path.len() >= 3,
26283 "expected backlog -> symbol -> main, got {path:?}"
26284 );
26285 }
26286
26287 #[test]
26288 fn traversal_report_recommends_next_bugfix_nodes() {
26289 let dir = setup_traversal_project();
26290 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26291 let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
26292
26293 assert_eq!(report.mode, "neighborhood");
26294 assert!(
26295 report
26296 .recommendations
26297 .iter()
26298 .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
26299 "expected helper recommendation, got {:?}",
26300 report.recommendations
26301 );
26302 assert!(
26303 !report.exploration.source_windows.is_empty(),
26304 "expected exploration source windows"
26305 );
26306 assert!(
26307 report
26308 .exploration
26309 .no_reread_guidance
26310 .contains("avoid whole-file reads")
26311 );
26312 }
26313
26314 #[test]
26315 fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
26316 let dir = setup_traversal_project();
26317 std::thread::sleep(std::time::Duration::from_millis(50));
26318 std::fs::write(
26319 dir.path().join("main.rs"),
26320 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26321 )
26322 .unwrap();
26323
26324 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26325
26326 assert!(
26327 graph
26328 .warnings
26329 .iter()
26330 .any(|warning| warning.contains("index refreshed")
26331 && warning.contains("graph traversal packet")),
26332 "expected refresh diagnostic, got {:?}",
26333 graph.warnings
26334 );
26335 assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
26336
26337 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26338 let summary = db.compute_changes(dir.path()).unwrap();
26339 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26340 }
26341
26342 #[test]
26343 fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
26344 let dir = setup_traversal_project();
26345 let db_path = dir.path().join(".tsift/index.db");
26346 let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
26347 std::thread::sleep(std::time::Duration::from_millis(50));
26348 std::fs::write(
26349 dir.path().join("main.rs"),
26350 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26351 )
26352 .unwrap();
26353
26354 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26355 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
26356
26357 assert!(
26358 graph
26359 .warnings
26360 .iter()
26361 .any(|warning| warning.contains("falling back to raw source file nodes")),
26362 "expected raw-source fallback diagnostic, got {:?}",
26363 graph.warnings
26364 );
26365 assert!(
26366 file.detail
26367 .as_deref()
26368 .is_some_and(|detail| detail.contains("raw source fallback")),
26369 "expected raw-source detail, got {:?}",
26370 file.detail
26371 );
26372 assert!(
26373 file.expand.contains("source-read"),
26374 "expected source-read fallback command, got {}",
26375 file.expand
26376 );
26377 assert!(
26378 resolve_traversal_node(&graph, "helper").is_none(),
26379 "stale symbol evidence should be skipped when refresh is blocked"
26380 );
26381 }
26382
26383 #[test]
26384 fn traversal_cmd_supports_json_and_html_outputs() {
26385 let dir = setup_traversal_project();
26386 cmd_traverse(
26387 Some("#kgnv"),
26388 Some("main"),
26389 dir.path(),
26390 None,
26391 1,
26392 50,
26393 TraverseFormat::Json,
26394 false,
26395 false,
26396 false,
26397 None,
26398 )
26399 .unwrap();
26400 cmd_traverse(
26401 None,
26402 None,
26403 dir.path(),
26404 None,
26405 1,
26406 50,
26407 TraverseFormat::Html,
26408 false,
26409 false,
26410 false,
26411 None,
26412 )
26413 .unwrap();
26414 }
26415
26416 #[test]
26417 fn traversal_html_renders_inline_graph_visualization() {
26418 let dir = setup_traversal_project();
26419 seed_traversal_semantic_summaries(dir.path());
26420 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26421 let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
26422 let html = traversal_report_html(&report).unwrap();
26423
26424 assert!(html.contains("id=\"graph-canvas\""));
26425 assert!(html.contains("semantic_concept"));
26426 assert!(html.contains("graph navigation"));
26427 assert!(html.contains("JSON.parse"));
26428 }
26429
26430 #[test]
26431 fn compact_helpers_trim_scores_and_snippets() {
26432 assert_eq!(format_score(0.12345, true), "0.12");
26433 assert_eq!(format_score(0.12345, false), "0.1235");
26434 let snippet = compact_snippet(" first line with useful context\nsecond");
26435 assert_eq!(snippet.as_deref(), Some("first line with useful context"));
26436 }
26437
26438 #[test]
26439 fn compact_members_caps_list() {
26440 let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
26441 .iter()
26442 .map(|n| graph::CommunityMember::new(*n))
26443 .collect();
26444 assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
26445 }
26446
26447 #[test]
26448 fn abbreviate_kind_maps_common_kinds() {
26449 assert_eq!(abbreviate_kind("function"), "fn");
26450 assert_eq!(abbreviate_kind("method"), "meth");
26451 assert_eq!(abbreviate_kind("class"), "cls");
26452 assert_eq!(abbreviate_kind("interface"), "iface");
26453 assert_eq!(abbreviate_kind("type_alias"), "type");
26454 assert_eq!(abbreviate_kind("data_class"), "data_cls");
26455 assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
26456 assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
26457 assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
26458 assert_eq!(abbreviate_kind("object"), "obj");
26459 assert_eq!(abbreviate_kind("heading"), "h");
26460 assert_eq!(abbreviate_kind("code_block"), "code");
26461 assert_eq!(abbreviate_kind("struct"), "struct");
26463 assert_eq!(abbreviate_kind("trait"), "trait");
26464 assert_eq!(abbreviate_kind("enum"), "enum");
26465 assert_eq!(abbreviate_kind("const"), "const");
26466 assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
26467 }
26468
26469 #[test]
26470 fn abbreviate_match_type_maps_search_types() {
26471 assert_eq!(abbreviate_match_type("exact_name"), "exact");
26472 assert_eq!(abbreviate_match_type("partial_tags"), "partial");
26473 assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
26474 assert_eq!(abbreviate_match_type("other_type"), "other_type");
26475 }
26476
26477 #[test]
26478 fn explain_compact_groups_edges_by_file() {
26479 let edges = vec![
26480 index::StoredEdge {
26481 caller_file: "src/main.rs".to_string(),
26482 caller_name: "main".to_string(),
26483 caller_line: 1,
26484 callee_name: "helper".to_string(),
26485 call_site_line: 2,
26486 tagpath_handle: None,
26487 },
26488 index::StoredEdge {
26489 caller_file: "src/main.rs".to_string(),
26490 caller_name: "main".to_string(),
26491 caller_line: 1,
26492 callee_name: "render".to_string(),
26493 call_site_line: 3,
26494 tagpath_handle: None,
26495 },
26496 ];
26497 let lines = format_edge_groups(&edges, false);
26498 assert_eq!(lines, vec![" src/main.rs (2): helper, render"]);
26499 }
26500
26501 #[test]
26502 fn search_hit_groups_preserve_file_counts_and_samples() {
26503 let dir = tempfile::tempdir().unwrap();
26504 let root = dir.path();
26505 let main_rs = root.join("src/main.rs");
26506 fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
26507 fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
26508 let freshness = exact_search_file_timestamp(&main_rs);
26509 let hits = vec![
26510 sift::SearchHit {
26511 artifact_id: "a".to_string(),
26512 artifact_kind: sift::ContextArtifactKind::File,
26513 path: main_rs.display().to_string(),
26514 rank: 1,
26515 score: 10.0,
26516 confidence: sift::ScoreConfidence::High,
26517 location: Some("line 3".to_string()),
26518 snippet: "claudescore-3 anchor".to_string(),
26519 provenance: sift::ArtifactProvenance {
26520 adapter: sift::AcquisitionAdapterKind::FileSystem,
26521 source: "ripgrep -F".to_string(),
26522 synthetic: false,
26523 },
26524 freshness: freshness.clone(),
26525 budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
26526 },
26527 sift::SearchHit {
26528 artifact_id: "b".to_string(),
26529 artifact_kind: sift::ContextArtifactKind::File,
26530 path: main_rs.display().to_string(),
26531 rank: 2,
26532 score: 9.0,
26533 confidence: sift::ScoreConfidence::High,
26534 location: Some("line 7".to_string()),
26535 snippet: "claudescore-3 follow-up".to_string(),
26536 provenance: sift::ArtifactProvenance {
26537 adapter: sift::AcquisitionAdapterKind::FileSystem,
26538 source: "ripgrep -F".to_string(),
26539 synthetic: false,
26540 },
26541 freshness: freshness.clone(),
26542 budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
26543 },
26544 sift::SearchHit {
26545 artifact_id: "c".to_string(),
26546 artifact_kind: sift::ContextArtifactKind::File,
26547 path: main_rs.display().to_string(),
26548 rank: 3,
26549 score: 8.0,
26550 confidence: sift::ScoreConfidence::High,
26551 location: Some("line 9".to_string()),
26552 snippet: "claudescore-3 tail".to_string(),
26553 provenance: sift::ArtifactProvenance {
26554 adapter: sift::AcquisitionAdapterKind::FileSystem,
26555 source: "ripgrep -F".to_string(),
26556 synthetic: false,
26557 },
26558 freshness,
26559 budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
26560 },
26561 ];
26562
26563 let groups = group_search_hits(&hits, root, false);
26564 assert_eq!(groups.len(), 1);
26565 assert_eq!(groups[0].path, "src/main.rs");
26566 assert_eq!(groups[0].hits, 3);
26567 assert_eq!(
26568 groups[0].samples,
26569 vec![
26570 "line 3: claudescore-3 anchor".to_string(),
26571 "line 7: claudescore-3 follow-up".to_string()
26572 ]
26573 );
26574 assert!(should_collapse_search_hits(&hits, root, false));
26575 }
26576
26577 #[test]
26578 fn dense_edge_groups_trigger_collapse() {
26579 let edges = vec![
26580 index::StoredEdge {
26581 caller_file: "src/main.rs".to_string(),
26582 caller_name: "main".to_string(),
26583 caller_line: 1,
26584 callee_name: "helper".to_string(),
26585 call_site_line: 2,
26586 tagpath_handle: None,
26587 },
26588 index::StoredEdge {
26589 caller_file: "src/main.rs".to_string(),
26590 caller_name: "beta".to_string(),
26591 caller_line: 5,
26592 callee_name: "helper".to_string(),
26593 call_site_line: 6,
26594 tagpath_handle: None,
26595 },
26596 index::StoredEdge {
26597 caller_file: "src/main.rs".to_string(),
26598 caller_name: "gamma".to_string(),
26599 caller_line: 9,
26600 callee_name: "helper".to_string(),
26601 call_site_line: 10,
26602 tagpath_handle: None,
26603 },
26604 ];
26605 assert!(should_collapse_edge_groups(&edges));
26606 }
26607
26608 fn setup_workspace() -> tempfile::TempDir {
26611 let dir = tempfile::tempdir().unwrap();
26612 let root = dir.path();
26613 std::fs::write(
26614 root.join(".gitmodules"),
26615 r#"[submodule "src/alpha"]
26616 path = src/alpha
26617 url = https://example.com/alpha
26618[submodule "src/beta"]
26619 path = src/beta
26620 url = https://example.com/beta
26621"#,
26622 )
26623 .unwrap();
26624 let alpha = root.join("src/alpha");
26625 let beta = root.join("src/beta");
26626 std::fs::create_dir_all(&alpha).unwrap();
26627 std::fs::create_dir_all(&beta).unwrap();
26628 std::fs::write(
26629 alpha.join("lib.rs"),
26630 "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
26631 )
26632 .unwrap();
26633 std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
26634 dir
26635 }
26636
26637 fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
26638 let dir = tempfile::tempdir().unwrap();
26639 let root = dir.path();
26640 std::fs::write(
26641 root.join(".gitmodules"),
26642 r#"[submodule "pkg/app/foo"]
26643 path = pkg/app/foo
26644 url = https://example.com/pkg-app-foo
26645[submodule "vendor/foo"]
26646 path = vendor/foo
26647 url = https://example.com/vendor-foo
26648"#,
26649 )
26650 .unwrap();
26651 let pkg_foo = root.join("pkg/app/foo");
26652 let vendor_foo = root.join("vendor/foo");
26653 std::fs::create_dir_all(&pkg_foo).unwrap();
26654 std::fs::create_dir_all(&vendor_foo).unwrap();
26655 std::fs::write(
26656 pkg_foo.join("lib.rs"),
26657 "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
26658 )
26659 .unwrap();
26660 std::fs::write(
26661 vendor_foo.join("lib.rs"),
26662 "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
26663 )
26664 .unwrap();
26665 dir
26666 }
26667
26668 #[test]
26669 fn workspace_index_creates_per_submodule_dbs() {
26670 let dir = setup_workspace();
26671 cmd_index(
26672 dir.path(),
26673 false,
26674 false,
26675 false,
26676 false,
26677 false,
26678 true,
26679 None,
26680 false,
26681 false,
26682 false,
26683 false,
26684 false,
26685 false,
26686 )
26687 .unwrap();
26688 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26689 assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
26690 }
26691
26692 #[test]
26693 fn workspace_index_single_submodule() {
26694 let dir = setup_workspace();
26695 cmd_index(
26696 dir.path(),
26697 false,
26698 false,
26699 false,
26700 false,
26701 false,
26702 false,
26703 Some("alpha"),
26704 false,
26705 false,
26706 false,
26707 false,
26708 false,
26709 false,
26710 )
26711 .unwrap();
26712 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26713 assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
26714 }
26715
26716 #[test]
26717 fn workspace_index_single_submodule_errors_on_unknown_scope() {
26718 let dir = setup_workspace();
26719
26720 let err = cmd_index(
26721 dir.path(),
26722 false,
26723 false,
26724 false,
26725 false,
26726 false,
26727 false,
26728 Some("missing"),
26729 false,
26730 false,
26731 false,
26732 false,
26733 false,
26734 false,
26735 )
26736 .unwrap_err();
26737
26738 let msg = err.to_string();
26739 assert!(msg.contains("unknown scope `missing`"));
26740 assert!(msg.contains("Available scopes: alpha, beta"));
26741 assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
26742 }
26743
26744 #[test]
26745 fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
26746 let dir = setup_workspace_with_duplicate_leaf_names();
26747 cmd_index(
26748 dir.path(),
26749 false,
26750 false,
26751 false,
26752 false,
26753 false,
26754 true,
26755 None,
26756 false,
26757 false,
26758 false,
26759 false,
26760 false,
26761 false,
26762 )
26763 .unwrap();
26764
26765 assert!(
26766 dir.path()
26767 .join(".tsift/indexes/pkg/app/foo/index.db")
26768 .exists()
26769 );
26770 assert!(
26771 dir.path()
26772 .join(".tsift/indexes/vendor/foo/index.db")
26773 .exists()
26774 );
26775 }
26776
26777 #[test]
26778 fn federated_search_across_submodules() {
26779 let dir = setup_workspace();
26780 cmd_index(
26781 dir.path(),
26782 false,
26783 false,
26784 false,
26785 false,
26786 false,
26787 true,
26788 None,
26789 false,
26790 false,
26791 false,
26792 false,
26793 false,
26794 false,
26795 )
26796 .unwrap();
26797 let (hits, _diag) = federated_symbol_search(
26798 dir.path(),
26799 "alpha_helper",
26800 10,
26801 &TagpathSearchOpts {
26802 no_tagpath: true,
26803 strict: false,
26804 },
26805 )
26806 .unwrap();
26807 assert!(
26808 !hits.is_empty(),
26809 "should find alpha_helper via federated search"
26810 );
26811 }
26812
26813 #[test]
26814 fn federated_search_respects_isolation() {
26815 let dir = setup_workspace();
26816 let tsift_dir = dir.path().join(".tsift");
26817 std::fs::create_dir_all(&tsift_dir).unwrap();
26818 std::fs::write(
26819 tsift_dir.join("config.toml"),
26820 r#"
26821[overrides.alpha]
26822tier = "isolated"
26823"#,
26824 )
26825 .unwrap();
26826 cmd_index(
26827 dir.path(),
26828 false,
26829 false,
26830 false,
26831 false,
26832 false,
26833 true,
26834 None,
26835 false,
26836 false,
26837 false,
26838 false,
26839 false,
26840 false,
26841 )
26842 .unwrap();
26843 let (hits, _diag) = federated_symbol_search(
26844 dir.path(),
26845 "alpha_helper",
26846 10,
26847 &TagpathSearchOpts {
26848 no_tagpath: true,
26849 strict: false,
26850 },
26851 )
26852 .unwrap();
26853 assert!(
26854 hits.is_empty(),
26855 "isolated submodule should not appear in federated search"
26856 );
26857 }
26858
26859 #[test]
26860 fn federated_lexical_search_respects_isolation() {
26861 let dir = setup_workspace();
26862 let tsift_dir = dir.path().join(".tsift");
26863 std::fs::create_dir_all(&tsift_dir).unwrap();
26864 std::fs::write(
26865 tsift_dir.join("config.toml"),
26866 r#"
26867[overrides.alpha]
26868tier = "isolated"
26869"#,
26870 )
26871 .unwrap();
26872 cmd_index(
26873 dir.path(),
26874 false,
26875 false,
26876 false,
26877 false,
26878 false,
26879 true,
26880 None,
26881 false,
26882 false,
26883 false,
26884 false,
26885 false,
26886 false,
26887 )
26888 .unwrap();
26889
26890 let response = federated_sift_search(
26891 dir.path(),
26892 &dir.path().join(".tsift/search-cache"),
26893 "fn",
26894 10,
26895 0,
26896 "lexical",
26897 None,
26898 )
26899 .unwrap();
26900
26901 assert!(
26902 !response.hits.is_empty(),
26903 "shared scopes should still contribute lexical hits"
26904 );
26905 assert!(
26906 response
26907 .hits
26908 .iter()
26909 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26910 "isolated scope should not leak lexical hits: {:?}",
26911 response.hits
26912 );
26913 }
26914
26915 #[test]
26916 fn federated_lexical_search_respects_private_tier() {
26917 let dir = setup_workspace();
26918 let tsift_dir = dir.path().join(".tsift");
26919 std::fs::create_dir_all(&tsift_dir).unwrap();
26920 std::fs::write(
26921 tsift_dir.join("config.toml"),
26922 r#"
26923[overrides.alpha]
26924tier = "private"
26925"#,
26926 )
26927 .unwrap();
26928 cmd_index(
26929 dir.path(),
26930 false,
26931 false,
26932 false,
26933 false,
26934 false,
26935 true,
26936 None,
26937 false,
26938 false,
26939 false,
26940 false,
26941 false,
26942 false,
26943 )
26944 .unwrap();
26945
26946 let response = federated_sift_search(
26947 dir.path(),
26948 &dir.path().join(".tsift/search-cache"),
26949 "fn",
26950 10,
26951 0,
26952 "lexical",
26953 None,
26954 )
26955 .unwrap();
26956
26957 assert!(
26958 !response.hits.is_empty(),
26959 "shared scopes should still contribute lexical hits"
26960 );
26961 assert!(
26962 response
26963 .hits
26964 .iter()
26965 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26966 "private scope should not leak lexical hits: {:?}",
26967 response.hits
26968 );
26969 }
26970
26971 #[test]
26972 fn scoped_search_finds_submodule_symbols() {
26973 let dir = setup_workspace();
26974 cmd_index(
26975 dir.path(),
26976 false,
26977 false,
26978 false,
26979 false,
26980 false,
26981 true,
26982 None,
26983 false,
26984 false,
26985 false,
26986 false,
26987 false,
26988 false,
26989 )
26990 .unwrap();
26991 let cfg = config::Config::load(dir.path()).unwrap();
26992 let db_path = cfg.db_path_for(dir.path(), "alpha");
26993 let db = index::IndexDb::open(&db_path).unwrap();
26994 let hits = db.symbol_search("alpha_main", 10).unwrap();
26995 assert!(!hits.is_empty());
26996 assert_eq!(hits[0].name, "alpha_main");
26997 }
26998
26999 #[test]
27000 fn scoped_search_cmd_errors_on_unknown_scope() {
27001 let dir = setup_workspace();
27002
27003 let err = cmd_search(
27004 "alpha_main".to_string(),
27005 Some(dir.path().to_path_buf()),
27006 5,
27007 Some("lexical".to_string()),
27008 Some("missing".to_string()),
27009 false,
27010 false,
27011 false,
27012 0,
27013 false,
27014 false,
27015 false,
27016 false,
27017 false,
27018 false,
27019 false,
27020 )
27021 .unwrap_err();
27022
27023 let msg = err.to_string();
27024 assert!(msg.contains("unknown scope `missing`"));
27025 assert!(msg.contains("Available scopes: alpha, beta"));
27026 }
27027
27028 #[test]
27029 fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
27030 let dir = setup_workspace_with_duplicate_leaf_names();
27031 cmd_index(
27032 dir.path(),
27033 false,
27034 false,
27035 false,
27036 false,
27037 false,
27038 true,
27039 None,
27040 false,
27041 false,
27042 false,
27043 false,
27044 false,
27045 false,
27046 )
27047 .unwrap();
27048
27049 let err = cmd_search(
27050 "vendor_only".to_string(),
27051 Some(dir.path().to_path_buf()),
27052 5,
27053 Some("lexical".to_string()),
27054 Some("foo".to_string()),
27055 false,
27056 false,
27057 false,
27058 0,
27059 false,
27060 false,
27061 false,
27062 false,
27063 false,
27064 false,
27065 false,
27066 )
27067 .unwrap_err();
27068
27069 let msg = err.to_string();
27070 assert!(msg.contains("ambiguous scope `foo`"));
27071 assert!(msg.contains("pkg/app/foo"));
27072 assert!(msg.contains("vendor/foo"));
27073 }
27074
27075 #[test]
27076 fn scoped_graph_query() {
27077 let dir = setup_workspace();
27078 cmd_index(
27079 dir.path(),
27080 false,
27081 false,
27082 false,
27083 false,
27084 false,
27085 true,
27086 None,
27087 false,
27088 false,
27089 false,
27090 false,
27091 false,
27092 false,
27093 )
27094 .unwrap();
27095 let cfg = config::Config::load(dir.path()).unwrap();
27096 let db_path = cfg.db_path_for(dir.path(), "alpha");
27097 let db = index::IndexDb::open(&db_path).unwrap();
27098 let callees = db.callees_of("alpha_main").unwrap();
27099 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
27100 assert!(names.contains(&"alpha_helper"));
27101 }
27102
27103 fn assert_workspace_query_requires_scope(err: anyhow::Error) {
27104 let msg = err.to_string();
27105 assert!(msg.contains("require `--scope <scope>`"), "{msg}");
27106 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27107 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27108 assert!(
27109 !msg.contains("no index found at"),
27110 "workspace query should fail with scope guidance, got: {msg}"
27111 );
27112 }
27113
27114 fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
27115 let msg = err.to_string();
27116 assert!(
27117 msg.contains("requires `--scope <scope>` or `--federated`"),
27118 "{msg}"
27119 );
27120 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27121 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27122 assert!(
27123 !msg.contains("autoindexing index"),
27124 "workspace search should fail before creating a shared root index: {msg}"
27125 );
27126 }
27127
27128 #[test]
27129 fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
27130 let dir = setup_workspace();
27131 cmd_index(
27132 dir.path(),
27133 false,
27134 false,
27135 false,
27136 false,
27137 false,
27138 true,
27139 None,
27140 false,
27141 false,
27142 false,
27143 false,
27144 false,
27145 false,
27146 )
27147 .unwrap();
27148
27149 let err = cmd_graph(
27150 "alpha_main",
27151 dir.path(),
27152 false,
27153 false,
27154 None,
27155 20,
27156 false,
27157 false,
27158 false,
27159 false,
27160 false,
27161 false,
27162 false,
27163 TagpathSearchOpts::default(),
27164 )
27165 .unwrap_err();
27166
27167 assert_workspace_query_requires_scope(err);
27168 }
27169
27170 #[test]
27171 fn graph_cmd_infers_scope_from_nested_workspace_path() {
27172 let dir = setup_workspace();
27173 cmd_index(
27174 dir.path(),
27175 false,
27176 false,
27177 false,
27178 false,
27179 false,
27180 true,
27181 None,
27182 false,
27183 false,
27184 false,
27185 false,
27186 false,
27187 false,
27188 )
27189 .unwrap();
27190 let nested = dir.path().join("src/alpha/nested");
27191 std::fs::create_dir_all(&nested).unwrap();
27192
27193 let result = cmd_graph(
27194 "alpha_main",
27195 &nested,
27196 false,
27197 false,
27198 None,
27199 20,
27200 false,
27201 false,
27202 false,
27203 false,
27204 false,
27205 false,
27206 false,
27207 TagpathSearchOpts::default(),
27208 );
27209
27210 assert!(result.is_ok());
27211 }
27212
27213 #[test]
27214 fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
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 err = cmd_communities(
27235 dir.path(),
27236 None,
27237 1,
27238 10,
27239 false,
27240 false,
27241 false,
27242 false,
27243 false,
27244 false,
27245 TagpathSearchOpts::default(),
27246 )
27247 .unwrap_err();
27248
27249 assert_workspace_query_requires_scope(err);
27250 }
27251
27252 #[test]
27253 fn communities_cmd_infers_scope_from_nested_workspace_path() {
27254 let dir = setup_workspace();
27255 cmd_index(
27256 dir.path(),
27257 false,
27258 false,
27259 false,
27260 false,
27261 false,
27262 true,
27263 None,
27264 false,
27265 false,
27266 false,
27267 false,
27268 false,
27269 false,
27270 )
27271 .unwrap();
27272 let nested = dir.path().join("src/alpha/nested");
27273 std::fs::create_dir_all(&nested).unwrap();
27274
27275 let result = cmd_communities(
27276 &nested,
27277 None,
27278 1,
27279 10,
27280 false,
27281 false,
27282 false,
27283 false,
27284 false,
27285 false,
27286 TagpathSearchOpts::default(),
27287 );
27288
27289 assert!(result.is_ok());
27290 }
27291
27292 #[test]
27293 fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
27294 let dir = setup_workspace();
27295 cmd_index(
27296 dir.path(),
27297 false,
27298 false,
27299 false,
27300 false,
27301 false,
27302 true,
27303 None,
27304 false,
27305 false,
27306 false,
27307 false,
27308 false,
27309 false,
27310 )
27311 .unwrap();
27312
27313 let err = cmd_path(
27314 "alpha_main",
27315 "alpha_helper",
27316 dir.path(),
27317 None,
27318 false,
27319 false,
27320 false,
27321 false,
27322 false,
27323 TagpathSearchOpts::default(),
27324 )
27325 .unwrap_err();
27326
27327 assert_workspace_query_requires_scope(err);
27328 }
27329
27330 #[test]
27331 fn path_cmd_infers_scope_from_nested_workspace_path() {
27332 let dir = setup_workspace();
27333 cmd_index(
27334 dir.path(),
27335 false,
27336 false,
27337 false,
27338 false,
27339 false,
27340 true,
27341 None,
27342 false,
27343 false,
27344 false,
27345 false,
27346 false,
27347 false,
27348 )
27349 .unwrap();
27350 let nested = dir.path().join("src/alpha/nested");
27351 std::fs::create_dir_all(&nested).unwrap();
27352
27353 let result = cmd_path(
27354 "alpha_main",
27355 "alpha_helper",
27356 &nested,
27357 None,
27358 false,
27359 false,
27360 false,
27361 false,
27362 false,
27363 TagpathSearchOpts::default(),
27364 );
27365
27366 assert!(result.is_ok());
27367 }
27368
27369 #[test]
27370 fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27371 let dir = setup_graph_index();
27372 let db_path = dir.path().join(".tsift/index.db");
27373 let _lock = hold_rollback_journal_lock(&db_path);
27374
27375 let result = cmd_path(
27376 "main",
27377 "helper",
27378 dir.path(),
27379 None,
27380 false,
27381 false,
27382 false,
27383 false,
27384 false,
27385 TagpathSearchOpts::default(),
27386 );
27387
27388 assert!(result.is_ok());
27389 }
27390
27391 #[test]
27392 fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
27393 let dir = setup_workspace();
27394 cmd_index(
27395 dir.path(),
27396 false,
27397 false,
27398 false,
27399 false,
27400 false,
27401 true,
27402 None,
27403 false,
27404 false,
27405 false,
27406 false,
27407 false,
27408 false,
27409 )
27410 .unwrap();
27411
27412 let err = cmd_explain(
27413 "alpha_main",
27414 dir.path(),
27415 None,
27416 15,
27417 false,
27418 false,
27419 false,
27420 false,
27421 false,
27422 false,
27423 false,
27424 false,
27425 )
27426 .unwrap_err();
27427
27428 assert_workspace_query_requires_scope(err);
27429 }
27430
27431 #[test]
27432 fn explain_cmd_infers_scope_from_nested_workspace_path() {
27433 let dir = setup_workspace();
27434 cmd_index(
27435 dir.path(),
27436 false,
27437 false,
27438 false,
27439 false,
27440 false,
27441 true,
27442 None,
27443 false,
27444 false,
27445 false,
27446 false,
27447 false,
27448 false,
27449 )
27450 .unwrap();
27451 let nested = dir.path().join("src/alpha/nested");
27452 std::fs::create_dir_all(&nested).unwrap();
27453
27454 let result = cmd_explain(
27455 "alpha_main",
27456 &nested,
27457 None,
27458 15,
27459 false,
27460 false,
27461 false,
27462 false,
27463 false,
27464 false,
27465 false,
27466 false,
27467 );
27468
27469 assert!(result.is_ok());
27470 }
27471
27472 #[test]
27473 fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27474 let dir = setup_graph_index();
27475 let db_path = dir.path().join(".tsift/index.db");
27476 let _lock = hold_rollback_journal_lock(&db_path);
27477
27478 let result = cmd_explain(
27479 "main",
27480 dir.path(),
27481 None,
27482 15,
27483 false,
27484 false,
27485 false,
27486 false,
27487 false,
27488 false,
27489 false,
27490 false,
27491 );
27492
27493 assert!(result.is_ok());
27494 }
27495
27496 #[test]
27499 fn community_detection_groups_related() {
27500 let dir = setup_graph_index();
27501 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27502 let edges = db.all_edges().unwrap();
27503 let result = graph::detect_communities(&edges);
27504 assert!(result.node_count > 0);
27505 assert!(!result.communities.is_empty());
27506 }
27507
27508 #[test]
27509 fn community_cmd_autoindexes_missing_index_by_default() {
27510 let dir = tempfile::tempdir().unwrap();
27511 let result = cmd_communities(
27512 dir.path(),
27513 None,
27514 2,
27515 10,
27516 false,
27517 false,
27518 false,
27519 false,
27520 false,
27521 false,
27522 TagpathSearchOpts::default(),
27523 );
27524
27525 assert!(result.is_ok());
27526 assert!(dir.path().join(".tsift/index.db").exists());
27527 }
27528
27529 #[test]
27532 fn path_finds_connected_symbols() {
27533 let dir = setup_graph_index();
27534 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27535 let edges = db.all_edges().unwrap();
27536 let result = graph::shortest_path(&edges, "main", "helper");
27537 assert!(result.is_some());
27538 let path = result.unwrap();
27539 assert_eq!(path.hops, 1);
27540 }
27541
27542 #[test]
27543 fn path_returns_none_for_unknown() {
27544 let dir = setup_graph_index();
27545 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27546 let edges = db.all_edges().unwrap();
27547 assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
27548 }
27549
27550 #[test]
27551 fn path_cmd_autoindexes_missing_index_by_default() {
27552 let dir = tempfile::tempdir().unwrap();
27553 let result = cmd_path(
27554 "a",
27555 "b",
27556 dir.path(),
27557 None,
27558 false,
27559 false,
27560 false,
27561 false,
27562 false,
27563 TagpathSearchOpts::default(),
27564 );
27565
27566 assert!(result.is_ok());
27567 assert!(dir.path().join(".tsift/index.db").exists());
27568 }
27569
27570 #[test]
27573 fn explain_shows_symbol_info() {
27574 let dir = setup_graph_index();
27575 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27576 let symbols = db.symbol_info("main").unwrap();
27577 assert!(!symbols.is_empty());
27578 assert_eq!(symbols[0].name, "main");
27579 assert_eq!(symbols[0].kind, "function");
27580 }
27581
27582 #[test]
27583 fn explain_cmd_autoindexes_missing_index_by_default() {
27584 let dir = tempfile::tempdir().unwrap();
27585 let result = cmd_explain(
27586 "main",
27587 dir.path(),
27588 None,
27589 15,
27590 false,
27591 false,
27592 false,
27593 false,
27594 false,
27595 false,
27596 false,
27597 false,
27598 );
27599
27600 assert!(result.is_ok());
27601 assert!(dir.path().join(".tsift/index.db").exists());
27602 }
27603
27604 fn hold_write_lock(db_path: &std::path::Path) -> Connection {
27605 let conn = Connection::open(db_path).unwrap();
27606 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
27607 conn
27608 }
27609
27610 fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
27611 use fs4::fs_std::FileExt;
27612 use std::io::Write;
27613
27614 let mut file = std::fs::OpenOptions::new()
27615 .read(true)
27616 .write(true)
27617 .create(true)
27618 .truncate(false)
27619 .open(lock_path)
27620 .unwrap();
27621 assert!(file.try_lock_exclusive().unwrap());
27622 writeln!(file, "{}", std::process::id()).unwrap();
27623 file
27624 }
27625
27626 fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
27627 let conn = Connection::open(db_path).unwrap();
27628 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
27629 .unwrap();
27630 std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
27631 conn
27632 }
27633
27634 fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
27635 let conn = Connection::open(db_path).unwrap();
27636 conn.execute_batch(
27637 "PRAGMA journal_mode=WAL;
27638 PRAGMA wal_autocheckpoint=0;
27639 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
27640 INSERT INTO wal_lock_probe DEFAULT VALUES;
27641 PRAGMA locking_mode=EXCLUSIVE;
27642 BEGIN EXCLUSIVE;",
27643 )
27644 .unwrap();
27645 assert!(substrate::wal_sidecar_path(db_path).exists());
27646 conn
27647 }
27648
27649 #[test]
27650 fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
27651 let dir = setup_graph_index();
27652 let db_path = dir.path().join(".tsift/index.db");
27653 let _lock = hold_wal_database_lock(&db_path);
27654
27655 let err = cmd_index(
27656 dir.path(),
27657 false,
27658 false,
27659 false,
27660 false,
27661 false,
27662 false,
27663 None,
27664 false,
27665 false,
27666 false,
27667 false,
27668 false,
27669 false,
27670 )
27671 .unwrap_err();
27672
27673 let msg = err.to_string();
27674 assert!(msg.contains("indexing"));
27675 assert!(msg.contains("lock diagnostics:"));
27676 assert!(msg.contains("lock: absent"));
27677 assert!(msg.contains("wal: present") || msg.contains("shm: present"));
27678 assert!(msg.contains("wedged writer holding live WAL sidecars"));
27679 assert!(msg.contains("snapshot fallback"));
27680 }
27681
27682 #[test]
27683 fn search_cmd_succeeds_while_writer_lock_is_held() {
27684 let dir = setup_graph_index();
27685 let db_path = dir.path().join(".tsift/index.db");
27686 let _lock = hold_write_lock(&db_path);
27687
27688 let result = cmd_search(
27689 "main".to_string(),
27690 Some(dir.path().to_path_buf()),
27691 5,
27692 Some("lexical".to_string()),
27693 None,
27694 false,
27695 false,
27696 false,
27697 0,
27698 true,
27699 false,
27700 false,
27701 false,
27702 false,
27703 false,
27704 false,
27705 );
27706
27707 assert!(result.is_ok());
27708 }
27709
27710 #[test]
27711 fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
27712 let dir = setup_graph_index();
27713 let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
27714
27715 let result = cmd_search(
27716 "main".to_string(),
27717 Some(dir.path().to_path_buf()),
27718 5,
27719 Some("lexical".to_string()),
27720 None,
27721 false,
27722 false,
27723 false,
27724 0,
27725 true,
27726 false,
27727 false,
27728 false,
27729 false,
27730 false,
27731 false,
27732 );
27733
27734 assert!(result.is_ok());
27735 }
27736
27737 #[test]
27738 fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
27739 let dir = setup_graph_index();
27740 let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
27741
27742 let result = cmd_search(
27743 "main".to_string(),
27744 Some(dir.path().to_path_buf()),
27745 5,
27746 Some("lexical".to_string()),
27747 None,
27748 false,
27749 false,
27750 false,
27751 0,
27752 true,
27753 false,
27754 false,
27755 false,
27756 false,
27757 false,
27758 false,
27759 );
27760
27761 assert!(result.is_ok());
27762 }
27763
27764 #[test]
27765 fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
27766 let dir = setup_graph_index();
27767 std::thread::sleep(std::time::Duration::from_millis(50));
27768 std::fs::write(
27769 dir.path().join("main.rs"),
27770 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27771 )
27772 .unwrap();
27773
27774 let err = cmd_search(
27775 "helper".to_string(),
27776 Some(dir.path().to_path_buf()),
27777 5,
27778 Some("lexical".to_string()),
27779 None,
27780 false,
27781 false,
27782 false,
27783 0,
27784 false,
27785 false,
27786 false,
27787 false,
27788 false,
27789 false,
27790 false,
27791 )
27792 .unwrap_err();
27793
27794 assert!(err.to_string().contains("search aborted"));
27795 assert!(err.to_string().contains("index is stale"));
27796 assert!(err.to_string().contains("--no-autoindex"));
27797 }
27798
27799 #[test]
27800 fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
27801 let dir = setup_graph_index();
27802 std::thread::sleep(std::time::Duration::from_millis(50));
27803 std::fs::write(
27804 dir.path().join("main.rs"),
27805 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27806 )
27807 .unwrap();
27808 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27809
27810 let err = cmd_search(
27811 "helper".to_string(),
27812 Some(dir.path().to_path_buf()),
27813 5,
27814 Some("lexical".to_string()),
27815 None,
27816 false,
27817 false,
27818 false,
27819 0,
27820 false,
27821 false,
27822 false,
27823 false,
27824 false,
27825 false,
27826 false,
27827 )
27828 .unwrap_err();
27829
27830 assert!(err.to_string().contains("search aborted"));
27831 assert!(err.to_string().contains("index is stale"));
27832 assert!(!err.to_string().contains("database is locked"));
27833 }
27834
27835 #[test]
27836 fn search_cmd_autoindexes_stale_index_by_default() {
27837 let dir = setup_graph_index();
27838 std::thread::sleep(std::time::Duration::from_millis(50));
27839 std::fs::write(
27840 dir.path().join("main.rs"),
27841 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27842 )
27843 .unwrap();
27844
27845 let result = cmd_search(
27846 "helper".to_string(),
27847 Some(dir.path().to_path_buf()),
27848 5,
27849 Some("lexical".to_string()),
27850 None,
27851 false,
27852 false,
27853 true,
27854 0,
27855 false,
27856 false,
27857 false,
27858 false,
27859 false,
27860 false,
27861 false,
27862 );
27863
27864 assert!(result.is_ok());
27865
27866 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27867 let summary = db.compute_changes(dir.path()).unwrap();
27868 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27869 }
27870
27871 #[test]
27872 fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
27873 let dir = setup_graph_index();
27874 std::thread::sleep(std::time::Duration::from_millis(50));
27875 std::fs::write(
27876 dir.path().join("main.rs"),
27877 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27878 )
27879 .unwrap();
27880 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27881
27882 let result = cmd_search(
27883 "helper".to_string(),
27884 Some(dir.path().to_path_buf()),
27885 5,
27886 Some("lexical".to_string()),
27887 None,
27888 false,
27889 false,
27890 true,
27891 0,
27892 false,
27893 false,
27894 false,
27895 false,
27896 false,
27897 false,
27898 false,
27899 );
27900
27901 assert!(result.is_ok());
27902
27903 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27904 let summary = db.compute_changes(dir.path()).unwrap();
27905 assert_eq!(summary.modified, 1);
27906 }
27907
27908 #[test]
27909 fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
27910 let dir = setup_graph_index();
27911 std::thread::sleep(std::time::Duration::from_millis(50));
27912 std::fs::write(
27913 dir.path().join("main.rs"),
27914 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27915 )
27916 .unwrap();
27917 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27918
27919 let err = cmd_search(
27920 "helper".to_string(),
27921 Some(dir.path().to_path_buf()),
27922 5,
27923 Some("lexical".to_string()),
27924 None,
27925 false,
27926 false,
27927 true,
27928 0,
27929 false,
27930 false,
27931 false,
27932 false,
27933 false,
27934 false,
27935 false,
27936 )
27937 .unwrap_err();
27938
27939 let msg = err.to_string();
27940 assert!(msg.contains("autoindexing index"));
27941 assert!(msg.contains("lock diagnostics:"));
27942 assert!(msg.contains("journal: present"));
27943 assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
27944 }
27945
27946 #[test]
27947 fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
27948 let dir = setup_graph_index();
27949 let nested = dir.path().join("src/nested");
27950 std::fs::create_dir_all(&nested).unwrap();
27951
27952 let result = cmd_search(
27953 "helper".to_string(),
27954 Some(nested.clone()),
27955 5,
27956 Some("lexical".to_string()),
27957 None,
27958 false,
27959 false,
27960 true,
27961 0,
27962 false,
27963 false,
27964 false,
27965 false,
27966 false,
27967 false,
27968 false,
27969 );
27970
27971 assert!(result.is_ok());
27972 assert!(!nested.join(".tsift/index.db").exists());
27973 }
27974
27975 #[test]
27976 fn exact_search_returns_literal_matches() {
27977 let dir = tempfile::tempdir().unwrap();
27978 std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
27979
27980 let response = run_exact_search_with_timeout(
27981 std::slice::from_ref(&dir.path().to_path_buf()),
27982 "claudescore-3",
27983 5,
27984 0,
27985 )
27986 .unwrap();
27987
27988 assert_eq!(response.strategy, "exact");
27989 assert_eq!(response.hits.len(), 1);
27990 assert!(response.hits[0].path.ends_with("notes.txt"));
27991 assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
27992 assert!(response.hits[0].snippet.contains("claudescore-3"));
27993 }
27994
27995 #[test]
27996 fn exact_search_skips_stale_index_precheck() {
27997 let dir = setup_graph_index();
27998 std::thread::sleep(std::time::Duration::from_millis(50));
27999 std::fs::write(
28000 dir.path().join("main.rs"),
28001 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
28002 )
28003 .unwrap();
28004
28005 let result = cmd_search(
28006 "println!(\"updated\")".to_string(),
28007 Some(dir.path().to_path_buf()),
28008 5,
28009 Some("exact".to_string()),
28010 None,
28011 false,
28012 false,
28013 false,
28014 0,
28015 false,
28016 false,
28017 false,
28018 false,
28019 false,
28020 false,
28021 false,
28022 );
28023
28024 assert!(result.is_ok());
28025 }
28026
28027 #[test]
28028 fn workspace_exact_search_does_not_require_shared_root_index() {
28029 let dir = setup_workspace();
28030 cmd_index(
28031 dir.path(),
28032 false,
28033 false,
28034 false,
28035 false,
28036 false,
28037 true,
28038 None,
28039 false,
28040 false,
28041 false,
28042 false,
28043 false,
28044 false,
28045 )
28046 .unwrap();
28047
28048 let result = cmd_search(
28049 "alpha_helper".to_string(),
28050 Some(dir.path().to_path_buf()),
28051 5,
28052 Some("exact".to_string()),
28053 None,
28054 false,
28055 false,
28056 false,
28057 0,
28058 false,
28059 false,
28060 false,
28061 false,
28062 false,
28063 false,
28064 false,
28065 );
28066
28067 assert!(result.is_ok());
28068 assert!(!dir.path().join(".tsift/index.db").exists());
28069 }
28070
28071 #[test]
28072 fn identifier_like_query_prefers_exact_search() {
28073 assert!(query_prefers_exact_search("claudescore-3"));
28074 assert!(query_prefers_exact_search("alpha_helper"));
28075 assert!(query_prefers_exact_search("src/main.rs"));
28076 assert!(query_prefers_exact_search("crate::module"));
28077 assert!(!query_prefers_exact_search("authenticate"));
28078 assert!(!query_prefers_exact_search("fn main"));
28079 assert!(!query_prefers_exact_search("."));
28080 }
28081
28082 #[test]
28083 fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
28084 assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
28085 assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
28086 assert_eq!(
28087 resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
28088 "hybrid"
28089 );
28090 }
28091
28092 #[test]
28093 fn workspace_identifier_like_search_auto_uses_exact_backend() {
28094 let dir = setup_workspace();
28095 cmd_index(
28096 dir.path(),
28097 false,
28098 false,
28099 false,
28100 false,
28101 false,
28102 true,
28103 None,
28104 false,
28105 false,
28106 false,
28107 false,
28108 false,
28109 false,
28110 )
28111 .unwrap();
28112
28113 let result = cmd_search(
28114 "alpha_helper".to_string(),
28115 Some(dir.path().to_path_buf()),
28116 5,
28117 None,
28118 None,
28119 false,
28120 false,
28121 false,
28122 0,
28123 false,
28124 false,
28125 false,
28126 false,
28127 false,
28128 false,
28129 false,
28130 );
28131
28132 assert!(result.is_ok());
28133 assert!(!dir.path().join(".tsift/index.db").exists());
28134 }
28135
28136 #[test]
28137 fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
28138 let dir = setup_graph_index();
28139 let nested = dir.path().join("src/nested");
28140 std::fs::create_dir_all(&nested).unwrap();
28141 std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
28142
28143 let result = cmd_index(
28144 &nested, false, false, false, false, false, false, None, false, false, false, false,
28145 false, false,
28146 );
28147
28148 assert!(result.is_ok());
28149 assert!(dir.path().join(".tsift/index.db").exists());
28150 assert!(!nested.join(".tsift/index.db").exists());
28151 }
28152
28153 #[test]
28154 fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
28155 let dir = setup_workspace();
28156 let nested = dir.path().join("docs/nested");
28157 std::fs::create_dir_all(&nested).unwrap();
28158
28159 let result = cmd_index(
28160 &nested, false, false, false, false, false, true, None, false, false, false, false,
28161 false, false,
28162 );
28163
28164 let cfg = config::Config::load(dir.path()).unwrap();
28165
28166 assert!(result.is_ok());
28167 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28168 assert!(cfg.db_path_for(dir.path(), "beta").exists());
28169 }
28170
28171 #[test]
28172 fn status_cmd_autoindexes_missing_workspace_scopes() {
28173 let dir = setup_workspace();
28174 let cfg = config::Config::load(dir.path()).unwrap();
28175 let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
28176 let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
28177 let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
28178 alpha_db.apply_changes(&alpha.source_root).unwrap();
28179
28180 let beta_db_path = cfg.db_path_for(dir.path(), "beta");
28181 assert!(!beta_db_path.exists());
28182
28183 cmd_status(
28184 dir.path(),
28185 StatusCommandOptions {
28186 fix: false,
28187 no_fix: false,
28188 json_output: true,
28189 compact: false,
28190 pretty: false,
28191 terse: false,
28192 schema: false,
28193 },
28194 )
28195 .unwrap();
28196
28197 assert!(beta_db_path.exists());
28198 let report = status::check_status(dir.path()).unwrap();
28199 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28200 }
28201
28202 #[test]
28203 fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
28204 let dir = setup_workspace();
28205 let cfg = config::Config::load(dir.path()).unwrap();
28206
28207 cmd_status(
28208 dir.path(),
28209 StatusCommandOptions {
28210 fix: false,
28211 no_fix: false,
28212 json_output: true,
28213 compact: false,
28214 pretty: false,
28215 terse: false,
28216 schema: false,
28217 },
28218 )
28219 .unwrap();
28220
28221 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28222 assert!(cfg.db_path_for(dir.path(), "beta").exists());
28223 let report = status::check_status(dir.path()).unwrap();
28224 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28225 }
28226
28227 #[test]
28228 fn status_fix_targets_only_stale_workspace_scopes() {
28229 let dir = setup_workspace();
28230 let cfg = config::Config::load(dir.path()).unwrap();
28231 for scope_id in ["alpha", "beta"] {
28232 let scope = config::Config::resolve_submodule(dir.path(), scope_id).unwrap();
28233 let db = index::IndexDb::open(&cfg.db_path_for(dir.path(), scope_id)).unwrap();
28234 db.apply_changes(&scope.source_root).unwrap();
28235 }
28236
28237 std::thread::sleep(std::time::Duration::from_millis(50));
28238 std::fs::write(
28239 dir.path().join("src/alpha/lib.rs"),
28240 "fn alpha_helper() { println!(\"updated\"); }\n",
28241 )
28242 .unwrap();
28243
28244 let report = status::check_status(dir.path()).unwrap();
28245 let scope_ids = status_workspace_scope_ids_needing_fix(&report);
28246 assert_eq!(scope_ids, std::collections::HashSet::from(["alpha"]));
28247
28248 cmd_status(
28249 dir.path(),
28250 StatusCommandOptions {
28251 fix: false,
28252 no_fix: false,
28253 json_output: true,
28254 compact: false,
28255 pretty: false,
28256 terse: false,
28257 schema: false,
28258 },
28259 )
28260 .unwrap();
28261
28262 let report = status::check_status(dir.path()).unwrap();
28263 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28264 }
28265
28266 #[test]
28267 fn status_cmd_fix_refreshes_stale_index() {
28268 let dir = setup_graph_index();
28269 std::thread::sleep(std::time::Duration::from_millis(50));
28270 std::fs::write(
28271 dir.path().join("main.rs"),
28272 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28273 )
28274 .unwrap();
28275
28276 let report = status::check_status(dir.path()).unwrap();
28277 assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
28278
28279 cmd_status(
28280 dir.path(),
28281 StatusCommandOptions {
28282 fix: false,
28283 no_fix: false,
28284 json_output: true,
28285 compact: false,
28286 pretty: false,
28287 terse: false,
28288 schema: false,
28289 },
28290 )
28291 .unwrap();
28292
28293 let report = status::check_status(dir.path()).unwrap();
28294 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28295 }
28296
28297 #[test]
28298 fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
28299 let dir = setup_graph_index();
28300 let db_path = dir.path().join(".tsift/index.db");
28301 let _lock = hold_wal_database_lock(&db_path);
28302
28303 cmd_status(
28304 dir.path(),
28305 StatusCommandOptions {
28306 fix: false,
28307 no_fix: false,
28308 json_output: true,
28309 compact: false,
28310 pretty: false,
28311 terse: false,
28312 schema: false,
28313 },
28314 )
28315 .unwrap();
28316
28317 let report = status::check_status(dir.path()).unwrap();
28318 assert!(matches!(
28319 report.index,
28320 status::IndexStatus::Fresh {
28321 recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
28322 ..
28323 }
28324 ));
28325 let locks = status::check_locks(dir.path(), None, None).unwrap();
28326 assert!(matches!(
28327 locks.writer_lock,
28328 status::WriterLockStatus::Absent { .. }
28329 ));
28330 assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
28331 assert!(
28332 locks
28333 .recommended_action
28334 .contains("wedged writer holding live WAL sidecars")
28335 );
28336 }
28337
28338 #[test]
28339 fn locks_report_uses_ancestor_project_root_for_nested_paths() {
28340 let dir = setup_graph_index();
28341 let nested = dir.path().join("src/nested");
28342 std::fs::create_dir_all(&nested).unwrap();
28343
28344 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28345 let report = status::check_locks(&root, Some(&nested), None).unwrap();
28346
28347 assert_eq!(report.source_root, dir.path());
28348 assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
28349 }
28350
28351 #[test]
28352 fn workspace_locks_report_infers_scope_from_nested_path() {
28353 let dir = setup_workspace();
28354 cmd_index(
28355 dir.path(),
28356 false,
28357 false,
28358 false,
28359 false,
28360 false,
28361 true,
28362 None,
28363 false,
28364 false,
28365 false,
28366 false,
28367 false,
28368 false,
28369 )
28370 .unwrap();
28371 let nested = dir.path().join("src/alpha/nested");
28372 std::fs::create_dir_all(&nested).unwrap();
28373
28374 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28375 let report = status::check_locks(&root, Some(&nested), None).unwrap();
28376 let cfg = config::Config::load(dir.path()).unwrap();
28377
28378 assert_eq!(report.label, "submodule `alpha` index");
28379 assert_eq!(report.source_root, dir.path().join("src/alpha"));
28380 assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
28381 assert_eq!(
28382 report.reindex_command,
28383 format!("tsift index --submodule alpha {}", dir.path().display())
28384 );
28385 }
28386
28387 #[test]
28388 fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
28389 let dir = setup_workspace();
28390 cmd_index(
28391 dir.path(),
28392 false,
28393 false,
28394 false,
28395 false,
28396 false,
28397 true,
28398 None,
28399 false,
28400 false,
28401 false,
28402 false,
28403 false,
28404 false,
28405 )
28406 .unwrap();
28407
28408 let alpha = dir.path().join("src/alpha/lib.rs");
28409 std::thread::sleep(std::time::Duration::from_millis(50));
28410 std::fs::write(
28411 &alpha,
28412 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28413 )
28414 .unwrap();
28415
28416 let result = cmd_search(
28417 "alpha_helper".to_string(),
28418 Some(dir.path().to_path_buf()),
28419 5,
28420 Some("lexical".to_string()),
28421 Some("alpha".to_string()),
28422 false,
28423 false,
28424 true,
28425 0,
28426 false,
28427 false,
28428 false,
28429 false,
28430 false,
28431 false,
28432 false,
28433 );
28434
28435 assert!(result.is_ok());
28436
28437 let cfg = config::Config::load(dir.path()).unwrap();
28438 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28439 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28440 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28441 }
28442
28443 #[test]
28444 fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28445 let dir = setup_workspace();
28446 cmd_index(
28447 dir.path(),
28448 false,
28449 false,
28450 false,
28451 false,
28452 false,
28453 true,
28454 None,
28455 false,
28456 false,
28457 false,
28458 false,
28459 false,
28460 false,
28461 )
28462 .unwrap();
28463
28464 let alpha = dir.path().join("src/alpha/lib.rs");
28465 std::thread::sleep(std::time::Duration::from_millis(50));
28466 std::fs::write(
28467 &alpha,
28468 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28469 )
28470 .unwrap();
28471
28472 let cfg = config::Config::load(dir.path()).unwrap();
28473 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28474
28475 let err = cmd_search(
28476 "alpha_helper".to_string(),
28477 Some(dir.path().to_path_buf()),
28478 5,
28479 Some("lexical".to_string()),
28480 Some("alpha".to_string()),
28481 false,
28482 false,
28483 false,
28484 0,
28485 false,
28486 false,
28487 false,
28488 false,
28489 false,
28490 false,
28491 false,
28492 )
28493 .unwrap_err();
28494
28495 assert!(err.to_string().contains("search aborted"));
28496 assert!(err.to_string().contains("submodule `alpha` index"));
28497 assert!(!err.to_string().contains("database is locked"));
28498 }
28499
28500 #[test]
28501 fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
28502 let dir = setup_workspace();
28503 cmd_index(
28504 dir.path(),
28505 false,
28506 false,
28507 false,
28508 false,
28509 false,
28510 true,
28511 None,
28512 false,
28513 false,
28514 false,
28515 false,
28516 false,
28517 false,
28518 )
28519 .unwrap();
28520
28521 let alpha = dir.path().join("src/alpha/lib.rs");
28522 std::thread::sleep(std::time::Duration::from_millis(50));
28523 std::fs::write(
28524 &alpha,
28525 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28526 )
28527 .unwrap();
28528
28529 let result = cmd_search(
28530 "alpha_helper".to_string(),
28531 Some(dir.path().to_path_buf()),
28532 5,
28533 Some("lexical".to_string()),
28534 None,
28535 true,
28536 false,
28537 true,
28538 0,
28539 false,
28540 false,
28541 false,
28542 false,
28543 false,
28544 false,
28545 false,
28546 );
28547
28548 assert!(result.is_ok());
28549
28550 let cfg = config::Config::load(dir.path()).unwrap();
28551 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28552 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28553 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28554 }
28555
28556 #[test]
28557 fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28558 let dir = setup_workspace();
28559 cmd_index(
28560 dir.path(),
28561 false,
28562 false,
28563 false,
28564 false,
28565 false,
28566 true,
28567 None,
28568 false,
28569 false,
28570 false,
28571 false,
28572 false,
28573 false,
28574 )
28575 .unwrap();
28576
28577 let alpha = dir.path().join("src/alpha/lib.rs");
28578 std::thread::sleep(std::time::Duration::from_millis(50));
28579 std::fs::write(
28580 &alpha,
28581 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28582 )
28583 .unwrap();
28584
28585 let cfg = config::Config::load(dir.path()).unwrap();
28586 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28587
28588 let err = cmd_search(
28589 "alpha_helper".to_string(),
28590 Some(dir.path().to_path_buf()),
28591 5,
28592 Some("lexical".to_string()),
28593 None,
28594 true,
28595 false,
28596 false,
28597 30,
28598 false,
28599 false,
28600 false,
28601 false,
28602 false,
28603 false,
28604 false,
28605 )
28606 .unwrap_err();
28607
28608 assert!(err.to_string().contains("stale"));
28609 assert!(err.to_string().contains("submodule `alpha` index"));
28610 assert!(!err.to_string().contains("database is locked"));
28611 }
28612
28613 #[test]
28614 fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
28615 let dir = setup_workspace();
28616 cmd_index(
28617 dir.path(),
28618 false,
28619 false,
28620 false,
28621 false,
28622 false,
28623 true,
28624 None,
28625 false,
28626 false,
28627 false,
28628 false,
28629 false,
28630 false,
28631 )
28632 .unwrap();
28633
28634 let err = cmd_search(
28635 "alpha_helper".to_string(),
28636 Some(dir.path().to_path_buf()),
28637 5,
28638 Some("lexical".to_string()),
28639 None,
28640 false,
28641 false,
28642 true,
28643 0,
28644 false,
28645 false,
28646 false,
28647 false,
28648 false,
28649 false,
28650 false,
28651 )
28652 .unwrap_err();
28653
28654 assert_workspace_search_requires_explicit_target(err);
28655 assert!(!dir.path().join(".tsift/index.db").exists());
28656 }
28657
28658 #[test]
28659 fn workspace_search_cmd_infers_scope_from_nested_path() {
28660 let dir = setup_workspace();
28661 cmd_index(
28662 dir.path(),
28663 false,
28664 false,
28665 false,
28666 false,
28667 false,
28668 true,
28669 None,
28670 false,
28671 false,
28672 false,
28673 false,
28674 false,
28675 false,
28676 )
28677 .unwrap();
28678 let nested = dir.path().join("src/alpha/nested");
28679 std::fs::create_dir_all(&nested).unwrap();
28680
28681 let result = cmd_search(
28682 "alpha_helper".to_string(),
28683 Some(nested),
28684 5,
28685 Some("lexical".to_string()),
28686 None,
28687 false,
28688 false,
28689 false,
28690 0,
28691 false,
28692 false,
28693 false,
28694 false,
28695 false,
28696 false,
28697 false,
28698 );
28699
28700 assert!(result.is_ok());
28701 }
28702
28703 #[test]
28704 fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
28705 let dir = setup_workspace_with_duplicate_leaf_names();
28706 cmd_index(
28707 dir.path(),
28708 false,
28709 false,
28710 false,
28711 false,
28712 false,
28713 true,
28714 None,
28715 false,
28716 false,
28717 false,
28718 false,
28719 false,
28720 false,
28721 )
28722 .unwrap();
28723 let nested = dir.path().join("vendor/foo/nested");
28724 std::fs::create_dir_all(&nested).unwrap();
28725
28726 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28727 let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
28728 let cfg = config::Config::load(dir.path()).unwrap();
28729
28730 assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
28731 }
28732
28733 #[test]
28734 fn graph_cmd_succeeds_while_writer_lock_is_held() {
28735 let dir = setup_graph_index();
28736 let db_path = dir.path().join(".tsift/index.db");
28737 let _lock = hold_write_lock(&db_path);
28738
28739 let result = cmd_graph(
28740 "main",
28741 dir.path(),
28742 false,
28743 false,
28744 None,
28745 20,
28746 false,
28747 true,
28748 false,
28749 false,
28750 false,
28751 false,
28752 false,
28753 TagpathSearchOpts::default(),
28754 );
28755
28756 assert!(result.is_ok());
28757 }
28758
28759 #[test]
28760 fn graph_cmd_autoindexes_stale_index_by_default() {
28761 let dir = setup_graph_index();
28762 std::thread::sleep(std::time::Duration::from_millis(50));
28763 std::fs::write(
28764 dir.path().join("main.rs"),
28765 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28766 )
28767 .unwrap();
28768
28769 let result = cmd_graph(
28770 "helper",
28771 dir.path(),
28772 true,
28773 false,
28774 None,
28775 20,
28776 false,
28777 true,
28778 false,
28779 false,
28780 false,
28781 false,
28782 false,
28783 TagpathSearchOpts::default(),
28784 );
28785
28786 assert!(result.is_ok());
28787 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
28788 let summary = db.compute_changes(dir.path()).unwrap();
28789 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28790 }
28791
28792 #[test]
28793 fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28794 let dir = setup_graph_index();
28795 let db_path = dir.path().join(".tsift/index.db");
28796 let _lock = hold_rollback_journal_lock(&db_path);
28797
28798 let result = cmd_graph(
28799 "main",
28800 dir.path(),
28801 false,
28802 false,
28803 None,
28804 20,
28805 false,
28806 true,
28807 false,
28808 false,
28809 false,
28810 false,
28811 false,
28812 TagpathSearchOpts::default(),
28813 );
28814
28815 assert!(result.is_ok());
28816 }
28817
28818 #[test]
28819 fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
28820 let dir = setup_graph_index();
28821 let nested = dir.path().join("src/nested");
28822 std::fs::create_dir_all(&nested).unwrap();
28823
28824 let result = cmd_graph(
28825 "helper",
28826 &nested,
28827 true,
28828 false,
28829 None,
28830 20,
28831 false,
28832 false,
28833 false,
28834 false,
28835 false,
28836 false,
28837 false,
28838 TagpathSearchOpts::default(),
28839 );
28840
28841 assert!(result.is_ok());
28842 }
28843
28844 #[test]
28845 fn communities_cmd_succeeds_while_writer_lock_is_held() {
28846 let dir = setup_graph_index();
28847 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
28848
28849 let result = cmd_communities(
28850 dir.path(),
28851 None,
28852 1,
28853 10,
28854 false,
28855 false,
28856 false,
28857 false,
28858 false,
28859 false,
28860 TagpathSearchOpts::default(),
28861 );
28862
28863 assert!(result.is_ok());
28864 }
28865
28866 #[test]
28867 fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28868 let dir = setup_graph_index();
28869 let db_path = dir.path().join(".tsift/index.db");
28870 let _lock = hold_rollback_journal_lock(&db_path);
28871
28872 let result = cmd_communities(
28873 dir.path(),
28874 None,
28875 1,
28876 10,
28877 false,
28878 false,
28879 false,
28880 false,
28881 false,
28882 false,
28883 TagpathSearchOpts::default(),
28884 );
28885
28886 assert!(result.is_ok());
28887 }
28888
28889 #[test]
28890 fn lint_finds_entities_from_project_root_index_db() {
28891 let dir = tempfile::tempdir().unwrap();
28892 std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
28893 std::fs::write(
28894 dir.path().join("README.md"),
28895 "alpha_helper should be backticked.\n",
28896 )
28897 .unwrap();
28898 cmd_index(
28899 dir.path(),
28900 false,
28901 false,
28902 false,
28903 false,
28904 false,
28905 false,
28906 None,
28907 false,
28908 false,
28909 false,
28910 false,
28911 false,
28912 false,
28913 )
28914 .unwrap();
28915
28916 let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
28917 .unwrap()
28918 .unwrap();
28919 let entities = lint::collect_entities_from_index_path(&root).unwrap();
28920 let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
28921
28922 assert!(
28923 result
28924 .annotations
28925 .iter()
28926 .any(|ann| ann.text == "alpha_helper")
28927 );
28928 }
28929
28930 #[test]
28933 fn search_direct_runs_ok() {
28934 let dir = tempfile::tempdir().unwrap();
28935 let search_dir = dir.path().to_path_buf();
28936 let cache_dir = search_dir.join(".tsift/search-cache");
28937 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28938 let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical", None);
28939 assert!(result.is_ok(), "direct search should succeed");
28940 assert!(
28941 cache_dir.exists(),
28942 "search should create the configured cache dir"
28943 );
28944 }
28945
28946 #[test]
28947 fn search_timeout_zero_disables_timeout() {
28948 let dir = tempfile::tempdir().unwrap();
28949 let search_dir = dir.path().to_path_buf();
28950 let cache_dir = search_dir.join(".tsift/search-cache");
28951 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28952 let result =
28953 run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[], None);
28954 assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
28955 assert!(
28956 cache_dir.exists(),
28957 "timeout=0 should keep using the stable search cache dir"
28958 );
28959 }
28960
28961 #[test]
28962 fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
28963 let dir = tempfile::tempdir().unwrap();
28964 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
28965 cmd_index(
28966 dir.path(),
28967 false,
28968 false,
28969 false,
28970 false,
28971 false,
28972 false,
28973 None,
28974 false,
28975 false,
28976 false,
28977 false,
28978 false,
28979 false,
28980 )
28981 .unwrap();
28982 let db_path = dir.path().join(".tsift/index.db");
28983 std::fs::remove_file(&db_path).unwrap();
28984 let search_target = SearchIndexTarget {
28985 label: "index".to_string(),
28986 db_path,
28987 source_root: dir.path().to_path_buf(),
28988 scope_name: None,
28989 reindex_cmd: format!("tsift index {}", dir.path().display()),
28990 };
28991
28992 let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
28993
28994 assert!(message.contains("timed out after 1s"));
28995 assert!(message.contains("index is missing"));
28996 assert!(message.contains("Run `tsift index"));
28997 assert!(!message.contains("search root looks fresh"));
28998 }
28999
29000 #[test]
29001 fn search_worker_output_path_uses_json_suffix() {
29002 let path = next_search_worker_output_path();
29003 assert!(path.extension().is_some_and(|ext| ext == "json"));
29004 }
29005
29006 #[test]
29007 fn fts_search_flag_value_parses_falsy_escape_hatch() {
29008 for falsy in ["0", "false", "FALSE", " no ", "Off"] {
29010 assert!(
29011 fts_flag_value_disabled(falsy),
29012 "{falsy:?} should force the legacy TokenIndex path"
29013 );
29014 }
29015 for keeps_default in ["", "1", "true", "yes", "on", "maybe"] {
29016 assert!(
29017 !fts_flag_value_disabled(keeps_default),
29018 "{keeps_default:?} should keep the FTS5 default"
29019 );
29020 }
29021 }
29022
29023 #[test]
29024 fn run_sift_search_defaults_to_fts_when_index_db_present() {
29025 let dir = tempfile::tempdir().unwrap();
29028 let root = dir.path();
29029 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29030 index::IndexDb::open(&root.join(".tsift/index.db"))
29031 .unwrap()
29032 .apply_changes(root)
29033 .unwrap();
29034 let cache_dir = root.join(".tsift/search-cache");
29035
29036 if fts_search_forced_off() {
29038 return;
29039 }
29040 let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
29041 assert_eq!(response.strategy, "fts");
29042 assert!(response.hits.iter().any(|h| h.path.ends_with("alpha.rs")));
29043 }
29044
29045 #[test]
29046 fn run_sift_search_falls_back_to_lexical_without_index_db() {
29047 let dir = tempfile::tempdir().unwrap();
29050 let root = dir.path();
29051 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29052 let cache_dir = root.join(".tsift/search-cache");
29053
29054 let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
29055 assert_eq!(response.strategy, "lexical");
29056 }
29057
29058 #[test]
29059 fn run_sift_search_honors_threaded_freshness_verdict() {
29060 let dir = tempfile::tempdir().unwrap();
29064 let root = dir.path();
29065 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29066 index::IndexDb::open(&root.join(".tsift/index.db"))
29067 .unwrap()
29068 .apply_changes(root)
29069 .unwrap();
29070 let cache_dir = root.join(".tsift/search-cache");
29071
29072 if fts_search_forced_off() {
29073 return;
29074 }
29075 let fresh =
29076 run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(true)).unwrap();
29077 assert_eq!(fresh.strategy, "fts");
29078
29079 let stale =
29080 run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(false)).unwrap();
29081 assert_eq!(stale.strategy, "lexical");
29082 }
29083
29084 #[test]
29087 fn index_quiet_suppresses_file_list() {
29088 let dir = setup_graph_index();
29089 let result = cmd_index(
29090 dir.path(),
29091 false,
29092 true,
29093 false,
29094 false,
29095 true,
29096 false,
29097 None,
29098 false,
29099 false,
29100 false,
29101 false,
29102 false,
29103 false,
29104 );
29105 assert!(result.is_ok());
29106 }
29107
29108 #[test]
29109 fn index_exit_code_implies_quiet() {
29110 let dir = setup_graph_index();
29111 let result = cmd_index(
29112 dir.path(),
29113 false,
29114 true,
29115 false,
29116 false,
29117 false,
29118 false,
29119 None,
29120 false,
29121 false,
29122 false,
29123 false,
29124 false,
29125 false,
29126 );
29127 assert!(result.is_ok());
29128 }
29129
29130 #[test]
29131 fn index_quiet_json_omits_changes() {
29132 let dir = setup_graph_index();
29133 let result = cmd_index(
29134 dir.path(),
29135 false,
29136 true,
29137 false,
29138 false,
29139 true,
29140 false,
29141 None,
29142 true,
29143 false,
29144 false,
29145 false,
29146 false,
29147 false,
29148 );
29149 assert!(result.is_ok());
29150 }
29151
29152 #[test]
29153 fn cli_workflow_defaults_to_search_topic() {
29154 let cli = parse_cli(["tsift", "workflow"]);
29155 match cli.command {
29156 Some(Commands::Workflow { topic, json }) => {
29157 assert_eq!(topic, "search");
29158 assert!(!json);
29159 }
29160 _ => panic!("expected Workflow command"),
29161 }
29162 }
29163
29164 #[test]
29165 fn search_workflow_recipe_preserves_handles_across_expansions() {
29166 let recipe = workflow::search_workflow_recipe();
29167 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29168 assert_eq!(
29169 step_names,
29170 vec![
29171 "exact-anchor",
29172 "semantic-search",
29173 "explain-symbol",
29174 "summarize-selection",
29175 "digest-expansion"
29176 ]
29177 );
29178 assert!(
29179 recipe
29180 .handle_contract
29181 .iter()
29182 .any(|item| item.contains("originating command"))
29183 );
29184 assert!(
29185 recipe.steps[1]
29186 .preserves
29187 .iter()
29188 .any(|item| item.contains("sfam-*"))
29189 );
29190 assert!(
29191 recipe.steps[2]
29192 .preserves
29193 .iter()
29194 .any(|item| item.contains("ecall-*"))
29195 );
29196 assert!(
29197 recipe.steps[4]
29198 .preserves
29199 .iter()
29200 .any(|item| item.contains("artifact handles"))
29201 );
29202 }
29203
29204 #[test]
29205 fn kg_workflow_recipe_covers_extract_to_evidence() {
29206 let recipe = workflow::kg_workflow_recipe();
29207 assert_eq!(recipe.topic, "kg");
29208 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29209 assert_eq!(
29210 step_names,
29211 vec!["smoke-check", "extract", "status", "refresh", "evidence"]
29212 );
29213 let evidence = recipe.steps.last().unwrap();
29215 assert!(evidence.command.contains("kg evidence --symbol"));
29216 assert!(!evidence.command.contains("--budget"));
29217 assert!(
29219 recipe
29220 .handle_contract
29221 .iter()
29222 .any(|item| item.contains("Extract once"))
29223 );
29224 }
29225
29226 #[test]
29229 fn to_json_compact_default() {
29230 let val = serde_json::json!({"a": 1, "b": [2, 3]});
29231 let compact = to_json(&val, false, false).unwrap();
29232 assert!(!compact.contains('\n'));
29233 assert!(
29234 compact.contains("\"a\":1")
29235 || compact.contains("\"a\": 1")
29236 || compact.contains("\"a\":")
29237 );
29238 }
29239
29240 #[test]
29241 fn to_json_pretty_indents() {
29242 let val = serde_json::json!({"a": 1, "b": [2, 3]});
29243 let pretty = to_json(&val, true, false).unwrap();
29244 assert!(pretty.contains('\n'));
29245 assert!(pretty.contains(" "));
29246 }
29247
29248 #[test]
29249 fn to_json_compact_is_shorter() {
29250 let val =
29251 serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
29252 let compact = to_json(&val, false, false).unwrap();
29253 let pretty = to_json(&val, true, false).unwrap();
29254 assert!(compact.len() < pretty.len());
29255 }
29256
29257 #[test]
29258 fn terse_renames_keys() {
29259 let val =
29260 serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
29261 let result = to_json(&val, false, true).unwrap();
29262 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29263 assert!(parsed["_s"].is_object());
29264 let d = &parsed["d"];
29265 assert_eq!(d["cf"], "a.rs");
29266 assert_eq!(d["cn"], "main");
29267 assert_eq!(d["csl"], 10);
29268 }
29269
29270 #[test]
29271 fn terse_schema_only_includes_used_keys() {
29272 let val = serde_json::json!({"name": "test", "score": 0.5});
29273 let result = to_json(&val, false, true).unwrap();
29274 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29275 let schema = parsed["_s"].as_object().unwrap();
29276 assert_eq!(schema["n"], "name");
29277 assert_eq!(schema["sc"], "score");
29278 assert!(!schema.contains_key("cf"));
29279 }
29280
29281 #[test]
29282 fn terse_nested_arrays() {
29283 let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
29284 let result = to_json(&val, false, true).unwrap();
29285 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29286 let d = &parsed["d"];
29287 assert_eq!(d["crs"][0]["cn"], "a");
29288 assert_eq!(d["crs"][0]["cf"], "b.rs");
29289 }
29290
29291 #[test]
29292 fn terse_preserves_unknown_keys() {
29293 let val = serde_json::json!({"custom_field": "value", "name": "test"});
29294 let result = to_json(&val, false, true).unwrap();
29295 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29296 let d = &parsed["d"];
29297 assert_eq!(d["custom_field"], "value");
29298 assert_eq!(d["n"], "test");
29299 }
29300
29301 #[test]
29304 fn ultra_terse_strips_properties_from_graph_nodes() {
29305 let val = serde_json::json!({
29306 "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
29307 });
29308 let result = to_json_schema(&val, false, true, true, false).unwrap();
29309 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29310 let node = &parsed["d"]["nodes"][0];
29311 assert_eq!(node["id"], "fn:main");
29312 assert_eq!(node["k"], "fn");
29313 assert_eq!(node["n"], "main");
29314 assert!(node.get("properties").is_none());
29315 }
29316
29317 #[test]
29318 fn ultra_terse_strips_properties_from_graph_edges() {
29319 let val = serde_json::json!({
29320 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
29321 });
29322 let result = to_json_schema(&val, false, true, true, false).unwrap();
29323 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29324 let edge = &parsed["d"]["edges"][0];
29325 assert_eq!(edge["from_id"], "a");
29326 assert_eq!(edge["to_id"], "b");
29327 assert_eq!(edge["k"], "c");
29328 assert!(edge.get("properties").is_none());
29329 }
29330
29331 #[test]
29332 fn ultra_terse_abbreviates_edge_kinds() {
29333 let val = serde_json::json!({
29334 "edges": [
29335 {"from_id": "a", "to_id": "b", "kind": "defines"},
29336 {"from_id": "a", "to_id": "c", "kind": "contains"},
29337 {"from_id": "a", "to_id": "d", "kind": "imports"},
29338 {"from_id": "a", "to_id": "e", "kind": "mentions"},
29339 {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
29340 {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
29341 {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
29342 {"from_id": "a", "to_id": "i", "kind": "uses"},
29343 {"from_id": "a", "to_id": "j", "kind": "parent"},
29344 {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
29345 ]
29346 });
29347 let result = to_json_schema(&val, false, true, true, false).unwrap();
29348 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29349 let edges = &parsed["d"]["edges"].as_array().unwrap();
29350 assert_eq!(edges[0]["k"], "d");
29351 assert_eq!(edges[1]["k"], "ct");
29352 assert_eq!(edges[2]["k"], "i");
29353 assert_eq!(edges[3]["k"], "m");
29354 assert_eq!(edges[4]["k"], "sr");
29355 assert_eq!(edges[5]["k"], "bt");
29356 assert_eq!(edges[6]["k"], "sctx");
29357 assert_eq!(edges[7]["k"], "u");
29358 assert_eq!(edges[8]["k"], "p");
29359 assert_eq!(edges[9]["k"], "unknown_edge");
29360 }
29361
29362 #[test]
29363 fn ultra_terse_strips_provenance_freshness_from_edges() {
29364 let val = serde_json::json!({
29365 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
29366 });
29367 let result = to_json_schema(&val, false, true, true, false).unwrap();
29368 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29369 let edge = &parsed["d"]["edges"][0];
29370 assert!(edge.get("provenance").is_none());
29371 assert!(edge.get("freshness").is_none());
29372 assert_eq!(edge["k"], "c");
29373 }
29374
29375 #[test]
29376 fn ultra_terse_truncates_snippets() {
29377 let long_snippet = "x".repeat(120);
29378 let val = serde_json::json!({"snippet": long_snippet});
29379 let result = to_json_schema(&val, false, true, true, false).unwrap();
29380 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29381 let snipped = parsed["d"]["sn"].as_str().unwrap();
29382 assert_eq!(snipped.len(), 80);
29383 assert!(snipped.ends_with("..."));
29384 }
29385
29386 #[test]
29387 fn ultra_terse_truncates_abbreviated_snippet_key() {
29388 let long_snippet = "y".repeat(100);
29389 let val = serde_json::json!({"snippet": long_snippet});
29390 let result = to_json_schema(&val, false, true, true, false).unwrap();
29391 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29392 let snipped = parsed["d"]["sn"].as_str().unwrap();
29393 assert_eq!(snipped.len(), 80);
29394 assert!(snipped.ends_with("..."));
29395 }
29396
29397 #[test]
29398 fn ultra_terse_compacts_coverage_snapshot() {
29399 let val = serde_json::json!({
29400 "mode": "incremental",
29401 "total_sector_count": 10,
29402 "dirty_sector_count": 2,
29403 "active_rebuild": Some("rebuild-1"),
29404 "completed_dirty_sector_count": 1,
29405 "mounted_sector_count": 8,
29406 "rebuilding_sector_count": 1,
29407 "resumed_sector_count": 3,
29408 "reused_sector_count": 5
29409 });
29410 let result = to_json_schema(&val, false, true, true, false).unwrap();
29411 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29412 let d = &parsed["d"];
29413 assert_eq!(d["mode"], "incremental");
29414 assert_eq!(d["total_sector_count"], 10);
29415 assert_eq!(d["dirty_sector_count"], 2);
29416 assert!(d.get("active_rebuild").is_none());
29417 assert!(d.get("completed_dirty_sector_count").is_none());
29418 assert!(d.get("mounted_sector_count").is_none());
29419 assert!(d.get("rebuilding_sector_count").is_none());
29420 assert!(d.get("resumed_sector_count").is_none());
29421 assert!(d.get("reused_sector_count").is_none());
29422 }
29423
29424 #[test]
29425 fn ultra_terse_short_snippet_unchanged() {
29426 let val = serde_json::json!({"snippet": "short text"});
29427 let result = to_json_schema(&val, false, true, true, false).unwrap();
29428 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29429 assert_eq!(parsed["d"]["sn"], "short text");
29430 }
29431
29432 #[test]
29433 fn ultra_terse_non_graph_object_properties_preserved() {
29434 let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
29435 let result = to_json_schema(&val, false, true, true, false).unwrap();
29436 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29437 assert!(parsed["d"]["config"]["properties"].is_object());
29438 }
29439
29440 #[test]
29443 fn schema_converts_homogeneous_arrays() {
29444 let val = serde_json::json!({"symbols": [
29445 {"name": "foo", "kind": "fn", "line": 10},
29446 {"name": "bar", "kind": "fn", "line": 20}
29447 ]});
29448 let result = to_json_schema(&val, false, false, false, true).unwrap();
29449 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29450 let syms = &parsed["symbols"];
29451 let columns = syms["_c"]
29452 .as_array()
29453 .unwrap()
29454 .iter()
29455 .map(|value| value.as_str().unwrap())
29456 .collect::<Vec<_>>();
29457 let row0 = syms["_r"][0].as_array().unwrap();
29458 let row1 = syms["_r"][1].as_array().unwrap();
29459 let name_index = columns.iter().position(|column| *column == "name").unwrap();
29460 let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
29461 let line_index = columns.iter().position(|column| *column == "line").unwrap();
29462 assert_eq!(row0[name_index], "foo");
29463 assert_eq!(row0[kind_index], "fn");
29464 assert_eq!(row0[line_index], 10);
29465 assert_eq!(row1[name_index], "bar");
29466 assert_eq!(row1[kind_index], "fn");
29467 assert_eq!(row1[line_index], 20);
29468 }
29469
29470 #[test]
29471 fn schema_skips_short_arrays() {
29472 let val = serde_json::json!({"items": [{"name": "only"}]});
29473 let result = to_json_schema(&val, false, false, false, true).unwrap();
29474 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29475 assert!(parsed["items"].is_array());
29476 assert_eq!(parsed["items"][0]["name"], "only");
29477 }
29478
29479 #[test]
29480 fn schema_skips_heterogeneous_arrays() {
29481 let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
29482 let result = to_json_schema(&val, false, false, false, true).unwrap();
29483 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29484 assert!(parsed["items"].is_array());
29485 assert_eq!(parsed["items"][0]["a"], 1);
29486 }
29487
29488 #[test]
29489 fn schema_with_terse_combines() {
29490 let val = serde_json::json!({"callers": [
29491 {"caller_name": "a", "caller_file": "x.rs"},
29492 {"caller_name": "b", "caller_file": "y.rs"}
29493 ]});
29494 let result = to_json_schema(&val, false, true, false, true).unwrap();
29495 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29496 assert!(parsed["_s"].is_object());
29497 let d = &parsed["d"];
29498 let crs = &d["crs"];
29499 assert!(crs["_c"].is_array());
29500 assert!(crs["_r"].is_array());
29501 let columns = crs["_c"]
29502 .as_array()
29503 .unwrap()
29504 .iter()
29505 .map(|value| value.as_str().unwrap())
29506 .collect::<Vec<_>>();
29507 let row = crs["_r"][0].as_array().unwrap();
29508 let name_index = columns.iter().position(|column| *column == "cn").unwrap();
29509 let file_index = columns.iter().position(|column| *column == "cf").unwrap();
29510 assert_eq!(row[name_index], "a");
29511 assert_eq!(row[file_index], "x.rs");
29512 }
29513
29514 #[test]
29515 fn schema_preserves_non_object_arrays() {
29516 let val = serde_json::json!({"tags": ["a", "b", "c"]});
29517 let result = to_json_schema(&val, false, false, false, true).unwrap();
29518 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29519 assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
29520 }
29521
29522 #[test]
29523 fn cli_accepts_global_schema_flag() {
29524 let cli = parse_cli(["tsift", "--schema", "search", "test"]);
29525 assert!(cli.schema);
29526 assert!(matches!(cli.command, Some(Commands::Search { .. })));
29527 }
29528
29529 #[test]
29530 fn cli_accepts_global_envelope_flag() {
29531 let cli = parse_cli([
29532 "tsift",
29533 "--envelope",
29534 "context-pack",
29535 "tasks/software/tsift.md",
29536 ]);
29537 assert!(cli.envelope);
29538 assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
29539 }
29540
29541 #[test]
29542 fn cli_accepts_locks_command() {
29543 let cli = parse_cli(["tsift", "locks"]);
29544 assert!(matches!(cli.command, Some(Commands::Locks { .. })));
29545 }
29546
29547 #[test]
29548 fn cli_parses_memory_budget_guard_command() {
29549 let cli = parse_cli([
29550 "tsift",
29551 "memory",
29552 "budget-guard",
29553 "--file",
29554 "tool.log",
29555 "--budget-tokens",
29556 "1000",
29557 "--json",
29558 ]);
29559 match cli.command {
29560 Some(Commands::Memory {
29561 command:
29562 crate::cli::MemoryCommand::BudgetGuard {
29563 file,
29564 budget_tokens,
29565 json,
29566 ..
29567 },
29568 }) => {
29569 assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
29570 assert_eq!(budget_tokens, 1000);
29571 assert!(json);
29572 }
29573 _ => panic!("expected memory budget-guard command"),
29574 }
29575 }
29576
29577 #[test]
29578 fn cli_parses_memory_capture_agent_doc_closeout_command() {
29579 let cli = parse_cli([
29580 "tsift",
29581 "memory",
29582 "capture-agent-doc-closeout",
29583 ".",
29584 "--session-path",
29585 "tasks/software/tsift.md",
29586 "--prompt-target",
29587 "do [#tsiftmemhooks]",
29588 "--response-summary",
29589 "wired closeout capture",
29590 "--commit-hash",
29591 "abc123",
29592 "--session-check-status",
29593 "clean",
29594 "--json",
29595 ]);
29596 match cli.command {
29597 Some(Commands::Memory {
29598 command:
29599 crate::cli::MemoryCommand::CaptureAgentDocCloseout {
29600 path,
29601 session_path,
29602 prompt_target,
29603 response_summary,
29604 commit_hash,
29605 session_check_status,
29606 json,
29607 },
29608 }) => {
29609 assert_eq!(path, std::path::PathBuf::from("."));
29610 assert_eq!(
29611 session_path,
29612 std::path::PathBuf::from("tasks/software/tsift.md")
29613 );
29614 assert_eq!(prompt_target, "do [#tsiftmemhooks]");
29615 assert_eq!(response_summary, "wired closeout capture");
29616 assert_eq!(commit_hash.as_deref(), Some("abc123"));
29617 assert_eq!(session_check_status, "clean");
29618 assert!(json);
29619 }
29620 _ => panic!("expected memory capture-agent-doc-closeout command"),
29621 }
29622 }
29623
29624 #[test]
29625 fn cli_parses_memory_project_graph_read_policy() {
29626 let cli = parse_cli([
29627 "tsift",
29628 "memory",
29629 "project-graph",
29630 ".",
29631 "--read-policy",
29632 "query-relevant",
29633 "--query",
29634 "semantic memory",
29635 "--limit",
29636 "7",
29637 "--json",
29638 ]);
29639 match cli.command {
29640 Some(Commands::Memory {
29641 command:
29642 crate::cli::MemoryCommand::ProjectGraph {
29643 read_policy,
29644 query,
29645 limit,
29646 json,
29647 ..
29648 },
29649 }) => {
29650 assert_eq!(
29651 read_policy,
29652 crate::cli::MemoryProjectReadPolicy::QueryRelevant
29653 );
29654 assert_eq!(query.as_deref(), Some("semantic memory"));
29655 assert_eq!(limit, 7);
29656 assert!(json);
29657 }
29658 _ => panic!("expected memory project-graph command"),
29659 }
29660 }
29661
29662 #[test]
29663 fn cli_locks_accepts_scope_flag() {
29664 let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
29665 match cli.command {
29666 Some(Commands::Locks { scope, .. }) => {
29667 assert_eq!(scope.as_deref(), Some("alpha"));
29668 }
29669 _ => panic!("expected Locks command"),
29670 }
29671 }
29672
29673 #[test]
29674 fn cli_search_accepts_autoindex_flag() {
29675 let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
29676 match cli.command {
29677 Some(Commands::Search {
29678 autoindex,
29679 no_autoindex,
29680 ..
29681 }) => {
29682 assert!(autoindex);
29683 assert!(!no_autoindex);
29684 }
29685 _ => panic!("expected Search command"),
29686 }
29687 }
29688
29689 #[test]
29690 fn cli_search_accepts_exact_flag() {
29691 let cli = parse_cli(["tsift", "search", "test", "--exact"]);
29692 match cli.command {
29693 Some(Commands::Search {
29694 exact, strategy, ..
29695 }) => {
29696 assert!(exact);
29697 assert!(strategy.is_none());
29698 }
29699 _ => panic!("expected Search command"),
29700 }
29701 }
29702
29703 #[test]
29704 fn cli_parses_diff_digest_command() {
29705 let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
29706 match cli.command {
29707 Some(Commands::DiffDigest {
29708 json,
29709 path,
29710 cached,
29711 revision,
29712 max_parsed_files,
29713 }) => {
29714 assert!(json);
29715 assert_eq!(path, PathBuf::from("."));
29716 assert!(!cached);
29717 assert!(revision.is_none());
29718 assert_eq!(max_parsed_files, 25);
29719 }
29720 _ => panic!("expected DiffDigest command"),
29721 }
29722 }
29723
29724 #[test]
29725 fn cli_rejects_conflicting_diff_digest_modes() {
29726 match try_parse_cli([
29727 "tsift",
29728 "diff-digest",
29729 "--cached",
29730 "--revision",
29731 "HEAD",
29732 ".",
29733 ]) {
29734 Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
29735 Err(err) => {
29736 assert!(err.to_string().contains("--cached"));
29737 assert!(err.to_string().contains("--revision"));
29738 }
29739 }
29740 }
29741
29742 #[test]
29743 fn cli_parses_test_digest_command() {
29744 let cli = parse_cli([
29745 "tsift",
29746 "test-digest",
29747 "--path",
29748 ".",
29749 "--input",
29750 "target/test.log",
29751 "--runner",
29752 "cargo",
29753 "--json",
29754 ]);
29755 match cli.command {
29756 Some(Commands::TestDigest {
29757 json,
29758 path,
29759 input,
29760 runner,
29761 }) => {
29762 assert!(json);
29763 assert_eq!(path, PathBuf::from("."));
29764 assert_eq!(input, Some(PathBuf::from("target/test.log")));
29765 assert_eq!(runner.as_deref(), Some("cargo"));
29766 }
29767 _ => panic!("expected TestDigest command"),
29768 }
29769 }
29770
29771 #[test]
29772 fn cli_parses_log_digest_command() {
29773 let cli = parse_cli([
29774 "tsift",
29775 "log-digest",
29776 "--path",
29777 ".",
29778 "--input",
29779 "target/build.log",
29780 "--json",
29781 ]);
29782 match cli.command {
29783 Some(Commands::LogDigest {
29784 json,
29785 path,
29786 input,
29787 fixture,
29788 fail_under,
29789 }) => {
29790 assert!(json);
29791 assert_eq!(path, PathBuf::from("."));
29792 assert_eq!(input, Some(PathBuf::from("target/build.log")));
29793 assert!(fixture.is_none());
29794 assert!(!fail_under);
29795 }
29796 _ => panic!("expected LogDigest command"),
29797 }
29798 }
29799
29800 #[test]
29801 fn cli_parses_metric_digest_command() {
29802 let cli = parse_cli([
29803 "tsift",
29804 "metric-digest",
29805 "--input",
29806 "target/runs.json",
29807 "--baseline",
29808 "target/prior.json",
29809 "--metric",
29810 "session_mae",
29811 "--lower-is-better",
29812 "session_mae",
29813 "--history",
29814 "4",
29815 "--top",
29816 "2",
29817 "--json",
29818 ]);
29819 match cli.command {
29820 Some(Commands::MetricDigest {
29821 input,
29822 baseline,
29823 metrics,
29824 lower_is_better,
29825 history,
29826 top,
29827 json,
29828 ..
29829 }) => {
29830 assert!(json);
29831 assert_eq!(input, Some(PathBuf::from("target/runs.json")));
29832 assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
29833 assert_eq!(metrics, vec!["session_mae"]);
29834 assert_eq!(lower_is_better, vec!["session_mae"]);
29835 assert_eq!(history, 4);
29836 assert_eq!(top, 2);
29837 }
29838 _ => panic!("expected MetricDigest command"),
29839 }
29840 }
29841
29842 #[test]
29843 fn cli_parses_dci_benchmark_command() {
29844 let cli = parse_cli([
29845 "tsift",
29846 "dci-benchmark",
29847 "--fixture",
29848 "fixtures/dci-search-benchmark.json",
29849 "--json",
29850 ]);
29851 match cli.command {
29852 Some(Commands::DciBenchmark { fixture, json }) => {
29853 assert!(json);
29854 assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
29855 }
29856 _ => panic!("expected DciBenchmark command"),
29857 }
29858 }
29859
29860 #[test]
29861 fn cli_parses_session_digest_command() {
29862 let cli = parse_cli([
29863 "tsift",
29864 "session-digest",
29865 "--path",
29866 ".",
29867 "--input",
29868 "target/session.md",
29869 "--source",
29870 "markdown",
29871 "--json",
29872 ]);
29873 match cli.command {
29874 Some(Commands::SessionDigest {
29875 json,
29876 path,
29877 input,
29878 source,
29879 }) => {
29880 assert!(json);
29881 assert_eq!(path, PathBuf::from("."));
29882 assert_eq!(input, Some(PathBuf::from("target/session.md")));
29883 assert_eq!(source.as_deref(), Some("markdown"));
29884 }
29885 _ => panic!("expected SessionDigest command"),
29886 }
29887 }
29888
29889 #[test]
29890 fn cli_parses_session_cost_command() {
29891 let cli = parse_cli([
29892 "tsift",
29893 "session-cost",
29894 "--input",
29895 "target/session.jsonl",
29896 "--source",
29897 "codex-jsonl",
29898 "--json",
29899 ]);
29900 match cli.command {
29901 Some(Commands::SessionCost {
29902 json,
29903 input,
29904 fixture,
29905 fail_under,
29906 source,
29907 }) => {
29908 assert!(json);
29909 assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
29910 assert_eq!(fixture, None);
29911 assert!(!fail_under);
29912 assert_eq!(source.as_deref(), Some("codex-jsonl"));
29913 }
29914 _ => panic!("expected SessionCost command"),
29915 }
29916
29917 let cli = parse_cli([
29918 "tsift",
29919 "session-cost",
29920 "--fixture",
29921 "fixtures/real-session-prompt-cache-effectiveness.json",
29922 "--fail-under",
29923 "--json",
29924 ]);
29925 match cli.command {
29926 Some(Commands::SessionCost {
29927 json,
29928 input,
29929 fixture,
29930 fail_under,
29931 source,
29932 }) => {
29933 assert!(json);
29934 assert_eq!(input, None);
29935 assert_eq!(
29936 fixture,
29937 Some(PathBuf::from(
29938 "fixtures/real-session-prompt-cache-effectiveness.json"
29939 ))
29940 );
29941 assert!(fail_under);
29942 assert_eq!(source, None);
29943 }
29944 _ => panic!("expected SessionCost command"),
29945 }
29946 }
29947
29948 #[test]
29949 fn cli_parses_session_review_command() {
29950 let cli = parse_cli([
29951 "tsift",
29952 "session-review",
29953 "tasks/software/tsift.md",
29954 "--next-context",
29955 "--json",
29956 ]);
29957 match cli.command {
29958 Some(Commands::SessionReview {
29959 json,
29960 next_context,
29961 path,
29962 ..
29963 }) => {
29964 assert!(json);
29965 assert!(next_context);
29966 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29967 }
29968 _ => panic!("expected SessionReview command"),
29969 }
29970 }
29971
29972 #[test]
29973 fn cli_search_accepts_budget_flags() {
29974 let cli = parse_cli([
29975 "tsift",
29976 "search",
29977 "alpha_helper",
29978 "--max-items",
29979 "3",
29980 "--max-bytes",
29981 "96",
29982 ]);
29983 match cli.command {
29984 Some(Commands::Search {
29985 max_items,
29986 max_bytes,
29987 ..
29988 }) => {
29989 assert_eq!(max_items, Some(3));
29990 assert_eq!(max_bytes, Some(96));
29991 }
29992 _ => panic!("expected Search command"),
29993 }
29994 }
29995
29996 #[test]
29997 fn cli_search_accepts_budget_preset() {
29998 let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
29999 match cli.command {
30000 Some(Commands::Search { budget, .. }) => {
30001 assert_eq!(budget, Some(ResponseBudgetPreset::Small));
30002 }
30003 _ => panic!("expected Search command"),
30004 }
30005 }
30006
30007 #[test]
30008 fn cli_search_accepts_ast_facet_filters() {
30009 let cli = parse_cli([
30010 "tsift",
30011 "search",
30012 "setup",
30013 "--lang",
30014 "markdown",
30015 "--kind",
30016 "list_item",
30017 "--node-kind",
30018 "list_item",
30019 "--section",
30020 "Install",
30021 "--parent",
30022 "Run setup.",
30023 "--child",
30024 "Confirm setup.",
30025 "--fence-language",
30026 "rust",
30027 "--list-depth",
30028 "1",
30029 "--heading-level",
30030 "2",
30031 ]);
30032 match cli.command {
30033 Some(Commands::Search {
30034 lang,
30035 kind,
30036 node_kind,
30037 section,
30038 parent,
30039 child,
30040 fence_language,
30041 list_depth,
30042 heading_level,
30043 ..
30044 }) => {
30045 assert_eq!(lang, vec!["markdown"]);
30046 assert_eq!(kind, vec!["list_item"]);
30047 assert_eq!(node_kind, vec!["list_item"]);
30048 assert_eq!(section, vec!["Install"]);
30049 assert_eq!(parent, vec!["Run setup."]);
30050 assert_eq!(child, vec!["Confirm setup."]);
30051 assert_eq!(fence_language, vec!["rust"]);
30052 assert_eq!(list_depth, vec![1]);
30053 assert_eq!(heading_level, vec![2]);
30054 }
30055 _ => panic!("expected Search command"),
30056 }
30057 }
30058
30059 #[test]
30060 fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
30061 let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
30062 assert_eq!(small.preview_items(), 3);
30063 assert_eq!(small.preview_bytes(), 120);
30064 assert_eq!(small.follow_up_items(), 4);
30065
30066 let overridden =
30067 ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
30068 assert_eq!(overridden.preview_items(), 7);
30069 assert_eq!(overridden.preview_bytes(), 120);
30070 assert_eq!(overridden.follow_up_items(), 7);
30071
30072 let envelope_default = ResponseBudget::from_cli(None, None, None, true);
30073 assert!(envelope_default.is_active());
30074 }
30075
30076 #[test]
30077 fn cli_explain_accepts_budget_flags() {
30078 let cli = parse_cli([
30079 "tsift",
30080 "explain",
30081 "alpha_helper",
30082 "--max-items",
30083 "2",
30084 "--max-bytes",
30085 "80",
30086 ]);
30087 match cli.command {
30088 Some(Commands::Explain {
30089 max_items,
30090 max_bytes,
30091 ..
30092 }) => {
30093 assert_eq!(max_items, Some(2));
30094 assert_eq!(max_bytes, Some(80));
30095 }
30096 _ => panic!("expected Explain command"),
30097 }
30098 }
30099
30100 #[test]
30101 fn cli_session_review_accepts_budget_flags() {
30102 let cli = parse_cli([
30103 "tsift",
30104 "session-review",
30105 "tasks/software/tsift.md",
30106 "--max-items",
30107 "4",
30108 "--max-bytes",
30109 "120",
30110 ]);
30111 match cli.command {
30112 Some(Commands::SessionReview {
30113 max_items,
30114 max_bytes,
30115 ..
30116 }) => {
30117 assert_eq!(max_items, Some(4));
30118 assert_eq!(max_bytes, Some(120));
30119 }
30120 _ => panic!("expected SessionReview command"),
30121 }
30122 }
30123
30124 #[test]
30125 fn cli_parses_context_pack_command() {
30126 let cli = parse_cli([
30127 "tsift",
30128 "context-pack",
30129 "tasks/software/tsift.md",
30130 "--test-input",
30131 "target/test.log",
30132 "--runner",
30133 "cargo",
30134 "--log-input",
30135 "target/build.log",
30136 "--max-items",
30137 "3",
30138 "--max-bytes",
30139 "96",
30140 "--json",
30141 ]);
30142 match cli.command {
30143 Some(Commands::ContextPack {
30144 path,
30145 test_input,
30146 runner,
30147 log_input,
30148 json,
30149 max_items,
30150 max_bytes,
30151 budget,
30152 convex_snapshot,
30153 }) => {
30154 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
30155 assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
30156 assert_eq!(runner.as_deref(), Some("cargo"));
30157 assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
30158 assert!(json);
30159 assert_eq!(max_items, Some(3));
30160 assert_eq!(max_bytes, Some(96));
30161 assert!(budget.is_none());
30162 assert!(convex_snapshot.is_none());
30163 }
30164 _ => panic!("expected ContextPack command"),
30165 }
30166 }
30167
30168 #[test]
30169 fn cli_parses_token_savings_command() {
30170 let cli = parse_cli([
30171 "tsift",
30172 "token-savings",
30173 "--fixture",
30174 "fixtures/tsift-token-savings.json",
30175 "--fail-under",
30176 "--json",
30177 ]);
30178 match cli.command {
30179 Some(Commands::TokenSavings {
30180 fixture,
30181 fail_under,
30182 json,
30183 }) => {
30184 assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
30185 assert!(fail_under);
30186 assert!(json);
30187 }
30188 _ => panic!("expected TokenSavings command"),
30189 }
30190 }
30191
30192 #[test]
30193 fn token_savings_report_records_fixture_thresholds() {
30194 let raw_symbols = [
30195 "validate_user",
30196 "validateUser",
30197 "ValidateUser",
30198 "validate-user",
30199 "VALIDATE_USER",
30200 "Validate_User",
30201 "raw_symbol",
30202 "rawSymbol",
30203 "RawSymbol",
30204 "raw-symbol",
30205 "RAW_SYMBOL",
30206 "Raw_Symbol",
30207 ]
30208 .iter()
30209 .enumerate()
30210 .map(|(idx, identifier)| TokenSavingsRawSymbol {
30211 identifier: (*identifier).to_string(),
30212 file: format!("src/example_{idx}.rs"),
30213 line: (idx + 1) as u64,
30214 context: "function".to_string(),
30215 })
30216 .collect();
30217 let fixture = TokenSavingsFixture {
30218 schema_version: 1,
30219 description: "fixture".to_string(),
30220 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30221 cases: vec![TokenSavingsFixtureCase {
30222 name: "search-preview".to_string(),
30223 surface: "search".to_string(),
30224 minimum_savings_percent: 40.0,
30225 raw_symbols,
30226 tagpath_families: vec![
30227 TokenSavingsFamily {
30228 canonical: "validate_user".to_string(),
30229 count: 6,
30230 aliases: BTreeMap::new(),
30231 },
30232 TokenSavingsFamily {
30233 canonical: "raw_symbol".to_string(),
30234 count: 6,
30235 aliases: BTreeMap::new(),
30236 },
30237 ],
30238 context_pack_inputs: None,
30239 session_review_inputs: None,
30240 source_read_inputs: None,
30241 markdown_projection_inputs: None,
30242 }],
30243 };
30244
30245 let report = build_token_savings_report(&fixture).unwrap();
30246
30247 assert!(report.pass);
30248 assert_eq!(report.cases[0].raw_symbol_count, 12);
30249 assert_eq!(report.cases[0].family_count, 2);
30250 assert_eq!(report.cases[0].status, "pass");
30251 assert!(report.cases[0].byte_delta > 0);
30252 assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
30253 assert!(report.cases[0].savings_percent >= 40.0);
30254 }
30255
30256 #[test]
30257 fn token_savings_source_read_inputs_preserve_required_anchors() {
30258 let fixture = TokenSavingsFixture {
30259 schema_version: 1,
30260 description: "fixture".to_string(),
30261 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30262 cases: vec![TokenSavingsFixtureCase {
30263 name: "source-read".to_string(),
30264 surface: "source-read".to_string(),
30265 minimum_savings_percent: 40.0,
30266 raw_symbols: Vec::new(),
30267 tagpath_families: Vec::new(),
30268 context_pack_inputs: None,
30269 session_review_inputs: None,
30270 source_read_inputs: Some(TokenSavingsSourceReadInputs {
30271 reads: vec![TokenSavingsSourceReadInput {
30272 command: "sed -n '40,160p' src/main.rs".to_string(),
30273 file: "src/main.rs".to_string(),
30274 raw_start: 40,
30275 raw_lines: 121,
30276 raw_excerpt: "line 40\n".repeat(121),
30277 envelope_start: 40,
30278 envelope_lines: 121,
30279 required_line_anchors: vec![40, 120, 160],
30280 }],
30281 }),
30282 markdown_projection_inputs: None,
30283 }],
30284 };
30285
30286 let report = build_token_savings_report(&fixture).unwrap();
30287
30288 assert!(report.pass);
30289 assert_eq!(report.cases[0].surface, "source-read");
30290 assert!(report.cases[0].savings_percent >= 40.0);
30291 }
30292
30293 #[test]
30294 fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
30295 let fixture = TokenSavingsFixture {
30296 schema_version: 1,
30297 description: "fixture".to_string(),
30298 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30299 cases: vec![TokenSavingsFixtureCase {
30300 name: "source-read".to_string(),
30301 surface: "source-read".to_string(),
30302 minimum_savings_percent: 40.0,
30303 raw_symbols: Vec::new(),
30304 tagpath_families: Vec::new(),
30305 context_pack_inputs: None,
30306 session_review_inputs: None,
30307 source_read_inputs: Some(TokenSavingsSourceReadInputs {
30308 reads: vec![TokenSavingsSourceReadInput {
30309 command: "cat src/main.rs".to_string(),
30310 file: "src/main.rs".to_string(),
30311 raw_start: 1,
30312 raw_lines: 200,
30313 raw_excerpt: "line\n".repeat(200),
30314 envelope_start: 1,
30315 envelope_lines: 80,
30316 required_line_anchors: vec![120],
30317 }],
30318 }),
30319 markdown_projection_inputs: None,
30320 }],
30321 };
30322
30323 let err = match build_token_savings_report(&fixture) {
30324 Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
30325 Err(err) => err,
30326 };
30327
30328 assert!(err.to_string().contains("hides required line anchor 120"));
30329 }
30330
30331 #[test]
30332 fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
30333 let fixture = TokenSavingsFixture {
30334 schema_version: 1,
30335 description: "fixture".to_string(),
30336 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30337 cases: vec![TokenSavingsFixtureCase {
30338 name: "markdown-projection".to_string(),
30339 surface: "context-pack".to_string(),
30340 minimum_savings_percent: 40.0,
30341 raw_symbols: Vec::new(),
30342 tagpath_families: Vec::new(),
30343 context_pack_inputs: None,
30344 session_review_inputs: None,
30345 source_read_inputs: None,
30346 markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
30347 documents: vec![TokenSavingsMarkdownProjectionInput {
30348 command: "context-pack markdown body".to_string(),
30349 file: "tasks/software/tsift.md".to_string(),
30350 raw_markdown: "# Heading\n\n".repeat(120),
30351 outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
30352 selected_nodes: vec!["mdast-selected".to_string()],
30353 expand:
30354 "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
30355 .to_string(),
30356 }],
30357 }),
30358 }],
30359 };
30360
30361 let report = build_token_savings_report(&fixture).unwrap();
30362
30363 assert!(report.pass);
30364 assert_eq!(report.cases[0].surface, "context-pack");
30365 assert!(report.cases[0].savings_percent >= 40.0);
30366 }
30367
30368 #[test]
30369 fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
30370 let mut content = String::from("# Cache Root\n\n");
30371 for idx in 0..96 {
30372 content.push_str(&format!(
30373 "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
30374 ));
30375 }
30376
30377 let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30378 assert!(!first.cache_hit);
30379 assert!(first.nodes.len() > 200);
30380
30381 let sections = markdown_section_spans(&content).unwrap();
30382 let list_items = markdown_block_spans(&content, "list_item").unwrap();
30383 let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
30384 let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30385
30386 assert!(second.cache_hit);
30387 assert_eq!(second.nodes.len(), first.nodes.len());
30388 assert_eq!(sections.len(), 97);
30389 assert_eq!(list_items.len(), 96);
30390 assert_eq!(code_blocks.len(), 96);
30391 let first_code = first
30392 .nodes
30393 .iter()
30394 .find(|node| node.kind == "code_block")
30395 .expect("expected a Markdown code block");
30396 let first_code_node = markdown_ast_node(
30397 Path::new("/repo"),
30398 "semantic-edit",
30399 first_code,
30400 content.as_bytes(),
30401 &first.nodes,
30402 8,
30403 );
30404 assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
30405 assert_eq!(
30406 first_code_node.metadata.embedded_symbols[0].name,
30407 "sample_0"
30408 );
30409 assert_eq!(
30410 first_code_node.metadata.embedded_symbols[0].language,
30411 "rust"
30412 );
30413 }
30414
30415 #[test]
30416 fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
30417 let response = empty_search_response(Path::new("/repo"), "lexical");
30418 let symbol_hits = vec![index::SymbolHit {
30419 name: "alpha_helper_with_a_long_name".to_string(),
30420 kind: "function".to_string(),
30421 language: "rust".to_string(),
30422 file: "/repo/src/lib.rs".to_string(),
30423 line: 12,
30424 end_line: None,
30425 node_kind: None,
30426 start_byte: None,
30427 end_byte: None,
30428 body_start_byte: None,
30429 body_end_byte: None,
30430 tags: None,
30431 score: 0.98,
30432 match_type: "exact_name".to_string(),
30433 tagpath_handle: None,
30434 }];
30435
30436 let report = build_relative_search_budget_report(
30437 "alpha_helper_with_a_long_name",
30438 "lexical",
30439 Path::new("/repo"),
30440 &response,
30441 &symbol_hits,
30442 ResponseBudget::new(Some(1), Some(12)),
30443 &SearchFacetFilters::default(),
30444 );
30445
30446 assert_eq!(report.symbols.len(), 1);
30447 assert!(report.symbols[0].handle.starts_with("sfam-"));
30448 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
30449 assert_eq!(report.symbols[0].name, "alpha_hel...");
30450 assert_eq!(report.symbols[0].file, "src/lib.rs");
30451 assert!(report.symbols[0].expand.contains("tsift search"));
30452 }
30453
30454 #[test]
30455 fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
30456 let dir = tempfile::tempdir().unwrap();
30457 let src_dir = dir.path().join("src");
30458 fs::create_dir_all(&src_dir).unwrap();
30459 let source = "fn alpha_helper() {\n beta();\n}\n";
30460 let file = src_dir.join("lib.rs");
30461 fs::write(&file, source).unwrap();
30462 let body_start = source.find("{\n").unwrap() + 1;
30463 let body_end = source.rfind("\n}").unwrap() + 1;
30464
30465 let response = empty_search_response(dir.path(), "lexical");
30466 let symbol_hits = vec![index::SymbolHit {
30467 name: "alpha_helper".to_string(),
30468 kind: "function".to_string(),
30469 language: "rust".to_string(),
30470 file: file.to_string_lossy().to_string(),
30471 line: 0,
30472 end_line: Some(2),
30473 node_kind: Some("function_item".to_string()),
30474 start_byte: Some(0),
30475 end_byte: Some(i64::try_from(source.len()).unwrap()),
30476 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30477 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30478 tags: Some("alpha,helper".to_string()),
30479 score: 0.98,
30480 match_type: "exact_name".to_string(),
30481 tagpath_handle: None,
30482 }];
30483
30484 let report = build_relative_search_budget_report(
30485 "alpha helper",
30486 "lexical",
30487 dir.path(),
30488 &response,
30489 &symbol_hits,
30490 ResponseBudget::new(Some(5), Some(96)),
30491 &SearchFacetFilters::default(),
30492 );
30493
30494 let symbol = &report.symbols[0];
30495 assert_eq!(symbol.language, "rust");
30496 assert_eq!(symbol.end_line, Some(2));
30497 let ast = symbol
30498 .ast
30499 .as_ref()
30500 .expect("search symbol preview should expose an AST span artifact");
30501 assert_eq!(ast.artifact_kind, "ast_span");
30502 assert!(ast.span.handle.starts_with("span-"));
30503 assert_eq!(ast.span.node_kind, "function_item");
30504 assert_eq!(ast.span.start_byte, 0);
30505 assert_eq!(ast.span.end_byte, source.len());
30506 assert_eq!(ast.span.body_start_byte, Some(body_start));
30507 assert_eq!(ast.span.body_end_byte, Some(body_end));
30508 assert!(ast.expand.source_window.contains("source-read"));
30509 assert!(
30510 ast.expand
30511 .source_body
30512 .as_ref()
30513 .unwrap()
30514 .contains("source-read")
30515 );
30516 assert!(ast.expand.symbol_read.contains("symbol-read"));
30517 assert!(ast.expand.markdown_ast.is_none());
30518 }
30519
30520 #[test]
30521 fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
30522 let dir = tempfile::tempdir().unwrap();
30523 let source = "# Guide\n\n## Install\n\n- Run setup.\n";
30524 let file = dir.path().join("README.md");
30525 fs::write(&file, source).unwrap();
30526 let heading_start = source.find("## Install").unwrap();
30527 let heading_end = source.len();
30528
30529 let response = empty_search_response(dir.path(), "lexical");
30530 let symbol_hits = vec![index::SymbolHit {
30531 name: "Install".to_string(),
30532 kind: "heading".to_string(),
30533 language: "markdown".to_string(),
30534 file: file.to_string_lossy().to_string(),
30535 line: 2,
30536 end_line: Some(4),
30537 node_kind: Some("atx_heading".to_string()),
30538 start_byte: Some(i64::try_from(heading_start).unwrap()),
30539 end_byte: Some(i64::try_from(heading_end).unwrap()),
30540 body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
30541 body_end_byte: Some(i64::try_from(heading_end).unwrap()),
30542 tags: Some("install".to_string()),
30543 score: 1.0,
30544 match_type: "exact_name".to_string(),
30545 tagpath_handle: None,
30546 }];
30547
30548 let report = build_relative_search_budget_report(
30549 "Install",
30550 "lexical",
30551 dir.path(),
30552 &response,
30553 &symbol_hits,
30554 ResponseBudget::new(Some(5), Some(96)),
30555 &SearchFacetFilters::default(),
30556 );
30557
30558 let ast = report.symbols[0]
30559 .ast
30560 .as_ref()
30561 .expect("Markdown search symbol should expose an AST span artifact");
30562 assert_eq!(ast.span.node_kind, "atx_heading");
30563 assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
30564 let markdown_ast = ast
30565 .expand
30566 .markdown_ast
30567 .as_ref()
30568 .expect("Markdown symbols should include markdown-ast expansion");
30569 assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
30570 assert!(markdown_ast.contains("--node"), "{markdown_ast}");
30571 assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
30572 assert!(ast.expand.source_window.contains("source-read"));
30573 assert!(ast.expand.symbol_read.contains("symbol-read"));
30574 }
30575
30576 #[test]
30577 fn search_budget_report_exposes_markdown_embedded_code_symbols() {
30578 let dir = tempfile::tempdir().unwrap();
30579 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30580 let file = dir.path().join("README.md");
30581 fs::write(&file, source).unwrap();
30582 let fence_start = source.find("```rust").unwrap();
30583 let body_start = source.find("fn sample").unwrap();
30584 let body_end = body_start + "fn sample() {}\n".len();
30585
30586 let response = empty_search_response(dir.path(), "lexical");
30587 let symbol_hits = vec![index::SymbolHit {
30588 name: "rust".to_string(),
30589 kind: "code_block".to_string(),
30590 language: "markdown".to_string(),
30591 file: file.to_string_lossy().to_string(),
30592 line: 2,
30593 end_line: Some(4),
30594 node_kind: Some("fenced_code_block".to_string()),
30595 start_byte: Some(i64::try_from(fence_start).unwrap()),
30596 end_byte: Some(i64::try_from(source.len()).unwrap()),
30597 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30598 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30599 tags: Some("rust".to_string()),
30600 score: 1.0,
30601 match_type: "exact_name".to_string(),
30602 tagpath_handle: None,
30603 }];
30604
30605 let report = build_relative_search_budget_report(
30606 "rust",
30607 "lexical",
30608 dir.path(),
30609 &response,
30610 &symbol_hits,
30611 ResponseBudget::new(Some(5), Some(96)),
30612 &SearchFacetFilters::default(),
30613 );
30614
30615 let embedded = &report.symbols[0]
30616 .ast
30617 .as_ref()
30618 .unwrap()
30619 .span
30620 .markdown
30621 .as_ref()
30622 .unwrap()
30623 .embedded_symbols;
30624 assert_eq!(embedded.len(), 1);
30625 assert_eq!(embedded[0].name, "sample");
30626 assert_eq!(embedded[0].kind, "function");
30627 assert_eq!(embedded[0].language, "rust");
30628 assert_eq!(embedded[0].node_kind, "function_item");
30629 assert!(embedded[0].handle.starts_with("span-"));
30630 assert_eq!(embedded[0].start_byte, body_start);
30631 assert_eq!(embedded[0].start_line, 4);
30632 }
30633
30634 fn test_lexical_search_hit(
30635 path: &Path,
30636 rank: usize,
30637 score: f64,
30638 snippet: &str,
30639 ) -> sift::SearchHit {
30640 sift::SearchHit {
30641 artifact_id: format!("hit-{rank}"),
30642 artifact_kind: sift::ContextArtifactKind::File,
30643 budget: sift::ArtifactBudget::from_text(snippet, 1),
30644 confidence: sift::ScoreConfidence::High,
30645 freshness: sift::ArtifactFreshness {
30646 modified_unix_secs: None,
30647 observed_unix_secs: 0,
30648 },
30649 location: Some("line 1".to_string()),
30650 path: path.to_string_lossy().to_string(),
30651 provenance: sift::ArtifactProvenance {
30652 adapter: sift::AcquisitionAdapterKind::FileSystem,
30653 source: "test lexical hit".to_string(),
30654 synthetic: false,
30655 },
30656 rank,
30657 score,
30658 snippet: snippet.to_string(),
30659 }
30660 }
30661
30662 fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
30663 summarize::Summary {
30664 id: 0,
30665 symbol_name: symbol_name.to_string(),
30666 file_path: file_path.to_string(),
30667 content_hash: "hash".to_string(),
30668 summary: summary.to_string(),
30669 entities: None,
30670 relationships: None,
30671 concept_labels: None,
30672 extracted_at: "2026-06-02T00:00:00Z".to_string(),
30673 model: "test".to_string(),
30674 tokens_input: None,
30675 tokens_output: None,
30676 }
30677 }
30678
30679 #[test]
30680 fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
30681 let dir = tempfile::tempdir().unwrap();
30682 let src_dir = dir.path().join("src");
30683 fs::create_dir_all(&src_dir).unwrap();
30684 let source = "fn alpha_helper() {}\n";
30685 let file = src_dir.join("lib.rs");
30686 let broad_file = dir.path().join("README.md");
30687 fs::write(&file, source).unwrap();
30688 fs::write(
30689 &broad_file,
30690 "alpha helper alpha helper alpha helper in prose\n",
30691 )
30692 .unwrap();
30693
30694 let mut response = empty_search_response(dir.path(), "lexical");
30695 response.hits.push(test_lexical_search_hit(
30696 &broad_file,
30697 1,
30698 240.0,
30699 "alpha helper alpha helper alpha helper in prose",
30700 ));
30701 let symbol_hits = vec![index::SymbolHit {
30702 name: "alpha_helper".to_string(),
30703 kind: "function".to_string(),
30704 language: "rust".to_string(),
30705 file: file.to_string_lossy().to_string(),
30706 line: 0,
30707 end_line: Some(0),
30708 node_kind: Some("function_item".to_string()),
30709 start_byte: Some(0),
30710 end_byte: Some(i64::try_from(source.len()).unwrap()),
30711 body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30712 body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30713 tags: Some("alpha,helper".to_string()),
30714 score: 0.8,
30715 match_type: "all_tags".to_string(),
30716 tagpath_handle: None,
30717 }];
30718
30719 let report = build_relative_search_budget_report(
30720 "alpha helper",
30721 "lexical",
30722 dir.path(),
30723 &response,
30724 &symbol_hits,
30725 ResponseBudget::new(Some(5), Some(128)),
30726 &SearchFacetFilters::default(),
30727 );
30728
30729 assert_eq!(report.ranked[0].source, "symbol_span");
30730 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30731 assert!(report.ranked[0].score > report.ranked[1].score);
30732 assert_eq!(report.ranked[1].source, "lexical_file");
30733 }
30734
30735 #[test]
30736 fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
30737 let dir = tempfile::tempdir().unwrap();
30738 let src_dir = dir.path().join("src");
30739 fs::create_dir_all(&src_dir).unwrap();
30740 let source = "fn alpha_helper() {\n let needle = \"needle\";\n}\n\nfn other() {}\n";
30741 let file = src_dir.join("lib.rs");
30742 fs::write(&file, source).unwrap();
30743
30744 let mut response = empty_search_response(dir.path(), "exact");
30745 let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
30746 hit.location = Some("line 2".to_string());
30747 response.hits.push(hit);
30748
30749 let symbol_hits = vec![index::SymbolHit {
30750 name: "alpha_helper".to_string(),
30751 kind: "function".to_string(),
30752 language: "rust".to_string(),
30753 file: file.to_string_lossy().to_string(),
30754 line: 0,
30755 end_line: Some(2),
30756 node_kind: Some("function_item".to_string()),
30757 start_byte: Some(0),
30758 end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
30759 body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
30760 body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
30761 tags: Some("alpha,helper".to_string()),
30762 score: 0.9,
30763 match_type: "all_tags".to_string(),
30764 tagpath_handle: None,
30765 }];
30766
30767 let report = build_relative_search_budget_report(
30768 "needle",
30769 "exact",
30770 dir.path(),
30771 &response,
30772 &symbol_hits,
30773 ResponseBudget::new(Some(5), Some(128)),
30774 &SearchFacetFilters::default(),
30775 );
30776
30777 let hit = &report.hits[0];
30778 assert_eq!(hit.line, Some(2));
30779 let source_handle = hit
30780 .source_handle
30781 .as_ref()
30782 .expect("exact hit should expose a bounded source_handle window");
30783 assert!(source_handle.handle.starts_with("xwin-"));
30784 assert_eq!(source_handle.kind, "source_handle");
30785 assert_eq!(source_handle.file, "src/lib.rs");
30786 assert_eq!(source_handle.start_line, 1);
30787 assert_eq!(source_handle.end_line, 3);
30788 assert!(source_handle.expand.contains("source-read"));
30789
30790 let containing_symbol = hit
30791 .containing_symbol
30792 .as_ref()
30793 .expect("exact hit should expose its containing symbol when indexed");
30794 assert_eq!(containing_symbol.name, "alpha_helper");
30795 assert_eq!(containing_symbol.kind, "function");
30796 assert_eq!(containing_symbol.line, 1);
30797 assert_eq!(containing_symbol.end_line, Some(3));
30798 assert!(containing_symbol.expand.contains("symbol-read"));
30799
30800 let lexical_rank = report
30801 .ranked
30802 .iter()
30803 .find(|item| item.source == "lexical_file")
30804 .expect("ranked preview should retain the lexical retrieval handle");
30805 assert!(
30806 lexical_rank
30807 .reasons
30808 .iter()
30809 .any(|reason| reason == "source_handle")
30810 );
30811 assert!(
30812 lexical_rank
30813 .reasons
30814 .iter()
30815 .any(|reason| reason == "containing_symbol")
30816 );
30817 }
30818
30819 #[test]
30820 fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
30821 let dir = tempfile::tempdir().unwrap();
30822 let src_dir = dir.path().join("src");
30823 let tests_dir = dir.path().join("tests");
30824 fs::create_dir_all(&src_dir).unwrap();
30825 fs::create_dir_all(&tests_dir).unwrap();
30826 let source_file = src_dir.join("lib.rs");
30827 let test_file = tests_dir.join("alpha_test.rs");
30828 fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
30829 fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
30830
30831 let response = empty_search_response(dir.path(), "lexical");
30832 let symbol_hits = vec![
30833 index::SymbolHit {
30834 name: "alpha_helper_test".to_string(),
30835 kind: "function".to_string(),
30836 language: "rust".to_string(),
30837 file: test_file.to_string_lossy().to_string(),
30838 line: 1,
30839 end_line: Some(1),
30840 node_kind: Some("function_item".to_string()),
30841 start_byte: Some(8),
30842 end_byte: Some(33),
30843 body_start_byte: Some(31),
30844 body_end_byte: Some(31),
30845 tags: Some("alpha,helper,test".to_string()),
30846 score: 1.0,
30847 match_type: "exact_name".to_string(),
30848 tagpath_handle: None,
30849 },
30850 index::SymbolHit {
30851 name: "alpha_helper".to_string(),
30852 kind: "function".to_string(),
30853 language: "rust".to_string(),
30854 file: source_file.to_string_lossy().to_string(),
30855 line: 0,
30856 end_line: Some(0),
30857 node_kind: Some("function_item".to_string()),
30858 start_byte: Some(0),
30859 end_byte: Some(20),
30860 body_start_byte: Some(18),
30861 body_end_byte: Some(18),
30862 tags: Some("alpha,helper".to_string()),
30863 score: 0.78,
30864 match_type: "all_tags".to_string(),
30865 tagpath_handle: None,
30866 },
30867 ];
30868
30869 let report = build_relative_search_budget_report(
30870 "alpha helper",
30871 "lexical",
30872 dir.path(),
30873 &response,
30874 &symbol_hits,
30875 ResponseBudget::new(Some(5), Some(128)),
30876 &SearchFacetFilters::default(),
30877 );
30878
30879 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30880 assert_eq!(report.ranked[0].path, "src/lib.rs");
30881 assert!(
30882 report.ranked[0]
30883 .reasons
30884 .iter()
30885 .any(|reason| reason == "definition_kind")
30886 );
30887 assert!(
30888 report.ranked[0]
30889 .reasons
30890 .iter()
30891 .any(|reason| reason == "source_path")
30892 );
30893 let test_rank = report
30894 .ranked
30895 .iter()
30896 .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
30897 .expect("test symbol should still be present in the ranked preview");
30898 assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
30899 }
30900
30901 #[test]
30902 fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
30903 let dir = tempfile::tempdir().unwrap();
30904 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30905 let file = dir.path().join("README.md");
30906 fs::write(&file, source).unwrap();
30907 let summary_db =
30908 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
30909 summary_db
30910 .insert(&test_summary(
30911 "rust",
30912 "README.md",
30913 "Rust fence contains a sample function.",
30914 ))
30915 .unwrap();
30916
30917 let fence_start = source.find("```rust").unwrap();
30918 let body_start = source.find("fn sample").unwrap();
30919 let body_end = body_start + "fn sample() {}\n".len();
30920 let response = empty_search_response(dir.path(), "lexical");
30921 let symbol_hits = vec![index::SymbolHit {
30922 name: "rust".to_string(),
30923 kind: "code_block".to_string(),
30924 language: "markdown".to_string(),
30925 file: file.to_string_lossy().to_string(),
30926 line: 2,
30927 end_line: Some(4),
30928 node_kind: Some("fenced_code_block".to_string()),
30929 start_byte: Some(i64::try_from(fence_start).unwrap()),
30930 end_byte: Some(i64::try_from(source.len()).unwrap()),
30931 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30932 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30933 tags: Some("rust".to_string()),
30934 score: 1.0,
30935 match_type: "exact_name".to_string(),
30936 tagpath_handle: None,
30937 }];
30938
30939 let report = build_relative_search_budget_report(
30940 "rust",
30941 "lexical",
30942 dir.path(),
30943 &response,
30944 &symbol_hits,
30945 ResponseBudget::new(Some(5), Some(128)),
30946 &SearchFacetFilters::default(),
30947 );
30948
30949 let symbol = &report.symbols[0];
30950 assert_eq!(symbol.summary_refs, 1);
30951 assert_eq!(symbol.graph_neighbors, 1);
30952 assert!(
30953 report.ranked[0]
30954 .reasons
30955 .iter()
30956 .any(|reason| reason == "summary_refs:1")
30957 );
30958 assert!(
30959 report.ranked[0]
30960 .reasons
30961 .iter()
30962 .any(|reason| reason == "graph_neighbors:1")
30963 );
30964 }
30965
30966 fn markdown_search_facet_fixture() -> tempfile::TempDir {
30967 let dir = tempfile::tempdir().unwrap();
30968 let source = r#"# Guide
30969
30970## Install
30971
30972- Run setup.
30973 - Confirm setup.
30974
30975```rust
30976fn sample() {}
30977```
30978"#;
30979 fs::write(dir.path().join("README.md"), source).unwrap();
30980 let index_dir = dir.path().join(".tsift");
30981 fs::create_dir_all(&index_dir).unwrap();
30982 run_index_update(
30983 &index_dir.join("index.db"),
30984 dir.path(),
30985 "indexing markdown search facet fixture".to_string(),
30986 dir.path(),
30987 None,
30988 false,
30989 false,
30990 )
30991 .unwrap();
30992 dir
30993 }
30994
30995 fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
30996 let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
30997 db.symbol_search(query, 20).unwrap()
30998 }
30999
31000 #[test]
31001 fn search_facet_filters_match_scalar_symbol_fields() {
31002 let dir = tempfile::tempdir().unwrap();
31003 let hits = vec![
31004 index::SymbolHit {
31005 name: "alpha_helper".to_string(),
31006 kind: "function".to_string(),
31007 language: "rust".to_string(),
31008 file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
31009 line: 0,
31010 end_line: None,
31011 node_kind: Some("function_item".to_string()),
31012 start_byte: None,
31013 end_byte: None,
31014 body_start_byte: None,
31015 body_end_byte: None,
31016 tags: None,
31017 score: 1.0,
31018 match_type: "exact_name".to_string(),
31019 tagpath_handle: None,
31020 },
31021 index::SymbolHit {
31022 name: "Install".to_string(),
31023 kind: "heading".to_string(),
31024 language: "markdown".to_string(),
31025 file: dir.path().join("README.md").to_string_lossy().to_string(),
31026 line: 0,
31027 end_line: None,
31028 node_kind: Some("atx_heading".to_string()),
31029 start_byte: None,
31030 end_byte: None,
31031 body_start_byte: None,
31032 body_end_byte: None,
31033 tags: None,
31034 score: 0.9,
31035 match_type: "exact_name".to_string(),
31036 tagpath_handle: None,
31037 },
31038 ];
31039
31040 let filtered = apply_search_facet_filters(
31041 dir.path(),
31042 hits,
31043 &SearchFacetFilters {
31044 languages: vec!["rust".to_string()],
31045 kinds: vec!["function".to_string()],
31046 node_kinds: vec!["function_item".to_string()],
31047 ..SearchFacetFilters::default()
31048 },
31049 );
31050
31051 assert_eq!(filtered.len(), 1);
31052 assert_eq!(filtered[0].name, "alpha_helper");
31053 }
31054
31055 #[test]
31056 fn search_facet_filters_match_markdown_sections_and_block_metadata() {
31057 let dir = markdown_search_facet_fixture();
31058
31059 let nested_list = apply_search_facet_filters(
31060 dir.path(),
31061 markdown_search_facet_hits(dir.path(), "setup"),
31062 &SearchFacetFilters {
31063 sections: vec!["Install".to_string()],
31064 parents: vec!["Run setup.".to_string()],
31065 list_depths: vec![1],
31066 ..SearchFacetFilters::default()
31067 },
31068 );
31069 assert_eq!(nested_list.len(), 1);
31070 assert_eq!(nested_list[0].name, "Confirm setup.");
31071
31072 let parent_list = apply_search_facet_filters(
31073 dir.path(),
31074 markdown_search_facet_hits(dir.path(), "setup"),
31075 &SearchFacetFilters {
31076 children: vec!["Confirm setup.".to_string()],
31077 ..SearchFacetFilters::default()
31078 },
31079 );
31080 assert_eq!(parent_list.len(), 1);
31081 assert_eq!(parent_list[0].name, "Run setup.");
31082
31083 let heading = apply_search_facet_filters(
31084 dir.path(),
31085 markdown_search_facet_hits(dir.path(), "Install"),
31086 &SearchFacetFilters {
31087 heading_levels: vec![2],
31088 node_kinds: vec!["atx_heading".to_string()],
31089 ..SearchFacetFilters::default()
31090 },
31091 );
31092 assert_eq!(heading.len(), 1);
31093 assert_eq!(heading[0].name, "Install");
31094
31095 let fence = apply_search_facet_filters(
31096 dir.path(),
31097 markdown_search_facet_hits(dir.path(), "rust"),
31098 &SearchFacetFilters {
31099 fence_languages: vec!["rust".to_string()],
31100 kinds: vec!["code_block".to_string()],
31101 ..SearchFacetFilters::default()
31102 },
31103 );
31104 assert_eq!(fence.len(), 1);
31105 assert_eq!(fence[0].kind, "code_block");
31106
31107 let embedded_child = apply_search_facet_filters(
31108 dir.path(),
31109 markdown_search_facet_hits(dir.path(), "rust"),
31110 &SearchFacetFilters {
31111 children: vec!["sample".to_string()],
31112 kinds: vec!["code_block".to_string()],
31113 ..SearchFacetFilters::default()
31114 },
31115 );
31116 assert_eq!(embedded_child.len(), 1);
31117 assert_eq!(embedded_child[0].name, "rust");
31118 }
31119
31120 #[test]
31121 fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
31122 let response = empty_search_response(Path::new("/repo"), "lexical");
31123 let symbol_hits = vec![
31124 index::SymbolHit {
31125 name: "alpha_helper".to_string(),
31126 kind: "function".to_string(),
31127 language: "rust".to_string(),
31128 file: "/repo/src/lib.rs".to_string(),
31129 line: 12,
31130 end_line: None,
31131 node_kind: None,
31132 start_byte: None,
31133 end_byte: None,
31134 body_start_byte: None,
31135 body_end_byte: None,
31136 tags: Some("alpha,helper".to_string()),
31137 score: 0.98,
31138 match_type: "exact_name".to_string(),
31139 tagpath_handle: None,
31140 },
31141 index::SymbolHit {
31142 name: "alphaHelper".to_string(),
31143 kind: "method".to_string(),
31144 language: "rust".to_string(),
31145 file: "/repo/src/main.rs".to_string(),
31146 line: 34,
31147 end_line: None,
31148 node_kind: None,
31149 start_byte: None,
31150 end_byte: None,
31151 body_start_byte: None,
31152 body_end_byte: None,
31153 tags: Some("alpha,helper".to_string()),
31154 score: 0.93,
31155 match_type: "tag_overlap".to_string(),
31156 tagpath_handle: None,
31157 },
31158 index::SymbolHit {
31159 name: "alpha_helper".to_string(),
31160 kind: "function".to_string(),
31161 language: "rust".to_string(),
31162 file: "/repo/src/worker.rs".to_string(),
31163 line: 56,
31164 end_line: None,
31165 node_kind: None,
31166 start_byte: None,
31167 end_byte: None,
31168 body_start_byte: None,
31169 body_end_byte: None,
31170 tags: Some("alpha,helper".to_string()),
31171 score: 0.91,
31172 match_type: "tag_overlap".to_string(),
31173 tagpath_handle: None,
31174 },
31175 ];
31176
31177 let report = build_relative_search_budget_report(
31178 "alpha helper",
31179 "lexical",
31180 Path::new("/repo"),
31181 &response,
31182 &symbol_hits,
31183 ResponseBudget::new(Some(5), Some(48)),
31184 &SearchFacetFilters::default(),
31185 );
31186
31187 assert_eq!(report.symbol_total, 1);
31188 assert_eq!(report.raw_symbol_total, 3);
31189 assert_eq!(report.symbols.len(), 1);
31190 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
31191 assert_eq!(report.symbols[0].match_count, 3);
31192 assert_eq!(report.symbols[0].surface_count, 2);
31193 assert_eq!(report.symbols[0].file_count, 3);
31194 assert_eq!(
31195 report.symbols[0].surface_examples,
31196 vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
31197 );
31198 assert!(report.symbols[0].name.contains("(+1 variant)"));
31199 assert!(report.symbols[0].file.contains("(+2 files)"));
31200 assert!(report.symbols[0].expand.contains("tsift search"));
31201 assert!(report.symbols[0].expand.contains("alpha helper"));
31202 }
31203
31204 #[test]
31205 fn search_budget_report_carries_active_filters() {
31206 let response = empty_search_response(Path::new("/repo"), "lexical");
31207 let symbol_hits = vec![index::SymbolHit {
31208 name: "alpha_helper".to_string(),
31209 kind: "function".to_string(),
31210 language: "rust".to_string(),
31211 file: "/repo/src/lib.rs".to_string(),
31212 line: 12,
31213 end_line: None,
31214 node_kind: Some("function_item".to_string()),
31215 start_byte: None,
31216 end_byte: None,
31217 body_start_byte: None,
31218 body_end_byte: None,
31219 tags: Some("alpha,helper".to_string()),
31220 score: 0.98,
31221 match_type: "exact_name".to_string(),
31222 tagpath_handle: None,
31223 }];
31224 let filters = SearchFacetFilters {
31225 languages: vec!["rust".to_string()],
31226 kinds: vec!["function".to_string()],
31227 node_kinds: vec!["function_item".to_string()],
31228 ..SearchFacetFilters::default()
31229 };
31230
31231 let report = build_relative_search_budget_report(
31232 "alpha helper",
31233 "lexical",
31234 Path::new("/repo"),
31235 &response,
31236 &symbol_hits,
31237 ResponseBudget::new(Some(5), Some(48)),
31238 &filters,
31239 );
31240
31241 assert_eq!(report.filters, filters);
31242 assert_eq!(
31243 search_facet_filters_summary(&report.filters),
31244 "lang=rust kind=function node-kind=function_item"
31245 );
31246 }
31247
31248 #[test]
31249 fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
31250 let mut response = empty_search_response(Path::new("/repo"), "lexical");
31251 response.indexed_artifacts = 450;
31252 let symbol_hits = vec![
31253 index::SymbolHit {
31254 name: "alpha_helper".to_string(),
31255 kind: "function".to_string(),
31256 language: "rust".to_string(),
31257 file: "/repo/src/lib.rs".to_string(),
31258 line: 12,
31259 end_line: None,
31260 node_kind: None,
31261 start_byte: None,
31262 end_byte: None,
31263 body_start_byte: None,
31264 body_end_byte: None,
31265 tags: Some("alpha,helper".to_string()),
31266 score: 0.98,
31267 match_type: "exact_name".to_string(),
31268 tagpath_handle: None,
31269 },
31270 index::SymbolHit {
31271 name: "beta_helper".to_string(),
31272 kind: "function".to_string(),
31273 language: "rust".to_string(),
31274 file: "/repo/src/beta.rs".to_string(),
31275 line: 21,
31276 end_line: None,
31277 node_kind: None,
31278 start_byte: None,
31279 end_byte: None,
31280 body_start_byte: None,
31281 body_end_byte: None,
31282 tags: Some("beta,helper".to_string()),
31283 score: 0.92,
31284 match_type: "tag_overlap".to_string(),
31285 tagpath_handle: None,
31286 },
31287 ];
31288
31289 let report = build_relative_search_budget_report(
31290 "helper",
31291 "lexical",
31292 Path::new("/repo"),
31293 &response,
31294 &symbol_hits,
31295 ResponseBudget::new(Some(1), Some(64)),
31296 &SearchFacetFilters::default(),
31297 );
31298
31299 let guard = report
31300 .scale_guard
31301 .as_ref()
31302 .expect("broad previews should emit a scale guard");
31303 assert_eq!(guard.level, "high-hit");
31304 assert_eq!(guard.signals.indexed_artifacts, 450);
31305 assert_eq!(guard.signals.raw_symbol_matches, 2);
31306 assert!(
31307 guard
31308 .narrow_commands
31309 .iter()
31310 .any(|command| command.contains("--exact"))
31311 );
31312 assert!(
31313 guard
31314 .narrow_commands
31315 .iter()
31316 .any(|command| command.contains("alpha helper"))
31317 );
31318 assert!(
31319 guard
31320 .narrow_commands
31321 .last()
31322 .unwrap()
31323 .contains("workflow search")
31324 );
31325 }
31326
31327 #[test]
31328 fn explain_budget_report_limits_edges_and_members() {
31329 let symbols = vec![index::StoredSymbol {
31330 name: "alpha_helper".to_string(),
31331 kind: "function".to_string(),
31332 language: "rust".to_string(),
31333 signature: None,
31334 file: "src/lib.rs".to_string(),
31335 line: 10,
31336 end_line: None,
31337 node_kind: None,
31338 start_byte: None,
31339 end_byte: None,
31340 body_start_byte: None,
31341 body_end_byte: None,
31342 parent_module: None,
31343 visibility: None,
31344 tags: None,
31345 tagpath_handle: None,
31346 }];
31347 let callers = vec![
31348 index::StoredEdge {
31349 caller_file: "src/main.rs".to_string(),
31350 caller_name: "main".to_string(),
31351 caller_line: 1,
31352 callee_name: "alpha_helper".to_string(),
31353 call_site_line: 3,
31354 tagpath_handle: None,
31355 },
31356 index::StoredEdge {
31357 caller_file: "src/worker.rs".to_string(),
31358 caller_name: "worker".to_string(),
31359 caller_line: 5,
31360 callee_name: "alpha_helper".to_string(),
31361 call_site_line: 8,
31362 tagpath_handle: None,
31363 },
31364 ];
31365 let community = graph::Community {
31366 id: 1,
31367 members: vec![
31368 graph::CommunityMember::new("alpha_helper"),
31369 graph::CommunityMember::new("main"),
31370 graph::CommunityMember::new("worker"),
31371 ],
31372 modularity_contribution: 0.5,
31373 };
31374
31375 let report = build_explain_budget_report(
31376 "alpha_helper",
31377 Path::new("/repo"),
31378 &symbols,
31379 &callers,
31380 2,
31381 false,
31382 &[],
31383 0,
31384 false,
31385 Some(&community),
31386 ResponseBudget::new(Some(1), Some(24)),
31387 );
31388
31389 assert_eq!(report.definitions.len(), 1);
31390 assert_eq!(report.callers.len(), 1);
31391 assert!(report.truncated);
31392 assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
31393 assert_eq!(
31394 report.definitions[0].tag_alias.as_deref(),
31395 Some("alpha/helper")
31396 );
31397 assert!(report.callers[0].handle.starts_with("ecall-"));
31398 assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
31399 }
31400
31401 #[test]
31402 fn session_review_next_context_budget_limits_lists() {
31403 let report = session_review::SessionReviewReport {
31404 root: "/repo".to_string(),
31405 target: "tasks/software/tsift.md".to_string(),
31406 target_kind: "file".to_string(),
31407 sessions_considered: 1,
31408 sessions_matched: 1,
31409 claude_sessions: 1,
31410 codex_sessions: 0,
31411 agent_doc_logs: 0,
31412 prompt_target_count: 2,
31413 command_groups: 0,
31414 file_groups: 2,
31415 symbol_groups: 1,
31416 failure_groups: 1,
31417 runtime_event_groups: 0,
31418 restart_churn_groups: 0,
31419 closeout_groups: 0,
31420 usage_samples: 1,
31421 prompt_tokens: 120,
31422 cached_input_tokens: 80,
31423 cache_creation_input_tokens: 0,
31424 output_tokens: 40,
31425 reasoning_output_tokens: 0,
31426 total_tokens: 240,
31427 cached_input_ratio: Some(40.0),
31428 largest_turn_total_tokens: 240,
31429 aggregate_cost: session_review::SessionReviewCostSummary {
31430 scope: "bounded_matched_sessions".to_string(),
31431 sessions: 1,
31432 usage_samples: 1,
31433 prompt_tokens: 120,
31434 cached_input_tokens: 80,
31435 cache_creation_input_tokens: 0,
31436 output_tokens: 40,
31437 reasoning_output_tokens: 0,
31438 total_tokens: 240,
31439 cached_input_ratio: Some(40.0),
31440 largest_turn_total_tokens: 240,
31441 },
31442 latest_session_cost: Some(session_review::SessionReviewCostSummary {
31443 scope: "latest_matched_session".to_string(),
31444 sessions: 1,
31445 usage_samples: 1,
31446 prompt_tokens: 120,
31447 cached_input_tokens: 80,
31448 cache_creation_input_tokens: 0,
31449 output_tokens: 40,
31450 reasoning_output_tokens: 0,
31451 total_tokens: 240,
31452 cached_input_ratio: Some(66.67),
31453 largest_turn_total_tokens: 240,
31454 }),
31455 prompt_cache_cross_run: None,
31456 prompt_cache_roi_scorecard: vec![],
31457 guardrails: vec![
31458 session_cost::SessionCostGuardrail {
31459 kind: "cache_resend".to_string(),
31460 severity: "warn".to_string(),
31461 message: "cached input ratio was high".to_string(),
31462 guidance: "compact or restart the session".to_string(),
31463 },
31464 session_cost::SessionCostGuardrail {
31465 kind: "prompt_budget".to_string(),
31466 severity: "warn".to_string(),
31467 message: "largest prompt turn reached 999999 tokens".to_string(),
31468 guidance: "compact the session before another large turn".to_string(),
31469 },
31470 session_cost::SessionCostGuardrail {
31471 kind: "restart_loop".to_string(),
31472 severity: "warn".to_string(),
31473 message: "restart churn detected".to_string(),
31474 guidance: "restart cleanly".to_string(),
31475 },
31476 session_cost::SessionCostGuardrail {
31477 kind: "noop_closeout".to_string(),
31478 severity: "warn".to_string(),
31479 message: "commit_already_current appeared 8 times".to_string(),
31480 guidance: "avoid reopening without new edits".to_string(),
31481 },
31482 ],
31483 loop_clusters: vec![session_cost::SessionCostLoopCluster {
31484 kind: "command_bundle".to_string(),
31485 label: "cargo test -> cargo build --release".to_string(),
31486 occurrences: 2,
31487 max_consecutive: 2,
31488 }],
31489 file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
31490 path: "src/lib.rs".to_string(),
31491 range: "12-40".to_string(),
31492 occurrences: 3,
31493 estimated_tokens: 1200,
31494 duplicate_estimated_tokens: 800,
31495 follow_up_commands: vec![
31496 "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
31497 .to_string(),
31498 ],
31499 }],
31500 prompt_targets: vec![
31501 session_review::SessionReviewPromptTarget {
31502 text: "do one".to_string(),
31503 occurrences: 1,
31504 },
31505 session_review::SessionReviewPromptTarget {
31506 text: "do two".to_string(),
31507 occurrences: 1,
31508 },
31509 ],
31510 commands: vec![],
31511 touched_files: vec![],
31512 touched_symbols: vec![],
31513 failures: vec![],
31514 runtime_events: vec![],
31515 restart_churn: vec![],
31516 closeout: vec![],
31517 largest_turns: vec![],
31518 sessions: vec![session_review::SessionReviewSession {
31519 source: "claude_jsonl".to_string(),
31520 path: "/tmp/session.jsonl".to_string(),
31521 matched_by: vec!["path".to_string()],
31522 modified_unix_secs: None,
31523 prompt_target_count: 2,
31524 command_groups: 0,
31525 file_groups: 2,
31526 symbol_groups: 1,
31527 failure_groups: 1,
31528 runtime_event_groups: 0,
31529 restart_churn_groups: 0,
31530 closeout_groups: 0,
31531 usage_samples: 1,
31532 prompt_tokens: 120,
31533 cached_input_tokens: 80,
31534 cache_creation_input_tokens: 0,
31535 output_tokens: 40,
31536 reasoning_output_tokens: 0,
31537 total_tokens: 240,
31538 largest_turn_total_tokens: 240,
31539 }],
31540 next_context: session_review::SessionReviewNextContext {
31541 target: "tasks/software/tsift.md".to_string(),
31542 active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
31543 last_verification: session_review::SessionReviewVerificationState {
31544 status: "green".to_string(),
31545 detail: "cargo test".to_string(),
31546 },
31547 touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
31548 touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
31549 unresolved_failures: vec![session_review::SessionReviewFailure {
31550 kind: "timeout".to_string(),
31551 message: "search timed out".to_string(),
31552 occurrences: 1,
31553 command: None,
31554 session_path: None,
31555 }],
31556 agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
31557 active_queue_prompt: Some(
31558 "[#one] do one with enough detail to truncate".to_string(),
31559 ),
31560 live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
31561 backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
31562 review_rows: vec![
31563 "[#review] review one".to_string(),
31564 "[#review2] review two".to_string(),
31565 ],
31566 prompt_presets: vec![
31567 "#spec-test-build-install-commit-push: update spec + tests"
31568 .to_string(),
31569 "#next-steps: collect follow-ups".to_string(),
31570 ],
31571 expansion_handles: vec![
31572 session_review::SessionReviewAgentDocExpansionHandle {
31573 handle: "adq-next-context".to_string(),
31574 label: "refresh next-context".to_string(),
31575 expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
31576 },
31577 session_review::SessionReviewAgentDocExpansionHandle {
31578 handle: "adq-context-pack".to_string(),
31579 label: "refresh context-pack".to_string(),
31580 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
31581 },
31582 ],
31583 }),
31584 prompt_cache_health: None,
31585 next_digest_commands: vec![
31586 "tsift session-review --next-context tasks/software/tsift.md".to_string(),
31587 "tsift diff-digest .".to_string(),
31588 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
31589 "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
31590 ],
31591 },
31592 warnings: vec![],
31593 };
31594
31595 let budget_report = build_session_review_next_context_budget_report(
31596 &report,
31597 ResponseBudget::new(Some(1), Some(12)),
31598 None,
31599 );
31600
31601 assert!(budget_report.truncated);
31602 assert_eq!(budget_report.prompt_targets, vec!["do one"]);
31603 assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
31604 assert!(
31605 budget_report.touched_symbol_refs[0]
31606 .handle
31607 .starts_with("ncsym-")
31608 );
31609 assert_eq!(
31610 budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
31611 Some("alpha/helper")
31612 );
31613 assert!(
31614 budget_report.unresolved_failures[0]
31615 .handle
31616 .starts_with("snf-")
31617 );
31618 assert_eq!(budget_report.next_digest_commands.len(), 4);
31619 assert_eq!(
31620 budget_report.next_digest_commands[2],
31621 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
31622 );
31623 let queue = budget_report
31624 .agent_doc_queue
31625 .as_ref()
31626 .expect("agent-doc queue budget profile should be present");
31627 assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
31628 assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
31629 assert_eq!(queue.review_row_total, 2);
31630 assert_eq!(queue.prompt_presets.len(), 1);
31631 assert_eq!(queue.expansion_handles.len(), 2);
31632 assert!(queue.truncated);
31633 assert_eq!(budget_report.next_token_actions.len(), 1);
31634 assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
31635
31636 let full_action_report = build_session_review_next_context_budget_report(
31637 &report,
31638 ResponseBudget::new(Some(6), Some(120)),
31639 None,
31640 );
31641 assert_eq!(
31642 full_action_report
31643 .next_token_actions
31644 .iter()
31645 .map(|action| action.kind.as_str())
31646 .collect::<Vec<_>>(),
31647 vec![
31648 "prompt_budget",
31649 "cache_resend",
31650 "repeated_raw_read",
31651 "repeated_command_bundle",
31652 "restart_loop",
31653 "noop_closeout"
31654 ]
31655 );
31656 assert_eq!(
31657 full_action_report.next_token_actions[0]
31658 .compact_command
31659 .as_deref(),
31660 Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
31661 );
31662 assert_eq!(
31663 full_action_report.next_token_actions[0]
31664 .restart_command
31665 .as_deref(),
31666 Some("agent-doc start \"tasks/software/tsift.md\"")
31667 );
31668 assert!(
31669 full_action_report.next_token_actions[0]
31670 .digest_commands
31671 .iter()
31672 .any(|command| command
31673 == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
31674 );
31675 let raw_read_action = full_action_report
31676 .next_token_actions
31677 .iter()
31678 .find(|action| action.kind == "repeated_raw_read")
31679 .expect("raw read action");
31680 assert!(
31681 raw_read_action.rewrite_commands.iter().any(
31682 |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
31683 ),
31684 "raw read rewrite commands: {:?}",
31685 raw_read_action.rewrite_commands
31686 );
31687 assert!(raw_read_action.rewrite_commands.iter().any(|command| command
31688 == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
31689 let command_bundle_action = full_action_report
31690 .next_token_actions
31691 .iter()
31692 .find(|action| action.kind == "repeated_command_bundle")
31693 .expect("command bundle action");
31694 assert!(
31695 command_bundle_action
31696 .rewrite_commands
31697 .iter()
31698 .any(|command| command == "tsift rewrite --run \"cargo test\"")
31699 );
31700 assert!(
31701 command_bundle_action
31702 .rewrite_commands
31703 .iter()
31704 .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
31705 );
31706 }
31707
31708 #[test]
31709 fn context_pack_diff_preview_limits_files_and_symbols() {
31710 let report = diff_digest::DiffDigestReport {
31711 root: "/repo".to_string(),
31712 mode: diff_digest::DiffDigestMode::WorkingTree,
31713 revision: None,
31714 files_changed: 2,
31715 files_with_current_summaries: 1,
31716 symbols_touched: 3,
31717 call_edges_added: 1,
31718 call_edges_removed: 0,
31719 files: vec![
31720 diff_digest::DiffDigestFile {
31721 path: "src/lib.rs".to_string(),
31722 status: diff_digest::DiffDigestFileStatus::Modified,
31723 touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
31724 summary_state: diff_digest::DiffDigestSummaryState::Current,
31725 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31726 symbol: "alpha_helper".to_string(),
31727 summary: "alpha helper handles the main alpha workflow".to_string(),
31728 }],
31729 added_call_edges: vec!["alpha->beta".to_string()],
31730 removed_call_edges: vec![],
31731 warnings: vec!["stale parse".to_string()],
31732 },
31733 diff_digest::DiffDigestFile {
31734 path: "src/main.rs".to_string(),
31735 status: diff_digest::DiffDigestFileStatus::Added,
31736 touched_symbols: vec!["main".to_string()],
31737 summary_state: diff_digest::DiffDigestSummaryState::Missing,
31738 current_summaries: vec![],
31739 added_call_edges: vec![],
31740 removed_call_edges: vec![],
31741 warnings: vec![],
31742 },
31743 ],
31744 };
31745
31746 let preview =
31747 build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
31748
31749 assert!(preview.truncated);
31750 assert_eq!(preview.files.len(), 1);
31751 assert_eq!(preview.files[0].path, "src/lib.rs");
31752 assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
31753 assert!(
31754 preview.files[0].touched_symbol_refs[0]
31755 .handle
31756 .starts_with("cdsym-")
31757 );
31758 assert_eq!(
31759 preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
31760 Some("alpha/he...")
31761 );
31762 assert!(
31763 preview.files[0].summary_refs[0]
31764 .handle
31765 .starts_with("cdsum-")
31766 );
31767 assert_eq!(
31768 preview.files[0].summary_refs[0].tag_alias.as_deref(),
31769 Some("alpha/he...")
31770 );
31771 assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
31772 assert_eq!(
31773 preview.files[0].summary_refs[0].expand,
31774 "tsift summarize --file \"src/lib.rs\""
31775 );
31776 assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
31777 }
31778
31779 #[test]
31780 fn context_pack_status_reminders_include_stale_index_state() {
31781 let dir = setup_graph_index();
31782 std::thread::sleep(std::time::Duration::from_millis(50));
31783 std::fs::write(
31784 dir.path().join("main.rs"),
31785 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
31786 )
31787 .unwrap();
31788
31789 let reminders = context_pack_status_reminders(dir.path());
31790
31791 assert_eq!(reminders.len(), 1);
31792 assert!(reminders[0].contains("index stale"));
31793 assert!(reminders[0].contains("tsift index ."));
31794 }
31795
31796 #[test]
31803 fn build_context_pack_reuses_inspect_within_scope() {
31804 let dir = setup_graph_index();
31805 init_git_repo(dir.path());
31806 let _guard = index::InspectScopeGuard::new();
31807 let _ = build_context_pack_report(
31808 dir.path(),
31809 None,
31810 None,
31811 None,
31812 ResponseBudget::new(Some(2), Some(96)),
31813 )
31814 .unwrap();
31815 let (hits, misses) = index::inspect_scope_stats();
31816 assert!(
31817 hits >= 1,
31818 "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
31819 );
31820 assert!(
31821 misses >= 1,
31822 "expected at least one initial inspect miss (hits={hits}, misses={misses})"
31823 );
31824 }
31825
31826 #[test]
31831 fn inspect_read_only_outside_scope_does_not_cache() {
31832 let dir = setup_graph_index();
31833 let db_path = dir.path().join(".tsift/index.db");
31834 let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31835 let (hits, misses) = index::inspect_scope_stats();
31836 assert_eq!(
31837 (hits, misses),
31838 (0, 0),
31839 "no scope guard => no hits/misses recorded"
31840 );
31841 let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31842 let (hits, _) = index::inspect_scope_stats();
31843 assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
31844 }
31845
31846 #[test]
31847 fn context_pack_refreshes_stale_index_before_handoff() {
31848 let dir = setup_graph_index();
31849 init_git_repo(dir.path());
31850 std::thread::sleep(std::time::Duration::from_millis(50));
31851 std::fs::write(
31852 dir.path().join("main.rs"),
31853 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
31854 )
31855 .unwrap();
31856
31857 let report = build_context_pack_report(
31858 dir.path(),
31859 None,
31860 None,
31861 None,
31862 ResponseBudget::new(Some(2), Some(96)),
31863 )
31864 .unwrap();
31865
31866 assert!(
31867 report
31868 .status_reminders
31869 .iter()
31870 .any(|reminder| reminder.contains("index refreshed")
31871 && reminder.contains("context-pack handoff")),
31872 "expected context-pack refresh diagnostic, got {:?}",
31873 report.status_reminders
31874 );
31875 assert!(
31876 !report
31877 .status_reminders
31878 .iter()
31879 .any(|reminder| reminder.contains("index stale")),
31880 "stale reminder should be gone after refresh: {:?}",
31881 report.status_reminders
31882 );
31883
31884 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
31885 let summary = db.compute_changes(dir.path()).unwrap();
31886 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
31887 }
31888
31889 #[test]
31890 fn context_pack_materializes_source_handles_into_graph_store() {
31891 let dir = tempfile::tempdir().unwrap();
31892 let packet = ExplorationPacket {
31893 budget: exploration_budget_for_counts(2, 1),
31894 relationship_map: vec![ExplorationRelation {
31895 from: "file:main.rs".to_string(),
31896 relation: "touches_symbol".to_string(),
31897 to: "symbol:helper".to_string(),
31898 label: Some("modified diff".to_string()),
31899 }],
31900 source_windows: vec![ExplorationSourceWindow {
31901 handle: "xwin-test".to_string(),
31902 file: "main.rs".to_string(),
31903 start: 1,
31904 end: 32,
31905 reason: "changed file".to_string(),
31906 expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
31907 }],
31908 worker_context: vec![ExplorationWorkerContext {
31909 handle: "xwrk-test".to_string(),
31910 target: "tasks/software/tsift.md".to_string(),
31911 summary: "do #kgnv".to_string(),
31912 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
31913 .to_string(),
31914 }],
31915 no_reread_guidance: "use windows".to_string(),
31916 };
31917
31918 let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
31919 assert_eq!(packet.source_windows[0].handle, "xwin-test");
31920
31921 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
31922 let source_handles = store.nodes_by_kind("source_handle").unwrap();
31923 assert_eq!(source_handles.len(), 1);
31924 assert_eq!(
31925 source_handles[0].properties.get("file"),
31926 Some(&"main.rs".to_string())
31927 );
31928 assert_eq!(
31929 store
31930 .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
31931 .unwrap()
31932 .len(),
31933 1
31934 );
31935 let worker_context = store.nodes_by_kind("worker_context").unwrap();
31936 assert_eq!(worker_context.len(), 1);
31937 assert_eq!(
31938 store
31939 .outgoing_edges("xwrk-test", Some("scopes_source"))
31940 .unwrap()
31941 .len(),
31942 1
31943 );
31944 }
31945
31946 #[test]
31947 fn context_pack_records_graph_orchestration_observability() {
31948 let dir = setup_traversal_project();
31949 init_git_repo(dir.path());
31950 let session = dir.path().join("tasks/software/tsift.md");
31951 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
31952
31953 let report = build_context_pack_report(
31954 &session,
31955 None,
31956 None,
31957 None,
31958 ResponseBudget::new(Some(4), Some(160)),
31959 )
31960 .unwrap();
31961
31962 assert_eq!(
31963 report.graph_orchestration.contract_version,
31964 CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
31965 );
31966 assert_eq!(
31967 report
31968 .graph_orchestration
31969 .projection_freshness
31970 .status
31971 .as_str(),
31972 "current"
31973 );
31974 assert!(!report.graph_orchestration.projection_hashes.is_empty());
31975 assert_eq!(report.graph_orchestration.readiness.status, "blocked");
31976 assert_eq!(
31977 report.graph_orchestration.readiness.reason,
31978 "summary_cache_empty"
31979 );
31980 assert!(report.graph_orchestration.readiness.fail_closed);
31981 assert!(
31982 report
31983 .graph_orchestration
31984 .readiness
31985 .next_commands
31986 .iter()
31987 .any(|command| command == "tsift summarize --extract ."),
31988 "{:?}",
31989 report.graph_orchestration.readiness.next_commands
31990 );
31991 assert!(
31992 report
31993 .graph_orchestration
31994 .evidence_packet_ids
31995 .iter()
31996 .all(|id| !id.starts_with("gevd-")),
31997 "evidence packet ids should be empty when readiness is blocked: {:?}",
31998 report.graph_orchestration.evidence_packet_ids
31999 );
32000 assert!(
32001 report
32002 .graph_orchestration
32003 .conflict_matrix_decisions
32004 .iter()
32005 .any(|decision| decision.contains("readiness blocked")),
32006 "conflict-matrix decisions should reference readiness block: {:?}",
32007 report.graph_orchestration.conflict_matrix_decisions
32008 );
32009 assert!(
32010 !report
32011 .graph_orchestration
32012 .follow_up_commands
32013 .iter()
32014 .any(|command| command.contains("conflict-matrix")),
32015 "conflict-matrix command should not appear when readiness is blocked: {:?}",
32016 report.graph_orchestration.follow_up_commands
32017 );
32018 assert!(
32019 report
32020 .graph_orchestration
32021 .follow_up_commands
32022 .iter()
32023 .any(|command| command == "tsift summarize --extract ."),
32024 "{:?}",
32025 report.graph_orchestration.follow_up_commands
32026 );
32027 assert!(
32028 !report
32029 .graph_orchestration
32030 .worker_ownership_blocks
32031 .is_empty()
32032 );
32033 }
32034
32035 #[test]
32036 fn convex_sync_report_chunks_upserts_and_tombstones() {
32037 let dir = setup_traversal_project();
32038 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
32039 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
32040 let mut snapshot = projection.to_convex_rows();
32041 snapshot.nodes.push(ConvexNodeRow {
32042 external_id: "stale-node".to_string(),
32043 kind: "backlog".to_string(),
32044 label: "stale".to_string(),
32045 properties: BTreeMap::new(),
32046 provenance: Vec::new(),
32047 freshness: None,
32048 });
32049 snapshot.edges.clear();
32050 snapshot.edges.push(ConvexEdgeRow {
32051 edge_key: "stale-edge".to_string(),
32052 from_external_id: "stale-node".to_string(),
32053 to_external_id: "stale-node".to_string(),
32054 kind: "mentions".to_string(),
32055 properties: BTreeMap::new(),
32056 provenance: Vec::new(),
32057 freshness: None,
32058 });
32059 let snapshot_path = dir.path().join("convex-snapshot.json");
32060 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
32061
32062 let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
32063
32064 assert_eq!(report.freshness.status, "stale");
32065 assert!(report.freshness.fail_closed);
32066 assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
32067 assert!(
32068 report.edge_upserts.len() > 1,
32069 "snapshot without edges should upsert local edges"
32070 );
32071 assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
32072 assert_eq!(
32073 report.chunks.first().map(|chunk| chunk.operation.as_str()),
32074 Some("delete_edges"),
32075 "edge tombstones should be planned before node tombstones"
32076 );
32077 assert!(
32078 report
32079 .chunks
32080 .iter()
32081 .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
32082 "expected chunked edge upserts, got {:?}",
32083 report.chunks
32084 );
32085 }
32086
32087 #[test]
32088 fn convex_snapshot_validation_fails_closed_when_stale() {
32089 let dir = setup_traversal_project();
32090 build_traversal_graph(dir.path(), dir.path(), None).unwrap();
32091 let snapshot = ConvexProjectionRows::default();
32092 let snapshot_path = dir.path().join("empty-convex-snapshot.json");
32093 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
32094
32095 let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
32096 assert!(
32097 err.to_string()
32098 .contains("Convex graph projection is not current"),
32099 "{err}"
32100 );
32101 }
32102
32103 #[test]
32104 fn convex_sync_report_marks_live_apply_mode_without_network() {
32105 let dir = setup_traversal_project();
32106 let report =
32107 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
32108
32109 assert!(!report.dry_run);
32110 assert!(
32111 !report
32112 .diagnostics
32113 .iter()
32114 .any(|diagnostic| diagnostic.contains("dry-run only")),
32115 "apply-mode report should not claim dry-run diagnostics"
32116 );
32117 assert!(
32118 report
32119 .chunks
32120 .iter()
32121 .any(|chunk| chunk.operation == "upsert_nodes"),
32122 "live apply mode should still expose chunked idempotent operations"
32123 );
32124 }
32125
32126 #[test]
32127 fn convex_sync_apply_round_trips_with_http_backend() {
32128 use std::net::TcpListener;
32129 use std::sync::{Arc, Mutex};
32130
32131 let dir = setup_traversal_project();
32132 let report =
32133 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
32134 let expected_chunks = report.chunks.len();
32135 assert!(expected_chunks > 0);
32136
32137 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
32138 let endpoint = format!("http://{}", listener.local_addr().unwrap());
32139 let operations = Arc::new(Mutex::new(Vec::<String>::new()));
32140 let server_operations = Arc::clone(&operations);
32141 let server = std::thread::spawn(move || {
32142 for _ in 0..expected_chunks {
32143 let (mut stream, _) = listener.accept().unwrap();
32144 let mut reader = BufReader::new(stream.try_clone().unwrap());
32145 let mut request_line = String::new();
32146 reader.read_line(&mut request_line).unwrap();
32147 assert!(request_line.starts_with("POST "));
32148
32149 let mut content_length = 0usize;
32150 loop {
32151 let mut line = String::new();
32152 reader.read_line(&mut line).unwrap();
32153 if line == "\r\n" {
32154 break;
32155 }
32156 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
32157 content_length = value.trim().parse().unwrap();
32158 }
32159 }
32160
32161 let mut body = vec![0u8; content_length];
32162 reader.read_exact(&mut body).unwrap();
32163 let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
32164 server_operations
32165 .lock()
32166 .unwrap()
32167 .push(request["operation"].as_str().unwrap().to_string());
32168
32169 let response = br#"{"status":"ok","message":"accepted"}"#;
32170 write!(
32171 stream,
32172 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
32173 response.len()
32174 )
32175 .unwrap();
32176 stream.write_all(response).unwrap();
32177 }
32178 });
32179
32180 cmd_convex_sync(
32181 ConvexSyncOptions {
32182 path: dir.path(),
32183 scope: None,
32184 snapshot: None,
32185 chunk_size: 100,
32186 remote_snapshot: false,
32187 apply: true,
32188 endpoint: Some(&endpoint),
32189 auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
32190 },
32191 OutputFormat {
32192 json_output: false,
32193 compact: true,
32194 pretty: false,
32195 terse: false,
32196 ultra_terse: false,
32197 schema: false,
32198 envelope: false,
32199 },
32200 )
32201 .unwrap();
32202 server.join().unwrap();
32203
32204 let operations = operations.lock().unwrap().clone();
32205 assert!(operations.contains(&"upsert_nodes".to_string()));
32206 assert!(operations.contains(&"upsert_edges".to_string()));
32207 }
32208
32209 #[test]
32210 fn context_pack_diff_preview_attaches_tag_ontology_refs() {
32211 let root = tempfile::tempdir().unwrap();
32212 fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
32213 fs::write(
32214 root.path().join(".naming/tags/alpha.md"),
32215 "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
32216 )
32217 .unwrap();
32218 let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
32219 let report = diff_digest::DiffDigestReport {
32220 root: root.path().display().to_string(),
32221 mode: diff_digest::DiffDigestMode::WorkingTree,
32222 revision: None,
32223 files_changed: 1,
32224 files_with_current_summaries: 1,
32225 symbols_touched: 1,
32226 call_edges_added: 0,
32227 call_edges_removed: 0,
32228 files: vec![diff_digest::DiffDigestFile {
32229 path: "src/lib.rs".to_string(),
32230 status: diff_digest::DiffDigestFileStatus::Modified,
32231 touched_symbols: vec!["alpha_helper".to_string()],
32232 summary_state: diff_digest::DiffDigestSummaryState::Current,
32233 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
32234 symbol: "alpha_helper".to_string(),
32235 summary: "alpha helper summary".to_string(),
32236 }],
32237 added_call_edges: vec![],
32238 removed_call_edges: vec![],
32239 warnings: vec![],
32240 }],
32241 };
32242
32243 let preview = build_context_pack_diff_preview(
32244 &report,
32245 ResponseBudget::new(Some(1), Some(80)),
32246 Some(&ontology),
32247 );
32248
32249 let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
32250 assert!(symbol_ref.handle.starts_with("tont-"));
32251 assert_eq!(symbol_ref.tag, "alpha");
32252 assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
32253 assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
32254 assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
32255 assert_eq!(
32256 preview.files[0].summary_refs[0].ontology_refs[0].path,
32257 ".naming/tags/alpha.md"
32258 );
32259 }
32260
32261 #[test]
32262 fn context_pack_test_preview_limits_failure_groups() {
32263 let report = test_digest::TestDigestReport {
32264 root: "/repo".to_string(),
32265 runner: "cargo".to_string(),
32266 failures: 2,
32267 grouped_failures: 2,
32268 counts: test_digest::TestDigestCounts {
32269 passed: Some(8),
32270 failed: Some(2),
32271 skipped: Some(1),
32272 },
32273 failure_groups: vec![
32274 test_digest::TestDigestFailure {
32275 tests: vec!["suite::alpha_failure".to_string()],
32276 message: "assertion failed".to_string(),
32277 path: Some("src/lib.rs".to_string()),
32278 line: Some(42),
32279 column: None,
32280 occurrences: 1,
32281 summary_state: test_digest::TestDigestSummaryState::Current,
32282 current_summaries: vec![test_digest::TestDigestSummarySnippet {
32283 symbol: "alpha_failure".to_string(),
32284 summary: "failure summary for alpha test".to_string(),
32285 }],
32286 },
32287 test_digest::TestDigestFailure {
32288 tests: vec!["suite::beta_failure".to_string()],
32289 message: "panic".to_string(),
32290 path: Some("src/main.rs".to_string()),
32291 line: Some(7),
32292 column: None,
32293 occurrences: 1,
32294 summary_state: test_digest::TestDigestSummaryState::Missing,
32295 current_summaries: vec![],
32296 },
32297 ],
32298 warnings: vec!["warning text".to_string()],
32299 };
32300
32301 let preview =
32302 build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32303
32304 assert!(preview.truncated);
32305 assert_eq!(preview.failure_groups.len(), 1);
32306 assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
32307 assert_eq!(preview.failure_groups[0].message, "assertion f...");
32308 assert!(
32309 preview.failure_groups[0].summary_refs[0]
32310 .handle
32311 .starts_with("ctsum-")
32312 );
32313 assert_eq!(
32314 preview.failure_groups[0].summary_refs[0].expand,
32315 "tsift summarize --file \"src/lib.rs\""
32316 );
32317 assert_eq!(preview.warnings, vec!["warning text"]);
32318 }
32319
32320 #[test]
32321 fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
32322 let dir = tempfile::tempdir().unwrap();
32323 let root = dir.path();
32324
32325 let small_input = "Compiling serde v1.0.130\n";
32327 let mut small = log_digest::compute(root, small_input).unwrap();
32328 maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
32329 assert!(small.raw_log_artifact.is_none());
32330 assert!(!root.join(".tsift/artifacts").exists());
32331
32332 let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
32334 let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
32335 maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
32336 let artifact = bulky
32337 .raw_log_artifact
32338 .expect("artifact attached for bulky log");
32339 assert!(artifact.handle.starts_with("logdg-"));
32340 assert_eq!(artifact.bytes, bulky_input.len());
32341 assert!(artifact.expand.contains("tsift log-digest"));
32342 assert!(artifact.expand.contains("--input"));
32343 let persisted = root.join(&artifact.path);
32344 assert!(persisted.exists(), "artifact file written to {persisted:?}");
32345 assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
32346 }
32347
32348 #[test]
32349 fn context_pack_log_preview_limits_signals_and_refs() {
32350 let report = log_digest::LogDigestReport {
32351 root: "/repo".to_string(),
32352 total_lines: 12,
32353 non_empty_lines: 10,
32354 signal_groups: 2,
32355 error_signal_groups: 1,
32356 repeated_line_groups: 2,
32357 repeated_line_occurrences: 3,
32358 line_family_groups: 0,
32359 file_ref_groups: 2,
32360 symbol_ref_groups: 2,
32361 stack_groups: 1,
32362 signals: vec![
32363 log_digest::LogDigestSignal {
32364 severity: "error".to_string(),
32365 message: "src/lib.rs:42 boom".to_string(),
32366 path: Some("src/lib.rs".to_string()),
32367 line: Some(42),
32368 column: None,
32369 occurrences: 2,
32370 summary_state: log_digest::LogDigestSummaryState::Current,
32371 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32372 symbol: "alpha_helper".to_string(),
32373 summary: "alpha helper cached log summary".to_string(),
32374 }],
32375 },
32376 log_digest::LogDigestSignal {
32377 severity: "warn".to_string(),
32378 message: "slow path".to_string(),
32379 path: None,
32380 line: None,
32381 column: None,
32382 occurrences: 1,
32383 summary_state: log_digest::LogDigestSummaryState::Unavailable,
32384 current_summaries: vec![],
32385 },
32386 ],
32387 repeated_lines: vec![
32388 log_digest::LogDigestRepeatedLine {
32389 line: "retrying work item alpha".to_string(),
32390 occurrences: 3,
32391 },
32392 log_digest::LogDigestRepeatedLine {
32393 line: "retrying work item beta".to_string(),
32394 occurrences: 2,
32395 },
32396 ],
32397 line_families: vec![],
32398 file_refs: vec![
32399 log_digest::LogDigestFileRef {
32400 path: "src/lib.rs".to_string(),
32401 line: Some(42),
32402 column: None,
32403 occurrences: 2,
32404 summary_state: log_digest::LogDigestSummaryState::Current,
32405 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32406 symbol: "alpha_helper".to_string(),
32407 summary: "alpha helper cached file summary".to_string(),
32408 }],
32409 },
32410 log_digest::LogDigestFileRef {
32411 path: "src/main.rs".to_string(),
32412 line: Some(7),
32413 column: None,
32414 occurrences: 1,
32415 summary_state: log_digest::LogDigestSummaryState::Missing,
32416 current_summaries: vec![],
32417 },
32418 ],
32419 symbol_refs: vec![
32420 log_digest::LogDigestSymbolRef {
32421 symbol: "alpha_helper".to_string(),
32422 occurrences: 2,
32423 summary_state: log_digest::LogDigestSummaryState::Current,
32424 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32425 symbol: "alpha_helper".to_string(),
32426 summary: "alpha helper cached symbol summary".to_string(),
32427 }],
32428 },
32429 log_digest::LogDigestSymbolRef {
32430 symbol: "beta_helper".to_string(),
32431 occurrences: 1,
32432 summary_state: log_digest::LogDigestSummaryState::Missing,
32433 current_summaries: vec![],
32434 },
32435 ],
32436 stack_traces: vec![log_digest::LogDigestStackGroup {
32437 frames: vec!["frame one".to_string()],
32438 occurrences: 1,
32439 }],
32440 raw_log_artifact: None,
32441 warnings: vec!["warning text".to_string()],
32442 };
32443
32444 let preview =
32445 build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32446
32447 assert!(preview.truncated);
32448 assert_eq!(preview.signals.len(), 1);
32449 assert_eq!(preview.signals[0].message, "src/lib.rs:...");
32450 assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
32451 assert_eq!(preview.file_refs.len(), 1);
32452 assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
32453 assert!(
32454 preview.signals[0].summary_refs[0]
32455 .handle
32456 .starts_with("clsum-")
32457 );
32458 assert!(
32459 preview.file_refs[0].summary_refs[0]
32460 .handle
32461 .starts_with("clfsum-")
32462 );
32463 assert!(
32464 preview.symbol_refs[0].summary_refs[0]
32465 .handle
32466 .starts_with("clssum-")
32467 );
32468 assert_eq!(
32469 preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
32470 Some("alpha/helper")
32471 );
32472 assert_eq!(
32473 preview.symbol_refs[0].summary_refs[0].expand,
32474 "tsift summarize \"alpha_helper\""
32475 );
32476 assert_eq!(preview.warnings, vec!["warning text"]);
32477 }
32478
32479 #[test]
32480 fn cli_search_rejects_exact_with_strategy_flag() {
32481 let cli = try_parse_cli([
32482 "tsift",
32483 "search",
32484 "test",
32485 "--exact",
32486 "--strategy",
32487 "lexical",
32488 ]);
32489 assert!(cli.is_err());
32490 }
32491
32492 #[test]
32493 fn cli_search_autoindexes_by_default() {
32494 let cli = parse_cli(["tsift", "search", "test"]);
32495 match cli.command {
32496 Some(Commands::Search {
32497 autoindex,
32498 no_autoindex,
32499 ..
32500 }) => {
32501 assert!(!autoindex);
32502 assert!(!no_autoindex);
32503 assert!(autoindex || !no_autoindex);
32504 }
32505 _ => panic!("expected Search command"),
32506 }
32507 }
32508
32509 #[test]
32510 fn cli_local_model_status_accepts_json_and_no_probe() {
32511 let cli = parse_cli(["tsift", "local-model", "status", "--json", "--no-probe"]);
32512 match cli.command {
32513 Some(Commands::LocalModel {
32514 command: LocalModelCommand::Status { json, no_probe },
32515 }) => {
32516 assert!(json);
32517 assert!(no_probe);
32518 }
32519 _ => panic!("expected LocalModel status command"),
32520 }
32521 }
32522
32523 #[test]
32524 fn cli_local_model_unload_accepts_probe_and_strict_flags() {
32525 let cli = parse_cli([
32526 "tsift",
32527 "local-model",
32528 "unload",
32529 "--profile",
32530 "qwen3-32b-q4",
32531 "--pre-used-mib",
32532 "200",
32533 "--post-used-mib",
32534 "800",
32535 "--provider-pid",
32536 "42",
32537 "--strict",
32538 "--json",
32539 ]);
32540 match cli.command {
32541 Some(Commands::LocalModel {
32542 command:
32543 LocalModelCommand::Unload {
32544 profile,
32545 provider_pid,
32546 pre_used_mib,
32547 post_used_mib,
32548 strict,
32549 json,
32550 ..
32551 },
32552 }) => {
32553 assert_eq!(profile, "qwen3-32b-q4");
32554 assert_eq!(provider_pid, Some(42));
32555 assert_eq!(pre_used_mib, Some(200));
32556 assert_eq!(post_used_mib, Some(800));
32557 assert!(strict);
32558 assert!(json);
32559 }
32560 _ => panic!("expected LocalModel unload command"),
32561 }
32562 }
32563
32564 #[test]
32565 fn cli_local_model_swap_parses_flags() {
32566 let cli = parse_cli([
32567 "tsift",
32568 "local-model",
32569 "swap",
32570 "--from",
32571 "qwen3-32b-q4",
32572 "--to",
32573 "qwen3-embedding-0.6b",
32574 "--provider-pid",
32575 "42",
32576 "--pre-used-mib",
32577 "200",
32578 "--post-used-mib",
32579 "180",
32580 "--strict",
32581 "--json",
32582 ]);
32583 match cli.command {
32584 Some(Commands::LocalModel {
32585 command:
32586 LocalModelCommand::Swap {
32587 from,
32588 to,
32589 provider_pid,
32590 pre_used_mib,
32591 post_used_mib,
32592 strict,
32593 json,
32594 ..
32595 },
32596 }) => {
32597 assert_eq!(from, "qwen3-32b-q4");
32598 assert_eq!(to, "qwen3-embedding-0.6b");
32599 assert_eq!(provider_pid, Some(42));
32600 assert_eq!(pre_used_mib, Some(200));
32601 assert_eq!(post_used_mib, Some(180));
32602 assert!(strict);
32603 assert!(json);
32604 }
32605 _ => panic!("expected LocalModel swap command"),
32606 }
32607 }
32608
32609 #[test]
32610 fn cli_local_model_resolve_parses_flags() {
32611 use cli::ResolveRole;
32612 let cli = parse_cli([
32613 "tsift",
32614 "local-model",
32615 "resolve",
32616 "--profile",
32617 "hash",
32618 "--role",
32619 "embed",
32620 "--no-probe",
32621 "--json",
32622 ]);
32623 match cli.command {
32624 Some(Commands::LocalModel {
32625 command:
32626 LocalModelCommand::Resolve {
32627 profile,
32628 role,
32629 no_probe,
32630 json,
32631 },
32632 }) => {
32633 assert_eq!(profile.as_deref(), Some("hash"));
32634 assert_eq!(role, ResolveRole::Embed);
32635 assert!(no_probe);
32636 assert!(json);
32637 }
32638 _ => panic!("expected LocalModel resolve command"),
32639 }
32640 }
32641
32642 #[test]
32643 fn cli_semantic_command_accepts_profile_flag() {
32644 let cli = parse_cli([
32645 "tsift",
32646 "semantic",
32647 "auth",
32648 "--profile",
32649 "qwen3-embedding-0.6b",
32650 "--json",
32651 ]);
32652 match cli.command {
32653 Some(Commands::Semantic { profile, query, .. }) => {
32654 assert_eq!(query, "auth");
32655 assert_eq!(profile.as_deref(), Some("qwen3-embedding-0.6b"));
32656 }
32657 _ => panic!("expected Semantic command"),
32658 }
32659 }
32660
32661 #[test]
32662 fn cli_summarize_command_accepts_profile_flag() {
32663 let cli = parse_cli([
32664 "tsift",
32665 "summarize",
32666 "--extract",
32667 "src",
32668 "--profile",
32669 "hash",
32670 "--json",
32671 ]);
32672 match cli.command {
32673 Some(Commands::Summarize {
32674 extract,
32675 profile,
32676 json,
32677 ..
32678 }) => {
32679 assert_eq!(extract.as_deref(), Some(std::path::Path::new("src")));
32680 assert_eq!(profile.as_deref(), Some("hash"));
32681 assert!(json);
32682 }
32683 _ => panic!("expected Summarize command"),
32684 }
32685 }
32686
32687 #[test]
32688 fn cli_local_model_lease_acquire_parses_flags() {
32689 let cli = parse_cli([
32690 "tsift",
32691 "local-model",
32692 "lease",
32693 "acquire",
32694 "--profile",
32695 "qwen3-32b-q4",
32696 "--holder-pid",
32697 "4242",
32698 "--holder-command",
32699 "corky",
32700 "--idle-ttl-seconds",
32701 "120",
32702 "--vram-baseline-mib",
32703 "200",
32704 "--lease-file",
32705 "/tmp/tsift-lease.json",
32706 "--strict",
32707 "--json",
32708 ]);
32709 match cli.command {
32710 Some(Commands::LocalModel {
32711 command:
32712 LocalModelCommand::Lease {
32713 command:
32714 LeaseCommand::Acquire {
32715 profile,
32716 holder_pid,
32717 holder_command,
32718 idle_ttl_seconds,
32719 vram_baseline_mib,
32720 lease_file,
32721 strict,
32722 json,
32723 ..
32724 },
32725 },
32726 }) => {
32727 assert_eq!(profile, "qwen3-32b-q4");
32728 assert_eq!(holder_pid, Some(4242));
32729 assert_eq!(holder_command, "corky");
32730 assert_eq!(idle_ttl_seconds, 120);
32731 assert_eq!(vram_baseline_mib, Some(200));
32732 assert_eq!(lease_file, Some(PathBuf::from("/tmp/tsift-lease.json")));
32733 assert!(strict);
32734 assert!(json);
32735 }
32736 _ => panic!("expected LocalModel lease acquire command"),
32737 }
32738 }
32739
32740 #[test]
32741 fn cli_local_model_lease_release_parses_flags() {
32742 let cli = parse_cli([
32743 "tsift",
32744 "local-model",
32745 "lease",
32746 "release",
32747 "--profile",
32748 "qwen3-embedding-0.6b",
32749 "--holder-pid",
32750 "999",
32751 "--json",
32752 ]);
32753 match cli.command {
32754 Some(Commands::LocalModel {
32755 command:
32756 LocalModelCommand::Lease {
32757 command:
32758 LeaseCommand::Release {
32759 profile,
32760 holder_pid,
32761 json,
32762 ..
32763 },
32764 },
32765 }) => {
32766 assert_eq!(profile, "qwen3-embedding-0.6b");
32767 assert_eq!(holder_pid, Some(999));
32768 assert!(json);
32769 }
32770 _ => panic!("expected LocalModel lease release command"),
32771 }
32772 }
32773
32774 #[test]
32775 fn cli_local_model_lease_show_parses_flags() {
32776 let cli = parse_cli([
32777 "tsift",
32778 "local-model",
32779 "lease",
32780 "show",
32781 "--include-stale",
32782 "--json",
32783 ]);
32784 match cli.command {
32785 Some(Commands::LocalModel {
32786 command:
32787 LocalModelCommand::Lease {
32788 command:
32789 LeaseCommand::Show {
32790 include_stale,
32791 json,
32792 ..
32793 },
32794 },
32795 }) => {
32796 assert!(include_stale);
32797 assert!(json);
32798 }
32799 _ => panic!("expected LocalModel lease show command"),
32800 }
32801 }
32802
32803 #[test]
32804 fn cli_search_accepts_no_autoindex_flag() {
32805 let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
32806 match cli.command {
32807 Some(Commands::Search {
32808 autoindex,
32809 no_autoindex,
32810 ..
32811 }) => {
32812 assert!(!autoindex);
32813 assert!(no_autoindex);
32814 }
32815 _ => panic!("expected Search command"),
32816 }
32817 }
32818
32819 #[test]
32820 fn cli_search_rejects_conflicting_autoindex_flags() {
32821 let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
32822 assert!(cli.is_err());
32823 }
32824
32825 #[test]
32828 fn cli_accepts_global_absolute_flag() {
32829 let cli = parse_cli(["tsift", "--absolute", "status"]);
32830 assert!(cli.absolute);
32831 assert!(matches!(cli.command, Some(Commands::Status { .. })));
32832 }
32833
32834 #[test]
32835 fn cli_accepts_global_tabular_flag() {
32836 let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
32837 assert!(cli.tabular);
32838 assert!(matches!(cli.command, Some(Commands::Search { .. })));
32839 }
32840
32841 #[test]
32842 fn cli_tabular_with_graph() {
32843 let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
32844 assert!(cli.tabular);
32845 assert!(matches!(cli.command, Some(Commands::Graph { .. })));
32846 }
32847
32848 #[test]
32849 fn cli_tabular_with_communities() {
32850 let cli = parse_cli(["tsift", "--tabular", "communities"]);
32851 assert!(cli.tabular);
32852 assert!(matches!(cli.command, Some(Commands::Communities { .. })));
32853 }
32854
32855 #[test]
32856 fn cli_tabular_with_explain() {
32857 let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
32858 assert!(cli.tabular);
32859 assert!(matches!(cli.command, Some(Commands::Explain { .. })));
32860 }
32861
32862 #[test]
32863 fn cli_traverse_accepts_path_target_and_html_format() {
32864 let cli = parse_cli([
32865 "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
32866 ]);
32867 match cli.command {
32868 Some(Commands::Traverse {
32869 node,
32870 to,
32871 path,
32872 format,
32873 ..
32874 }) => {
32875 assert_eq!(node.as_deref(), Some("#kgnv"));
32876 assert_eq!(to.as_deref(), Some("main"));
32877 assert_eq!(path, PathBuf::from("."));
32878 assert_eq!(format, TraverseFormat::Html);
32879 }
32880 _ => panic!("expected Traverse command"),
32881 }
32882 }
32883
32884 #[test]
32885 fn cli_parses_semantic_related_command() {
32886 let cli = parse_cli([
32887 "tsift",
32888 "semantic",
32889 "graph navigation",
32890 "--path",
32891 ".",
32892 "--kind",
32893 "all",
32894 "--limit",
32895 "3",
32896 "--json",
32897 ]);
32898 match cli.command {
32899 Some(Commands::Semantic {
32900 query,
32901 path,
32902 kind,
32903 limit,
32904 json,
32905 ..
32906 }) => {
32907 assert_eq!(query, "graph navigation");
32908 assert_eq!(path, PathBuf::from("."));
32909 assert_eq!(kind, SemanticRelatedKind::All);
32910 assert_eq!(limit, 3);
32911 assert!(json);
32912 }
32913 _ => panic!("expected Semantic command"),
32914 }
32915 }
32916
32917 #[test]
32918 fn cli_parses_convex_sync_command() {
32919 let cli = parse_cli([
32920 "tsift",
32921 "convex-sync",
32922 ".",
32923 "--snapshot",
32924 "rows.json",
32925 "--chunk-size",
32926 "25",
32927 "--json",
32928 ]);
32929 match cli.command {
32930 Some(Commands::ConvexSync {
32931 path,
32932 snapshot,
32933 chunk_size,
32934 json,
32935 ..
32936 }) => {
32937 assert_eq!(path, PathBuf::from("."));
32938 assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
32939 assert_eq!(chunk_size, 25);
32940 assert!(json);
32941 }
32942 _ => panic!("expected ConvexSync command"),
32943 }
32944 }
32945
32946 #[test]
32947 fn cli_parses_convex_sync_live_flags() {
32948 let cli = parse_cli([
32949 "tsift",
32950 "convex-sync",
32951 ".",
32952 "--remote-snapshot",
32953 "--apply",
32954 "--endpoint",
32955 "https://example.test/convex-graph",
32956 "--auth-token-env",
32957 "TSIFT_TEST_TOKEN",
32958 ]);
32959 match cli.command {
32960 Some(Commands::ConvexSync {
32961 remote_snapshot,
32962 apply,
32963 endpoint,
32964 auth_token_env,
32965 ..
32966 }) => {
32967 assert!(remote_snapshot);
32968 assert!(apply);
32969 assert_eq!(
32970 endpoint.as_deref(),
32971 Some("https://example.test/convex-graph")
32972 );
32973 assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
32974 }
32975 _ => panic!("expected ConvexSync command"),
32976 }
32977 }
32978
32979 #[test]
32980 fn cli_parses_graph_db_query() {
32981 let cli = parse_cli([
32982 "tsift",
32983 "graph-db",
32984 "--backend",
32985 "convex-snapshot",
32986 "--convex-snapshot",
32987 "rows.json",
32988 "--json",
32989 "neighborhood",
32990 "gbak-kgnv",
32991 "--depth",
32992 "2",
32993 "--edge-kind",
32994 "mentions",
32995 "--property",
32996 "path=tasks/software/tsift.md",
32997 "--cursor",
32998 "gbak-old",
32999 "--limit",
33000 "10",
33001 ]);
33002 match cli.command {
33003 Some(Commands::GraphDb {
33004 backend,
33005 convex_snapshot,
33006 json,
33007 query,
33008 ..
33009 }) => {
33010 assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
33011 assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
33012 assert!(json);
33013 match query {
33014 GraphDbQuery::Neighborhood {
33015 id,
33016 depth,
33017 edge_kind,
33018 cursor,
33019 limit,
33020 property_filters,
33021 } => {
33022 assert_eq!(id, "gbak-kgnv");
33023 assert_eq!(depth, 2);
33024 assert_eq!(edge_kind.as_deref(), Some("mentions"));
33025 assert_eq!(cursor.as_deref(), Some("gbak-old"));
33026 assert_eq!(limit, Some(10));
33027 assert_eq!(
33028 property_filters,
33029 vec!["path=tasks/software/tsift.md".to_string()]
33030 );
33031 }
33032 _ => panic!("expected graph-db neighborhood query"),
33033 }
33034 }
33035 _ => panic!("expected GraphDb command"),
33036 }
33037 }
33038
33039 #[test]
33040 fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
33041 let cli = parse_cli([
33042 "tsift",
33043 "graph-db",
33044 "--json",
33045 "backend-eval",
33046 "--candidate",
33047 "surrealdb",
33048 "--target",
33049 "gval",
33050 "--full-projection",
33051 ]);
33052 match cli.command {
33053 Some(Commands::GraphDb { json, query, .. }) => {
33054 assert!(json);
33055 match query {
33056 GraphDbQuery::BackendEval {
33057 candidates,
33058 targets,
33059 full_projection,
33060 } => {
33061 assert_eq!(candidates, vec!["surrealdb".to_string()]);
33062 assert_eq!(targets, vec!["gval".to_string()]);
33063 assert!(full_projection);
33064 }
33065 _ => panic!("expected graph-db backend-eval query"),
33066 }
33067 }
33068 _ => panic!("expected GraphDb command"),
33069 }
33070 }
33071
33072 #[test]
33073 fn cli_parses_graph_db_tokensave_backend() {
33074 let cli = parse_cli([
33075 "tsift",
33076 "graph-db",
33077 "--backend",
33078 "tokensave",
33079 "--json",
33080 "node",
33081 "fn:main",
33082 ]);
33083 match cli.command {
33084 Some(Commands::GraphDb {
33085 backend,
33086 json,
33087 query,
33088 ..
33089 }) => {
33090 assert_eq!(backend, GraphDbBackend::Tokensave);
33091 assert!(json);
33092 match query {
33093 GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
33094 _ => panic!("expected graph-db node query"),
33095 }
33096 }
33097 _ => panic!("expected GraphDb command"),
33098 }
33099 }
33100
33101 #[test]
33102 fn cli_parses_analyze_command() {
33103 let cli = parse_cli([
33104 "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
33105 "--limit", "7", "--json",
33106 ]);
33107 match cli.command {
33108 Some(Commands::Analyze {
33109 path,
33110 scope,
33111 entry_points,
33112 limit,
33113 json,
33114 }) => {
33115 assert_eq!(path, PathBuf::from("."));
33116 assert_eq!(scope.as_deref(), Some("core"));
33117 assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
33118 assert_eq!(limit, 7);
33119 assert!(json);
33120 }
33121 _ => panic!("expected Analyze command"),
33122 }
33123 }
33124
33125 #[test]
33126 fn cli_parses_graph_db_related_query() {
33127 let cli = parse_cli([
33128 "tsift",
33129 "graph-db",
33130 "--json",
33131 "related",
33132 "voice avatar memory retrieval",
33133 "--kind",
33134 "all",
33135 "--depth",
33136 "3",
33137 "--seed-limit",
33138 "4",
33139 "--limit",
33140 "12",
33141 ]);
33142 match cli.command {
33143 Some(Commands::GraphDb { json, query, .. }) => {
33144 assert!(json);
33145 match query {
33146 GraphDbQuery::Related {
33147 query,
33148 kind,
33149 depth,
33150 seed_limit,
33151 limit,
33152 } => {
33153 assert_eq!(query, "voice avatar memory retrieval");
33154 assert_eq!(kind, SemanticRelatedKind::All);
33155 assert_eq!(depth, 3);
33156 assert_eq!(seed_limit, 4);
33157 assert_eq!(limit, 12);
33158 }
33159 _ => panic!("expected graph-db related query"),
33160 }
33161 }
33162 _ => panic!("expected GraphDb command"),
33163 }
33164 }
33165
33166 #[test]
33167 fn cli_parses_graph_db_compact_query() {
33168 let cli = parse_cli([
33169 "tsift",
33170 "graph-db",
33171 "--path",
33172 ".",
33173 "compact",
33174 "--apply",
33175 "--prune-tombstones",
33176 "--confirmed-convex-reconciled",
33177 ]);
33178 match cli.command {
33179 Some(Commands::GraphDb { query, .. }) => match query {
33180 GraphDbQuery::Compact {
33181 apply,
33182 prune_tombstones,
33183 confirmed_convex_reconciled,
33184 } => {
33185 assert!(apply);
33186 assert!(prune_tombstones);
33187 assert!(confirmed_convex_reconciled);
33188 }
33189 _ => panic!("expected graph-db compact query"),
33190 },
33191 _ => panic!("expected GraphDb command"),
33192 }
33193 }
33194
33195 #[test]
33196 fn cli_parses_graph_db_snapshot_queries() {
33197 let export_cli = parse_cli([
33198 "tsift",
33199 "graph-db",
33200 "--json",
33201 "snapshot-export",
33202 "graph.db.gz",
33203 "--force",
33204 ]);
33205 match export_cli.command {
33206 Some(Commands::GraphDb { json, query, .. }) => {
33207 assert!(json);
33208 match query {
33209 GraphDbQuery::SnapshotExport { output, force } => {
33210 assert_eq!(output, PathBuf::from("graph.db.gz"));
33211 assert!(force);
33212 }
33213 _ => panic!("expected graph-db snapshot-export query"),
33214 }
33215 }
33216 _ => panic!("expected GraphDb command"),
33217 }
33218
33219 let import_cli = parse_cli([
33220 "tsift",
33221 "graph-db",
33222 "snapshot-import",
33223 "graph.db.gz",
33224 "--replace",
33225 ]);
33226 match import_cli.command {
33227 Some(Commands::GraphDb { query, .. }) => match query {
33228 GraphDbQuery::SnapshotImport { artifact, replace } => {
33229 assert_eq!(artifact, PathBuf::from("graph.db.gz"));
33230 assert!(replace);
33231 }
33232 _ => panic!("expected graph-db snapshot-import query"),
33233 },
33234 _ => panic!("expected GraphDb command"),
33235 }
33236 }
33237
33238 #[test]
33239 fn cli_parses_impact_command() {
33240 let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
33241 match cli.command {
33242 Some(Commands::Impact {
33243 path,
33244 cached,
33245 limit,
33246 ..
33247 }) => {
33248 assert_eq!(path, PathBuf::from("."));
33249 assert!(cached);
33250 assert_eq!(limit, 5);
33251 }
33252 _ => panic!("expected Impact command"),
33253 }
33254 }
33255
33256 #[test]
33257 fn cli_parses_conflict_matrix_command() {
33258 let cli = parse_cli([
33259 "tsift",
33260 "conflict-matrix",
33261 "--path",
33262 "tasks/software/tsift.md",
33263 "--depth",
33264 "4",
33265 "--limit",
33266 "12",
33267 "--impact-limit",
33268 "6",
33269 "--json",
33270 "pwcm",
33271 "#g6kf",
33272 ]);
33273 match cli.command {
33274 Some(Commands::ConflictMatrix {
33275 targets,
33276 path,
33277 depth,
33278 limit,
33279 impact_limit,
33280 json,
33281 ..
33282 }) => {
33283 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33284 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33285 assert_eq!(depth, 4);
33286 assert_eq!(limit, 12);
33287 assert_eq!(impact_limit, 6);
33288 assert!(json);
33289 }
33290 _ => panic!("expected ConflictMatrix command"),
33291 }
33292 }
33293
33294 #[test]
33295 fn cli_parses_dispatch_trace_command() {
33296 let cli = parse_cli([
33297 "tsift",
33298 "dispatch-trace",
33299 "--path",
33300 "tasks/software/tsift.md",
33301 "--format",
33302 "html",
33303 "--depth",
33304 "4",
33305 "pwcm",
33306 "#g6kf",
33307 ]);
33308 match cli.command {
33309 Some(Commands::DispatchTrace {
33310 targets,
33311 path,
33312 format,
33313 depth,
33314 ..
33315 }) => {
33316 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33317 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33318 assert_eq!(format, DispatchTraceFormat::Html);
33319 assert_eq!(depth, 4);
33320 }
33321 _ => panic!("expected DispatchTrace command"),
33322 }
33323 }
33324
33325 #[test]
33326 fn cli_parses_dependency_dag_command() {
33327 let cli = parse_cli([
33328 "tsift",
33329 "dependency-dag",
33330 "--path",
33331 "tasks/software/tsift.md",
33332 "--depth",
33333 "5",
33334 "--limit",
33335 "20",
33336 "--json",
33337 "alpha",
33338 "#beta",
33339 ]);
33340 match cli.command {
33341 Some(Commands::DependencyDag {
33342 targets,
33343 path,
33344 depth,
33345 limit,
33346 json,
33347 ..
33348 }) => {
33349 assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
33350 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33351 assert_eq!(depth, 5);
33352 assert_eq!(limit, 20);
33353 assert!(json);
33354 }
33355 _ => panic!("expected DependencyDag command"),
33356 }
33357 }
33358
33359 #[test]
33360 fn relativize_strips_root_prefix() {
33361 let root = std::path::Path::new("/home/user/project");
33362 assert_eq!(
33363 relativize("/home/user/project/src/main.rs", root),
33364 "src/main.rs"
33365 );
33366 }
33367
33368 #[test]
33369 fn relativize_leaves_non_matching_path() {
33370 let root = std::path::Path::new("/home/user/project");
33371 assert_eq!(
33372 relativize("/other/path/file.rs", root),
33373 "/other/path/file.rs"
33374 );
33375 }
33376
33377 #[test]
33378 fn relativize_leaves_already_relative() {
33379 let root = std::path::Path::new("/home/user/project");
33380 assert_eq!(relativize("src/main.rs", root), "src/main.rs");
33381 }
33382
33383 #[test]
33384 fn relativize_pathbuf_strips_prefix() {
33385 let root = std::path::Path::new("/home/user/project");
33386 let path = std::path::Path::new("/home/user/project/src/lib.rs");
33387 assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
33388 }
33389
33390 #[test]
33391 fn relativize_edges_strips_caller_file() {
33392 let root = std::path::Path::new("/tmp/proj");
33393 let mut edges = vec![index::StoredEdge {
33394 caller_file: "/tmp/proj/src/main.rs".to_string(),
33395 caller_name: "main".to_string(),
33396 caller_line: 1,
33397 callee_name: "helper".to_string(),
33398 call_site_line: 5,
33399 tagpath_handle: None,
33400 }];
33401 relativize_edges(&mut edges, root);
33402 assert_eq!(edges[0].caller_file, "src/main.rs");
33403 }
33404
33405 #[test]
33406 fn relativize_json_paths_strips_known_keys() {
33407 let root = std::path::Path::new("/tmp/proj");
33408 let mut val = serde_json::json!({
33409 "file": "/tmp/proj/src/main.rs",
33410 "path": "/tmp/proj/test.rs",
33411 "name": "/tmp/proj/not-a-path",
33412 "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
33413 });
33414 relativize_json_paths(&mut val, root);
33415 assert_eq!(val["file"], "src/main.rs");
33416 assert_eq!(val["path"], "test.rs");
33417 assert_eq!(val["name"], "/tmp/proj/not-a-path");
33418 assert_eq!(val["hits"][0]["path"], "nested.rs");
33419 }
33420
33421 #[test]
33424 fn cli_graph_accepts_limit_flag() {
33425 let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
33426 match cli.command {
33427 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
33428 _ => panic!("expected Graph command"),
33429 }
33430 }
33431
33432 #[test]
33433 fn cli_graph_default_limit_is_20() {
33434 let cli = parse_cli(["tsift", "graph", "main"]);
33435 match cli.command {
33436 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
33437 _ => panic!("expected Graph command"),
33438 }
33439 }
33440
33441 #[test]
33442 fn cli_communities_accepts_limit_flag() {
33443 let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
33444 match cli.command {
33445 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
33446 _ => panic!("expected Communities command"),
33447 }
33448 }
33449
33450 #[test]
33451 fn cli_communities_default_limit_is_10() {
33452 let cli = parse_cli(["tsift", "communities"]);
33453 match cli.command {
33454 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
33455 _ => panic!("expected Communities command"),
33456 }
33457 }
33458
33459 #[test]
33460 fn cli_explain_accepts_limit_flag() {
33461 let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
33462 match cli.command {
33463 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
33464 _ => panic!("expected Explain command"),
33465 }
33466 }
33467
33468 #[test]
33469 fn cli_explain_default_limit_is_15() {
33470 let cli = parse_cli(["tsift", "explain", "main"]);
33471 match cli.command {
33472 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
33473 _ => panic!("expected Explain command"),
33474 }
33475 }
33476
33477 #[test]
33478 fn cli_limit_zero_means_unlimited() {
33479 let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
33480 match cli.command {
33481 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
33482 _ => panic!("expected Graph command"),
33483 }
33484 }
33485
33486 #[test]
33487 fn graph_cmd_limit_runs_ok() {
33488 let dir = setup_graph_index();
33489 let result = cmd_graph(
33490 "main",
33491 dir.path(),
33492 false,
33493 false,
33494 None,
33495 1,
33496 false,
33497 false,
33498 false,
33499 false,
33500 false,
33501 false,
33502 false,
33503 TagpathSearchOpts::default(),
33504 );
33505 assert!(result.is_ok());
33506 }
33507
33508 #[test]
33509 fn graph_cmd_unlimited_runs_ok() {
33510 let dir = setup_graph_index();
33511 let result = cmd_graph(
33512 "main",
33513 dir.path(),
33514 false,
33515 false,
33516 None,
33517 0,
33518 false,
33519 false,
33520 false,
33521 false,
33522 false,
33523 false,
33524 false,
33525 TagpathSearchOpts::default(),
33526 );
33527 assert!(result.is_ok());
33528 }
33529
33530 #[test]
33531 fn graph_cmd_tabular_runs_ok() {
33532 let dir = setup_graph_index();
33533 let result = cmd_graph(
33534 "main",
33535 dir.path(),
33536 false,
33537 false,
33538 None,
33539 20,
33540 false,
33541 false,
33542 false,
33543 false,
33544 false,
33545 true,
33546 false,
33547 TagpathSearchOpts::default(),
33548 );
33549 assert!(result.is_ok());
33550 }
33551
33552 #[test]
33553 fn communities_cmd_tabular_runs_ok() {
33554 let dir = setup_graph_index();
33555 let result = cmd_communities(
33556 dir.path(),
33557 None,
33558 1,
33559 10,
33560 false,
33561 false,
33562 false,
33563 false,
33564 true,
33565 false,
33566 TagpathSearchOpts::default(),
33567 );
33568 assert!(result.is_ok());
33569 }
33570
33571 #[test]
33572 fn explain_cmd_tabular_runs_ok() {
33573 let dir = setup_graph_index();
33574 let result = cmd_explain(
33575 "main",
33576 dir.path(),
33577 None,
33578 15,
33579 false,
33580 false,
33581 false,
33582 false,
33583 false,
33584 true,
33585 false,
33586 false,
33587 );
33588 assert!(result.is_ok());
33589 }
33590
33591 #[test]
33592 fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
33593 let cases = [
33598 ".agent-doc",
33599 ".agent-doc/snapshots/abc.md",
33600 ".agent-doc/baselines/abc.md",
33601 ".agent-doc/archives/2026.md",
33602 ".agent-doc/runtime/run.jsonl",
33603 "src/foo/.agent-doc",
33604 "src/foo/.agent-doc/snapshots/x.md",
33605 "./.agent-doc/snapshots/x.md",
33606 ];
33607 for path in cases {
33608 assert!(
33609 traversal_relative_path_is_generated_artifact(path),
33610 "expected `{path}` to be excluded from source watermark"
33611 );
33612 }
33613 for path in [
33615 "src/main.rs",
33616 "tests/perf_gate.rs",
33617 "fixtures/x.json",
33618 "agent-doc/src/lib.rs", "src/.agent-doc-helper.rs",
33620 ] {
33621 assert!(
33622 !traversal_relative_path_is_generated_artifact(path),
33623 "expected `{path}` to be included in source watermark"
33624 );
33625 }
33626 }
33627
33628 #[test]
33629 fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
33630 let cases = [
33638 ".tsift",
33639 ".tsift/index.db",
33640 ".tsift/indexes/foo/index.db",
33641 ".tsift/conflict-matrix-cache/inputs/abc.json",
33642 ".tsift/summaries.db",
33643 "src/foo/.tsift",
33644 "src/foo/.tsift/graph.db",
33645 "./.tsift/index.db",
33646 "target",
33647 "target/debug/build/x",
33648 "target/release/tsift",
33649 "src/foo/target/debug/x",
33650 "./target/release/x",
33651 ];
33652 for path in cases {
33653 assert!(
33654 traversal_relative_path_is_generated_artifact(path),
33655 "expected `{path}` to be excluded from source watermark"
33656 );
33657 }
33658 for path in [
33660 "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
33661 "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
33662 "src/tsift-extras/lib.rs",
33663 "tsift/README.md",
33664 "src/targeting.rs",
33665 "src/.tsiftrc",
33666 "src/agent-doc-helper.rs",
33667 ] {
33668 assert!(
33669 !traversal_relative_path_is_generated_artifact(path),
33670 "expected `{path}` to be included in source watermark"
33671 );
33672 }
33673 }
33674
33675 #[test]
33676 fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
33677 let dir = tempfile::tempdir().unwrap();
33686 let root = dir.path();
33687 std::fs::create_dir_all(root.join("src")).unwrap();
33688 std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
33689 let hint = root.join("README.md");
33690 std::fs::write(&hint, "# stable\n").unwrap();
33691 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33693 std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
33694 std::fs::create_dir_all(root.join("target/debug")).unwrap();
33695 std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
33696
33697 let first = traversal_source_watermark(root, &hint, None, true)
33698 .expect("first watermark call must succeed")
33699 .expect("first watermark must produce a hash for hinted markdown");
33700 let second = traversal_source_watermark(root, &hint, None, true)
33701 .expect("second watermark call must succeed")
33702 .expect("second watermark must produce a hash for hinted markdown");
33703 assert_eq!(
33704 first, second,
33705 "watermark must be identical across back-to-back invocations on a quiescent root"
33706 );
33707
33708 std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
33710 std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
33711 let third = traversal_source_watermark(root, &hint, None, true)
33712 .expect("third watermark call must succeed")
33713 .expect("third watermark must produce a hash for hinted markdown");
33714 assert_eq!(
33715 first, third,
33716 "watermark must ignore mutations under .tsift/ and target/"
33717 );
33718
33719 std::thread::sleep(std::time::Duration::from_millis(20));
33724 std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
33725 let fourth = traversal_source_watermark(root, &hint, None, true)
33726 .expect("fourth watermark call must succeed")
33727 .expect("fourth watermark must produce a hash for hinted markdown");
33728 assert_ne!(
33729 first, fourth,
33730 "watermark must invalidate when the hinted markdown file changes"
33731 );
33732 }
33733
33734 #[test]
33735 fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
33736 let dir = tempfile::tempdir().unwrap();
33740 let root = dir.path();
33741 std::fs::write(root.join("README.md"), "# stable\n").unwrap();
33742 let summaries_db_path = root.join(".tsift/summaries.db");
33743 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33744 let mut summary = summarize::Summary {
33745 id: 0,
33746 symbol_name: "main".to_string(),
33747 file_path: "src/main.rs".to_string(),
33748 content_hash: "hash-main".to_string(),
33749 summary: "main wires the CLI".to_string(),
33750 entities: Some(vec![summarize::Entity {
33751 name: "Cli".to_string(),
33752 kind: "type".to_string(),
33753 description: "Command-line interface".to_string(),
33754 }]),
33755 relationships: None,
33756 concept_labels: Some(vec!["cli".to_string()]),
33757 extracted_at: "1700000000".to_string(),
33758 model: "test-model".to_string(),
33759 tokens_input: Some(10),
33760 tokens_output: Some(5),
33761 };
33762 summary_db.insert(&summary).unwrap();
33763 drop(summary_db);
33764
33765 let hint = root.join("README.md");
33766 let first = traversal_source_watermark(root, &hint, None, true)
33767 .expect("first watermark call must succeed")
33768 .expect("first watermark must produce a hash");
33769
33770 std::thread::sleep(std::time::Duration::from_millis(20));
33771 let conn = Connection::open(&summaries_db_path).unwrap();
33772 conn.pragma_update(None, "user_version", 1).unwrap();
33773 conn.pragma_update(None, "user_version", 0).unwrap();
33774 drop(conn);
33775
33776 let second = traversal_source_watermark(root, &hint, None, true)
33777 .expect("second watermark call must succeed")
33778 .expect("second watermark must produce a hash");
33779 assert_eq!(
33780 first, second,
33781 "metadata-only summaries.db churn must not invalidate the source watermark"
33782 );
33783
33784 summary.entities = Some(vec![summarize::Entity {
33785 name: "GraphCache".to_string(),
33786 kind: "type".to_string(),
33787 description: "Stable full-projection cache input".to_string(),
33788 }]);
33789 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33790 summary_db.delete_by_file("src/main.rs").unwrap();
33791 summary_db.insert(&summary).unwrap();
33792 drop(summary_db);
33793
33794 let third = traversal_source_watermark(root, &hint, None, true)
33795 .expect("third watermark call must succeed")
33796 .expect("third watermark must produce a hash");
33797 assert_ne!(
33798 first, third,
33799 "semantic summary row changes must invalidate the source watermark"
33800 );
33801 }
33802
33803 #[test]
33804 fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
33805 let dir = tempfile::tempdir().unwrap();
33809 let root = dir.path();
33810 std::fs::create_dir_all(root.join("src")).unwrap();
33811 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33812 let source = root.join("src/lib.rs");
33813 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33814 std::fs::write(&source, source_body).unwrap();
33815 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33816 db.rebuild(root).unwrap();
33817 drop(db);
33818
33819 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33820 .unwrap()
33821 .value;
33822 std::thread::sleep(std::time::Duration::from_millis(20));
33823 std::fs::write(&source, source_body).unwrap();
33824 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33825 db.apply_changes(root).unwrap();
33826 drop(db);
33827
33828 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33829 .unwrap()
33830 .value;
33831 assert_eq!(
33832 first, second,
33833 "mtime-only source index churn must not invalidate the full-projection cache"
33834 );
33835 }
33836
33837 #[test]
33838 fn full_projection_source_watermark_ignores_session_markdown_churn() {
33839 let dir = tempfile::tempdir().unwrap();
33844 let root = dir.path();
33845 std::fs::create_dir_all(root.join("src")).unwrap();
33846 std::fs::create_dir_all(root.join("tasks/software")).unwrap();
33847 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33848 std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
33849 let task_doc = root.join("tasks/software/tsift.md");
33850 std::fs::write(
33851 &task_doc,
33852 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
33853 )
33854 .unwrap();
33855 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33856 db.rebuild(root).unwrap();
33857 drop(db);
33858
33859 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33860 .unwrap()
33861 .value;
33862 std::fs::write(
33863 &task_doc,
33864 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
33865 )
33866 .unwrap();
33867 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33868 .unwrap()
33869 .value;
33870 assert_eq!(
33871 first, second,
33872 "session markdown churn must not invalidate the full-projection code/summary cache"
33873 );
33874 }
33875
33876 #[test]
33877 fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
33878 let dir = tempfile::tempdir().unwrap();
33882 let root = dir.path();
33883 std::fs::create_dir_all(root.join("src")).unwrap();
33884 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33885 let source = root.join("src/lib.rs");
33886 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33887 std::fs::write(&source, source_body).unwrap();
33888 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33889 db.rebuild(root).unwrap();
33890 drop(db);
33891
33892 let (_projection, _warnings, _phases, first_stats) =
33893 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33894 assert!(
33895 !first_stats.hit,
33896 "the first full-projection run should populate the cache"
33897 );
33898
33899 std::thread::sleep(std::time::Duration::from_millis(20));
33900 std::fs::write(&source, source_body).unwrap();
33901 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33902 db.apply_changes(root).unwrap();
33903 drop(db);
33904
33905 let (_projection, _warnings, phases, second_stats) =
33906 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33907 assert!(second_stats.hit, "mtime-only churn should still cache-hit");
33908 let source_graph_build = phases
33909 .iter()
33910 .find(|phase| phase.name == "full_projection.source_graph_build")
33911 .expect("cache hit must report source_graph_build");
33912 let projection_rows = phases
33913 .iter()
33914 .find(|phase| phase.name == "full_projection.projection_rows")
33915 .expect("cache hit must report projection_rows");
33916 assert_eq!(source_graph_build.duration_micros, 0);
33917 assert_eq!(projection_rows.duration_micros, 0);
33918 }
33919
33920 #[test]
33921 fn build_token_capped_preview_within_cap() {
33922 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 2", "}"];
33923 let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
33924 assert!(!capped.was_capped);
33925 assert_eq!(capped.preview.len(), 3);
33926 assert_eq!(capped.capped_end, 3);
33927 }
33928
33929 #[test]
33930 fn build_token_capped_preview_truncates_long_body() {
33931 let owned: Vec<String> = (0..200)
33932 .map(|i| format!(" let line_{i} = {i};"))
33933 .collect();
33934 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33935 let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
33936 assert!(capped.was_capped);
33937 assert!(capped.preview.len() < 200);
33938 assert!(capped.capped_end < 200);
33939 assert!(!capped.preview.is_empty());
33940 }
33941
33942 #[test]
33943 fn build_token_capped_preview_respects_start_offset() {
33944 let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
33945 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33946 let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
33947 assert!(capped.was_capped);
33948 assert!(capped.capped_end >= 50);
33949 assert!(capped.capped_end < 100);
33950 assert_eq!(capped.preview[0].line, 50);
33951 }
33952
33953 #[test]
33954 fn response_budget_body_token_cap_defaults() {
33955 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
33956 assert_eq!(budget.body_token_cap(), 1500);
33957
33958 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
33959 assert_eq!(budget.body_token_cap(), 500);
33960
33961 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
33962 assert_eq!(budget.body_token_cap(), 3000);
33963 }
33964
33965 #[test]
33966 fn build_token_capped_preview_empty_input() {
33967 let lines: Vec<&str> = vec![];
33968 let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
33969 assert!(!capped.was_capped);
33970 assert!(capped.preview.is_empty());
33971 }
33972
33973 #[test]
33974 fn build_token_capped_preview_single_long_line_fits() {
33975 let lines: Vec<&str> = vec!["short"];
33976 let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
33977 assert!(!capped.was_capped);
33978 assert_eq!(capped.preview.len(), 1);
33979 assert_eq!(capped.capped_end, 1);
33980 }
33981
33982 #[test]
33983 fn edge_index_replaces_from_id_to_id_with_positions() {
33984 let input = serde_json::json!({
33985 "nodes": [
33986 {"id": "symbol:src/lib.rs:foo"},
33987 {"id": "symbol:src/lib.rs:bar"},
33988 {"id": "symbol:src/lib.rs:baz"}
33989 ],
33990 "edges": [
33991 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
33992 {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
33993 ]
33994 });
33995 let result = edge_index_transform(input);
33996 let edges = result.get("edges").unwrap().as_array().unwrap();
33997 assert_eq!(edges.len(), 2);
33998 assert_eq!(edges[0]["from"], 0);
33999 assert_eq!(edges[0]["to"], 1);
34000 assert_eq!(edges[1]["from"], 1);
34001 assert_eq!(edges[1]["to"], 2);
34002 assert!(edges[0].get("from_id").is_none());
34003 assert!(edges[0].get("to_id").is_none());
34004 }
34005
34006 #[test]
34007 fn edge_index_preserves_unresolved_ids_as_strings() {
34008 let input = serde_json::json!({
34009 "nodes": [{"id": "symbol:src/lib.rs:foo"}],
34010 "edges": [
34011 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
34012 ]
34013 });
34014 let result = edge_index_transform(input);
34015 let edge = &result["edges"][0];
34016 assert_eq!(edge["from"], 0);
34017 assert_eq!(edge["to_id"], "symbol:other.rs:missing");
34018 }
34019
34020 #[test]
34021 fn edge_index_noop_without_nodes_and_edges() {
34022 let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
34023 let result = edge_index_transform(input);
34024 assert_eq!(result["report"]["entries"][0]["from_id"], "a");
34025 }
34026}
34027
34028#[derive(Serialize)]
34031struct TableInfo {
34032 name: String,
34033 columns: Vec<ColumnInfo>,
34034 row_count: i64,
34035}
34036
34037#[derive(Serialize)]
34038struct ColumnInfo {
34039 name: String,
34040 #[serde(rename = "type")]
34041 col_type: String,
34042 notnull: bool,
34043 pk: bool,
34044 #[serde(skip_serializing_if = "Option::is_none")]
34045 default_value: Option<String>,
34046}
34047
34048pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
34050 let conn = Connection::open_with_flags(
34051 path,
34052 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
34053 )
34054 .with_context(|| format!("opening database: {}", path.display()))?;
34055 Ok(conn)
34056}
34057
34058pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
34060 let mut stmt = conn.prepare(
34061 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
34062 )?;
34063 let table_names: Vec<String> = stmt
34064 .query_map([], |row| row.get(0))?
34065 .collect::<std::result::Result<Vec<_>, _>>()?;
34066
34067 let mut tables = Vec::new();
34068 for tbl in table_names {
34069 let columns = table_columns(conn, &tbl)?;
34070 let row_count: i64 =
34071 conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
34072 row.get(0)
34073 })?;
34074 tables.push(TableInfo {
34075 name: tbl,
34076 columns,
34077 row_count,
34078 });
34079 }
34080 Ok(tables)
34081}
34082
34083pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
34085 let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
34086 let cols = stmt
34087 .query_map([], |row| {
34088 Ok(ColumnInfo {
34089 name: row.get(1)?,
34090 col_type: row.get::<_, String>(2).unwrap_or_default(),
34091 notnull: row.get::<_, bool>(3).unwrap_or(false),
34092 pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
34093 default_value: row.get(4)?,
34094 })
34095 })?
34096 .collect::<std::result::Result<Vec<_>, _>>()?;
34097 Ok(cols)
34098}
34099
34100pub(crate) fn execute_query(
34102 conn: &Connection,
34103 sql: &str,
34104) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
34105 let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
34106 let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
34107 let col_count = col_names.len();
34108
34109 let mut rows = Vec::new();
34110 let mut query_rows = stmt.query([])?;
34111 while let Some(row) = query_rows.next()? {
34112 let mut vals = Vec::with_capacity(col_count);
34113 for i in 0..col_count {
34114 let val = match row.get_ref(i)? {
34115 rusqlite::types::ValueRef::Null => serde_json::Value::Null,
34116 rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
34117 rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
34118 rusqlite::types::ValueRef::Text(s) => {
34119 serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
34120 }
34121 rusqlite::types::ValueRef::Blob(b) => {
34122 serde_json::Value::String(format!("<blob {} bytes>", b.len()))
34123 }
34124 };
34125 vals.push(val);
34126 }
34127 rows.push(vals);
34128 }
34129 Ok((col_names, rows))
34130}
34131
34132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34133enum DigestRunnerKind {
34134 Test,
34135 Log,
34136}
34137
34138impl DigestRunnerKind {
34139 fn parse(raw: &str) -> Result<Self> {
34140 match raw.trim().to_ascii_lowercase().as_str() {
34141 "test" => Ok(Self::Test),
34142 "log" => Ok(Self::Log),
34143 other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
34144 }
34145 }
34146
34147 fn as_str(self) -> &'static str {
34148 match self {
34149 Self::Test => "test",
34150 Self::Log => "log",
34151 }
34152 }
34153}
34154
34155pub(crate) fn shell_split(s: &str) -> Vec<&str> {
34157 let mut parts = Vec::new();
34158 let mut i = 0;
34159 let bytes = s.as_bytes();
34160 while i < bytes.len() {
34161 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
34163 i += 1;
34164 }
34165 if i >= bytes.len() {
34166 break;
34167 }
34168 let start = i;
34169 if bytes[i] == b'"' || bytes[i] == b'\'' {
34170 let quote = bytes[i];
34171 i += 1;
34172 while i < bytes.len() && bytes[i] != quote {
34173 i += 1;
34174 }
34175 if i < bytes.len() {
34176 i += 1; }
34178 } else {
34179 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
34180 i += 1;
34181 }
34182 }
34183 parts.push(&s[start..i]);
34184 }
34185 parts
34186}
34187
34188pub(crate) fn shell_quote(s: &str) -> String {
34190 let unquoted =
34192 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
34193 &s[1..s.len() - 1]
34194 } else {
34195 s
34196 };
34197
34198 if unquoted
34199 .chars()
34200 .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
34201 {
34202 format!("\"{}\"", unquoted)
34203 } else {
34204 format!(
34205 "\"{}\"",
34206 unquoted.replace('\\', "\\\\").replace('"', "\\\"")
34207 )
34208 }
34209}
34210
34211fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
34212 sift::SearchCoverageSnapshot {
34213 mode: sift::SearchCoverageMode::Sealed,
34214 total_sector_count: 0,
34215 mounted_sector_count: 0,
34216 reused_sector_count: 0,
34217 dirty_sector_count: 0,
34218 completed_dirty_sector_count: 0,
34219 rebuilding_sector_count: 0,
34220 resumed_sector_count: 0,
34221 active_rebuild: None,
34222 }
34223}
34224
34225fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
34226 let total_sector_count = responses
34227 .iter()
34228 .map(|response| response.coverage.total_sector_count)
34229 .sum();
34230 let mounted_sector_count = responses
34231 .iter()
34232 .map(|response| response.coverage.mounted_sector_count)
34233 .sum();
34234 let reused_sector_count = responses
34235 .iter()
34236 .map(|response| response.coverage.reused_sector_count)
34237 .sum();
34238 let dirty_sector_count = responses
34239 .iter()
34240 .map(|response| response.coverage.dirty_sector_count)
34241 .sum();
34242 let completed_dirty_sector_count = responses
34243 .iter()
34244 .map(|response| response.coverage.completed_dirty_sector_count)
34245 .sum();
34246 let rebuilding_sector_count = responses
34247 .iter()
34248 .map(|response| response.coverage.rebuilding_sector_count)
34249 .sum();
34250 let resumed_sector_count = responses
34251 .iter()
34252 .map(|response| response.coverage.resumed_sector_count)
34253 .sum();
34254
34255 let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
34256 sift::SearchCoverageMode::Sealed
34257 } else if completed_dirty_sector_count > 0
34258 || rebuilding_sector_count > 0
34259 || resumed_sector_count > 0
34260 {
34261 sift::SearchCoverageMode::Converging
34262 } else {
34263 sift::SearchCoverageMode::Frontier
34264 };
34265
34266 sift::SearchCoverageSnapshot {
34267 mode,
34268 total_sector_count,
34269 mounted_sector_count,
34270 reused_sector_count,
34271 dirty_sector_count,
34272 completed_dirty_sector_count,
34273 rebuilding_sector_count,
34274 resumed_sector_count,
34275 active_rebuild: responses
34276 .iter()
34277 .find_map(|response| response.coverage.active_rebuild.clone()),
34278 }
34279}
34280
34281fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
34282 sift::SearchResponse {
34283 strategy: strategy.to_string(),
34284 root: root.display().to_string(),
34285 indexed_artifacts: 0,
34286 skipped_artifacts: 0,
34287 coverage: empty_search_coverage(),
34288 hits: Vec::new(),
34289 }
34290}
34291
34292fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
34293 for hit in &mut response.hits {
34294 let path = Path::new(&hit.path);
34295 if path.is_relative() {
34296 hit.path = search_root.join(path).display().to_string();
34297 }
34298 }
34299}
34300
34301fn merge_search_responses(
34302 root: &Path,
34303 strategy: &str,
34304 limit: usize,
34305 responses: Vec<sift::SearchResponse>,
34306) -> sift::SearchResponse {
34307 let indexed_artifacts = responses
34308 .iter()
34309 .map(|response| response.indexed_artifacts)
34310 .sum();
34311 let skipped_artifacts = responses
34312 .iter()
34313 .map(|response| response.skipped_artifacts)
34314 .sum();
34315 let coverage = if responses.is_empty() {
34316 empty_search_coverage()
34317 } else {
34318 aggregate_search_coverage(&responses)
34319 };
34320 let mut hits: Vec<sift::SearchHit> = responses
34321 .into_iter()
34322 .flat_map(|response| response.hits)
34323 .collect();
34324 hits.sort_by(|left, right| {
34325 right
34326 .score
34327 .partial_cmp(&left.score)
34328 .unwrap_or(Ordering::Equal)
34329 .then_with(|| left.path.cmp(&right.path))
34330 .then_with(|| left.location.cmp(&right.location))
34331 });
34332 hits.truncate(limit);
34333 for (rank, hit) in hits.iter_mut().enumerate() {
34334 hit.rank = rank + 1;
34335 }
34336
34337 sift::SearchResponse {
34338 strategy: strategy.to_string(),
34339 root: root.display().to_string(),
34340 indexed_artifacts,
34341 skipped_artifacts,
34342 coverage,
34343 hits,
34344 }
34345}
34346
34347pub(crate) fn federated_sift_search(
34348 root: &Path,
34349 cache_dir: &Path,
34350 query: &str,
34351 limit: usize,
34352 timeout_secs: u64,
34353 strategy: &str,
34354 fts_index_fresh: Option<bool>,
34355) -> Result<sift::SearchResponse> {
34356 let targets = resolve_search_index_targets(root, root, None, true)?;
34357 if targets.is_empty() {
34358 if config::Config::submodule_dirs(root)?.is_empty() {
34359 return run_search_with_timeout(
34360 root,
34361 cache_dir,
34362 query,
34363 limit,
34364 timeout_secs,
34365 strategy,
34366 &[],
34367 fts_index_fresh,
34368 );
34369 }
34370 return Ok(empty_search_response(root, strategy));
34371 }
34372
34373 let mut responses = Vec::with_capacity(targets.len());
34374 for target in &targets {
34375 let mut response = run_search_with_timeout(
34376 &target.source_root,
34377 cache_dir,
34378 query,
34379 limit,
34380 timeout_secs,
34381 strategy,
34382 std::slice::from_ref(target),
34383 fts_index_fresh,
34384 )?;
34385 absolutize_search_hit_paths(&mut response, &target.source_root);
34386 response.root = root.display().to_string();
34387 responses.push(response);
34388 }
34389
34390 Ok(merge_search_responses(root, strategy, limit, responses))
34391}
34392
34393pub(crate) fn federated_symbol_search(
34401 root: &std::path::Path,
34402 query: &str,
34403 limit: usize,
34404 tagpath_opts: &TagpathSearchOpts,
34405) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
34406 let cfg = config::Config::load(root)?;
34407 let submodules = config::Config::submodule_dirs(root)?;
34408 let mut all_hits: Vec<index::SymbolHit> = Vec::new();
34409 let mut combined = TagpathAnnotationDiagnostic::default();
34410 for scope in &submodules {
34411 if !cfg.federation_for_scope(scope) {
34412 continue;
34413 }
34414 let db_path = cfg.db_path_for(root, &scope.id);
34415 if !db_path.exists() {
34416 continue;
34417 }
34418 let db = index::IndexDb::open_read_only(&db_path)?;
34419 let mut hits = db.symbol_search(query, limit)?;
34420 let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
34421 combined.loaded |= diag.loaded;
34422 if diag.stale && !combined.stale {
34423 combined.stale = true;
34424 combined.reason = diag.reason;
34425 }
34426 all_hits.append(&mut hits);
34427 }
34428 all_hits.sort_by(|a, b| {
34429 b.score
34430 .partial_cmp(&a.score)
34431 .unwrap_or(std::cmp::Ordering::Equal)
34432 });
34433 all_hits.truncate(limit);
34434 Ok((all_hits, combined))
34435}
34436
34437#[derive(Debug, Deserialize)]
34438#[serde(tag = "type", rename_all = "lowercase")]
34439enum RipgrepJsonEvent {
34440 Match {
34441 data: RipgrepMatchData,
34442 },
34443 #[serde(other)]
34444 Other,
34445}
34446
34447#[derive(Debug, Deserialize)]
34448struct RipgrepMatchData {
34449 path: RipgrepTextField,
34450 lines: RipgrepTextField,
34451 line_number: Option<usize>,
34452}
34453
34454#[derive(Debug, Deserialize)]
34455struct RipgrepTextField {
34456 text: Option<String>,
34457}
34458
34459pub(crate) fn federated_exact_search(
34460 root: &Path,
34461 query: &str,
34462 limit: usize,
34463 timeout_secs: u64,
34464) -> Result<sift::SearchResponse> {
34465 let cfg = config::Config::load(root)?;
34466 let mut responses = Vec::new();
34467 for scope in config::Config::submodule_dirs(root)? {
34468 if !cfg.federation_for_scope(&scope) {
34469 continue;
34470 }
34471 let mut response =
34472 run_exact_search_with_timeout(std::slice::from_ref(&scope.source_root), query, limit, timeout_secs)?;
34473 absolutize_search_hit_paths(&mut response, &scope.source_root);
34474 response.root = root.display().to_string();
34475 responses.push(response);
34476 }
34477
34478 Ok(merge_search_responses(root, "exact", limit, responses))
34479}
34480
34481pub(crate) fn run_sift_search(
34482 search_path: &Path,
34483 cache_dir: &Path,
34484 query: &str,
34485 limit: usize,
34486 strategy: &str,
34487 fts_index_fresh: Option<bool>,
34496) -> Result<sift::SearchResponse> {
34497 if !fts_search_forced_off() {
34514 let db_path = search_path.join(".tsift/index.db");
34515 let use_fts = match fts_index_fresh {
34516 Some(fresh) => fresh && db_path.exists(),
34517 None => db_path.exists() && index_db_is_fresh_for_fts(&db_path, search_path),
34518 };
34519 if use_fts {
34520 return sift::fts_search(&db_path, search_path, query, limit)
34521 .context("index.db FTS5 search failed");
34522 }
34523 }
34524
34525 let engine = Sift::builder().with_cache_dir(cache_dir).build();
34526 let options = SearchOptions::default()
34527 .with_limit(limit)
34528 .with_strategy(strategy.to_string());
34529 let input = SearchInput::new(search_path, query).with_options(options);
34530 engine.search(input).context("sift search failed")
34531}
34532
34533fn index_db_is_fresh_for_fts(db_path: &Path, search_path: &Path) -> bool {
34542 match index::IndexDb::inspect_read_only(db_path, search_path, false) {
34543 Ok(inspection) => {
34544 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted == 0
34545 }
34546 Err(_) => false,
34547 }
34548}
34549
34550fn fts_search_forced_off() -> bool {
34555 std::env::var("TSIFT_FTS_SEARCH")
34556 .map(|value| fts_flag_value_disabled(&value))
34557 .unwrap_or(false)
34558}
34559
34560fn fts_flag_value_disabled(value: &str) -> bool {
34563 matches!(
34564 value.trim().to_ascii_lowercase().as_str(),
34565 "0" | "false" | "no" | "off"
34566 )
34567}
34568
34569fn exact_search_timeout_message(timeout_secs: u64) -> String {
34570 format!(
34571 "tsift search timed out after {}s (strategy: exact). \
34572 Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
34573 timeout_secs
34574 )
34575}
34576
34577fn exact_search_command(search_paths: &[PathBuf], query: &str) -> Command {
34578 let mut command = Command::new("rg");
34579 command
34580 .arg("--json")
34581 .arg("--fixed-strings")
34582 .arg("--line-number")
34583 .arg("--hidden")
34584 .arg("--")
34585 .arg(query);
34586 if search_paths.is_empty() {
34587 command.arg(Path::new("."));
34588 } else {
34589 command.args(search_paths);
34590 }
34591 command
34592}
34593
34594fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
34595 let observed_unix_secs = SystemTime::now()
34596 .duration_since(UNIX_EPOCH)
34597 .unwrap_or_default()
34598 .as_secs() as i64;
34599 let modified_unix_secs = fs::metadata(path)
34600 .ok()
34601 .and_then(|metadata| metadata.modified().ok())
34602 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
34603 .map(|duration| duration.as_secs() as i64);
34604 sift::ArtifactFreshness {
34605 observed_unix_secs,
34606 modified_unix_secs,
34607 }
34608}
34609
34610fn parse_exact_search_output(
34611 search_path: &Path,
34612 limit: usize,
34613 raw: &str,
34614) -> Result<sift::SearchResponse> {
34615 if limit == 0 {
34616 return Ok(sift::SearchResponse {
34617 strategy: "exact".to_string(),
34618 root: search_path.display().to_string(),
34619 indexed_artifacts: 0,
34620 skipped_artifacts: 0,
34621 coverage: empty_search_coverage(),
34622 hits: Vec::new(),
34623 });
34624 }
34625
34626 let mut hits = Vec::new();
34627 for line in raw.lines() {
34628 let event: RipgrepJsonEvent =
34629 serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
34630 let RipgrepJsonEvent::Match { data } = event else {
34631 continue;
34632 };
34633 let Some(path_text) = data.path.text else {
34634 continue;
34635 };
34636 let Some(lines_text) = data.lines.text else {
34637 continue;
34638 };
34639 let path = PathBuf::from(path_text);
34640 let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
34641 let rank = hits.len() + 1;
34642 hits.push(sift::SearchHit {
34643 artifact_id: format!(
34644 "exact:{}:{}:{}",
34645 path.display(),
34646 data.line_number.unwrap_or(0),
34647 rank
34648 ),
34649 artifact_kind: sift::ContextArtifactKind::File,
34650 path: path.display().to_string(),
34651 rank,
34652 score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
34653 confidence: sift::ScoreConfidence::High,
34654 location: data.line_number.map(|line| format!("line {}", line)),
34655 snippet: snippet.clone(),
34656 provenance: sift::ArtifactProvenance {
34657 adapter: sift::AcquisitionAdapterKind::FileSystem,
34658 source: "ripgrep -F".to_string(),
34659 synthetic: false,
34660 },
34661 freshness: exact_search_file_timestamp(&path),
34662 budget: sift::ArtifactBudget::from_text(&snippet, 1),
34663 });
34664 if hits.len() >= limit {
34665 break;
34666 }
34667 }
34668
34669 Ok(sift::SearchResponse {
34670 strategy: "exact".to_string(),
34671 root: search_path.display().to_string(),
34672 indexed_artifacts: hits.len(),
34673 skipped_artifacts: 0,
34674 coverage: empty_search_coverage(),
34675 hits,
34676 })
34677}
34678
34679fn exact_search_response_from_process(
34680 search_path: &Path,
34681 limit: usize,
34682 status: std::process::ExitStatus,
34683 stdout: &[u8],
34684 stderr: &[u8],
34685) -> Result<sift::SearchResponse> {
34686 if !status.success() && status.code() != Some(1) {
34687 let message = String::from_utf8_lossy(stderr);
34688 let trimmed = message.trim();
34689 if trimmed.is_empty() {
34690 bail!("ripgrep exact search exited with status {}", status);
34691 }
34692 bail!("{}", trimmed);
34693 }
34694
34695 let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
34696 parse_exact_search_output(search_path, limit, &raw)
34697}
34698
34699fn run_exact_search(search_paths: &[PathBuf], query: &str, limit: usize) -> Result<sift::SearchResponse> {
34700 let output = exact_search_command(search_paths, query)
34701 .output()
34702 .context("running exact search with ripgrep")?;
34703 let root_display = search_paths
34704 .first()
34705 .map(|p| p.as_path())
34706 .unwrap_or_else(|| Path::new("."));
34707 exact_search_response_from_process(
34708 root_display,
34709 limit,
34710 output.status,
34711 &output.stdout,
34712 &output.stderr,
34713 )
34714}
34715
34716pub(crate) fn run_exact_search_with_timeout(
34717 search_paths: &[PathBuf],
34718 query: &str,
34719 limit: usize,
34720 timeout_secs: u64,
34721) -> Result<sift::SearchResponse> {
34722 if timeout_secs == 0 {
34723 return run_exact_search(search_paths, query, limit);
34724 }
34725
34726 let mut child = exact_search_command(search_paths, query)
34727 .stdin(Stdio::null())
34728 .stdout(Stdio::piped())
34729 .stderr(Stdio::piped())
34730 .spawn()
34731 .context("spawning timed exact search worker")?;
34732
34733 let timeout = Duration::from_secs(timeout_secs);
34734 let status = wait_for_child_exit(&mut child, timeout)
34735 .context("waiting for timed exact search worker")?;
34736 if status.is_none() {
34737 let _ = child.kill();
34738 let _ = child.wait();
34739 bail!("{}", exact_search_timeout_message(timeout_secs));
34740 }
34741
34742 let status = status.unwrap();
34743 let stdout = read_child_stdout(&mut child)?;
34744 let stderr = read_child_stderr(&mut child)?;
34745 let root_display = search_paths
34746 .first()
34747 .map(|p| p.as_path())
34748 .unwrap_or_else(|| Path::new("."));
34749 exact_search_response_from_process(
34750 root_display,
34751 limit,
34752 status,
34753 stdout.as_bytes(),
34754 stderr.as_bytes(),
34755 )
34756}
34757
34758#[allow(clippy::too_many_arguments)]
34759pub(crate) fn run_search_with_timeout(
34760 search_path: &Path,
34761 cache_dir: &Path,
34762 query: &str,
34763 limit: usize,
34764 timeout_secs: u64,
34765 strategy: &str,
34766 search_targets: &[SearchIndexTarget],
34767 fts_index_fresh: Option<bool>,
34770) -> Result<sift::SearchResponse> {
34771 if timeout_secs == 0 {
34772 return run_sift_search(search_path, cache_dir, query, limit, strategy, fts_index_fresh);
34773 }
34774
34775 let output_path = next_search_worker_output_path();
34776 let mut command = Command::new(
34777 std::env::current_exe().context("resolving tsift executable for timed search")?,
34778 );
34779 command
34780 .arg("__search-worker")
34781 .arg("--path")
34782 .arg(search_path)
34783 .arg("--cache-dir")
34784 .arg(cache_dir)
34785 .arg("--query")
34786 .arg(query)
34787 .arg("--limit")
34788 .arg(limit.to_string())
34789 .arg("--strategy")
34790 .arg(strategy)
34791 .arg("--output")
34792 .arg(&output_path);
34793 if let Some(fresh) = fts_index_fresh {
34794 command.arg("--fts-index-fresh").arg(fresh.to_string());
34795 }
34796 let mut child = command
34797 .stdin(Stdio::null())
34798 .stdout(Stdio::null())
34799 .stderr(Stdio::piped())
34800 .spawn()
34801 .context("spawning timed sift search worker")?;
34802
34803 let timeout = Duration::from_secs(timeout_secs);
34804 let status =
34805 wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
34806 if status.is_none() {
34807 let _ = child.kill();
34808 let _ = child.wait();
34809 let _ = fs::remove_file(&output_path);
34810 bail!(
34811 "{}",
34812 search_timeout_message(timeout_secs, strategy, search_targets)?
34813 );
34814 }
34815
34816 let status = status.unwrap();
34817 let stderr = read_child_stderr(&mut child)?;
34818 if !status.success() {
34819 let _ = fs::remove_file(&output_path);
34820 let message = stderr.trim();
34821 if message.is_empty() {
34822 bail!("sift search worker exited with status {}", status);
34823 }
34824 bail!("{}", message);
34825 }
34826
34827 let raw = fs::read_to_string(&output_path)
34828 .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
34829 let _ = fs::remove_file(&output_path);
34830 serde_json::from_str(&raw).context("parsing search worker output")
34831}
34832
34833fn next_search_worker_output_path() -> PathBuf {
34834 let stamp = SystemTime::now()
34835 .duration_since(UNIX_EPOCH)
34836 .unwrap_or_default()
34837 .as_nanos();
34838 std::env::temp_dir().join(format!(
34839 "tsift-search-{}-{}.json",
34840 std::process::id(),
34841 stamp
34842 ))
34843}
34844
34845fn wait_for_child_exit(
34846 child: &mut std::process::Child,
34847 timeout: Duration,
34848) -> Result<Option<std::process::ExitStatus>> {
34849 let started = Instant::now();
34850 loop {
34851 if let Some(status) = child.try_wait()? {
34852 return Ok(Some(status));
34853 }
34854 if started.elapsed() >= timeout {
34855 return Ok(None);
34856 }
34857 let remaining = timeout.saturating_sub(started.elapsed());
34858 std::thread::sleep(remaining.min(Duration::from_millis(10)));
34859 }
34860}
34861
34862fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
34863 let mut stderr = String::new();
34864 if let Some(mut pipe) = child.stderr.take() {
34865 pipe.read_to_string(&mut stderr)
34866 .context("reading search worker stderr")?;
34867 }
34868 Ok(stderr)
34869}
34870
34871fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
34872 let mut stdout = String::new();
34873 if let Some(mut pipe) = child.stdout.take() {
34874 pipe.read_to_string(&mut stdout)
34875 .context("reading search worker stdout")?;
34876 }
34877 Ok(stdout)
34878}
34879
34880pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
34881 if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
34882 fs::write(&path, std::process::id().to_string())
34883 .with_context(|| format!("writing search worker pid file: {path}"))?;
34884 }
34885 if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
34886 let delay_ms = ms
34887 .parse::<u64>()
34888 .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
34889 std::thread::sleep(Duration::from_millis(delay_ms));
34890 }
34891 Ok(())
34892}
34893
34894#[cfg(test)]
34895thread_local! {
34896 static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
34897}
34898
34899#[cfg(test)]
34900enum SearchPostPrecheckLockMode {
34901 RollbackJournal,
34902 Wal,
34903}
34904
34905#[cfg(test)]
34906struct SearchPostPrecheckLockHook {
34907 db_path: PathBuf,
34908 mode: SearchPostPrecheckLockMode,
34909}
34910
34911#[cfg(test)]
34912struct SearchPostPrecheckLockGuard;
34913
34914#[cfg(test)]
34915impl Drop for SearchPostPrecheckLockGuard {
34916 fn drop(&mut self) {
34917 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34918 hook.borrow_mut().take();
34919 });
34920 }
34921}
34922
34923#[cfg(test)]
34924fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34925 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
34926}
34927
34928#[cfg(test)]
34929fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34930 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
34931}
34932
34933#[cfg(test)]
34934fn install_search_post_precheck_lock_hook(
34935 db_path: PathBuf,
34936 mode: SearchPostPrecheckLockMode,
34937) -> SearchPostPrecheckLockGuard {
34938 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34939 assert!(
34940 hook.borrow().is_none(),
34941 "search post-precheck lock hook already installed"
34942 );
34943 *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
34944 });
34945 SearchPostPrecheckLockGuard
34946}
34947
34948#[cfg(test)]
34949pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34950 let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
34951 return Ok(());
34952 };
34953 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
34954 std::thread::spawn(move || {
34955 let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
34956 match hook.mode {
34957 SearchPostPrecheckLockMode::RollbackJournal => {
34958 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
34959 .expect("acquiring rollback-journal hook lock");
34960 fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
34961 .expect("writing rollback journal marker");
34962 }
34963 SearchPostPrecheckLockMode::Wal => {
34964 conn.execute_batch(
34965 "PRAGMA journal_mode=WAL;
34966 PRAGMA wal_autocheckpoint=0;
34967 CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
34968 INSERT INTO search_wal_lock_probe DEFAULT VALUES;
34969 PRAGMA locking_mode=EXCLUSIVE;
34970 BEGIN EXCLUSIVE;",
34971 )
34972 .expect("acquiring WAL hook lock");
34973 assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
34974 }
34975 }
34976 ready_tx.send(()).expect("signaling search lock hook");
34977 std::thread::sleep(Duration::from_millis(200));
34978 drop(conn);
34979 let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
34980 });
34981 ready_rx
34982 .recv_timeout(Duration::from_secs(1))
34983 .context("waiting for search post-precheck lock hook")?;
34984 Ok(())
34985}
34986
34987#[cfg(not(test))]
34988pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34989 Ok(())
34990}