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 "gdscript" | "godot" => "gd",
16535 "shell" | "sh" | "zsh" => "bash",
16536 other => other,
16537 };
16538 let lang = graph::Lang::from_extension(extension)?;
16539 (lang.name() != "markdown").then_some(lang)
16540}
16541
16542fn markdown_embedded_ast_span_handle(
16543 file: &str,
16544 language: &str,
16545 name: &str,
16546 kind: &str,
16547 start_byte: usize,
16548 end_byte: usize,
16549) -> String {
16550 stable_handle(
16551 "span",
16552 &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
16553 )
16554}
16555
16556fn markdown_embedded_symbols(
16557 file: &str,
16558 source: &[u8],
16559 body_start_byte: Option<usize>,
16560 body_end_byte: Option<usize>,
16561 fence_language: Option<&str>,
16562) -> Vec<MarkdownEmbeddedSymbol> {
16563 let Some(fence_language) = fence_language else {
16564 return Vec::new();
16565 };
16566 let Some(lang) = markdown_embedded_lang(fence_language) else {
16567 return Vec::new();
16568 };
16569 let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
16570 return Vec::new();
16571 };
16572 let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
16573 else {
16574 return Vec::new();
16575 };
16576 if body.is_empty() {
16577 return Vec::new();
16578 }
16579
16580 let Ok(symbols) = lang.extract_symbols(body) else {
16581 return Vec::new();
16582 };
16583 let language = lang.name().to_string();
16584 symbols
16585 .into_iter()
16586 .map(|symbol| {
16587 let start_byte = body_start_byte.saturating_add(symbol.start_byte);
16588 let end_byte = body_start_byte.saturating_add(symbol.end_byte);
16589 let body_start = symbol
16590 .body_start_byte
16591 .map(|byte| body_start_byte.saturating_add(byte));
16592 let body_end = symbol
16593 .body_end_byte
16594 .map(|byte| body_start_byte.saturating_add(byte));
16595 let start_line = source_line_for_byte(source, start_byte);
16596 let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
16597 MarkdownEmbeddedSymbol {
16598 handle: markdown_embedded_ast_span_handle(
16599 file,
16600 &language,
16601 &symbol.name,
16602 &symbol.kind,
16603 start_byte,
16604 end_byte,
16605 ),
16606 name: symbol.name,
16607 kind: symbol.kind,
16608 language: language.clone(),
16609 node_kind: symbol.node_kind,
16610 start_byte,
16611 end_byte,
16612 start_line,
16613 end_line,
16614 body_start_byte: body_start,
16615 body_end_byte: body_end,
16616 body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
16617 body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
16618 }
16619 })
16620 .collect()
16621}
16622
16623fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
16624 let start = start_byte.min(source.len());
16625 let line_start = source[..start]
16626 .iter()
16627 .rposition(|value| *value == b'\n')
16628 .map(|pos| pos + 1)
16629 .unwrap_or(0);
16630 let line_end = source[start..]
16631 .iter()
16632 .position(|value| *value == b'\n')
16633 .map(|pos| start + pos)
16634 .unwrap_or(source.len());
16635 std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
16636}
16637
16638fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
16639 let line = markdown_source_line(source, start_byte);
16640 let trimmed = line.trim_start();
16641 for marker in ["-", "*", "+"] {
16642 if trimmed
16643 .strip_prefix(marker)
16644 .and_then(|rest| rest.strip_prefix(' '))
16645 .is_some()
16646 {
16647 return (Some(marker.to_string()), None);
16648 }
16649 }
16650
16651 let digit_end = trimmed
16652 .find(|ch: char| !ch.is_ascii_digit())
16653 .unwrap_or(trimmed.len());
16654 let (digits, rest) = trimmed.split_at(digit_end);
16655 if !digits.is_empty() {
16656 for marker in [".", ")"] {
16657 if rest
16658 .strip_prefix(marker)
16659 .and_then(|value| value.strip_prefix(' '))
16660 .is_some()
16661 {
16662 return (
16663 Some(format!("{digits}{marker}")),
16664 digits.parse::<usize>().ok(),
16665 );
16666 }
16667 }
16668 }
16669 (None, None)
16670}
16671
16672fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
16673 let line = markdown_source_line(source, start_byte);
16674 let trimmed = line.trim_start();
16675 ["```", "~~~"]
16676 .into_iter()
16677 .find(|marker| trimmed.starts_with(marker))
16678 .map(str::to_string)
16679}
16680
16681fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16682 let mut nodes = graph::Lang::Markdown
16683 .extract_symbols(source)
16684 .context("extracting Markdown AST nodes")?
16685 .into_iter()
16686 .map(|symbol| {
16687 let body_start_byte = symbol.body_start_byte;
16688 let body_end_byte = symbol.body_end_byte;
16689 let span_handle = ast_span_handle(
16690 file,
16691 &symbol.name,
16692 &symbol.kind,
16693 symbol.start_byte,
16694 symbol.end_byte,
16695 );
16696 MarkdownAstRawNode {
16697 handle: stable_handle(
16698 "mdast",
16699 &format!(
16700 "{}:{}:{}:{}:{}",
16701 file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16702 ),
16703 ),
16704 span_handle,
16705 name: symbol.name,
16706 kind: symbol.kind.clone(),
16707 block_kind: markdown_ast_block_kind(&symbol.kind),
16708 node_kind: symbol.node_kind,
16709 start_byte: symbol.start_byte,
16710 end_byte: symbol.end_byte,
16711 body_start_byte,
16712 body_end_byte,
16713 }
16714 })
16715 .collect::<Vec<_>>();
16716 nodes.sort_by(|left, right| {
16717 left.start_byte
16718 .cmp(&right.start_byte)
16719 .then(left.end_byte.cmp(&right.end_byte))
16720 .then(left.kind.cmp(&right.kind))
16721 .then(left.name.cmp(&right.name))
16722 });
16723 Ok(nodes)
16724}
16725
16726pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16727 let source_hash = blake3::hash(source).to_hex().to_string();
16728 let cache_key = format!("{file}:{source_hash}");
16729 let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16730 if let Some(entry) = cache
16731 .lock()
16732 .expect("markdown ast cache poisoned")
16733 .get(&cache_key)
16734 {
16735 return Ok(MarkdownAstProjection {
16736 source_hash: entry.source_hash.clone(),
16737 nodes: entry.nodes.clone(),
16738 parse_duration_micros: entry.parse_duration_micros,
16739 cache_hit: true,
16740 });
16741 }
16742
16743 let started = Instant::now();
16744 let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16745 let parse_duration_micros = started.elapsed().as_micros();
16746 cache.lock().expect("markdown ast cache poisoned").insert(
16747 cache_key,
16748 MarkdownAstCacheEntry {
16749 source_hash: source_hash.clone(),
16750 nodes: nodes.clone(),
16751 parse_duration_micros,
16752 },
16753 );
16754 Ok(MarkdownAstProjection {
16755 source_hash,
16756 nodes,
16757 parse_duration_micros,
16758 cache_hit: false,
16759 })
16760}
16761
16762fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16763 MarkdownAstCacheReport {
16764 source_hash: projection.source_hash.clone(),
16765 cache_hit: projection.cache_hit,
16766 parse_duration_micros: projection.parse_duration_micros,
16767 node_count: projection.nodes.len(),
16768 section_count: projection
16769 .nodes
16770 .iter()
16771 .filter(|node| node.kind == "heading")
16772 .count(),
16773 list_item_count: projection
16774 .nodes
16775 .iter()
16776 .filter(|node| node.kind == "list_item")
16777 .count(),
16778 code_block_count: projection
16779 .nodes
16780 .iter()
16781 .filter(|node| node.kind == "code_block")
16782 .count(),
16783 }
16784}
16785
16786fn markdown_ast_node_direct_child_count(
16787 node: &MarkdownAstRawNode,
16788 nodes: &[MarkdownAstRawNode],
16789) -> usize {
16790 nodes
16791 .iter()
16792 .filter(|candidate| {
16793 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16794 })
16795 .count()
16796}
16797
16798fn markdown_ast_outline_entry(
16799 root: &Path,
16800 file: &str,
16801 source: &[u8],
16802 nodes: &[MarkdownAstRawNode],
16803 node: &MarkdownAstRawNode,
16804 max_bytes: usize,
16805) -> MarkdownAstOutlineEntry {
16806 let line = source_line_for_byte(source, node.start_byte);
16807 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16808 MarkdownAstOutlineEntry {
16809 handle: node.handle.clone(),
16810 span_handle: node.span_handle.clone(),
16811 name: truncate_for_budget(&node.name, max_bytes),
16812 kind: node.kind.clone(),
16813 block_kind: node.block_kind.clone(),
16814 line,
16815 end_line,
16816 section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16817 child_count: markdown_ast_node_direct_child_count(node, nodes),
16818 expand: markdown_ast_command(root, file, Some(&node.handle)),
16819 }
16820}
16821
16822fn markdown_ast_outline_entries(
16823 root: &Path,
16824 file: &str,
16825 source: &[u8],
16826 nodes: &[MarkdownAstRawNode],
16827 limit: usize,
16828 max_bytes: usize,
16829) -> Vec<MarkdownAstOutlineEntry> {
16830 let mut headings = nodes
16831 .iter()
16832 .filter(|node| node.kind == "heading")
16833 .collect::<Vec<_>>();
16834 let mut blocks = nodes
16835 .iter()
16836 .filter(|node| node.kind != "heading")
16837 .collect::<Vec<_>>();
16838 headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16839 blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16840 headings
16841 .into_iter()
16842 .chain(blocks)
16843 .take(limit)
16844 .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16845 .collect()
16846}
16847
16848fn markdown_ast_node_intersects_lines(
16849 source: &[u8],
16850 node: &MarkdownAstRawNode,
16851 start: usize,
16852 end: usize,
16853) -> bool {
16854 let line = source_line_for_byte(source, node.start_byte);
16855 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16856 line <= end && end_line >= start
16857}
16858
16859fn source_read_markdown_projection(
16860 root: &Path,
16861 file: &str,
16862 source: &[u8],
16863 start: usize,
16864 end: usize,
16865 budget: ResponseBudget,
16866) -> Result<SourceReadMarkdownProjection> {
16867 let projection = markdown_ast_projection(file, source)?;
16868 let visible_nodes = projection
16869 .nodes
16870 .iter()
16871 .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16872 .collect::<Vec<_>>();
16873 let mut outline_nodes = visible_nodes.clone();
16874 outline_nodes.sort_by_key(|node| {
16875 (
16876 node.kind != "heading",
16877 node.start_byte,
16878 node.end_byte,
16879 node.name.as_str(),
16880 )
16881 });
16882 let outline = outline_nodes
16883 .into_iter()
16884 .take(budget.preview_items())
16885 .map(|node| {
16886 markdown_ast_outline_entry(
16887 root,
16888 file,
16889 source,
16890 &projection.nodes,
16891 node,
16892 budget.preview_bytes(),
16893 )
16894 })
16895 .collect::<Vec<_>>();
16896 Ok(SourceReadMarkdownProjection {
16897 handle: stable_handle(
16898 "mdproj",
16899 &format!("{file}:{start}:{end}:{}", projection.source_hash),
16900 ),
16901 mode: "window_outline".to_string(),
16902 total_nodes: projection.nodes.len(),
16903 visible_nodes: visible_nodes.len(),
16904 outline,
16905 expand: markdown_ast_command(root, file, None),
16906 })
16907}
16908
16909fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16910 if parent.handle == child.handle {
16911 return false;
16912 }
16913 parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16914}
16915
16916fn markdown_ast_parent_handle(
16917 node: &MarkdownAstRawNode,
16918 nodes: &[MarkdownAstRawNode],
16919) -> Option<String> {
16920 nodes
16921 .iter()
16922 .filter(|candidate| markdown_ast_contains(candidate, node))
16923 .min_by_key(|candidate| {
16924 (
16925 candidate.end_byte.saturating_sub(candidate.start_byte),
16926 candidate.start_byte,
16927 )
16928 })
16929 .map(|candidate| candidate.handle.clone())
16930}
16931
16932fn markdown_ast_child_handles(
16933 node: &MarkdownAstRawNode,
16934 nodes: &[MarkdownAstRawNode],
16935 limit: usize,
16936) -> Vec<String> {
16937 nodes
16938 .iter()
16939 .filter(|candidate| {
16940 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16941 })
16942 .take(limit)
16943 .map(|candidate| candidate.handle.clone())
16944 .collect()
16945}
16946
16947fn markdown_ast_section_nodes<'a>(
16948 node: &MarkdownAstRawNode,
16949 nodes: &'a [MarkdownAstRawNode],
16950) -> Vec<&'a MarkdownAstRawNode> {
16951 let mut headings = nodes
16952 .iter()
16953 .filter(|candidate| candidate.kind == "heading")
16954 .filter(|candidate| {
16955 candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16956 })
16957 .collect::<Vec<_>>();
16958 headings.sort_by(|left, right| {
16959 left.start_byte
16960 .cmp(&right.start_byte)
16961 .then(left.end_byte.cmp(&right.end_byte))
16962 .then(left.name.cmp(&right.name))
16963 });
16964 headings
16965}
16966
16967fn markdown_ast_node_metadata(
16968 file: &str,
16969 node: &MarkdownAstRawNode,
16970 source: &[u8],
16971 nodes: &[MarkdownAstRawNode],
16972) -> MarkdownAstNodeMetadata {
16973 let section_nodes = markdown_ast_section_nodes(node, nodes);
16974 let section_path = section_nodes
16975 .iter()
16976 .map(|heading| heading.name.clone())
16977 .collect::<Vec<_>>();
16978 let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16979 let heading_level = (node.kind == "heading")
16980 .then(|| markdown_heading_level(source, node.start_byte))
16981 .flatten();
16982 let (list_marker, list_order) = if node.kind == "list_item" {
16983 markdown_list_attributes(source, node.start_byte)
16984 } else {
16985 (None, None)
16986 };
16987 let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16988 let embedded_symbols = if node.kind == "code_block" {
16989 markdown_embedded_symbols(
16990 file,
16991 source,
16992 node.body_start_byte,
16993 node.body_end_byte,
16994 fence_language.as_deref(),
16995 )
16996 } else {
16997 Vec::new()
16998 };
16999 MarkdownAstNodeMetadata {
17000 heading_level,
17001 section_path,
17002 section_handle,
17003 list_depth: (node.kind == "list_item")
17004 .then(|| markdown_list_depth(source, node.start_byte)),
17005 list_marker,
17006 list_order,
17007 fence_language,
17008 fence_marker: (node.kind == "code_block")
17009 .then(|| markdown_fence_marker(source, node.start_byte))
17010 .flatten(),
17011 embedded_symbols,
17012 }
17013}
17014
17015fn markdown_ast_node_expand(
17016 root: &Path,
17017 file: &str,
17018 node: &MarkdownAstRawNode,
17019 source: &[u8],
17020) -> MarkdownAstNodeExpand {
17021 let start_line = source_line_for_byte(source, node.start_byte);
17022 let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
17023 let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
17024 let body_start_line = node
17025 .body_start_byte
17026 .map(|byte| source_line_for_byte(source, byte))
17027 .unwrap_or(start_line);
17028 let body_end_line = node
17029 .body_end_byte
17030 .map(|byte| source_line_for_end_byte(source, byte))
17031 .unwrap_or(end_line)
17032 .max(body_start_line);
17033 let body_line_count = body_end_line
17034 .saturating_sub(body_start_line)
17035 .saturating_add(1)
17036 .max(1);
17037 MarkdownAstNodeExpand {
17038 source_window: source_read_command(root, file, start_line, line_count),
17039 source_body: source_read_command(root, file, body_start_line, body_line_count),
17040 symbol_read: source_symbol_read_command(root, &node.name, file),
17041 edit_intents: markdown_edit_intents_command(root),
17042 }
17043}
17044
17045fn markdown_ast_node(
17046 root: &Path,
17047 file: &str,
17048 node: &MarkdownAstRawNode,
17049 source: &[u8],
17050 nodes: &[MarkdownAstRawNode],
17051 child_limit: usize,
17052) -> MarkdownAstNode {
17053 let line = source_line_for_byte(source, node.start_byte);
17054 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
17055 let body_byte_span = node
17056 .body_start_byte
17057 .zip(node.body_end_byte)
17058 .map(|(start, end)| SourceByteRangePreview { start, end });
17059 MarkdownAstNode {
17060 handle: node.handle.clone(),
17061 span_handle: node.span_handle.clone(),
17062 name: node.name.clone(),
17063 kind: node.kind.clone(),
17064 block_kind: node.block_kind.clone(),
17065 node_kind: node.node_kind.clone(),
17066 line,
17067 end_line,
17068 byte_span: SourceByteRangePreview {
17069 start: node.start_byte,
17070 end: node.end_byte,
17071 },
17072 body_byte_span,
17073 parent_handle: markdown_ast_parent_handle(node, nodes),
17074 child_handles: markdown_ast_child_handles(node, nodes, child_limit),
17075 metadata: markdown_ast_node_metadata(file, node, source, nodes),
17076 expand: markdown_ast_node_expand(root, file, node, source),
17077 }
17078}
17079
17080pub(crate) fn stored_symbol_ast_span(
17081 symbol: &index::StoredSymbol,
17082 source: &[u8],
17083 symbols: &[index::StoredSymbol],
17084 child_limit: usize,
17085) -> Option<AstSpanPreview> {
17086 let file_symbols = symbols.iter().collect::<Vec<_>>();
17087 stored_symbol_ast_span_in_file(symbol, source, &file_symbols, child_limit)
17088}
17089
17090fn stored_symbol_ast_span_in_file(
17091 symbol: &index::StoredSymbol,
17092 source: &[u8],
17093 symbols: &[&index::StoredSymbol],
17094 child_limit: usize,
17095) -> Option<AstSpanPreview> {
17096 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
17097 let node_kind = symbol.node_kind.clone()?;
17098 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17099 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17100 Some(AstSpanPreview {
17101 handle: ast_span_handle(
17102 &symbol.file,
17103 &symbol.name,
17104 &symbol.kind,
17105 start_byte,
17106 end_byte,
17107 ),
17108 node_kind,
17109 start_byte,
17110 end_byte,
17111 start_line: source_line_for_byte(source, start_byte),
17112 end_line: source_line_for_end_byte(source, end_byte),
17113 body_start_byte,
17114 body_end_byte,
17115 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17116 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17117 parent_handle: stored_symbol_parent_span_handle_in_file(symbol, symbols),
17118 child_handles: stored_symbol_child_span_handles_in_file(symbol, symbols, child_limit),
17119 markdown: markdown_stored_symbol_metadata_in_file(symbol, source, symbols),
17120 })
17121}
17122
17123pub(crate) fn symbol_hit_ast_span(
17124 symbol: &index::SymbolHit,
17125 source: &[u8],
17126) -> Option<AstSpanPreview> {
17127 let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
17128 let node_kind = symbol.node_kind.clone()?;
17129 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17130 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17131 Some(AstSpanPreview {
17132 handle: ast_span_handle(
17133 &symbol.file,
17134 &symbol.name,
17135 &symbol.kind,
17136 start_byte,
17137 end_byte,
17138 ),
17139 node_kind,
17140 start_byte,
17141 end_byte,
17142 start_line: source_line_for_byte(source, start_byte),
17143 end_line: source_line_for_end_byte(source, end_byte),
17144 body_start_byte,
17145 body_end_byte,
17146 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17147 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17148 parent_handle: None,
17149 child_handles: Vec::new(),
17150 markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
17151 })
17152}
17153
17154pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
17155 usize::try_from(symbol.line)
17156 .ok()
17157 .and_then(|line| line.checked_add(1))
17158 .unwrap_or(1)
17159}
17160
17161pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
17162 symbol
17163 .end_line
17164 .and_then(|line| usize::try_from(line).ok())
17165 .and_then(|line| line.checked_add(1))
17166}
17167
17168fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
17169 if end == 0 {
17170 return false;
17171 }
17172 let symbol_start = source_symbol_line(symbol);
17173 let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
17174 symbol_start <= end && symbol_end >= start
17175}
17176
17177#[allow(clippy::too_many_arguments)]
17178fn load_source_symbols(
17179 root: &Path,
17180 file_abs: &Path,
17181 file_display: &str,
17182 source: &[u8],
17183 scope: Option<&str>,
17184 start: usize,
17185 end: usize,
17186 limit: usize,
17187 max_bytes: usize,
17188 warnings: &mut Vec<String>,
17189) -> Vec<SourceSymbolRef> {
17190 let target = match resolve_query_index_target(root, file_abs, scope) {
17191 Ok(target) => target,
17192 Err(err) => {
17193 warnings.push(format!("index refs unavailable: {err:#}"));
17194 return Vec::new();
17195 }
17196 };
17197 if let Err(err) = ensure_query_index_current(root, &target) {
17206 warnings.push(format!("index refs unavailable: {err:#}"));
17207 return Vec::new();
17208 }
17209 let db_path = target.db_path;
17210 if !db_path.exists() {
17211 warnings.push(format!(
17212 "index refs unavailable: no index found at {}",
17213 db_path.display()
17214 ));
17215 return Vec::new();
17216 }
17217
17218 let db = match index::IndexDb::open_read_only_resilient(&db_path) {
17219 Ok(db) => db,
17220 Err(err) => {
17221 warnings.push(format!("index refs unavailable: {err:#}"));
17222 return Vec::new();
17223 }
17224 };
17225
17226 let file_key = file_abs.to_string_lossy().to_string();
17227 let symbols = match db.symbols_for_file(&file_key) {
17228 Ok(symbols) => symbols,
17229 Err(err) => {
17230 warnings.push(format!("symbol refs unavailable: {err:#}"));
17231 return Vec::new();
17232 }
17233 };
17234
17235 symbols
17236 .iter()
17237 .filter(|symbol| source_symbol_intersects(symbol, start, end))
17238 .take(limit)
17239 .map(|symbol| {
17240 let line = source_symbol_line(symbol);
17241 let end_line = source_symbol_end_line(symbol);
17242 let handle = stable_handle(
17243 "ssym",
17244 &format!("{}:{}:{}", file_display, symbol.name, line),
17245 );
17246 SourceSymbolRef {
17247 handle,
17248 name: truncate_for_budget(&symbol.name, max_bytes),
17249 kind: symbol.kind.clone(),
17250 language: symbol.language.clone(),
17251 file: file_display.to_string(),
17252 line,
17253 end_line,
17254 signature: symbol
17255 .signature
17256 .clone()
17257 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17258 span: stored_symbol_ast_span(symbol, source, &symbols, limit),
17259 expand: source_symbol_read_command(root, &symbol.name, file_display),
17260 }
17261 })
17262 .collect()
17263}
17264
17265fn load_source_summaries(
17266 root: &Path,
17267 file_display: &str,
17268 limit: usize,
17269 max_bytes: usize,
17270 warnings: &mut Vec<String>,
17271) -> Vec<SourceSummaryRef> {
17272 let db_path = root.join(".tsift/summaries.db");
17273 if !db_path.exists() {
17274 return Vec::new();
17275 }
17276 let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
17277 Ok(db) => db,
17278 Err(err) => {
17279 warnings.push(format!("summary refs unavailable: {err:#}"));
17280 return Vec::new();
17281 }
17282 };
17283 let summaries = match db.get_by_file(file_display) {
17284 Ok(summaries) => summaries,
17285 Err(err) => {
17286 warnings.push(format!("summary refs unavailable: {err:#}"));
17287 return Vec::new();
17288 }
17289 };
17290
17291 summaries
17292 .into_iter()
17293 .take(limit)
17294 .map(|summary| SourceSummaryRef {
17295 handle: stable_handle(
17296 "sum",
17297 &format!(
17298 "{}:{}:{}",
17299 summary.file_path, summary.symbol_name, summary.id
17300 ),
17301 ),
17302 symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
17303 file_path: summary.file_path,
17304 summary: truncate_for_budget(&summary.summary, max_bytes),
17305 expand: source_summary_expand_command(root, &summary.symbol_name),
17306 })
17307 .collect()
17308}
17309
17310fn cmd_markdown_ast(
17311 file: &Path,
17312 path: &Path,
17313 node: Option<&str>,
17314 format: OutputFormat,
17315 absolute: bool,
17316 budget: ResponseBudget,
17317) -> Result<()> {
17318 let root = lint::resolve_project_root_or_canonical_path(path)?;
17319 let file_abs = resolve_source_file(&root, file)?;
17320 if !is_markdown_path(&file_abs) {
17321 bail!(
17322 "markdown-ast only supports Markdown files (.md/.mdx): {}",
17323 file_abs.display()
17324 );
17325 }
17326 let file_display = if absolute {
17327 file_abs.to_string_lossy().to_string()
17328 } else {
17329 relativize_pathbuf(&file_abs, &root)
17330 .to_string_lossy()
17331 .to_string()
17332 };
17333 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17334 let text = String::from_utf8_lossy(&source);
17335 let total_lines = text.lines().count();
17336 let projection = markdown_ast_projection(&file_display, &source)?;
17337 let raw_nodes = &projection.nodes;
17338 let max_items = budget.preview_items();
17339 let max_bytes = budget.preview_bytes();
17340
17341 let selected_nodes = if let Some(handle) = node {
17342 let matches = raw_nodes
17343 .iter()
17344 .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
17345 .collect::<Vec<_>>();
17346 if matches.is_empty() {
17347 bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
17348 }
17349 matches
17350 } else {
17351 raw_nodes.iter().take(max_items).collect::<Vec<_>>()
17352 };
17353 let nodes = selected_nodes
17354 .into_iter()
17355 .map(|raw| {
17356 let mut node =
17357 markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
17358 node.name = truncate_for_budget(&node.name, max_bytes);
17359 node
17360 })
17361 .collect::<Vec<_>>();
17362 let outline_started = Instant::now();
17363 let outline = markdown_ast_outline_entries(
17364 &root,
17365 &file_display,
17366 &source,
17367 raw_nodes,
17368 max_items,
17369 max_bytes,
17370 );
17371 let outline_duration_micros = outline_started.elapsed().as_micros();
17372 let projection_preview = MarkdownAstProjectionPreview {
17373 mode: if node.is_some() {
17374 "selected_node".to_string()
17375 } else {
17376 "outline_first".to_string()
17377 },
17378 total_nodes: raw_nodes.len(),
17379 returned_nodes: nodes.len(),
17380 omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
17381 selected_node: node.map(str::to_string),
17382 cache: markdown_ast_cache_report(&projection),
17383 outline,
17384 phase_timings: vec![
17385 MarkdownAstPhaseTiming {
17386 name: "parse_extract".to_string(),
17387 duration_micros: projection.parse_duration_micros,
17388 detail: if projection.cache_hit {
17389 "reused cached tree-sitter Markdown symbol extraction".to_string()
17390 } else {
17391 "tree-sitter Markdown symbol extraction".to_string()
17392 },
17393 },
17394 MarkdownAstPhaseTiming {
17395 name: "outline_projection".to_string(),
17396 duration_micros: outline_duration_micros,
17397 detail: "outline-first section/block preview construction".to_string(),
17398 },
17399 ],
17400 };
17401 let report = MarkdownAstReport {
17402 handle: stable_handle("mdastrep", &file_display),
17403 root: root.to_string_lossy().to_string(),
17404 file: file_display.clone(),
17405 range: SourceRangePreview {
17406 start: 1,
17407 end: total_lines,
17408 total_lines,
17409 truncated_before: false,
17410 truncated_after: false,
17411 },
17412 projection: projection_preview,
17413 nodes,
17414 expand: MarkdownAstExpandCommands {
17415 file: markdown_ast_command(&root, &file_display, None),
17416 source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
17417 edit_intents: markdown_edit_intents_command(&root),
17418 },
17419 warnings: Vec::new(),
17420 };
17421
17422 if format.json_output {
17423 let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
17424 let mut follow_up = vec![
17425 report.expand.file.clone(),
17426 report.expand.source_read.clone(),
17427 report.expand.edit_intents.clone(),
17428 ];
17429 follow_up.extend(
17430 report
17431 .nodes
17432 .iter()
17433 .map(|node| node.expand.source_window.clone()),
17434 );
17435 print_json_or_envelope(
17436 &report,
17437 &format,
17438 "markdown-ast",
17439 "ast",
17440 ToolEnvelopeSummary {
17441 text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
17442 metrics: vec![
17443 envelope_metric("nodes", report.nodes.len()),
17444 envelope_metric("total_nodes", report.projection.total_nodes),
17445 envelope_metric(
17446 "parse_duration_micros",
17447 report.projection.cache.parse_duration_micros,
17448 ),
17449 envelope_metric("total_lines", report.range.total_lines),
17450 ],
17451 },
17452 truncated,
17453 follow_up,
17454 )?;
17455 } else if format.compact {
17456 println!(
17457 "markdown-ast {} nodes:{} handle:{}",
17458 report.file,
17459 report.nodes.len(),
17460 report.handle
17461 );
17462 for node in &report.nodes {
17463 println!(
17464 " {} {} {}:{}-{}",
17465 node.handle, node.kind, node.name, node.line, node.end_line
17466 );
17467 }
17468 if node.is_none() && raw_nodes.len() > report.nodes.len() {
17469 println!("expand: {}", report.expand.file);
17470 }
17471 } else {
17472 println!(
17473 "Markdown AST `{}` nodes {} of {} ({})",
17474 report.file,
17475 report.nodes.len(),
17476 raw_nodes.len(),
17477 report.handle
17478 );
17479 for node in &report.nodes {
17480 println!(
17481 " {} `{}` {}:{}-{} — {}",
17482 node.handle,
17483 node.name,
17484 node.kind,
17485 node.line,
17486 node.end_line,
17487 node.expand.source_window
17488 );
17489 }
17490 if node.is_none() && raw_nodes.len() > report.nodes.len() {
17491 println!();
17492 println!("Expand:");
17493 println!(" file: {}", report.expand.file);
17494 }
17495 }
17496
17497 Ok(())
17498}
17499
17500#[allow(clippy::too_many_arguments)]
17501fn cmd_source_read(
17502 file: &Path,
17503 path: &Path,
17504 style: SourceReadStyle,
17505 start: usize,
17506 lines: usize,
17507 end: Option<usize>,
17508 scope: Option<&str>,
17509 format: OutputFormat,
17510 absolute: bool,
17511 budget: ResponseBudget,
17512) -> Result<()> {
17513 if start == 0 {
17514 bail!("--start is 1-based and must be greater than zero");
17515 }
17516 if lines == 0 {
17517 bail!("--lines must be greater than zero");
17518 }
17519 if let Some(end) = end
17520 && end < start
17521 {
17522 bail!("--end must be greater than or equal to --start");
17523 }
17524
17525 let root = lint::resolve_project_root_or_canonical_path(path)?;
17526 let file_abs = resolve_source_file(&root, file)?;
17527 let file_display = if absolute {
17528 file_abs.to_string_lossy().to_string()
17529 } else {
17530 relativize_pathbuf(&file_abs, &root)
17531 .to_string_lossy()
17532 .to_string()
17533 };
17534
17535 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17536 let text = String::from_utf8_lossy(&source);
17537 let all_lines: Vec<&str> = text.lines().collect();
17538 let total_lines = all_lines.len();
17539 if total_lines > 0 && start > total_lines {
17540 bail!(
17541 "--start {} is beyond end of {} ({} lines)",
17542 start,
17543 file_display,
17544 total_lines
17545 );
17546 }
17547 let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
17548 let end_line = requested_end.min(total_lines);
17549 let mut warnings = Vec::new();
17550 let max_items = budget.preview_items();
17551 let max_bytes = budget.preview_bytes();
17552 if style == SourceReadStyle::Ast {
17553 let symbols = load_source_symbols(
17554 &root,
17555 &file_abs,
17556 &file_display,
17557 &source,
17558 scope,
17559 start,
17560 end_line,
17561 max_items,
17562 max_bytes,
17563 &mut warnings,
17564 );
17565 let summaries =
17566 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17567 let markdown = if is_markdown_path(&file_abs) {
17568 match source_read_markdown_projection(
17569 &root,
17570 &file_display,
17571 &source,
17572 start,
17573 end_line,
17574 budget,
17575 ) {
17576 Ok(markdown) => Some(markdown),
17577 Err(err) => {
17578 warnings.push(format!("markdown projection unavailable: {err:#}"));
17579 None
17580 }
17581 }
17582 } else {
17583 None
17584 };
17585 let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
17586 let report = SourceReadAstReport {
17587 handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
17588 root: root.to_string_lossy().to_string(),
17589 file: file_display.clone(),
17590 range: SourceRangePreview {
17591 start,
17592 end: end_line,
17593 total_lines,
17594 truncated_before: start > 1,
17595 truncated_after: end_line < total_lines,
17596 },
17597 symbols,
17598 summaries,
17599 markdown,
17600 expand: SourceReadAstExpandCommands {
17601 window: source_read_window_command(&root, &file_display, start, window_lines),
17602 file_window: source_read_window_command(
17603 &root,
17604 &file_display,
17605 1,
17606 total_lines.max(window_lines),
17607 ),
17608 markdown_ast: is_markdown_path(&file_abs)
17609 .then(|| markdown_ast_command(&root, &file_display, None)),
17610 },
17611 warnings,
17612 };
17613
17614 if format.json_output {
17615 let truncated = report.range.truncated_before
17616 || report.range.truncated_after
17617 || report.symbols.len() >= max_items
17618 || report.summaries.len() >= max_items;
17619 let follow_up = [
17620 Some(report.expand.window.clone()),
17621 Some(report.expand.file_window.clone()),
17622 report.expand.markdown_ast.clone(),
17623 ]
17624 .into_iter()
17625 .flatten()
17626 .collect::<Vec<_>>();
17627 print_json_or_envelope(
17628 &report,
17629 &format,
17630 "source-read",
17631 "ast",
17632 ToolEnvelopeSummary {
17633 text: format!(
17634 "source ast {}:{}-{}",
17635 report.file, report.range.start, report.range.end
17636 ),
17637 metrics: vec![
17638 envelope_metric("symbols", report.symbols.len()),
17639 envelope_metric("summaries", report.summaries.len()),
17640 envelope_metric(
17641 "markdown_nodes",
17642 report
17643 .markdown
17644 .as_ref()
17645 .map_or(0, |markdown| markdown.visible_nodes),
17646 ),
17647 ],
17648 },
17649 truncated,
17650 follow_up,
17651 )?;
17652 } else if format.compact {
17653 println!(
17654 "source-ast {}:{}-{} / {} handle:{}",
17655 report.file,
17656 report.range.start,
17657 report.range.end,
17658 report.range.total_lines,
17659 report.handle
17660 );
17661 for symbol in &report.symbols {
17662 println!(
17663 " {} {}:{} {}",
17664 symbol.name, symbol.file, symbol.line, symbol.expand
17665 );
17666 }
17667 if !report.summaries.is_empty() {
17668 println!("summaries[{}]", report.summaries.len());
17669 }
17670 for warning in &report.warnings {
17671 eprintln!("warning: {warning}");
17672 }
17673 } else {
17674 println!(
17675 "Source AST `{}` lines {}-{} of {} ({})",
17676 report.file,
17677 report.range.start,
17678 report.range.end,
17679 report.range.total_lines,
17680 report.handle
17681 );
17682 if !report.symbols.is_empty() {
17683 println!();
17684 println!("Symbol refs:");
17685 for symbol in &report.symbols {
17686 println!(
17687 " {} `{}` {}:{} — {}",
17688 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17689 );
17690 }
17691 }
17692 if !report.summaries.is_empty() {
17693 println!();
17694 println!("Summary refs:");
17695 for summary in &report.summaries {
17696 println!(
17697 " {} `{}` — {}",
17698 summary.handle, summary.symbol_name, summary.expand
17699 );
17700 }
17701 }
17702 println!();
17703 println!("Expand:");
17704 println!(" window: {}", report.expand.window);
17705 println!(" file window: {}", report.expand.file_window);
17706 if let Some(markdown_ast) = &report.expand.markdown_ast {
17707 println!(" markdown: {}", markdown_ast);
17708 }
17709 for warning in &report.warnings {
17710 eprintln!("warning: {warning}");
17711 }
17712 }
17713
17714 return Ok(());
17715 }
17716 let max_bytes = budget.preview_bytes();
17717 let token_cap = budget.body_token_cap();
17718 let (preview, preview_end, body_truncated) = if total_lines == 0 {
17719 (Vec::new(), end_line, false)
17720 } else {
17721 let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17722 (capped.preview, capped.capped_end, capped.was_capped)
17723 };
17724 let effective_end = if body_truncated {
17725 preview_end
17726 } else {
17727 end_line
17728 };
17729
17730 if body_truncated {
17731 warnings.push(format!(
17732 "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17733 ));
17734 }
17735 let symbols = load_source_symbols(
17736 &root,
17737 &file_abs,
17738 &file_display,
17739 &source,
17740 scope,
17741 start,
17742 effective_end,
17743 max_items,
17744 max_bytes,
17745 &mut warnings,
17746 );
17747 let summaries =
17748 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17749 let markdown = if is_markdown_path(&file_abs) {
17750 match source_read_markdown_projection(
17751 &root,
17752 &file_display,
17753 &source,
17754 start,
17755 effective_end,
17756 budget,
17757 ) {
17758 Ok(markdown) => Some(markdown),
17759 Err(err) => {
17760 warnings.push(format!("markdown projection unavailable: {err:#}"));
17761 None
17762 }
17763 }
17764 } else {
17765 None
17766 };
17767
17768 let expand = SourceExpandCommands {
17769 before: (start > 1).then(|| {
17770 let before_start = start.saturating_sub(lines).max(1);
17771 source_read_window_command(&root, &file_display, before_start, start - before_start)
17772 }),
17773 after: (effective_end < total_lines)
17774 .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17775 body: body_truncated.then(|| {
17776 let remaining = end_line.saturating_sub(effective_end);
17777 source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17778 }),
17779 file: source_read_ast_command(&root, &file_display),
17780 markdown_ast: is_markdown_path(&file_abs)
17781 .then(|| markdown_ast_command(&root, &file_display, None)),
17782 };
17783
17784 let report = SourceReadReport {
17785 handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17786 root: root.to_string_lossy().to_string(),
17787 file: file_display,
17788 range: SourceRangePreview {
17789 start,
17790 end: effective_end,
17791 total_lines,
17792 truncated_before: start > 1,
17793 truncated_after: effective_end < total_lines,
17794 },
17795 preview,
17796 symbols,
17797 summaries,
17798 markdown,
17799 expand,
17800 warnings,
17801 };
17802
17803 if format.json_output {
17804 let truncated = report.range.truncated_before || report.range.truncated_after;
17805 let follow_up = [
17806 report.expand.before.clone(),
17807 report.expand.after.clone(),
17808 report.expand.body.clone(),
17809 Some(report.expand.file.clone()),
17810 report.expand.markdown_ast.clone(),
17811 ]
17812 .into_iter()
17813 .flatten()
17814 .collect::<Vec<_>>();
17815 print_json_or_envelope(
17816 &report,
17817 &format,
17818 "source-read",
17819 "window",
17820 ToolEnvelopeSummary {
17821 text: format!(
17822 "source window {}:{}-{}",
17823 report.file, report.range.start, report.range.end
17824 ),
17825 metrics: vec![
17826 envelope_metric("lines", report.preview.len()),
17827 envelope_metric("symbols", report.symbols.len()),
17828 envelope_metric("summaries", report.summaries.len()),
17829 envelope_metric(
17830 "markdown_nodes",
17831 report
17832 .markdown
17833 .as_ref()
17834 .map_or(0, |markdown| markdown.visible_nodes),
17835 ),
17836 ],
17837 },
17838 truncated,
17839 follow_up,
17840 )?;
17841 } else if format.compact {
17842 println!(
17843 "source {}:{}-{} / {} handle:{}",
17844 report.file,
17845 report.range.start,
17846 report.range.end,
17847 report.range.total_lines,
17848 report.handle
17849 );
17850 for line in &report.preview {
17851 println!("{:>5} {}", line.line, line.text);
17852 }
17853 if !report.symbols.is_empty() {
17854 println!("syms[{}]:", report.symbols.len());
17855 for symbol in &report.symbols {
17856 println!(" {} {}:{}", symbol.name, symbol.file, symbol.line);
17857 }
17858 }
17859 if report.range.truncated_before || report.range.truncated_after {
17860 println!("expand: {}", report.expand.file);
17861 }
17862 } else {
17863 println!(
17864 "Source window `{}` lines {}-{} of {} ({})",
17865 report.file,
17866 report.range.start,
17867 report.range.end,
17868 report.range.total_lines,
17869 report.handle
17870 );
17871 for line in &report.preview {
17872 println!("{:>5} | {}", line.line, line.text);
17873 }
17874 if !report.symbols.is_empty() {
17875 println!();
17876 println!("Symbol refs:");
17877 for symbol in &report.symbols {
17878 println!(
17879 " {} `{}` {}:{} — {}",
17880 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17881 );
17882 }
17883 }
17884 if !report.summaries.is_empty() {
17885 println!();
17886 println!("Summary refs:");
17887 for summary in &report.summaries {
17888 println!(
17889 " {} `{}` — {}",
17890 summary.handle, summary.symbol_name, summary.expand
17891 );
17892 }
17893 }
17894 if report.range.truncated_before || report.range.truncated_after {
17895 println!();
17896 println!("Expand:");
17897 if let Some(before) = &report.expand.before {
17898 println!(" before: {}", before);
17899 }
17900 if let Some(after) = &report.expand.after {
17901 println!(" after: {}", after);
17902 }
17903 println!(" file: {}", report.expand.file);
17904 }
17905 for warning in &report.warnings {
17906 eprintln!("warning: {warning}");
17907 }
17908 }
17909
17910 Ok(())
17911}
17912
17913#[allow(clippy::too_many_arguments)]
17914fn cmd_symbol_read(
17915 symbol: &str,
17916 file_hint: Option<&Path>,
17917 path: &Path,
17918 scope: Option<&str>,
17919 format: OutputFormat,
17920 absolute: bool,
17921 budget: ResponseBudget,
17922) -> Result<()> {
17923 let root = lint::resolve_project_root_or_canonical_path(path)?;
17924 let hinted_file_abs = file_hint
17925 .map(|file| resolve_source_file(&root, file))
17926 .transpose()?;
17927 let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17928 let target = resolve_query_index_target(&root, path_hint, scope)?;
17934 ensure_query_index_current(&root, &target)?;
17935 let db_path = target.db_path;
17936 if !db_path.exists() {
17937 bail!(
17938 "index refs unavailable: no index found at {}",
17939 db_path.display()
17940 );
17941 }
17942 let db = index::IndexDb::open_read_only_resilient(&db_path)
17943 .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17944 let search_limit = budget.follow_up_items().max(10);
17945 let hits = db
17946 .symbol_search(symbol, search_limit)
17947 .with_context(|| format!("searching symbols for {symbol:?}"))?;
17948 let selected = hits
17949 .into_iter()
17950 .find(|hit| {
17951 let Some(hinted_file_abs) = &hinted_file_abs else {
17952 return true;
17953 };
17954 resolve_source_file(&root, Path::new(&hit.file))
17955 .map(|hit_file| hit_file == *hinted_file_abs)
17956 .unwrap_or(false)
17957 })
17958 .with_context(|| {
17959 let hint = file_hint
17960 .map(|file| format!(" in {}", file.display()))
17961 .unwrap_or_default();
17962 format!("no indexed symbol matched {symbol:?}{hint}")
17963 })?;
17964
17965 let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17966 let file_display = if absolute {
17967 file_abs.to_string_lossy().to_string()
17968 } else {
17969 relativize_pathbuf(&file_abs, &root)
17970 .to_string_lossy()
17971 .to_string()
17972 };
17973 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17974 let content_hash = blake3::hash(&source).to_hex().to_string();
17975 let text = String::from_utf8_lossy(&source);
17976 let all_lines: Vec<&str> = text.lines().collect();
17977 let total_lines = all_lines.len();
17978 let file_symbols = db
17979 .symbols_for_file(&file_abs.to_string_lossy())
17980 .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17981 let max_items = budget.preview_items();
17982 let max_bytes = budget.preview_bytes();
17983 let selected_start = symbol_hit_line(&selected);
17984 let selected_end = symbol_hit_end_line(&selected)
17985 .unwrap_or(selected_start)
17986 .max(selected_start);
17987 let stored_target = file_symbols.iter().find(|candidate| {
17988 candidate.name == selected.name
17989 && candidate.kind == selected.kind
17990 && source_symbol_line(candidate) == selected_start
17991 });
17992 let target_span = stored_target
17993 .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17994 .or_else(|| symbol_hit_ast_span(&selected, &source));
17995 let target_start = target_span
17996 .as_ref()
17997 .map(|span| span.start_line)
17998 .unwrap_or(selected_start);
17999 let target_end = target_span
18000 .as_ref()
18001 .map(|span| span.end_line)
18002 .or_else(|| stored_target.and_then(source_symbol_end_line))
18003 .unwrap_or(selected_end)
18004 .max(target_start);
18005 let target_bounds = stored_target
18006 .and_then(stored_symbol_span_bounds)
18007 .or_else(|| symbol_hit_span_bounds(&selected));
18008 let target_end = stored_target
18009 .and_then(source_symbol_end_line)
18010 .unwrap_or(target_end)
18011 .max(target_start);
18012 let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
18013 let line_capped_end = target_start
18014 .saturating_add(body_line_budget)
18015 .saturating_sub(1)
18016 .min(target_end)
18017 .min(total_lines.max(target_start));
18018 let token_cap = budget.body_token_cap();
18019 let (body, effective_preview_end, body_truncated) =
18020 if total_lines == 0 || target_start > total_lines {
18021 (Vec::new(), line_capped_end, false)
18022 } else {
18023 let capped = build_token_capped_preview(
18024 &all_lines,
18025 target_start,
18026 line_capped_end,
18027 max_bytes,
18028 token_cap,
18029 );
18030 (capped.preview, capped.capped_end, capped.was_capped)
18031 };
18032 let preview_end = if body_truncated {
18033 effective_preview_end
18034 } else {
18035 line_capped_end
18036 };
18037 let child_symbols = file_symbols
18038 .iter()
18039 .filter(|candidate| {
18040 if let Some((target_start_byte, target_end_byte)) = target_bounds {
18041 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
18042 else {
18043 return false;
18044 };
18045 return candidate_start >= target_start_byte
18046 && candidate_end <= target_end_byte
18047 && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
18048 }
18049 let line = source_symbol_line(candidate);
18050 line > target_start && line <= target_end
18051 })
18052 .take(max_items)
18053 .map(|symbol| {
18054 let line = source_symbol_line(symbol);
18055 let end_line = source_symbol_end_line(symbol);
18056 SourceSymbolRef {
18057 handle: stable_handle(
18058 "ssym",
18059 &format!("{}:{}:{}", file_display, symbol.name, line),
18060 ),
18061 name: truncate_for_budget(&symbol.name, max_bytes),
18062 kind: symbol.kind.clone(),
18063 language: symbol.language.clone(),
18064 file: file_display.clone(),
18065 line,
18066 end_line,
18067 signature: symbol
18068 .signature
18069 .clone()
18070 .map(|signature| truncate_for_budget(&signature, max_bytes)),
18071 span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
18072 expand: source_symbol_read_command(&root, &symbol.name, &file_display),
18073 }
18074 })
18075 .collect::<Vec<_>>();
18076 let mut warnings = Vec::new();
18077 if body_truncated {
18078 warnings.push(format!(
18079 "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
18080 ));
18081 }
18082 let summaries =
18083 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
18084 let symbol_handle = stable_handle(
18085 "sread",
18086 &format!("{}:{}:{}", file_display, selected.name, target_start),
18087 );
18088 let source_lines = preview_end
18089 .saturating_sub(target_start)
18090 .saturating_add(1)
18091 .max(1);
18092 let expand = SymbolReadExpandCommands {
18093 source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
18094 body: body_truncated.then(|| {
18095 let remaining = target_end.saturating_sub(preview_end);
18096 source_read_window_command(&root, &file_display, preview_end + 1, remaining)
18097 }),
18098 file: source_read_ast_command(&root, &file_display),
18099 explain: source_symbol_expand_command(&root, &selected.name),
18100 callers: source_symbol_graph_command(&root, &selected.name, "callers"),
18101 callees: source_symbol_graph_command(&root, &selected.name, "callees"),
18102 markdown_ast: (selected.language == "markdown").then(|| {
18103 markdown_ast_command(
18104 &root,
18105 &file_display,
18106 target_span.as_ref().map(|span| span.handle.as_str()),
18107 )
18108 }),
18109 };
18110 let report = SymbolReadReport {
18111 handle: symbol_handle.clone(),
18112 root: root.to_string_lossy().to_string(),
18113 query: symbol.to_string(),
18114 symbol: SymbolReadTarget {
18115 handle: symbol_handle,
18116 name: selected.name.clone(),
18117 kind: selected.kind.clone(),
18118 language: selected.language.clone(),
18119 file: file_display.clone(),
18120 line: target_start,
18121 end_line: Some(target_end),
18122 signature: stored_target
18123 .and_then(|stored| stored.signature.clone())
18124 .map(|signature| truncate_for_budget(&signature, max_bytes)),
18125 parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
18126 visibility: stored_target.and_then(|stored| stored.visibility.clone()),
18127 span: target_span,
18128 },
18129 range: SourceRangePreview {
18130 start: target_start,
18131 end: preview_end,
18132 total_lines,
18133 truncated_before: false,
18134 truncated_after: preview_end < target_end,
18135 },
18136 body,
18137 child_symbols,
18138 summaries,
18139 expand,
18140 warnings,
18141 };
18142
18143 if format.json_output {
18144 let truncated = report.range.truncated_after
18145 || report.body.iter().any(|line| line.text.len() >= max_bytes)
18146 || report.child_symbols.len() >= max_items;
18147 let follow_up = [
18148 Some(report.expand.source_window.clone()),
18149 report.expand.body.clone(),
18150 Some(report.expand.file.clone()),
18151 Some(report.expand.explain.clone()),
18152 Some(report.expand.callers.clone()),
18153 Some(report.expand.callees.clone()),
18154 ]
18155 .into_iter()
18156 .flatten()
18157 .chain(report.expand.markdown_ast.clone())
18158 .collect::<Vec<_>>();
18159 print_json_or_envelope(
18160 &report,
18161 &format,
18162 "symbol-read",
18163 "symbol",
18164 ToolEnvelopeSummary {
18165 text: format!(
18166 "symbol {} {}:{}-{}",
18167 report.symbol.name, report.symbol.file, report.range.start, report.range.end
18168 ),
18169 metrics: vec![
18170 envelope_metric("body_lines", report.body.len()),
18171 envelope_metric("child_symbols", report.child_symbols.len()),
18172 envelope_metric("summaries", report.summaries.len()),
18173 ],
18174 },
18175 truncated,
18176 follow_up,
18177 )?;
18178 } else if format.compact {
18179 println!(
18180 "symbol {} {}:{}-{} handle:{} hash:{}",
18181 report.symbol.name,
18182 report.symbol.file,
18183 report.range.start,
18184 report.range.end,
18185 report.handle,
18186 content_hash
18187 );
18188 for line in &report.body {
18189 println!("{:>5} {}", line.line, line.text);
18190 }
18191 if !report.child_symbols.is_empty() {
18192 println!("children[{}]:", report.child_symbols.len());
18193 for child in &report.child_symbols {
18194 println!(" {} {}:{}", child.name, child.file, child.line);
18195 }
18196 }
18197 } else {
18198 println!(
18199 "Symbol `{}` in `{}` lines {}-{} ({})",
18200 report.symbol.name,
18201 report.symbol.file,
18202 report.range.start,
18203 report.range.end,
18204 report.handle
18205 );
18206 for line in &report.body {
18207 println!("{:>5} | {}", line.line, line.text);
18208 }
18209 if !report.child_symbols.is_empty() {
18210 println!();
18211 println!("Child symbols:");
18212 for child in &report.child_symbols {
18213 println!(
18214 " {} `{}` {}:{} — {}",
18215 child.handle, child.name, child.file, child.line, child.expand
18216 );
18217 }
18218 }
18219 println!();
18220 println!("Expand:");
18221 println!(" source: {}", report.expand.source_window);
18222 println!(" file: {}", report.expand.file);
18223 println!(" explain: {}", report.expand.explain);
18224 println!(" callers: {}", report.expand.callers);
18225 println!(" callees: {}", report.expand.callees);
18226 for warning in &report.warnings {
18227 eprintln!("warning: {warning}");
18228 }
18229 }
18230
18231 Ok(())
18232}
18233
18234#[allow(clippy::too_many_arguments)]
18235#[derive(Serialize)]
18236struct ExplainBudgetDefinitionPreview {
18237 handle: String,
18238 #[serde(skip_serializing_if = "Option::is_none")]
18239 tag_alias: Option<String>,
18240 kind: String,
18241 name: String,
18242 file: String,
18243 line: i64,
18244 expand: String,
18245}
18246
18247#[derive(Serialize)]
18248struct ExplainBudgetEdgePreview {
18249 handle: String,
18250 #[serde(skip_serializing_if = "Option::is_none")]
18251 tag_alias: Option<String>,
18252 name: String,
18253 file: String,
18254 line: i64,
18255 expand: String,
18256}
18257
18258#[derive(Serialize)]
18259struct ExplainBudgetCommunityPreview {
18260 size: usize,
18261 members: Vec<String>,
18262}
18263
18264#[derive(Serialize)]
18265struct ExplainBudgetReport {
18266 symbol: String,
18267 max_items: usize,
18268 max_bytes: usize,
18269 definition_total: usize,
18270 callers_total: usize,
18271 callers_truncated_by_limit: bool,
18272 callees_total: usize,
18273 callees_truncated_by_limit: bool,
18274 truncated: bool,
18275 definitions: Vec<ExplainBudgetDefinitionPreview>,
18276 callers: Vec<ExplainBudgetEdgePreview>,
18277 callees: Vec<ExplainBudgetEdgePreview>,
18278 #[serde(skip_serializing_if = "Option::is_none")]
18279 community: Option<ExplainBudgetCommunityPreview>,
18280}
18281
18282#[allow(clippy::too_many_arguments)]
18283pub(crate) fn build_explain_budget_report(
18284 symbol: &str,
18285 _root: &Path,
18286 symbols: &[index::StoredSymbol],
18287 callers: &[index::StoredEdge],
18288 callers_total: usize,
18289 callers_truncated_by_limit: bool,
18290 callees: &[index::StoredEdge],
18291 callees_total: usize,
18292 callees_truncated_by_limit: bool,
18293 community: Option<&graph::Community>,
18294 budget: ResponseBudget,
18295) -> ExplainBudgetReport {
18296 let max_items = budget.preview_items();
18297 let max_bytes = budget.preview_bytes();
18298 let definitions = symbols
18299 .iter()
18300 .take(max_items)
18301 .map(|entry| {
18302 let symbol_ref = build_compact_symbol_ref(
18303 "edef",
18304 &format!(
18305 "{}:{}:{}:{}",
18306 entry.kind, entry.name, entry.file, entry.line
18307 ),
18308 &entry.name,
18309 entry.tags.as_deref(),
18310 max_bytes,
18311 );
18312 ExplainBudgetDefinitionPreview {
18313 handle: symbol_ref.handle,
18314 tag_alias: symbol_ref.tag_alias,
18315 kind: entry.kind.clone(),
18316 name: symbol_ref.name,
18317 file: truncate_for_budget(&entry.file, max_bytes),
18318 line: entry.line,
18319 expand: format!(
18320 "tsift search {} --exact --path {} --limit 20",
18321 shell_quote(&entry.name),
18322 shell_quote(&entry.file)
18323 ),
18324 }
18325 })
18326 .collect();
18327 let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
18328 .iter()
18329 .take(max_items)
18330 .map(|entry| {
18331 let symbol_ref = build_compact_symbol_ref(
18332 "ecall",
18333 &format!(
18334 "{}:{}:{}:{}",
18335 entry.caller_name, entry.caller_file, entry.call_site_line, symbol
18336 ),
18337 &entry.caller_name,
18338 None,
18339 max_bytes,
18340 );
18341 ExplainBudgetEdgePreview {
18342 handle: symbol_ref.handle,
18343 tag_alias: symbol_ref.tag_alias,
18344 name: symbol_ref.name,
18345 file: truncate_for_budget(&entry.caller_file, max_bytes),
18346 line: entry.call_site_line,
18347 expand: format!(
18348 "tsift explain {} --path {} --limit 0",
18349 shell_quote(&entry.caller_name),
18350 shell_quote(&entry.caller_file)
18351 ),
18352 }
18353 })
18354 .collect();
18355 let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
18356 .iter()
18357 .take(max_items)
18358 .map(|entry| {
18359 let symbol_ref = build_compact_symbol_ref(
18360 "eces",
18361 &format!(
18362 "{}:{}:{}:{}",
18363 entry.callee_name, entry.caller_file, entry.call_site_line, symbol
18364 ),
18365 &entry.callee_name,
18366 None,
18367 max_bytes,
18368 );
18369 ExplainBudgetEdgePreview {
18370 handle: symbol_ref.handle,
18371 tag_alias: symbol_ref.tag_alias,
18372 name: symbol_ref.name,
18373 file: truncate_for_budget(&entry.caller_file, max_bytes),
18374 line: entry.call_site_line,
18375 expand: format!(
18376 "tsift explain {} --path {} --limit 0",
18377 shell_quote(&entry.callee_name),
18378 shell_quote(&entry.caller_file)
18379 ),
18380 }
18381 })
18382 .collect();
18383 let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
18384 size: entry.members.len(),
18385 members: entry
18386 .members
18387 .iter()
18388 .take(max_items)
18389 .map(|member| truncate_for_budget(&member.name, max_bytes))
18390 .collect(),
18391 });
18392
18393 ExplainBudgetReport {
18394 symbol: symbol.to_string(),
18395 max_items,
18396 max_bytes,
18397 definition_total: symbols.len(),
18398 callers_total,
18399 callers_truncated_by_limit,
18400 callees_total,
18401 callees_truncated_by_limit,
18402 truncated: symbols.len() > max_items
18403 || callers_total > callers_preview.len()
18404 || callees_total > callees_preview.len()
18405 || community
18406 .map(|entry| entry.members.len() > max_items)
18407 .unwrap_or(false),
18408 definitions,
18409 callers: callers_preview,
18410 callees: callees_preview,
18411 community: community_preview,
18412 }
18413}
18414
18415pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
18416 println!(
18417 "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
18418 shell_quote(&report.symbol),
18419 report.definitions.len(),
18420 report.definition_total,
18421 report.callers.len(),
18422 report.callers_total,
18423 report.callees.len(),
18424 report.callees_total
18425 );
18426 for entry in &report.definitions {
18427 println!(
18428 "def {} {} {}:{} expand:{}",
18429 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18430 entry.kind,
18431 entry.file,
18432 entry.line,
18433 entry.expand
18434 );
18435 }
18436 for entry in &report.callers {
18437 println!(
18438 "caller {} {}:{} expand:{}",
18439 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18440 entry.file,
18441 entry.line,
18442 entry.expand
18443 );
18444 }
18445 for entry in &report.callees {
18446 println!(
18447 "callee {} {}:{} expand:{}",
18448 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18449 entry.file,
18450 entry.line,
18451 entry.expand
18452 );
18453 }
18454 if let Some(community) = &report.community {
18455 println!(
18456 "community size:{} members:{}",
18457 community.size,
18458 community.members.join(", ")
18459 );
18460 }
18461 if report.truncated {
18462 println!(
18463 "budget truncated items:{} bytes:{}",
18464 report.max_items, report.max_bytes
18465 );
18466 }
18467}
18468
18469const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
18479 ".git",
18480 "node_modules",
18481 "target",
18482 "__pycache__",
18483 ".venv",
18484 "vendor",
18485];
18486
18487const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
18488 "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
18489 "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
18490 "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
18491 "sh", "bash", "zsh", "sql", "css", "tsx", "gd",
18492];
18493
18494pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
18495 let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
18496 .iter()
18497 .map(|ext| (*ext).to_string())
18498 .collect::<BTreeSet<_>>();
18499
18500 let config_path = root.join(".naming.toml");
18501 if !config_path.exists() {
18502 return extensions;
18503 }
18504
18505 match tagpath::config::resolve(&config_path) {
18506 Ok(config) => {
18507 if let Some(grammars) = config.grammars {
18508 for grammar in grammars.languages.values() {
18509 for ext in &grammar.extensions {
18510 if let Some(normalized) = normalize_extension(ext) {
18511 extensions.insert(normalized);
18512 }
18513 }
18514 }
18515 }
18516 }
18517 Err(err) => {
18518 eprintln!("tagpath_policy_hint_config_unreadable: {err}");
18519 }
18520 }
18521 extensions
18522}
18523
18524pub(crate) fn tagpath_audit_policy_hints(
18525 rel_path: &str,
18526 supported_extensions: &BTreeSet<String>,
18527) -> Vec<String> {
18528 let path = Path::new(rel_path);
18529 let mut hints = BTreeSet::new();
18530 if let Some(parent) = path.parent() {
18531 for component in parent.components() {
18532 if let std::path::Component::Normal(name) = component {
18533 let name = name.to_string_lossy();
18534 if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
18535 hints.insert(format!("skip_dir:{name}"));
18536 }
18537 }
18538 }
18539 }
18540 if path
18541 .extension()
18542 .and_then(|ext| ext.to_str())
18543 .and_then(normalize_extension)
18544 .is_some_and(|ext| !supported_extensions.contains(&ext))
18545 {
18546 hints.insert("extension_unsupported".to_string());
18547 }
18548 hints.into_iter().collect()
18549}
18550
18551fn normalize_extension(ext: &str) -> Option<String> {
18552 let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
18553 if normalized.is_empty() {
18554 None
18555 } else {
18556 Some(normalized)
18557 }
18558}
18559
18560pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
18561 match status {
18562 diff_digest::DiffDigestFileStatus::Added => "added",
18563 diff_digest::DiffDigestFileStatus::Modified => "modified",
18564 diff_digest::DiffDigestFileStatus::Deleted => "deleted",
18565 }
18566}
18567
18568pub(crate) fn diff_digest_summary_label(
18569 state: diff_digest::DiffDigestSummaryState,
18570) -> &'static str {
18571 match state {
18572 diff_digest::DiffDigestSummaryState::Current => "current",
18573 diff_digest::DiffDigestSummaryState::Stale => "stale",
18574 diff_digest::DiffDigestSummaryState::Missing => "missing",
18575 diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
18576 }
18577}
18578
18579fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
18580 match state {
18581 test_digest::TestDigestSummaryState::Current => "current",
18582 test_digest::TestDigestSummaryState::Stale => "stale",
18583 test_digest::TestDigestSummaryState::Missing => "missing",
18584 test_digest::TestDigestSummaryState::Unavailable => "unavailable",
18585 }
18586}
18587
18588fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
18589 match state {
18590 log_digest::LogDigestSummaryState::Current => "current",
18591 log_digest::LogDigestSummaryState::Stale => "stale",
18592 log_digest::LogDigestSummaryState::Missing => "missing",
18593 log_digest::LogDigestSummaryState::Unavailable => "unavailable",
18594 }
18595}
18596
18597pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
18598 match mode {
18599 diff_digest::DiffDigestMode::WorkingTree => "worktree",
18600 diff_digest::DiffDigestMode::Cached => "cached",
18601 diff_digest::DiffDigestMode::Revision => "revision",
18602 }
18603}
18604
18605pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
18606 match (&report.mode, &report.revision) {
18607 (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
18608 (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
18609 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18610 format!("revision {revision}")
18611 }
18612 (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
18613 }
18614}
18615
18616pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
18617 match (&report.mode, &report.revision) {
18618 (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
18619 (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
18620 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18621 format!("No diff found for revision {revision}.")
18622 }
18623 (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
18624 }
18625}
18626
18627fn cmd_impact(
18628 path: &Path,
18629 cached: bool,
18630 revision: Option<&str>,
18631 scope: Option<&str>,
18632 limit: usize,
18633 format: OutputFormat,
18634) -> Result<()> {
18635 let report = impact::compute(
18636 path,
18637 impact::ImpactOptions {
18638 cached,
18639 revision,
18640 scope,
18641 limit,
18642 },
18643 )?;
18644 if format.json_output {
18645 println!(
18646 "{}",
18647 to_json_schema(
18648 &report,
18649 format.pretty,
18650 format.terse,
18651 format.ultra_terse,
18652 format.schema
18653 )?
18654 );
18655 return Ok(());
18656 }
18657
18658 if format.compact {
18659 println!(
18660 "impact mode:{} changed:{} symbols:{} tests:{}/{}",
18661 diff_digest_mode_label(report.mode),
18662 report.changed_files.len(),
18663 report.changed_symbols.len(),
18664 report.affected_tests.len(),
18665 report.affected_tests_total
18666 );
18667 for target in &report.affected_tests {
18668 println!(
18669 "{} reasons:{} command:{}",
18670 target.path,
18671 target.reasons.len(),
18672 target.commands.join(" && ")
18673 );
18674 }
18675 for warning in &report.warnings {
18676 println!("warning {warning}");
18677 }
18678 return Ok(());
18679 }
18680
18681 println!("Impact ({})", diff_digest_mode_label(report.mode));
18682 println!(" changed files: {}", report.changed_files.len());
18683 println!(" changed symbols: {}", report.changed_symbols.len());
18684 println!(
18685 " affected tests: {}/{}",
18686 report.affected_tests.len(),
18687 report.affected_tests_total
18688 );
18689 for target in &report.affected_tests {
18690 println!();
18691 println!("{}", target.path);
18692 for reason in &target.reasons {
18693 println!(" - {reason}");
18694 }
18695 if !target.symbols.is_empty() {
18696 println!(" symbols: {}", target.symbols.join(", "));
18697 }
18698 for command in &target.commands {
18699 println!(" run: {}", command);
18700 }
18701 }
18702 for warning in &report.warnings {
18703 println!("warning: {warning}");
18704 }
18705 Ok(())
18706}
18707
18708pub(crate) fn render_test_digest_from_input(
18709 path: &Path,
18710 input: &str,
18711 runner: Option<&str>,
18712 format: OutputFormat,
18713) -> Result<()> {
18714 let report = test_digest::compute(path, input, runner)?;
18715 if format.json_output {
18716 println!(
18717 "{}",
18718 to_json_schema(
18719 &report,
18720 format.pretty,
18721 format.terse,
18722 format.ultra_terse,
18723 format.schema
18724 )?
18725 );
18726 return Ok(());
18727 }
18728
18729 if report.failure_groups.is_empty() {
18730 println!("No failures detected (runner: {}).", report.runner);
18731 for warning in &report.warnings {
18732 println!("warning: {warning}");
18733 }
18734 return Ok(());
18735 }
18736
18737 if format.compact {
18738 println!(
18739 "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18740 report.runner,
18741 report.failures,
18742 report.grouped_failures,
18743 report.counts.passed.unwrap_or(0),
18744 report.counts.failed.unwrap_or(report.grouped_failures),
18745 report.counts.skipped.unwrap_or(0),
18746 );
18747 for failure in &report.failure_groups {
18748 let tests = truncate_for_compact(&failure.tests.join(","), 60);
18749 let location = match (&failure.path, failure.line) {
18750 (Some(path), Some(line)) => format!("{path}:{line}"),
18751 (Some(path), None) => path.clone(),
18752 _ => "-".to_string(),
18753 };
18754 println!(
18755 "{} tests:{} count:{} summaries:{} msg:{}",
18756 location,
18757 tests,
18758 failure.occurrences,
18759 test_digest_summary_label(failure.summary_state),
18760 truncate_for_compact(&failure.message, 80)
18761 );
18762 }
18763 for warning in &report.warnings {
18764 println!("warning: {warning}");
18765 }
18766 return Ok(());
18767 }
18768
18769 println!("Test digest ({})", report.runner);
18770 println!(" failures: {}", report.failures);
18771 println!(" failure groups: {}", report.grouped_failures);
18772 if let Some(passed) = report.counts.passed {
18773 println!(" passed: {}", passed);
18774 }
18775 if let Some(failed) = report.counts.failed {
18776 println!(" failed: {}", failed);
18777 }
18778 if let Some(skipped) = report.counts.skipped {
18779 println!(" skipped: {}", skipped);
18780 }
18781
18782 for failure in &report.failure_groups {
18783 println!();
18784 match (&failure.path, failure.line, failure.column) {
18785 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18786 (Some(path), Some(line), None) => println!("{path}:{line}"),
18787 (Some(path), None, _) => println!("{path}"),
18788 (None, _, _) => println!("(no file anchor)"),
18789 }
18790 println!(" tests: {}", failure.tests.join(", "));
18791 println!(" occurrences: {}", failure.occurrences);
18792 println!(" message: {}", failure.message);
18793 println!(
18794 " cached summaries: {}",
18795 test_digest_summary_label(failure.summary_state)
18796 );
18797 for summary in &failure.current_summaries {
18798 println!(
18799 " - {}: {}",
18800 summary.symbol,
18801 truncate_for_compact(&summary.summary, 160)
18802 );
18803 }
18804 }
18805 for warning in &report.warnings {
18806 println!("warning: {warning}");
18807 }
18808 Ok(())
18809}
18810
18811#[derive(Clone, Serialize, Deserialize)]
18812struct DispatchTraceSummary {
18813 backlog: usize,
18814 job_packet: usize,
18815 worker_result: usize,
18816 worker_context: usize,
18817 source_handle: usize,
18818 semantic_rows: usize,
18819}
18820
18821#[derive(Clone, Serialize, Deserialize)]
18822struct DispatchTraceReport {
18823 contract_version: String,
18824 root: String,
18825 #[serde(skip_serializing_if = "Option::is_none")]
18826 scope: Option<String>,
18827 targets: Vec<String>,
18828 projection_freshness: GraphDbFreshnessReport,
18829 projection_hashes: Vec<String>,
18830 evidence_packet_ids: Vec<String>,
18831 shared_preparation: ConflictMatrixSharedPreparationSummary,
18832 worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18833 worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18834 summary: DispatchTraceSummary,
18835 nodes: Vec<SubstrateTerseGraphNode>,
18836 edges: Vec<SubstrateTerseGraphEdge>,
18837 conflict_matrix_decisions: Vec<String>,
18838 replay_commands: Vec<String>,
18839 repair_commands: Vec<String>,
18840 truncated: bool,
18841 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18842 warnings: Vec<String>,
18843}
18844
18845fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18846 matches!(
18847 kind,
18848 "session"
18849 | "backlog"
18850 | "job_packet"
18851 | "worker_result"
18852 | "worker_context"
18853 | "source_handle"
18854 | "semantic_concept"
18855 | "semantic_entity"
18856 | "file"
18857 | "symbol"
18858 | "route"
18859 )
18860}
18861
18862fn dispatch_trace_kind_rank(kind: &str) -> usize {
18863 match kind {
18864 "backlog" => 0,
18865 "job_packet" => 1,
18866 "worker_result" => 2,
18867 "worker_context" => 3,
18868 "source_handle" => 4,
18869 "file" => 5,
18870 "symbol" => 6,
18871 "route" => 7,
18872 "semantic_concept" => 8,
18873 "semantic_entity" => 9,
18874 "session" => 10,
18875 _ => 99,
18876 }
18877}
18878
18879fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18880 DispatchTraceSummary {
18881 backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18882 job_packet: nodes
18883 .iter()
18884 .filter(|node| node.kind == "job_packet")
18885 .count(),
18886 worker_result: nodes
18887 .iter()
18888 .filter(|node| node.kind == "worker_result")
18889 .count(),
18890 worker_context: nodes
18891 .iter()
18892 .filter(|node| node.kind == "worker_context")
18893 .count(),
18894 source_handle: nodes
18895 .iter()
18896 .filter(|node| node.kind == "source_handle")
18897 .count(),
18898 semantic_rows: nodes
18899 .iter()
18900 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18901 .count(),
18902 }
18903}
18904
18905fn dispatch_trace_shared_preparation_summary(
18906 graph_nodes: &[SubstrateGraphNode],
18907 graph_edges: &[SubstrateGraphEdge],
18908 conflict: &ConflictMatrixReport,
18909) -> ConflictMatrixSharedPreparationSummary {
18910 ConflictMatrixSharedPreparationSummary {
18911 evidence_cache_status: conflict
18912 .inputs
18913 .shared_preparation
18914 .evidence_cache_status
18915 .clone(),
18916 graph_nodes: graph_nodes.len(),
18917 graph_edges: graph_edges.len(),
18918 evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18919 source_handles: conflict
18920 .candidates
18921 .iter()
18922 .map(|candidate| candidate.source_handles.len())
18923 .sum(),
18924 worker_context: conflict
18925 .candidates
18926 .iter()
18927 .map(|candidate| candidate.worker_context_handles.len())
18928 .sum(),
18929 worker_results: conflict
18930 .candidates
18931 .iter()
18932 .map(|candidate| candidate.worker_feedback.total)
18933 .sum(),
18934 semantic_rows: conflict
18935 .candidates
18936 .iter()
18937 .map(|candidate| candidate.semantic_related.len())
18938 .sum(),
18939 dispatch_trace_snapshot_nodes: graph_nodes.len(),
18940 dispatch_trace_snapshot_edges: graph_edges.len(),
18941 }
18942}
18943
18944fn dispatch_trace_collect_ids(
18945 targets: &[String],
18946 candidates: &[ConflictMatrixCandidate],
18947 graph_nodes: &[SubstrateGraphNode],
18948 graph_edges: &[SubstrateGraphEdge],
18949 depth: usize,
18950 limit: usize,
18951) -> (BTreeSet<String>, bool) {
18952 let target_refs = targets
18953 .iter()
18954 .map(|target| target.trim_start_matches('#').to_string())
18955 .collect::<BTreeSet<_>>();
18956 let mut ids = BTreeSet::new();
18957 for candidate in candidates {
18958 ids.insert(candidate.target_node_id.clone());
18959 for source in &candidate.source_handles {
18960 ids.insert(source.handle.clone());
18961 }
18962 for handle in &candidate.worker_context_handles {
18963 ids.insert(handle.clone());
18964 }
18965 for semantic in &candidate.semantic_related {
18966 ids.insert(semantic.handle.clone());
18967 }
18968 }
18969 for node in graph_nodes {
18970 if !dispatch_trace_allowed_node_kind(&node.kind) {
18971 continue;
18972 }
18973 if node
18974 .properties
18975 .get("ref_id")
18976 .is_some_and(|ref_id| target_refs.contains(ref_id))
18977 {
18978 ids.insert(node.id.clone());
18979 }
18980 }
18981
18982 let node_by_id = graph_nodes
18983 .iter()
18984 .map(|node| (node.id.as_str(), node))
18985 .collect::<BTreeMap<_, _>>();
18986 let max_nodes = if limit == 0 {
18987 usize::MAX
18988 } else {
18989 limit
18990 .saturating_mul(targets.len().max(1))
18991 .saturating_mul(12)
18992 .max(64)
18993 };
18994 let mut truncated = false;
18995 for _ in 0..depth.max(1) {
18996 let before = ids.len();
18997 let current_ids = ids.clone();
18998 for edge in graph_edges {
18999 if ids.len() >= max_nodes {
19000 truncated = true;
19001 break;
19002 }
19003 let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
19004 if !touches {
19005 continue;
19006 }
19007 for endpoint in [&edge.from_id, &edge.to_id] {
19008 let Some(node) = node_by_id.get(endpoint.as_str()) else {
19009 continue;
19010 };
19011 if dispatch_trace_allowed_node_kind(&node.kind) {
19012 ids.insert(endpoint.clone());
19013 }
19014 }
19015 }
19016 if ids.len() == before || truncated {
19017 break;
19018 }
19019 }
19020 (ids, truncated)
19021}
19022
19023#[allow(clippy::too_many_arguments)]
19024fn build_dispatch_trace_report_from_conflict_snapshot(
19025 root: &Path,
19026 scope: Option<&str>,
19027 conflict: ConflictMatrixReport,
19028 graph_nodes: Vec<SubstrateGraphNode>,
19029 graph_edges: Vec<SubstrateGraphEdge>,
19030 depth: usize,
19031 limit: usize,
19032 extra_warnings: Vec<String>,
19033) -> Result<DispatchTraceReport> {
19034 let shared_preparation =
19035 dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
19036 let (ids, truncated) = dispatch_trace_collect_ids(
19037 &conflict.targets,
19038 &conflict.candidates,
19039 &graph_nodes,
19040 &graph_edges,
19041 depth,
19042 limit,
19043 );
19044 let mut nodes = graph_nodes
19045 .into_iter()
19046 .filter(|node| ids.contains(&node.id))
19047 .collect::<Vec<_>>();
19048 nodes.sort_by(|left, right| {
19049 dispatch_trace_kind_rank(&left.kind)
19050 .cmp(&dispatch_trace_kind_rank(&right.kind))
19051 .then(left.id.cmp(&right.id))
19052 });
19053 let node_ids = nodes
19054 .iter()
19055 .map(|node| node.id.as_str())
19056 .collect::<BTreeSet<_>>();
19057 let mut edges = graph_edges
19058 .into_iter()
19059 .filter(|edge| {
19060 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
19061 })
19062 .collect::<Vec<_>>();
19063 edges.sort_by(|left, right| {
19064 left.from_id
19065 .cmp(&right.from_id)
19066 .then(left.kind.cmp(&right.kind))
19067 .then(left.to_id.cmp(&right.to_id))
19068 });
19069 let mut warnings = conflict.warnings;
19070 warnings.extend(extra_warnings);
19071
19072 Ok(DispatchTraceReport {
19073 contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
19074 root: conflict.root,
19075 scope: conflict.scope,
19076 targets: conflict.targets,
19077 projection_freshness: conflict.orchestration.projection_freshness,
19078 projection_hashes: conflict.orchestration.projection_hashes,
19079 evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
19080 shared_preparation,
19081 worker_prompt_packets: conflict.worker_prompt_packets,
19082 worker_feedback: conflict
19083 .candidates
19084 .iter()
19085 .map(|candidate| candidate.worker_feedback.clone())
19086 .collect(),
19087 summary: dispatch_trace_summary(&nodes),
19088 nodes: nodes.into_iter().map(Into::into).collect(),
19089 edges: edges.into_iter().map(Into::into).collect(),
19090 conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
19091 replay_commands: conflict.next_commands,
19092 repair_commands: graph_db_repair_commands(root, scope),
19093 truncated,
19094 warnings,
19095 })
19096}
19097
19098fn build_dispatch_trace_report(
19099 path: &Path,
19100 scope: Option<&str>,
19101 raw_targets: &[String],
19102 depth: usize,
19103 limit: usize,
19104 impact_limit: usize,
19105) -> Result<DispatchTraceReport> {
19106 let root = lint::resolve_project_root_or_canonical_path(path)?;
19107 let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
19108 if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
19109 write_traversal_graph_store(&root, path, scope)
19110 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19111 }
19112 let graph_db = graph_substrate_db_path(&root, scope);
19113 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19114 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19115 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19116 let extra_warnings = store
19117 .read_only_recovery()
19118 .map(graph_db_read_recovery_diagnostic)
19119 .into_iter()
19120 .collect::<Vec<_>>();
19121 let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
19122 let graph_prepared = prepare_conflict_matrix_graph_orchestration(
19123 &root,
19124 scope,
19125 "sqlite",
19126 raw_targets,
19127 &prepared,
19128 depth,
19129 limit,
19130 &store,
19131 freshness.clone(),
19132 )?;
19133 let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
19134 &prepared.preparation_cache.source_watermark,
19135 &prepared.preparation_cache.document_watermark,
19136 &prepared.preparation_cache.staged_diff_watermark,
19137 &[
19138 &format!("targets:{}", raw_targets.join(",")),
19139 &format!("depth:{depth}"),
19140 &format!("limit:{limit}"),
19141 ],
19142 );
19143 if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
19144 &root,
19145 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19146 &dt_cache_key,
19147 ) {
19148 return Ok(cached_report);
19149 }
19150 let conflict = build_conflict_matrix_report_from_prepared_graph(
19151 &root,
19152 path,
19153 scope,
19154 depth,
19155 limit,
19156 impact_limit,
19157 freshness,
19158 extra_warnings.clone(),
19159 &prepared,
19160 &graph_prepared,
19161 )?;
19162 let report = build_dispatch_trace_report_from_conflict_snapshot(
19163 &root,
19164 scope,
19165 conflict,
19166 graph_prepared.graph.nodes,
19167 graph_prepared.graph.edges,
19168 depth,
19169 limit,
19170 extra_warnings,
19171 )?;
19172 cycle_packet_cache::cycle_packet_write_cache(
19173 &root,
19174 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19175 &dt_cache_key,
19176 &report,
19177 );
19178 Ok(report)
19179}
19180
19181fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
19182 let json = serde_json::to_string(report)?.replace("</", "<\\/");
19183 let mut html = String::new();
19184 html.push_str(
19185 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
19186 );
19187 html.push_str(
19188 r#"<style>
19189:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
19190@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
19191*{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}}
19192</style>"#,
19193 );
19194 html.push_str("</head><body><div class=\"page\">");
19195 html.push_str(&format!(
19196 "<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>",
19197 html_escape(&report.targets.join(", ")),
19198 report.evidence_packet_ids.len(),
19199 report.nodes.len(),
19200 report.worker_prompt_packets.len(),
19201 html_escape(&report.contract_version)
19202 ));
19203 html.push_str(
19204 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>"#,
19205 );
19206 html.push_str("<script id=\"trace-data\" type=\"application/json\">");
19207 html.push_str(&json);
19208 html.push_str(
19209 r##"</script><script>
19210const report = JSON.parse(document.getElementById("trace-data").textContent);
19211const svg = document.getElementById("graph-canvas");
19212const nodeList = document.getElementById("nodes");
19213const packets = document.getElementById("packets");
19214const feedback = document.getElementById("feedback");
19215const nodes = report.nodes.map((node, index) => ({...node, index}));
19216const nodeById = new Map(nodes.map(node => [node.id, node]));
19217const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
19218const 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"]]);
19219function color(kind){return colorByKind.get(kind)||"#6b7280";}
19220function text(value){return value == null ? "" : String(value);}
19221function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));}
19222function layout(){
19223 const rect = svg.getBoundingClientRect();
19224 const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
19225 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
19226 const counts = new Map();
19227 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
19228 const offsets = new Map();
19229 for (const node of nodes) {
19230 const group = kinds.indexOf(node.kind);
19231 const index = offsets.get(node.kind) || 0;
19232 offsets.set(node.kind, index + 1);
19233 const total = counts.get(node.kind) || 1;
19234 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
19235 const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
19236 node.x = cx + Math.cos(angle) * ring;
19237 node.y = cy + Math.sin(angle) * ring;
19238 }
19239}
19240function draw(){
19241 svg.innerHTML = "";
19242 for (const edge of edges) {
19243 const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
19244 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
19245 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
19246 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
19247 line.setAttribute("class", "edge");
19248 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
19249 svg.appendChild(line);
19250 }
19251 for (const node of nodes) {
19252 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
19253 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
19254 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
19255 circle.setAttribute("fill", color(node.kind));
19256 circle.setAttribute("class", "node");
19257 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
19258 svg.appendChild(circle);
19259 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
19260 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
19261 label.setAttribute("class", "node-label");
19262 label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
19263 svg.appendChild(label);
19264 }
19265}
19266packets.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>";
19267feedback.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>";
19268nodeList.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("");
19269window.addEventListener("resize", () => { layout(); draw(); });
19270layout(); draw();
19271</script></div></body></html>"##,
19272 );
19273 Ok(html)
19274}
19275
19276struct DispatchTraceOptions<'a> {
19277 path: &'a Path,
19278 scope: Option<&'a str>,
19279 raw_targets: &'a [String],
19280 depth: usize,
19281 limit: usize,
19282 impact_limit: usize,
19283 trace_format: DispatchTraceFormat,
19284}
19285
19286fn cmd_dispatch_trace(
19287 options: DispatchTraceOptions<'_>,
19288 output_format: OutputFormat,
19289) -> Result<()> {
19290 let report = build_dispatch_trace_report(
19291 options.path,
19292 options.scope,
19293 options.raw_targets,
19294 options.depth,
19295 options.limit,
19296 options.impact_limit,
19297 )?;
19298 match options.trace_format {
19299 DispatchTraceFormat::Json => {
19300 if output_format.envelope {
19301 print_json_or_envelope(
19302 &report,
19303 &output_format,
19304 "dispatch-trace",
19305 "operator-review",
19306 ToolEnvelopeSummary {
19307 text: format!(
19308 "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
19309 report.targets.len(),
19310 report.nodes.len(),
19311 report.worker_prompt_packets.len()
19312 ),
19313 metrics: vec![
19314 envelope_metric("targets", report.targets.len()),
19315 envelope_metric("nodes", report.nodes.len()),
19316 envelope_metric("edges", report.edges.len()),
19317 envelope_metric(
19318 "worker_prompt_packets",
19319 report.worker_prompt_packets.len(),
19320 ),
19321 ],
19322 },
19323 report.truncated,
19324 report.replay_commands.clone(),
19325 )
19326 } else {
19327 println!(
19328 "{}",
19329 to_json_schema(
19330 &report,
19331 output_format.pretty,
19332 output_format.terse,
19333 output_format.ultra_terse,
19334 output_format.schema
19335 )?
19336 );
19337 Ok(())
19338 }
19339 }
19340 DispatchTraceFormat::Html => {
19341 println!("{}", dispatch_trace_html(&report)?);
19342 Ok(())
19343 }
19344 }
19345}
19346
19347#[derive(Clone, Debug)]
19348struct DependencyDagProfile {
19349 id: String,
19350 graph_node_id: String,
19351 label: String,
19352 path: Option<String>,
19353 line: Option<i64>,
19354 detail: Option<String>,
19355 source_files: BTreeSet<String>,
19356 source_symbols: BTreeSet<String>,
19357 config_files: BTreeSet<String>,
19358 expected_tests: BTreeSet<String>,
19359 semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
19360 worker_feedback: ConflictMatrixWorkerFeedback,
19361}
19362
19363#[derive(Clone, Debug, Serialize)]
19364struct DependencyDagNode {
19365 id: String,
19366 graph_node_id: String,
19367 label: String,
19368 #[serde(skip_serializing_if = "Option::is_none")]
19369 path: Option<String>,
19370 #[serde(skip_serializing_if = "Option::is_none")]
19371 line: Option<i64>,
19372 #[serde(skip_serializing_if = "Option::is_none")]
19373 detail: Option<String>,
19374 source_files: Vec<String>,
19375 source_symbols: Vec<String>,
19376 config_files: Vec<String>,
19377 expected_tests: Vec<String>,
19378 semantic_refs: Vec<ConflictMatrixSemanticRef>,
19379 worker_feedback: ConflictMatrixWorkerFeedback,
19380}
19381
19382#[derive(Clone, Debug, Serialize)]
19383struct DependencyDagEdge {
19384 from: String,
19385 to: String,
19386 kind: String,
19387 weight: usize,
19388 reasons: Vec<String>,
19389 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19390 shared_files: Vec<String>,
19391 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19392 shared_symbols: Vec<String>,
19393 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19394 shared_tests: Vec<String>,
19395 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19396 shared_config_files: Vec<String>,
19397 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19398 shared_semantic_refs: Vec<String>,
19399}
19400
19401#[derive(Clone, Debug, Serialize)]
19402struct DependencyDagTopoBatch {
19403 batch: usize,
19404 targets: Vec<String>,
19405}
19406
19407#[derive(Clone, Debug, Serialize)]
19408struct DependencyDagCycleDiagnostics {
19409 has_cycles: bool,
19410 blocked_nodes: Vec<String>,
19411 cycle_edges: Vec<DependencyDagEdge>,
19412}
19413
19414#[derive(Serialize)]
19415struct DependencyDagSummary {
19416 nodes: usize,
19417 edges: usize,
19418 topo_batches: usize,
19419 has_cycles: bool,
19420}
19421
19422#[derive(Serialize)]
19423struct DependencyDagReport {
19424 contract_version: &'static str,
19425 root: String,
19426 #[serde(skip_serializing_if = "Option::is_none")]
19427 scope: Option<String>,
19428 path: String,
19429 targets: Vec<String>,
19430 projection_freshness: GraphDbFreshnessReport,
19431 projection_hashes: Vec<String>,
19432 nodes: Vec<DependencyDagNode>,
19433 edges: Vec<DependencyDagEdge>,
19434 topo_batches: Vec<DependencyDagTopoBatch>,
19435 cycle_diagnostics: DependencyDagCycleDiagnostics,
19436 summary: DependencyDagSummary,
19437 replay_commands: Vec<String>,
19438 repair_commands: Vec<String>,
19439 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19440 warnings: Vec<String>,
19441}
19442
19443fn dependency_dag_backlog_node_for_target(
19444 store: &impl GraphStore,
19445 target: &str,
19446) -> Result<SubstrateGraphNode> {
19447 let resolved = graph_db_resolve_evidence_target(store, target)?
19448 .with_context(|| format!("dependency-dag target not found: {target}"))?;
19449 if resolved.kind == "backlog" {
19450 return Ok(resolved);
19451 }
19452 let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
19453 bail!(
19454 "dependency-dag target {} resolved to {} without a backlog ref_id",
19455 target,
19456 resolved.kind
19457 );
19458 };
19459 store
19460 .nodes_by_kind("backlog")?
19461 .into_iter()
19462 .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
19463 .min_by(|left, right| {
19464 left.properties
19465 .get("line")
19466 .and_then(|value| value.parse::<i64>().ok())
19467 .cmp(
19468 &right
19469 .properties
19470 .get("line")
19471 .and_then(|value| value.parse::<i64>().ok()),
19472 )
19473 .then(left.id.cmp(&right.id))
19474 })
19475 .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
19476}
19477
19478fn dependency_dag_resolve_backlog_nodes(
19479 root: &Path,
19480 path: &Path,
19481 store: &impl GraphStore,
19482 raw_targets: &[String],
19483) -> Result<Vec<SubstrateGraphNode>> {
19484 let mut nodes = Vec::new();
19485 let mut seen = BTreeSet::new();
19486 if raw_targets.is_empty() {
19487 let hinted_path = if path.is_absolute() {
19488 path.to_path_buf()
19489 } else {
19490 root.join(path)
19491 };
19492 let hinted_markdown = hinted_path
19493 .extension()
19494 .and_then(|ext| ext.to_str())
19495 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
19496 let hinted_rel = hinted_markdown.then(|| {
19497 relativize_pathbuf(&hinted_path, root)
19498 .to_string_lossy()
19499 .replace('\\', "/")
19500 });
19501 for node in store.nodes_by_kind("backlog")? {
19502 if let Some(expected_path) = &hinted_rel
19503 && node.properties.get("path") != Some(expected_path)
19504 {
19505 continue;
19506 }
19507 if seen.insert(node.id.clone()) {
19508 nodes.push(node);
19509 }
19510 }
19511 if nodes.is_empty() && hinted_rel.is_some() {
19512 for node in store.nodes_by_kind("backlog")? {
19513 if seen.insert(node.id.clone()) {
19514 nodes.push(node);
19515 }
19516 }
19517 }
19518 } else {
19519 for target in raw_targets {
19520 let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
19521 let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
19522 if seen.insert(node.id.clone()) {
19523 nodes.push(node);
19524 }
19525 }
19526 }
19527 if nodes.is_empty() {
19528 bail!("dependency-dag needs at least one resolvable backlog id");
19529 }
19530 nodes.sort_by(|left, right| {
19531 left.properties
19532 .get("line")
19533 .and_then(|value| value.parse::<i64>().ok())
19534 .cmp(
19535 &right
19536 .properties
19537 .get("line")
19538 .and_then(|value| value.parse::<i64>().ok()),
19539 )
19540 .then(left.id.cmp(&right.id))
19541 });
19542 Ok(nodes)
19543}
19544
19545fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
19546 node.properties
19547 .get("ref_id")
19548 .cloned()
19549 .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
19550}
19551
19552fn dependency_dag_node_profile(
19553 root: &Path,
19554 store: &impl GraphStore,
19555 node: &SubstrateGraphNode,
19556 graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
19557 graph_edges: &[SubstrateGraphEdge],
19558 depth: usize,
19559 limit: usize,
19560) -> Result<DependencyDagProfile> {
19561 let id = dependency_dag_node_id(node);
19562 let mut source_files = BTreeSet::new();
19563 let mut source_symbols = BTreeSet::new();
19564 for edge in graph_edges
19565 .iter()
19566 .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
19567 {
19568 let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
19569 continue;
19570 };
19571 match target.kind.as_str() {
19572 "file" | "route" => {
19573 if let Some(path) = target.properties.get("path") {
19574 source_files.insert(path.clone());
19575 }
19576 }
19577 "symbol" => {
19578 source_symbols.insert(target.label.clone());
19579 if let Some(path) = target.properties.get("path") {
19580 source_files.insert(path.clone());
19581 }
19582 }
19583 _ => {}
19584 }
19585 }
19586
19587 let max_rows = if limit == 0 { usize::MAX } else { limit };
19588 for (source, _) in
19589 graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
19590 {
19591 let terse: SubstrateTerseGraphNode = (&source).into();
19592 if let Some(handle) = conflict_matrix_source_handle(&terse) {
19593 source_files.insert(handle.file);
19594 }
19595 }
19596
19597 let worker_results = graph_nodes_by_id
19598 .values()
19599 .filter(|candidate| {
19600 candidate.kind == "worker_result"
19601 && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
19602 })
19603 .map(SubstrateTerseGraphNode::from)
19604 .collect::<Vec<_>>();
19605 let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
19606 let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
19607 let config_files = source_files
19608 .iter()
19609 .filter(|file| is_planner_config_path(file))
19610 .cloned()
19611 .collect();
19612
19613 let mut semantic_refs = BTreeMap::new();
19614 for kind in ["semantic_concept", "semantic_entity"] {
19615 for (semantic, _) in
19616 graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
19617 {
19618 let terse: SubstrateTerseGraphNode = (&semantic).into();
19619 let item = conflict_matrix_semantic_ref(root, &terse);
19620 semantic_refs
19621 .entry(format!("{}:{}", item.kind, item.label))
19622 .or_insert(item);
19623 }
19624 }
19625
19626 Ok(DependencyDagProfile {
19627 id,
19628 graph_node_id: node.id.clone(),
19629 label: node.label.clone(),
19630 path: node.properties.get("path").cloned(),
19631 line: node
19632 .properties
19633 .get("line")
19634 .and_then(|value| value.parse::<i64>().ok()),
19635 detail: node.properties.get("detail").cloned(),
19636 source_files,
19637 source_symbols,
19638 config_files,
19639 expected_tests,
19640 semantic_refs,
19641 worker_feedback,
19642 })
19643}
19644
19645fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
19646 let lower = text.to_ascii_lowercase();
19647 let mut refs = Vec::new();
19648 for marker in markers {
19649 let mut offset = 0usize;
19650 while let Some(pos) = lower[offset..].find(marker) {
19651 let start = offset + pos + marker.len();
19652 let segment = text[start..]
19653 .split(['\n', '.'])
19654 .next()
19655 .unwrap_or(&text[start..]);
19656 refs.extend(extract_conflict_target_refs(segment));
19657 offset = start;
19658 }
19659 }
19660 dedupe_preserve_order(refs)
19661}
19662
19663fn dependency_dag_push_edge(
19664 edges: &mut Vec<DependencyDagEdge>,
19665 seen: &mut BTreeSet<(String, String, String)>,
19666 edge: DependencyDagEdge,
19667) {
19668 if edge.from == edge.to {
19669 return;
19670 }
19671 if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
19672 edges.push(edge);
19673 }
19674}
19675
19676fn dependency_dag_explicit_edges(
19677 profiles: &[DependencyDagProfile],
19678 target_ids: &BTreeSet<String>,
19679 edges: &mut Vec<DependencyDagEdge>,
19680 seen: &mut BTreeSet<(String, String, String)>,
19681) {
19682 for profile in profiles {
19683 let detail = profile.detail.as_deref().unwrap_or_default();
19684 for dep in dependency_dag_marker_refs(
19685 detail,
19686 &[
19687 "depends on",
19688 "depends-on",
19689 "deps:",
19690 "after",
19691 "blocked by",
19692 "requires",
19693 ],
19694 ) {
19695 if target_ids.contains(&dep) {
19696 dependency_dag_push_edge(
19697 edges,
19698 seen,
19699 DependencyDagEdge {
19700 from: dep.clone(),
19701 to: profile.id.clone(),
19702 kind: "explicit_depends_on".to_string(),
19703 weight: 1000,
19704 reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
19705 shared_files: Vec::new(),
19706 shared_symbols: Vec::new(),
19707 shared_tests: Vec::new(),
19708 shared_config_files: Vec::new(),
19709 shared_semantic_refs: Vec::new(),
19710 },
19711 );
19712 }
19713 }
19714 for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
19715 if target_ids.contains(&downstream) {
19716 dependency_dag_push_edge(
19717 edges,
19718 seen,
19719 DependencyDagEdge {
19720 from: profile.id.clone(),
19721 to: downstream.clone(),
19722 kind: "explicit_before".to_string(),
19723 weight: 900,
19724 reasons: vec![format!(
19725 "{} declares it should run before #{downstream}",
19726 profile.id
19727 )],
19728 shared_files: Vec::new(),
19729 shared_symbols: Vec::new(),
19730 shared_tests: Vec::new(),
19731 shared_config_files: Vec::new(),
19732 shared_semantic_refs: Vec::new(),
19733 },
19734 );
19735 }
19736 }
19737 }
19738}
19739
19740fn dependency_dag_worker_follow_up_edges(
19741 profiles: &[DependencyDagProfile],
19742 target_ids: &BTreeSet<String>,
19743 edges: &mut Vec<DependencyDagEdge>,
19744 seen: &mut BTreeSet<(String, String, String)>,
19745) {
19746 for profile in profiles {
19747 for follow_up in &profile.worker_feedback.follow_up_ids {
19748 if target_ids.contains(follow_up) {
19749 dependency_dag_push_edge(
19750 edges,
19751 seen,
19752 DependencyDagEdge {
19753 from: profile.id.clone(),
19754 to: follow_up.clone(),
19755 kind: "worker_result_follow_up".to_string(),
19756 weight: 700,
19757 reasons: vec![format!(
19758 "worker_result for #{} references follow-up #{}",
19759 profile.id, follow_up
19760 )],
19761 shared_files: Vec::new(),
19762 shared_symbols: Vec::new(),
19763 shared_tests: Vec::new(),
19764 shared_config_files: Vec::new(),
19765 shared_semantic_refs: Vec::new(),
19766 },
19767 );
19768 }
19769 }
19770 }
19771}
19772
19773fn dependency_dag_overlap_edges(
19774 profiles: &[DependencyDagProfile],
19775 edges: &mut Vec<DependencyDagEdge>,
19776 seen: &mut BTreeSet<(String, String, String)>,
19777) {
19778 for left_idx in 0..profiles.len() {
19779 for right_idx in (left_idx + 1)..profiles.len() {
19780 let left = &profiles[left_idx];
19781 let right = &profiles[right_idx];
19782 let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19783 let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19784 let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19785 let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19786 let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19787 let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19788 let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19789 if shared_files.is_empty()
19790 && shared_symbols.is_empty()
19791 && shared_tests.is_empty()
19792 && shared_config_files.is_empty()
19793 && shared_semantic_refs.is_empty()
19794 {
19795 continue;
19796 }
19797 let kind = if shared_files.is_empty()
19798 && shared_symbols.is_empty()
19799 && shared_tests.is_empty()
19800 && shared_config_files.is_empty()
19801 {
19802 "semantic_relation"
19803 } else {
19804 "shared_resource"
19805 };
19806 let mut reasons = Vec::new();
19807 if !shared_files.is_empty() {
19808 reasons.push(format!("shared files: {}", shared_files.join(", ")));
19809 }
19810 if !shared_symbols.is_empty() {
19811 reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19812 }
19813 if !shared_tests.is_empty() {
19814 reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19815 }
19816 if !shared_config_files.is_empty() {
19817 reasons.push(format!(
19818 "shared config files: {}",
19819 shared_config_files.join(", ")
19820 ));
19821 }
19822 if !shared_semantic_refs.is_empty() {
19823 reasons.push(format!(
19824 "shared semantic refs: {}",
19825 shared_semantic_refs.join(", ")
19826 ));
19827 }
19828 let weight = shared_files.len() * 100
19829 + shared_config_files.len() * 100
19830 + shared_symbols.len() * 40
19831 + shared_tests.len() * 10
19832 + shared_semantic_refs.len() * 5;
19833 dependency_dag_push_edge(
19834 edges,
19835 seen,
19836 DependencyDagEdge {
19837 from: left.id.clone(),
19838 to: right.id.clone(),
19839 kind: kind.to_string(),
19840 weight,
19841 reasons,
19842 shared_files,
19843 shared_symbols,
19844 shared_tests,
19845 shared_config_files,
19846 shared_semantic_refs,
19847 },
19848 );
19849 }
19850 }
19851}
19852
19853fn dependency_dag_topo_batches(
19854 targets: &[String],
19855 edges: &[DependencyDagEdge],
19856) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19857 let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19858 let order = targets
19859 .iter()
19860 .enumerate()
19861 .map(|(idx, id)| (id.clone(), idx))
19862 .collect::<BTreeMap<_, _>>();
19863 let mut indegree = targets
19864 .iter()
19865 .map(|id| (id.clone(), 0usize))
19866 .collect::<BTreeMap<_, _>>();
19867 let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19868 let mut seen_pairs = BTreeSet::<(String, String)>::new();
19869 for edge in edges {
19870 if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19871 continue;
19872 }
19873 if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19874 continue;
19875 }
19876 *indegree.entry(edge.to.clone()).or_default() += 1;
19877 outgoing
19878 .entry(edge.from.clone())
19879 .or_default()
19880 .push(edge.to.clone());
19881 }
19882 for values in outgoing.values_mut() {
19883 values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19884 values.dedup();
19885 }
19886
19887 let mut processed = BTreeSet::new();
19888 let mut batches = Vec::new();
19889 loop {
19890 let mut ready = targets
19891 .iter()
19892 .filter(|id| !processed.contains(*id))
19893 .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19894 .cloned()
19895 .collect::<Vec<_>>();
19896 ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19897 if ready.is_empty() {
19898 break;
19899 }
19900 for id in &ready {
19901 processed.insert(id.clone());
19902 for next in outgoing.get(id).into_iter().flatten() {
19903 if let Some(value) = indegree.get_mut(next) {
19904 *value = value.saturating_sub(1);
19905 }
19906 }
19907 }
19908 batches.push(DependencyDagTopoBatch {
19909 batch: batches.len() + 1,
19910 targets: ready,
19911 });
19912 }
19913
19914 let blocked_nodes = targets
19915 .iter()
19916 .filter(|id| !processed.contains(*id))
19917 .cloned()
19918 .collect::<Vec<_>>();
19919 let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19920 let cycle_edges = edges
19921 .iter()
19922 .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19923 .cloned()
19924 .collect::<Vec<_>>();
19925 (
19926 batches,
19927 DependencyDagCycleDiagnostics {
19928 has_cycles: !blocked_nodes.is_empty(),
19929 blocked_nodes,
19930 cycle_edges,
19931 },
19932 )
19933}
19934
19935fn dependency_dag_replay_commands(
19936 path: &Path,
19937 scope: Option<&str>,
19938 targets: &[String],
19939 depth: usize,
19940 limit: usize,
19941) -> Vec<String> {
19942 let target_args = targets
19943 .iter()
19944 .map(|target| shell_quote(target))
19945 .collect::<Vec<_>>()
19946 .join(" ");
19947 let mut command = format!(
19948 "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19949 shell_quote(path.to_string_lossy().as_ref()),
19950 scope
19951 .map(|scope| format!(" --scope {}", shell_quote(scope)))
19952 .unwrap_or_default(),
19953 depth,
19954 limit
19955 );
19956 if !target_args.is_empty() {
19957 command.push(' ');
19958 command.push_str(&target_args);
19959 }
19960 vec![command]
19961}
19962
19963fn build_dependency_dag_report(
19964 path: &Path,
19965 scope: Option<&str>,
19966 raw_targets: &[String],
19967 depth: usize,
19968 limit: usize,
19969) -> Result<DependencyDagReport> {
19970 let root = lint::resolve_project_root_or_canonical_path(path)?;
19971 write_traversal_graph_store(&root, path, scope)
19972 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19973 let graph_db = graph_substrate_db_path(&root, scope);
19974 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19975 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19976 let mut warnings = Vec::new();
19977 if let Some(recovery) = store.read_only_recovery() {
19978 warnings.push(graph_db_read_recovery_diagnostic(recovery));
19979 }
19980 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19981 if freshness.fail_closed {
19982 bail!(
19983 "dependency-dag graph projection failed closed: {}; repair: {}",
19984 freshness.diagnostics.join("; "),
19985 graph_db_repair_commands(&root, scope).join("; ")
19986 );
19987 }
19988
19989 let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19990 let graph_nodes = store.all_nodes()?;
19991 let graph_edges = store.all_edges()?;
19992 let graph_nodes_by_id = graph_nodes
19993 .into_iter()
19994 .map(|node| (node.id.clone(), node))
19995 .collect::<BTreeMap<_, _>>();
19996 let profiles = target_nodes
19997 .iter()
19998 .map(|node| {
19999 dependency_dag_node_profile(
20000 &root,
20001 &store,
20002 node,
20003 &graph_nodes_by_id,
20004 &graph_edges,
20005 depth,
20006 limit,
20007 )
20008 })
20009 .collect::<Result<Vec<_>>>()?;
20010 let targets = profiles
20011 .iter()
20012 .map(|profile| profile.id.clone())
20013 .collect::<Vec<_>>();
20014 let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
20015
20016 let mut edges = Vec::new();
20017 let mut seen_edges = BTreeSet::new();
20018 dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
20019 dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
20020 dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
20021 edges.sort_by(|left, right| {
20022 left.from
20023 .cmp(&right.from)
20024 .then(left.to.cmp(&right.to))
20025 .then(left.kind.cmp(&right.kind))
20026 });
20027 let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
20028
20029 let nodes = profiles
20030 .into_iter()
20031 .map(|profile| DependencyDagNode {
20032 id: profile.id,
20033 graph_node_id: profile.graph_node_id,
20034 label: profile.label,
20035 path: profile.path,
20036 line: profile.line,
20037 detail: profile.detail,
20038 source_files: sorted_set(&profile.source_files),
20039 source_symbols: sorted_set(&profile.source_symbols),
20040 config_files: sorted_set(&profile.config_files),
20041 expected_tests: sorted_set(&profile.expected_tests),
20042 semantic_refs: profile.semantic_refs.into_values().collect(),
20043 worker_feedback: profile.worker_feedback,
20044 })
20045 .collect::<Vec<_>>();
20046 let projection_hashes = freshness
20047 .content_hash
20048 .clone()
20049 .into_iter()
20050 .collect::<Vec<_>>();
20051 let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
20052 let repair_commands = graph_db_repair_commands(&root, scope);
20053 let summary = DependencyDagSummary {
20054 nodes: nodes.len(),
20055 edges: edges.len(),
20056 topo_batches: topo_batches.len(),
20057 has_cycles: cycle_diagnostics.has_cycles,
20058 };
20059
20060 Ok(DependencyDagReport {
20061 contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
20062 root: root.to_string_lossy().to_string(),
20063 scope: scope.map(str::to_string),
20064 path: path.to_string_lossy().to_string(),
20065 targets,
20066 projection_freshness: freshness,
20067 projection_hashes,
20068 nodes,
20069 edges,
20070 topo_batches,
20071 cycle_diagnostics,
20072 summary,
20073 replay_commands,
20074 repair_commands,
20075 warnings,
20076 })
20077}
20078
20079fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
20080 if compact {
20081 println!(
20082 "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
20083 report.targets.len(),
20084 report.edges.len(),
20085 report.topo_batches.len(),
20086 report.cycle_diagnostics.has_cycles
20087 );
20088 } else {
20089 println!("Dependency DAG");
20090 println!(" targets: {}", report.targets.join(", "));
20091 println!(" edges: {}", report.edges.len());
20092 println!(" cycles: {}", report.cycle_diagnostics.has_cycles);
20093 }
20094 for batch in &report.topo_batches {
20095 println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
20096 }
20097 for edge in &report.edges {
20098 println!(
20099 "edge {} -> {} kind:{} weight:{}",
20100 edge.from, edge.to, edge.kind, edge.weight
20101 );
20102 for reason in &edge.reasons {
20103 println!(" reason: {reason}");
20104 }
20105 }
20106 if report.cycle_diagnostics.has_cycles {
20107 println!(
20108 "cycle blocked nodes: {}",
20109 report.cycle_diagnostics.blocked_nodes.join(", ")
20110 );
20111 }
20112 for command in &report.replay_commands {
20113 println!("replay: {command}");
20114 }
20115 for command in &report.repair_commands {
20116 println!("repair: {command}");
20117 }
20118 for warning in &report.warnings {
20119 println!("warning: {warning}");
20120 }
20121}
20122
20123fn cmd_dependency_dag(
20124 path: &Path,
20125 scope: Option<&str>,
20126 raw_targets: &[String],
20127 depth: usize,
20128 limit: usize,
20129 format: OutputFormat,
20130) -> Result<()> {
20131 let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
20132 if format.json_output {
20133 print_json_or_envelope(
20134 &report,
20135 &format,
20136 "dependency-dag",
20137 "topological-planning",
20138 ToolEnvelopeSummary {
20139 text: format!(
20140 "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
20141 report.targets.len(),
20142 report.edges.len(),
20143 report.topo_batches.len(),
20144 report.cycle_diagnostics.has_cycles
20145 ),
20146 metrics: vec![
20147 envelope_metric("targets", report.targets.len()),
20148 envelope_metric("edges", report.edges.len()),
20149 envelope_metric("topo_batches", report.topo_batches.len()),
20150 envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
20151 ],
20152 },
20153 report.cycle_diagnostics.has_cycles,
20154 report.replay_commands.clone(),
20155 )
20156 } else {
20157 print_dependency_dag_human(&report, format.compact);
20158 Ok(())
20159 }
20160}
20161
20162fn maybe_attach_log_digest_raw_artifact(
20167 root: &Path,
20168 report: &mut log_digest::LogDigestReport,
20169 input: &str,
20170) -> Result<()> {
20171 if input.trim().is_empty() || !log_digest::raw_log_artifact_recommended(report, input.len()) {
20172 return Ok(());
20173 }
20174 let key = format!("logdigest:{}:{}", report.total_lines, input.len());
20175 let artifact_path = root
20176 .join(".tsift/artifacts")
20177 .join(format!("{}.log", stable_handle("logdg", &key)));
20178 let expand = format!(
20179 "tsift log-digest --path {} --input {} --json",
20180 shell_quote(root.to_string_lossy().as_ref()),
20181 shell_quote(artifact_path.to_string_lossy().as_ref())
20182 );
20183 let artifact = persist_transcript_artifact(root, "logdg", "log", &key, input, expand)?;
20184 report.raw_log_artifact = Some(log_digest::LogDigestArtifactRef {
20185 handle: artifact.handle,
20186 path: artifact.path,
20187 bytes: artifact.bytes,
20188 lines: artifact.lines,
20189 expand: artifact.expand,
20190 });
20191 Ok(())
20192}
20193
20194pub(crate) fn render_log_digest_fixture(
20198 path: &Path,
20199 fixture_path: &Path,
20200 fail_under: bool,
20201 format: OutputFormat,
20202) -> Result<()> {
20203 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20204 let fixture_body = fs::read_to_string(fixture_path)
20205 .with_context(|| format!("reading log-digest fixture: {}", fixture_path.display()))?;
20206 let fixture: log_digest::LogDigestFixture = serde_json::from_str(&fixture_body)
20207 .with_context(|| format!("parsing log-digest fixture: {}", fixture_path.display()))?;
20208 let report = log_digest::evaluate_fixture(&root, &fixture)?;
20209
20210 if format.json_output {
20211 print_json_or_envelope(
20212 &report,
20213 &format,
20214 "log-digest-fixture",
20215 "report",
20216 ToolEnvelopeSummary {
20217 text: if report.passed {
20218 format!("log-digest gate passed for {} case(s)", report.total_cases)
20219 } else {
20220 format!("log-digest gate failed {} case(s)", report.failed_cases)
20221 },
20222 metrics: vec![
20223 envelope_metric("cases", report.total_cases),
20224 envelope_metric("failed", report.failed_cases),
20225 envelope_metric("passed", report.passed),
20226 ],
20227 },
20228 false,
20229 vec![],
20230 )?;
20231 } else {
20232 println!("Log digest fixture gate");
20233 println!(" cases: {}", report.total_cases);
20234 println!(" failed: {}", report.failed_cases);
20235 println!(" status: {}", if report.passed { "pass" } else { "fail" });
20236 for case in &report.cases {
20237 println!(
20238 " [{}] {} ({}): savings {:.1}% (min {:.1}%) raw_tok {} digest_tok {}",
20239 if case.passed { "pass" } else { "FAIL" },
20240 case.name,
20241 case.ecosystem,
20242 case.savings_percent,
20243 case.minimum_savings_percent,
20244 case.raw_tokens,
20245 case.digest_tokens
20246 );
20247 if !case.missing_required_signals.is_empty() {
20248 println!(
20249 " missing required signals: {}",
20250 case.missing_required_signals.join(", ")
20251 );
20252 }
20253 if !case.present_forbidden_signals.is_empty() {
20254 println!(
20255 " present forbidden signals: {}",
20256 case.present_forbidden_signals.join(", ")
20257 );
20258 }
20259 }
20260 }
20261
20262 if fail_under && !report.passed {
20263 bail!("log-digest fixture gate failed");
20264 }
20265 Ok(())
20266}
20267
20268pub(crate) fn render_log_digest_from_input(
20269 path: &Path,
20270 input: &str,
20271 format: OutputFormat,
20272) -> Result<()> {
20273 let mut report = log_digest::compute(path, input)?;
20274 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20275 maybe_attach_log_digest_raw_artifact(&root, &mut report, input)?;
20276 if format.json_output {
20277 println!(
20278 "{}",
20279 to_json_schema(
20280 &report,
20281 format.pretty,
20282 format.terse,
20283 format.ultra_terse,
20284 format.schema
20285 )?
20286 );
20287 return Ok(());
20288 }
20289
20290 if format.compact {
20291 println!(
20292 "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
20293 report.non_empty_lines,
20294 report.signal_groups,
20295 report.repeated_line_groups,
20296 report.file_ref_groups,
20297 report.symbol_ref_groups,
20298 report.stack_groups
20299 );
20300 for signal in &report.signals {
20301 let location = match (&signal.path, signal.line) {
20302 (Some(path), Some(line)) => format!("{path}:{line}"),
20303 (Some(path), None) => path.clone(),
20304 _ => "-".to_string(),
20305 };
20306 println!(
20307 "{} sev:{} count:{} sums:{} msg:{}",
20308 location,
20309 signal.severity,
20310 signal.occurrences,
20311 log_digest_summary_label(signal.summary_state),
20312 truncate_for_compact(&signal.message, 80)
20313 );
20314 }
20315 for repeated in &report.repeated_lines {
20316 println!(
20317 "repeat count:{} line:{}",
20318 repeated.occurrences,
20319 truncate_for_compact(&repeated.line, 80)
20320 );
20321 }
20322 for family in &report.line_families {
20323 println!(
20324 "family count:{} variants:{} template:{}",
20325 family.occurrences,
20326 family.variants,
20327 truncate_for_compact(&family.template, 80)
20328 );
20329 }
20330 for symbol in &report.symbol_refs {
20331 println!(
20332 "sym:{} count:{} sums:{}",
20333 symbol.symbol,
20334 symbol.occurrences,
20335 log_digest_summary_label(symbol.summary_state)
20336 );
20337 }
20338 if let Some(artifact) = &report.raw_log_artifact {
20339 println!(
20340 "raw-artifact handle:{} lines:{} bytes:{} expand:{}",
20341 artifact.handle, artifact.lines, artifact.bytes, artifact.expand
20342 );
20343 }
20344 for warning in &report.warnings {
20345 println!("warning: {warning}");
20346 }
20347 return Ok(());
20348 }
20349
20350 println!("Log digest");
20351 println!(" lines: {}", report.total_lines);
20352 println!(" non-empty lines: {}", report.non_empty_lines);
20353 println!(" signal groups: {}", report.signal_groups);
20354 println!(
20355 " repeated lines: {}",
20356 report.repeated_line_groups
20357 );
20358 println!(
20359 " repeated line instances: {}",
20360 report.repeated_line_occurrences
20361 );
20362 println!(" line families: {}", report.line_family_groups);
20363 println!(" file refs: {}", report.file_ref_groups);
20364 println!(" symbol refs: {}", report.symbol_ref_groups);
20365 println!(" stack groups: {}", report.stack_groups);
20366
20367 if !report.signals.is_empty() {
20368 println!();
20369 println!("Signals:");
20370 for signal in &report.signals {
20371 match (&signal.path, signal.line, signal.column) {
20372 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
20373 (Some(path), Some(line), None) => println!("{path}:{line}"),
20374 (Some(path), None, _) => println!("{path}"),
20375 (None, _, _) => println!("(no file anchor)"),
20376 }
20377 println!(" severity: {}", signal.severity);
20378 println!(" occurrences: {}", signal.occurrences);
20379 println!(" message: {}", signal.message);
20380 println!(
20381 " cached summaries: {}",
20382 log_digest_summary_label(signal.summary_state)
20383 );
20384 for summary in &signal.current_summaries {
20385 println!(
20386 " - {}: {}",
20387 summary.symbol,
20388 truncate_for_compact(&summary.summary, 160)
20389 );
20390 }
20391 }
20392 }
20393
20394 if !report.repeated_lines.is_empty() {
20395 println!();
20396 println!("Repeated lines:");
20397 for repeated in &report.repeated_lines {
20398 println!(
20399 " {}x {}",
20400 repeated.occurrences,
20401 truncate_for_compact(&repeated.line, 180)
20402 );
20403 }
20404 }
20405
20406 if !report.line_families.is_empty() {
20407 println!();
20408 println!("Line families (near-duplicate folds):");
20409 for family in &report.line_families {
20410 println!(
20411 " {}x ({} variants) {}",
20412 family.occurrences,
20413 family.variants,
20414 truncate_for_compact(&family.template, 180)
20415 );
20416 println!(
20417 " first: {}",
20418 truncate_for_compact(&family.first_sample, 180)
20419 );
20420 println!(
20421 " last: {}",
20422 truncate_for_compact(&family.last_sample, 180)
20423 );
20424 }
20425 }
20426
20427 if !report.file_refs.is_empty() {
20428 println!();
20429 println!("Anchored files:");
20430 for file_ref in &report.file_refs {
20431 match (file_ref.line, file_ref.column) {
20432 (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
20433 (Some(line), None) => println!("{}:{}", file_ref.path, line),
20434 (None, _) => println!("{}", file_ref.path),
20435 }
20436 println!(" occurrences: {}", file_ref.occurrences);
20437 println!(
20438 " cached summaries: {}",
20439 log_digest_summary_label(file_ref.summary_state)
20440 );
20441 for summary in &file_ref.current_summaries {
20442 println!(
20443 " - {}: {}",
20444 summary.symbol,
20445 truncate_for_compact(&summary.summary, 160)
20446 );
20447 }
20448 }
20449 }
20450
20451 if !report.symbol_refs.is_empty() {
20452 println!();
20453 println!("Symbol candidates:");
20454 for symbol in &report.symbol_refs {
20455 println!("{}", symbol.symbol);
20456 println!(" occurrences: {}", symbol.occurrences);
20457 println!(
20458 " cached summaries: {}",
20459 log_digest_summary_label(symbol.summary_state)
20460 );
20461 for summary in &symbol.current_summaries {
20462 println!(
20463 " - {}: {}",
20464 summary.symbol,
20465 truncate_for_compact(&summary.summary, 160)
20466 );
20467 }
20468 }
20469 }
20470
20471 if !report.stack_traces.is_empty() {
20472 println!();
20473 println!("Stack groups:");
20474 for stack in &report.stack_traces {
20475 println!(" occurrences: {}", stack.occurrences);
20476 for frame in &stack.frames {
20477 println!(" - {}", frame);
20478 }
20479 }
20480 }
20481
20482 if let Some(artifact) = &report.raw_log_artifact {
20483 println!();
20484 println!("Raw log artifact:");
20485 println!(" handle: {}", artifact.handle);
20486 println!(" path: {}", artifact.path);
20487 println!(" lines: {}", artifact.lines);
20488 println!(" bytes: {}", artifact.bytes);
20489 println!(" expand: {}", artifact.expand);
20490 }
20491
20492 for warning in &report.warnings {
20493 println!("warning: {warning}");
20494 }
20495 Ok(())
20496}
20497
20498pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
20499 match trend {
20500 metric_digest::MetricDigestTrend::Improved => "improved",
20501 metric_digest::MetricDigestTrend::Regressed => "regressed",
20502 metric_digest::MetricDigestTrend::Flat => "flat",
20503 metric_digest::MetricDigestTrend::Unknown => "changed",
20504 }
20505}
20506
20507pub(crate) fn metric_digest_gate_label(
20508 decision: metric_digest::CommunitySearchGateDecision,
20509) -> &'static str {
20510 match decision {
20511 metric_digest::CommunitySearchGateDecision::Pass => "pass",
20512 metric_digest::CommunitySearchGateDecision::Block => "block",
20513 }
20514}
20515
20516pub(crate) fn memgraphrag_metric_digest_gate_label(
20517 decision: metric_digest::MemGraphRagPerformanceGateDecision,
20518) -> &'static str {
20519 match decision {
20520 metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
20521 metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
20522 }
20523}
20524
20525fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
20526 let input = fs::read_to_string(fixture_path)
20527 .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
20528 let report = dci_benchmark::compute(&input)?;
20529
20530 if format.json_output {
20531 println!(
20532 "{}",
20533 to_json_schema(
20534 &report,
20535 format.pretty,
20536 format.terse,
20537 format.ultra_terse,
20538 format.schema
20539 )?
20540 );
20541 return Ok(());
20542 }
20543
20544 if format.compact {
20545 println!(
20546 "dci tasks:{} strategies:{} warnings:{}",
20547 report.tasks_loaded,
20548 report.strategies_compared,
20549 report.warnings.len()
20550 );
20551 for summary in &report.strategy_summaries {
20552 println!(
20553 "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
20554 summary.strategy,
20555 summary.rank,
20556 summary.localized,
20557 summary.task_runs,
20558 dci_benchmark::format_number(summary.localization_rate * 100.0),
20559 dci_benchmark::format_number(summary.avg_useful_hits),
20560 dci_benchmark::format_number(summary.zero_output_rate * 100.0),
20561 dci_benchmark::format_number(summary.avg_tool_calls),
20562 dci_benchmark::format_number(summary.avg_latency_ms),
20563 dci_benchmark::format_number(summary.avg_estimated_tokens),
20564 dci_benchmark::format_number(summary.avg_output_tokens)
20565 );
20566 }
20567 if let Some(gate) = &report.memory_retrieval_gate {
20568 println!(
20569 "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
20570 gate.decision,
20571 gate.baseline_strategy,
20572 dci_benchmark::format_number(gate.min_avg_useful_hits),
20573 gate.max_zero_output_failures,
20574 gate.diagnostics.len()
20575 );
20576 }
20577 for warning in &report.warnings {
20578 println!("warning: {warning}");
20579 }
20580 return Ok(());
20581 }
20582
20583 println!("DCI benchmark");
20584 if let Some(description) = &report.description {
20585 println!(" description: {}", description);
20586 }
20587 println!(" tasks loaded: {}", report.tasks_loaded);
20588 println!(" strategies compared: {}", report.strategies_compared);
20589
20590 println!();
20591 println!("Strategy summary:");
20592 for summary in &report.strategy_summaries {
20593 println!(
20594 " #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
20595 summary.rank,
20596 summary.strategy,
20597 summary.localized,
20598 summary.task_runs,
20599 summary.localization_rate * 100.0,
20600 dci_benchmark::format_number(summary.avg_useful_hits),
20601 summary.zero_output_rate * 100.0,
20602 dci_benchmark::format_number(summary.avg_tool_calls),
20603 dci_benchmark::format_number(summary.avg_latency_ms),
20604 dci_benchmark::format_number(summary.avg_estimated_tokens),
20605 dci_benchmark::format_number(summary.avg_output_tokens)
20606 );
20607 }
20608
20609 if let Some(gate) = &report.memory_retrieval_gate {
20610 println!();
20611 println!("Memory retrieval gate:");
20612 println!(" decision: {}", gate.decision);
20613 println!(
20614 " baseline: {}, min avg useful hits {}, max zero-output failures {}",
20615 gate.baseline_strategy,
20616 dci_benchmark::format_number(gate.min_avg_useful_hits),
20617 gate.max_zero_output_failures
20618 );
20619 for row in &gate.rows {
20620 println!(
20621 " {}: status {}, avg useful hits {}, zero-output failures {}",
20622 row.strategy,
20623 row.status,
20624 dci_benchmark::format_number(row.avg_useful_hits),
20625 row.zero_output_failures
20626 );
20627 }
20628 for diagnostic in &gate.diagnostics {
20629 println!(" diagnostic: {diagnostic}");
20630 }
20631 }
20632
20633 println!();
20634 println!("Task winners:");
20635 for row in &report.task_rows {
20636 let label = row
20637 .label
20638 .as_ref()
20639 .map(|value| format!(" ({value})"))
20640 .unwrap_or_default();
20641 println!(" {}{}", row.task_id, label);
20642 println!(" localized: {}", row.best_localization.join(", "));
20643 println!(" most useful hits: {}", row.most_useful_hits.join(", "));
20644 println!(
20645 " lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
20646 row.lowest_tool_calls.as_deref().unwrap_or("-"),
20647 row.lowest_latency.as_deref().unwrap_or("-"),
20648 row.lowest_token_budget.as_deref().unwrap_or("-"),
20649 row.lowest_output_tokens.as_deref().unwrap_or("-")
20650 );
20651 if !row.zero_output_failures.is_empty() {
20652 println!(" zero output: {}", row.zero_output_failures.join(", "));
20653 }
20654 }
20655
20656 for warning in &report.warnings {
20657 println!("warning: {warning}");
20658 }
20659 Ok(())
20660}
20661
20662pub(crate) fn format_compact_count(value: u64) -> String {
20663 if value >= 1_000_000 {
20664 format!("{:.1}M", value as f64 / 1_000_000.0)
20665 } else if value >= 1_000 {
20666 format!("{:.1}K", value as f64 / 1_000.0)
20667 } else {
20668 value.to_string()
20669 }
20670}
20671
20672fn cmd_digest_runner(
20673 kind: &str,
20674 path: &Path,
20675 runner: Option<&str>,
20676 shell_command: &str,
20677 format: OutputFormat,
20678) -> Result<()> {
20679 let digest_kind = DigestRunnerKind::parse(kind)?;
20680 let root = transcript_artifact_root(path)?;
20681 let execution = run_digest_runner_command(shell_command)?;
20682 let output = &execution.output;
20683 let captured = String::from_utf8_lossy(&output.stdout).into_owned();
20684 let exit_code = output.status.code().unwrap_or(-1);
20685 if format.json_output && format.envelope {
20686 let artifact_key = format!(
20687 "{}:{}:{}:{}",
20688 digest_kind.as_str(),
20689 shell_command,
20690 execution.executed_command,
20691 captured
20692 );
20693 let artifact = if captured.trim().is_empty() {
20694 None
20695 } else {
20696 let (suffix, expand) = match digest_kind {
20697 DigestRunnerKind::Test => (
20698 "test.log",
20699 format!(
20700 "tsift test-digest --path {} --input {}{} --json",
20701 shell_quote(root.to_string_lossy().as_ref()),
20702 shell_quote(
20703 root.join(".tsift/artifacts")
20704 .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
20705 .to_string_lossy()
20706 .as_ref()
20707 ),
20708 runner
20709 .map(|value| format!(" --runner {}", shell_quote(value)))
20710 .unwrap_or_default()
20711 ),
20712 ),
20713 DigestRunnerKind::Log => (
20714 "log",
20715 format!(
20716 "tsift log-digest --path {} --input {} --json",
20717 shell_quote(root.to_string_lossy().as_ref()),
20718 shell_quote(
20719 root.join(".tsift/artifacts")
20720 .join(format!("{}.log", stable_handle("tart", &artifact_key)))
20721 .to_string_lossy()
20722 .as_ref()
20723 )
20724 ),
20725 ),
20726 };
20727 Some(persist_transcript_artifact(
20728 &root,
20729 "tart",
20730 suffix,
20731 &artifact_key,
20732 &captured,
20733 expand,
20734 )?)
20735 };
20736 let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
20737
20738 match digest_kind {
20739 DigestRunnerKind::Test => {
20740 let digest_report = test_digest::compute(path, &captured, runner)?;
20741 let report = serde_json::json!({
20742 "kind": digest_kind.as_str(),
20743 "command": shell_command,
20744 "executed_command": execution.executed_command,
20745 "exit_code": exit_code,
20746 "success": output.status.success(),
20747 "filter": filter_report,
20748 "artifact": artifact,
20749 "digest": digest_report,
20750 });
20751 let mut follow_up = artifact
20752 .as_ref()
20753 .map(|entry| vec![entry.expand.clone()])
20754 .unwrap_or_default();
20755 follow_up.push(format!(
20756 "tsift rewrite --run {}",
20757 shell_quote(shell_command)
20758 ));
20759 let summary_text = if output.status.success() && digest_report.failures == 0 {
20760 format!("test run passed for {}", runner.unwrap_or("auto"))
20761 } else {
20762 format!("test run captured {} failure(s)", digest_report.failures)
20763 };
20764 print_json_or_envelope(
20765 &report,
20766 &format,
20767 "digest-runner",
20768 "test-run",
20769 ToolEnvelopeSummary {
20770 text: summary_text,
20771 metrics: vec![
20772 envelope_metric("runner", &digest_report.runner),
20773 envelope_metric("exit_code", exit_code),
20774 envelope_metric("filter", execution.filter_label()),
20775 envelope_metric("failures", digest_report.failures),
20776 envelope_metric("groups", digest_report.grouped_failures),
20777 envelope_metric(
20778 "artifact",
20779 artifact
20780 .as_ref()
20781 .map(|entry| entry.handle.as_str())
20782 .unwrap_or("-"),
20783 ),
20784 ],
20785 },
20786 false,
20787 follow_up,
20788 )?;
20789 }
20790 DigestRunnerKind::Log => {
20791 let digest_report = log_digest::compute(path, &captured)?;
20792 let report = serde_json::json!({
20793 "kind": digest_kind.as_str(),
20794 "command": shell_command,
20795 "executed_command": execution.executed_command,
20796 "exit_code": exit_code,
20797 "success": output.status.success(),
20798 "filter": filter_report,
20799 "artifact": artifact,
20800 "digest": digest_report,
20801 });
20802 let mut follow_up = artifact
20803 .as_ref()
20804 .map(|entry| vec![entry.expand.clone()])
20805 .unwrap_or_default();
20806 follow_up.push(format!(
20807 "tsift rewrite --run {}",
20808 shell_quote(shell_command)
20809 ));
20810 let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
20811 "command finished without log signals".to_string()
20812 } else {
20813 format!(
20814 "command emitted {} log signal group(s)",
20815 digest_report.signal_groups
20816 )
20817 };
20818 print_json_or_envelope(
20819 &report,
20820 &format,
20821 "digest-runner",
20822 "command-run",
20823 ToolEnvelopeSummary {
20824 text: summary_text,
20825 metrics: vec![
20826 envelope_metric("exit_code", exit_code),
20827 envelope_metric("filter", execution.filter_label()),
20828 envelope_metric("signals", digest_report.signal_groups),
20829 envelope_metric("file_refs", digest_report.file_ref_groups),
20830 envelope_metric(
20831 "artifact",
20832 artifact
20833 .as_ref()
20834 .map(|entry| entry.handle.as_str())
20835 .unwrap_or("-"),
20836 ),
20837 ],
20838 },
20839 false,
20840 follow_up,
20841 )?;
20842 }
20843 }
20844
20845 if output.status.success() {
20846 return Ok(());
20847 }
20848 if let Some(code) = output.status.code() {
20849 std::process::exit(code);
20850 }
20851 bail!("digest-wrapped command terminated by signal: {shell_command}");
20852 }
20853
20854 if captured.trim().is_empty() {
20855 let label = match digest_kind {
20856 DigestRunnerKind::Test => "test",
20857 DigestRunnerKind::Log => "log",
20858 };
20859 println!("No {label} output captured.");
20860 } else {
20861 match digest_kind {
20862 DigestRunnerKind::Test => {
20863 render_test_digest_from_input(path, &captured, runner, format)?
20864 }
20865 DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20866 }
20867 }
20868
20869 if output.status.success() {
20870 return Ok(());
20871 }
20872 if let Some(code) = output.status.code() {
20873 std::process::exit(code);
20874 }
20875 bail!("digest-wrapped command terminated by signal: {shell_command}");
20876}
20877
20878struct DigestRunnerExecution {
20879 output: std::process::Output,
20880 executed_command: String,
20881 filter: Option<DigestRunnerFilter>,
20882}
20883
20884impl DigestRunnerExecution {
20885 fn filter_label(&self) -> &'static str {
20886 self.filter
20887 .as_ref()
20888 .map(|filter| filter.tool)
20889 .unwrap_or("none")
20890 }
20891}
20892
20893struct DigestRunnerFilter {
20894 tool: &'static str,
20895 command: String,
20896}
20897
20898impl DigestRunnerFilter {
20899 fn to_json(&self) -> serde_json::Value {
20900 serde_json::json!({
20901 "tool": self.tool,
20902 "command": self.command,
20903 })
20904 }
20905}
20906
20907fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20908 let filter = rtk_rewrite_for_digest_runner(shell_command);
20909 let executed_command = filter
20910 .as_ref()
20911 .map(|filter| filter.command.as_str())
20912 .unwrap_or(shell_command);
20913 let output = Command::new("sh")
20914 .arg("-lc")
20915 .arg(format!("({executed_command}) 2>&1"))
20916 .stdout(Stdio::piped())
20917 .output()
20918 .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20919
20920 Ok(DigestRunnerExecution {
20921 output,
20922 executed_command: executed_command.to_string(),
20923 filter,
20924 })
20925}
20926
20927fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20928 if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20929 return None;
20930 }
20931 let output = Command::new("rtk")
20932 .arg("rewrite")
20933 .arg(shell_command)
20934 .output()
20935 .ok()?;
20936 if !output.status.success() {
20937 return None;
20938 }
20939 let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20940 if rewritten.is_empty() || rewritten == shell_command {
20941 return None;
20942 }
20943 Some(DigestRunnerFilter {
20944 tool: "rtk",
20945 command: rewritten,
20946 })
20947}
20948
20949fn find_command_on_path(command: &str) -> Option<PathBuf> {
20950 let path_var = std::env::var_os("PATH")?;
20951 std::env::split_paths(&path_var)
20952 .map(|dir| dir.join(command))
20953 .find(|candidate| candidate.is_file())
20954}
20955
20956pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20957 if !db_path.exists() {
20958 bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20959 }
20960 summarize::SummaryDb::open_read_only_resilient(db_path)
20961}
20962
20963fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20964 !matches!(report.index, status::IndexStatus::Fresh { .. })
20965}
20966
20967fn status_workspace_scope_ids_needing_fix(
20968 report: &status::StatusReport,
20969) -> std::collections::HashSet<&str> {
20970 let (workspace_scopes, missing_scopes) = match &report.index {
20971 status::IndexStatus::Fresh {
20972 workspace_scopes,
20973 missing_scopes,
20974 ..
20975 }
20976 | status::IndexStatus::Stale {
20977 workspace_scopes,
20978 missing_scopes,
20979 ..
20980 } => (workspace_scopes.as_slice(), missing_scopes.as_slice()),
20981 status::IndexStatus::Missing { missing_scopes } => (&[][..], missing_scopes.as_slice()),
20982 };
20983
20984 workspace_scopes
20985 .iter()
20986 .filter(|scope| scope.stale_files > 0)
20987 .map(|scope| scope.scope.as_str())
20988 .chain(missing_scopes.iter().map(|scope| scope.scope.as_str()))
20989 .collect()
20990}
20991
20992fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20993 !matches!(report.instructions, init::InstructionStatus::Current { .. })
20994}
20995
20996pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20997 if status_instructions_need_fix(report) {
20998 eprintln!("status fix: refreshing tsift instructions");
20999 init::init(root, false, false)?;
21000 }
21001
21002 let eviction = cycle_packet_cache::cycle_packet_cache_evict(
21003 root,
21004 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
21005 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
21006 );
21007 if eviction.evicted_entries > 0 {
21008 eprintln!(
21009 "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
21010 eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
21011 );
21012 }
21013
21014 if !status_index_needs_fix(report) {
21015 return Ok(());
21016 }
21017
21018 let scopes = config::Config::submodule_dirs(root)?;
21019 if scopes.is_empty() {
21020 eprintln!("status fix: refreshing index");
21021 run_index_update(
21022 &root.join(".tsift/index.db"),
21023 root,
21024 "status --fix refreshing index".to_string(),
21025 root,
21026 None,
21027 false,
21028 false,
21029 )?;
21030 return Ok(());
21031 }
21032
21033 let cfg = config::Config::load(root)?;
21034 let scope_ids_needing_fix = status_workspace_scope_ids_needing_fix(report);
21035 for scope in scopes {
21036 if !scope_ids_needing_fix.contains(scope.id.as_str()) {
21037 continue;
21038 }
21039 if !scope.source_root.exists() {
21040 eprintln!(
21041 "status fix: skipping missing submodule `{}` ({})",
21042 scope.id,
21043 scope.source_root.display()
21044 );
21045 continue;
21046 }
21047 eprintln!("status fix: refreshing submodule `{}` index", scope.id);
21048 run_index_update(
21049 &cfg.db_path_for(root, &scope.id),
21050 &scope.source_root,
21051 format!("status --fix refreshing submodule `{}` index", scope.id),
21052 root,
21053 Some(scope.id.as_str()),
21054 false,
21055 false,
21056 )?;
21057 }
21058
21059 Ok(())
21060}
21061
21062pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
21063 match &report.index {
21064 status::IndexStatus::Fresh { missing_scopes, .. }
21065 | status::IndexStatus::Stale { missing_scopes, .. }
21066 | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
21067 }
21068}
21069
21070pub(crate) fn autoindex_missing_workspace_scopes(
21071 root: &Path,
21072 report: &status::StatusReport,
21073) -> Result<()> {
21074 let missing_scopes = match &report.index {
21075 status::IndexStatus::Fresh { missing_scopes, .. }
21076 | status::IndexStatus::Stale { missing_scopes, .. }
21077 | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
21078 };
21079 if missing_scopes.is_empty() {
21080 return Ok(());
21081 }
21082
21083 let missing_scope_ids = missing_scopes
21084 .iter()
21085 .map(|scope| scope.scope.as_str())
21086 .collect::<std::collections::HashSet<_>>();
21087 let cfg = config::Config::load(root)?;
21088 for scope in config::Config::submodule_dirs(root)? {
21089 if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
21090 continue;
21091 }
21092 let db_path = cfg.db_path_for(root, &scope.id);
21093 run_index_update(
21094 &db_path,
21095 &scope.source_root,
21096 format!(
21097 "autoindexing missing submodule `{}` during status",
21098 scope.id
21099 ),
21100 root,
21101 Some(scope.id.as_str()),
21102 false,
21103 false,
21104 )?;
21105 }
21106 Ok(())
21107}
21108
21109pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
21110 for warning in &stats.warnings {
21111 let rel_path = relativize_pathbuf(&warning.path, root);
21112 eprintln!(
21113 "warning: summarize stats {}: {}",
21114 rel_path.display(),
21115 warning.message
21116 );
21117 }
21118}
21119
21120fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
21121 Result::<(), anyhow::Error>::Err(err)
21122 .context(context)
21123 .unwrap_err()
21124}
21125
21126fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
21127 let message = err.to_string();
21128 message.contains("another tsift index writer is already active")
21129 || substrate::error_mentions_locked_db(err)
21130}
21131
21132fn add_write_lock_context(
21133 err: anyhow::Error,
21134 action: String,
21135 root: &std::path::Path,
21136 scope: Option<&str>,
21137) -> anyhow::Error {
21138 if !should_attach_lock_diagnostics(&err) {
21139 return contextualize_error(err, action);
21140 }
21141
21142 let Ok(report) = status::check_locks(root, None, scope) else {
21143 return contextualize_error(err, action);
21144 };
21145
21146 contextualize_error(
21147 err,
21148 format!(
21149 "{}\n\nlock diagnostics:\n{}",
21150 action,
21151 status::format_locks_human(&report, false).trim_end()
21152 ),
21153 )
21154}
21155
21156pub(crate) fn run_index_update(
21157 db_path: &std::path::Path,
21158 source_root: &std::path::Path,
21159 action: String,
21160 root: &std::path::Path,
21161 scope: Option<&str>,
21162 rebuild: bool,
21163 prune: bool,
21164) -> Result<index::IndexSummary> {
21165 let result = (|| {
21166 let db = index::IndexDb::open(db_path)?;
21167 if rebuild {
21168 db.rebuild(source_root)
21169 } else if prune {
21170 db.apply_changes_pruned(source_root)
21171 } else {
21172 db.apply_changes(source_root)
21173 }
21174 })();
21175
21176 let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
21177 emit_index_warnings(&summary, source_root, scope);
21178 Ok(summary)
21179}
21180
21181pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
21182 for change in &mut summary.changes {
21183 change.path = relativize_pathbuf(&change.path, root);
21184 }
21185 for warning in &mut summary.warnings {
21186 warning.path = relativize_pathbuf(&warning.path, root);
21187 }
21188}
21189
21190fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
21191 for warning in &summary.warnings {
21192 let rel_path = relativize_pathbuf(&warning.path, root);
21193 let stage = match warning.stage {
21194 index::IndexWarningStage::ReadSource => "read failed",
21195 index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
21196 index::IndexWarningStage::ExtractCallSites => "call extraction failed",
21197 index::IndexWarningStage::ExtractRoutes => "route extraction failed",
21198 };
21199 let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
21200 let lang_suffix = warning
21201 .language
21202 .as_deref()
21203 .map(|lang| format!(" [{}]", lang))
21204 .unwrap_or_default();
21205 eprintln!(
21206 "warning: {}{}{}: {}: {}",
21207 scope_prefix,
21208 rel_path.display(),
21209 lang_suffix,
21210 stage,
21211 warning.message
21212 );
21213 }
21214}
21215
21216pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
21217 let config_path = root.join(".tsift/config.toml");
21218 if !config_path.exists() {
21219 return summarize::SummarizeConfig::default();
21220 }
21221 #[derive(serde::Deserialize, Default)]
21222 struct RawConfig {
21223 #[serde(default)]
21224 summarize: Option<RawSummarize>,
21225 }
21226 #[derive(serde::Deserialize)]
21227 struct RawSummarize {
21228 model: Option<String>,
21229 max_file_tokens: Option<usize>,
21230 api_key_env: Option<String>,
21231 }
21232 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
21233 let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
21234 let defaults = summarize::SummarizeConfig::default();
21235 match raw.summarize {
21236 Some(s) => summarize::SummarizeConfig {
21237 model: s.model.unwrap_or(defaults.model),
21238 max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
21239 api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
21240 },
21241 None => defaults,
21242 }
21243}
21244
21245#[derive(Debug, Clone, PartialEq, Eq)]
21246struct ExtractSymbolContext {
21247 db_path: PathBuf,
21248 source_root: PathBuf,
21249}
21250
21251pub(crate) fn find_symbols_db_for_file(
21252 root: &Path,
21253 file_path: &Path,
21254) -> Result<Option<ExtractSymbolContext>> {
21255 let cfg = config::Config::load(root)?;
21256 let mut submodules = config::Config::submodule_dirs(root)?;
21257 submodules.sort_by(|left, right| {
21258 right
21259 .source_root
21260 .components()
21261 .count()
21262 .cmp(&left.source_root.components().count())
21263 });
21264
21265 for scope in submodules {
21266 if !file_path.starts_with(&scope.source_root) {
21267 continue;
21268 }
21269 let db_path = cfg.db_path_for(root, &scope.id);
21270 if db_path.exists() {
21271 return Ok(Some(ExtractSymbolContext {
21272 db_path,
21273 source_root: scope.source_root,
21274 }));
21275 }
21276 }
21277
21278 let single = root.join(".tsift/index.db");
21279 if single.exists() && file_path.starts_with(root) {
21280 return Ok(Some(ExtractSymbolContext {
21281 db_path: single,
21282 source_root: root.to_path_buf(),
21283 }));
21284 }
21285
21286 Ok(None)
21287}
21288
21289pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
21290 let canonical = path
21291 .canonicalize()
21292 .with_context(|| format!("canonicalizing {}", path.display()))?;
21293
21294 Ok(if canonical.is_dir() {
21295 canonical
21296 } else {
21297 canonical
21298 .parent()
21299 .map(Path::to_path_buf)
21300 .unwrap_or(canonical)
21301 })
21302}
21303
21304fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
21305 if path.exists() {
21306 return path
21307 .canonicalize()
21308 .with_context(|| format!("canonicalizing extract scope {}", path.display()));
21309 }
21310
21311 Ok(summarize::normalize_lexical_path(path))
21312}
21313
21314pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
21315 let scope = if extract_path.is_absolute() {
21316 extract_path.to_path_buf()
21317 } else {
21318 root.join(extract_path)
21319 };
21320 normalize_extract_scope_path(&scope)
21321}
21322
21323pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
21324 normalize_extract_scope_path(changed_path)
21325 .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
21326 .starts_with(extract_scope)
21327}
21328
21329pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
21330 summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
21331}
21332
21333pub(crate) fn summarize_full_extract_deleted_summary_paths(
21334 summary_db: &summarize::SummaryDb,
21335 root: &Path,
21336 extract_scope: &Path,
21337 files_to_extract: &[PathBuf],
21338) -> Result<BTreeSet<String>> {
21339 let live_paths = files_to_extract
21340 .iter()
21341 .map(|file_path| summarize_relative_file_path(root, file_path))
21342 .collect::<BTreeSet<_>>();
21343 let mut deleted = BTreeSet::new();
21344
21345 for cached_path in summary_db.cached_file_paths()? {
21346 if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
21347 continue;
21348 }
21349 if !live_paths.contains(&cached_path) {
21350 deleted.insert(cached_path);
21351 }
21352 }
21353
21354 Ok(deleted)
21355}
21356
21357#[derive(Debug, Clone)]
21358struct SearchIndexTarget {
21359 label: String,
21360 db_path: PathBuf,
21361 source_root: PathBuf,
21362 scope_name: Option<String>,
21363 reindex_cmd: String,
21364}
21365
21366fn cargo_package_index_target(
21367 root: &Path,
21368 package: multiplicity::CargoPackageInfo,
21369) -> SearchIndexTarget {
21370 SearchIndexTarget {
21371 label: format!("cargo package `{}` index", package.scope_id),
21372 db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
21373 source_root: package.package_root.clone(),
21374 scope_name: Some(package.scope_id.clone()),
21375 reindex_cmd: format!(
21376 "tsift index --submodule {} {}",
21377 package.scope_id,
21378 root.display()
21379 ),
21380 }
21381}
21382
21383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21384enum SearchIndexState {
21385 Missing,
21386 Fresh,
21387 Stale { stale_files: usize },
21388}
21389
21390fn resolve_search_index_targets(
21391 root: &Path,
21392 path_hint: &Path,
21393 scope: Option<&str>,
21394 federated: bool,
21395) -> Result<Vec<SearchIndexTarget>> {
21396 if let Some(scope_name) = scope {
21397 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
21398 let cfg = config::Config::load(root)?;
21399 return Ok(vec![SearchIndexTarget {
21400 label: format!("submodule `{}` index", scope.id),
21401 db_path: cfg.db_path_for(root, &scope.id),
21402 source_root: scope.source_root.clone(),
21403 scope_name: Some(scope.id.clone()),
21404 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21405 }]);
21406 }
21407 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
21408 return Ok(vec![cargo_package_index_target(root, package)]);
21409 }
21410 config::Config::resolve_submodule(root, scope_name)?;
21411 }
21412
21413 if federated {
21414 let cfg = config::Config::load(root)?;
21415 let mut targets = Vec::new();
21416 for scope in config::Config::submodule_dirs(root)? {
21417 if !cfg.federation_for_scope(&scope) {
21418 continue;
21419 }
21420 targets.push(SearchIndexTarget {
21421 label: format!("submodule `{}` index", scope.id),
21422 db_path: cfg.db_path_for(root, &scope.id),
21423 source_root: scope.source_root.clone(),
21424 scope_name: Some(scope.id.clone()),
21425 reindex_cmd: format!("tsift index --workspace {}", root.display()),
21426 });
21427 }
21428 return Ok(targets);
21429 }
21430
21431 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
21432 let cfg = config::Config::load(root)?;
21433 return Ok(vec![SearchIndexTarget {
21434 label: format!("submodule `{}` index", scope.id),
21435 db_path: cfg.db_path_for(root, &scope.id),
21436 source_root: scope.source_root.clone(),
21437 scope_name: Some(scope.id.clone()),
21438 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21439 }]);
21440 }
21441
21442 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
21443 return Ok(vec![cargo_package_index_target(root, package)]);
21444 }
21445
21446 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
21447 let cfg = config::Config::load(root)?;
21448 return Ok(vec![SearchIndexTarget {
21449 label: format!("submodule `{}` index", scope.id),
21450 db_path: cfg.db_path_for(root, &scope.id),
21451 source_root: scope.source_root.clone(),
21452 scope_name: Some(scope.id.clone()),
21453 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21454 }]);
21455 }
21456
21457 let scopes = config::Config::submodule_dirs(root)?;
21458 if !scopes.is_empty() {
21459 let root_db = root.join(".tsift/index.db");
21460 if !root_db.exists() {
21461 let available_scopes = scopes
21462 .iter()
21463 .map(|scope| scope.id.as_str())
21464 .collect::<Vec<_>>()
21465 .join(", ");
21466 let cfg = config::Config::load(root)?;
21467 let indexed_scopes = scopes
21468 .iter()
21469 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
21470 .map(|scope| scope.id.as_str())
21471 .collect::<Vec<_>>();
21472 let indexed_label = if indexed_scopes.is_empty() {
21473 "none".to_string()
21474 } else {
21475 indexed_scopes.join(", ")
21476 };
21477 bail!(
21478 "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: {}.",
21479 root.display(),
21480 root_db.display(),
21481 available_scopes,
21482 indexed_label,
21483 );
21484 }
21485 }
21486
21487 Ok(vec![SearchIndexTarget {
21488 label: "index".to_string(),
21489 db_path: root.join(".tsift/index.db"),
21490 source_root: root.to_path_buf(),
21491 scope_name: None,
21492 reindex_cmd: format!("tsift index {}", root.display()),
21493 }])
21494}
21495
21496fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
21497 if !target.source_root.exists() || !target.db_path.exists() {
21498 return Ok(SearchIndexState::Missing);
21499 }
21500
21501 let inspection =
21502 index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
21503 let stale_files =
21504 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
21505 if stale_files == 0 {
21506 Ok(SearchIndexState::Fresh)
21507 } else {
21508 Ok(SearchIndexState::Stale { stale_files })
21509 }
21510}
21511
21512#[derive(Debug, Clone, PartialEq, Eq)]
21513struct RebuildSearchTarget {
21514 label: String,
21515 reason: RebuildSearchReason,
21516 reindex_cmd: String,
21517}
21518
21519#[derive(Debug, Clone, PartialEq, Eq)]
21520enum RebuildSearchReason {
21521 Missing,
21522 Stale { stale_files: usize },
21523}
21524
21525#[derive(Debug, Clone, PartialEq, Eq)]
21526struct DegradedSearchTarget {
21527 label: String,
21528 reason: RebuildSearchReason,
21529 reindex_cmd: String,
21530}
21531
21532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21533pub(crate) enum DegradedSearchMode {
21534 ReadOnly,
21535 Exact,
21536}
21537
21538#[derive(Debug)]
21539struct SearchPrecheck {
21540 targets: Vec<SearchIndexTarget>,
21541 degraded_targets: Vec<DegradedSearchTarget>,
21542}
21543
21544fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
21545 err.chain().any(|cause| {
21546 cause
21547 .to_string()
21548 .contains("another tsift index writer is already active")
21549 })
21550}
21551
21552fn infer_agent_doc_task_submodule(
21553 root: &Path,
21554 path_hint: &Path,
21555) -> Result<Option<config::WorkspaceScope>> {
21556 let hinted_path = if path_hint.is_absolute() {
21557 path_hint.to_path_buf()
21558 } else {
21559 root.join(path_hint)
21560 };
21561 let Ok(relative) = hinted_path.strip_prefix(root) else {
21562 return Ok(None);
21563 };
21564 let mut components = relative.components();
21565 let Some(std::path::Component::Normal(first)) = components.next() else {
21566 return Ok(None);
21567 };
21568 if first != "tasks" {
21569 return Ok(None);
21570 }
21571 let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
21572 return Ok(None);
21573 };
21574 config::Config::find_submodule(root, file_stem)
21575}
21576
21577fn degraded_search_target(
21578 target: &SearchIndexTarget,
21579 reason: RebuildSearchReason,
21580) -> DegradedSearchTarget {
21581 DegradedSearchTarget {
21582 label: target.label.clone(),
21583 reason,
21584 reindex_cmd: target.reindex_cmd.clone(),
21585 }
21586}
21587
21588fn apply_search_index_update(
21589 root: &Path,
21590 target: &SearchIndexTarget,
21591) -> Result<index::IndexSummary> {
21592 run_index_update(
21593 &target.db_path,
21594 &target.source_root,
21595 format!("autoindexing {}", target.label),
21596 root,
21597 target.scope_name.as_deref(),
21598 false,
21599 false,
21600 )
21601}
21602
21603fn collect_rebuild_search_targets(
21604 targets: &[SearchIndexTarget],
21605) -> Result<Vec<RebuildSearchTarget>> {
21606 let mut rebuild_targets = Vec::new();
21607 for target in targets {
21608 let reason = match inspect_search_index(target)? {
21609 SearchIndexState::Missing => RebuildSearchReason::Missing,
21610 SearchIndexState::Fresh => continue,
21611 SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
21612 };
21613 rebuild_targets.push(RebuildSearchTarget {
21614 label: target.label.clone(),
21615 reason,
21616 reindex_cmd: target.reindex_cmd.clone(),
21617 });
21618 }
21619 Ok(rebuild_targets)
21620}
21621
21622fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
21623 match target.reason {
21624 RebuildSearchReason::Missing => format!("{} is missing", target.label),
21625 RebuildSearchReason::Stale { stale_files } => {
21626 let file_suffix = if stale_files == 1 { "" } else { "s" };
21627 format!(
21628 "{} is stale ({} file{})",
21629 target.label, stale_files, file_suffix
21630 )
21631 }
21632 }
21633}
21634
21635fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
21636 if rebuild_targets.len() == 1 {
21637 let target = &rebuild_targets[0];
21638 return format!(
21639 "{}. Run `{}` to rebuild before retrying.",
21640 rebuild_search_target_detail(target),
21641 target.reindex_cmd
21642 );
21643 }
21644
21645 let summary: Vec<String> = rebuild_targets
21646 .iter()
21647 .take(3)
21648 .map(rebuild_search_target_detail)
21649 .collect();
21650 let overflow = rebuild_targets.len().saturating_sub(summary.len());
21651 let mut details = summary.join(", ");
21652 if overflow > 0 {
21653 details.push_str(&format!(", +{} more", overflow));
21654 }
21655 let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
21656 format!(
21657 "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
21658 rebuild_targets.len(),
21659 details,
21660 reindex_cmd
21661 )
21662}
21663
21664pub(crate) fn precheck_search_indexes(
21665 root: &Path,
21666 path_hint: &Path,
21667 scope: Option<&str>,
21668 federated: bool,
21669 autoindex: bool,
21670) -> Result<SearchPrecheck> {
21671 let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
21672 let mut stale_targets = Vec::new();
21673 let mut degraded_targets = Vec::new();
21674
21675 for target in &targets {
21676 match inspect_search_index(target)? {
21677 SearchIndexState::Missing => {
21678 if autoindex && let Err(err) = apply_search_index_update(root, target) {
21679 if is_active_writer_lock_error(&err) {
21680 degraded_targets
21681 .push(degraded_search_target(target, RebuildSearchReason::Missing));
21682 } else {
21683 return Err(err);
21684 }
21685 }
21686 }
21687 SearchIndexState::Fresh => {}
21688 SearchIndexState::Stale { stale_files } => {
21689 if autoindex {
21690 if let Err(err) = apply_search_index_update(root, target) {
21691 if is_active_writer_lock_error(&err) {
21692 degraded_targets.push(degraded_search_target(
21693 target,
21694 RebuildSearchReason::Stale { stale_files },
21695 ));
21696 } else {
21697 return Err(err);
21698 }
21699 }
21700 } else {
21701 stale_targets.push(RebuildSearchTarget {
21702 label: target.label.clone(),
21703 reason: RebuildSearchReason::Stale { stale_files },
21704 reindex_cmd: target.reindex_cmd.clone(),
21705 });
21706 }
21707 }
21708 }
21709 }
21710
21711 if stale_targets.is_empty() {
21712 return Ok(SearchPrecheck {
21713 targets,
21714 degraded_targets,
21715 });
21716 }
21717
21718 bail!(
21719 "tsift search aborted: {} \
21720 or re-run without `--no-autoindex`.",
21721 rebuild_search_targets_message(&stale_targets),
21722 );
21723}
21724
21725pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
21726 if targets.is_empty() {
21727 return None;
21728 }
21729
21730 if targets
21731 .iter()
21732 .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
21733 {
21734 Some(DegradedSearchMode::Exact)
21735 } else {
21736 Some(DegradedSearchMode::ReadOnly)
21737 }
21738}
21739
21740fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
21741 if targets.len() == 1 {
21742 let target = &targets[0];
21743 return match target.reason {
21744 RebuildSearchReason::Missing => format!("{} is missing", target.label),
21745 RebuildSearchReason::Stale { stale_files } => {
21746 let file_suffix = if stale_files == 1 { "" } else { "s" };
21747 format!(
21748 "{} is stale ({} file{})",
21749 target.label, stale_files, file_suffix
21750 )
21751 }
21752 };
21753 }
21754
21755 let missing = targets
21756 .iter()
21757 .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
21758 .count();
21759 let stale = targets.len().saturating_sub(missing);
21760 let mut parts = Vec::new();
21761 if stale > 0 {
21762 let suffix = if stale == 1 { "" } else { "es" };
21763 parts.push(format!("{stale} stale index{suffix}"));
21764 }
21765 if missing > 0 {
21766 let suffix = if missing == 1 { "" } else { "es" };
21767 parts.push(format!("{missing} missing index{suffix}"));
21768 }
21769 parts.join(", ")
21770}
21771
21772pub(crate) fn emit_degraded_search_note(
21773 targets: &[DegradedSearchTarget],
21774 mode: DegradedSearchMode,
21775) {
21776 let summary = degraded_search_targets_summary(targets);
21777 let reindex_cmd = &targets[0].reindex_cmd;
21778 match mode {
21779 DegradedSearchMode::ReadOnly => eprintln!(
21780 "note: active tsift writer detected; skipping autoindex because {}. \
21781 Continuing with read-only search and the current index snapshot; symbol hits may lag. \
21782 Retry `{}` after the active writer finishes for fresh index results.",
21783 summary, reindex_cmd
21784 ),
21785 DegradedSearchMode::Exact => eprintln!(
21786 "note: active tsift writer detected; skipping autoindex because {}. \
21787 Continuing with exact live-file search. Retry `{}` after the active writer finishes \
21788 for indexed symbol hits.",
21789 summary, reindex_cmd
21790 ),
21791 }
21792}
21793
21794fn search_timeout_message(
21795 timeout_secs: u64,
21796 strategy: &str,
21797 targets: &[SearchIndexTarget],
21798) -> Result<String> {
21799 let rebuild_targets = collect_rebuild_search_targets(targets)?;
21800 if rebuild_targets.is_empty() {
21801 return Ok(format!(
21802 "tsift search timed out after {}s (strategy: {}). \
21803 The search root looks fresh, so reindexing is unlikely to help. \
21804 Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
21805 or try a different strategy.",
21806 timeout_secs, strategy,
21807 ));
21808 }
21809
21810 Ok(format!(
21811 "tsift search timed out after {}s (strategy: {}). {}",
21812 timeout_secs,
21813 strategy,
21814 rebuild_search_targets_message(&rebuild_targets),
21815 ))
21816}
21817
21818fn is_exact_preferring_query_char(ch: char) -> bool {
21819 matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
21820}
21821
21822fn query_prefers_exact_search(query: &str) -> bool {
21823 let trimmed = query.trim();
21824 !trimmed.is_empty()
21825 && !trimmed.chars().any(char::is_whitespace)
21826 && trimmed.chars().any(|ch| ch.is_alphanumeric())
21827 && trimmed.chars().any(is_exact_preferring_query_char)
21828 && trimmed
21829 .chars()
21830 .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
21831}
21832
21833pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
21834 strategy.unwrap_or_else(|| {
21835 if query_prefers_exact_search(query) {
21836 "exact".to_string()
21837 } else {
21838 "lexical".to_string()
21839 }
21840 })
21841}
21842
21843pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21844 let mut files = Vec::new();
21845 if path.is_file() {
21846 files.push(path.to_path_buf());
21847 return Ok(files);
21848 }
21849 let walker = ignore::WalkBuilder::new(path)
21850 .hidden(true)
21851 .git_ignore(true)
21852 .build();
21853 for entry in walker {
21854 let entry = entry?;
21855 if entry.file_type().is_some_and(|ft| ft.is_file()) {
21856 let p = entry.path();
21857 if let Some(ext) = p.extension() {
21858 let ext = ext.to_string_lossy();
21859 if matches!(
21860 ext.as_ref(),
21861 "rs" | "py"
21862 | "ts"
21863 | "tsx"
21864 | "js"
21865 | "jsx"
21866 | "kt"
21867 | "kts"
21868 | "zig"
21869 | "gd"
21870 | "sh"
21871 | "bash"
21872 | "zsh"
21873 ) {
21874 files.push(p.to_path_buf());
21875 }
21876 }
21877 }
21878 }
21879 Ok(files)
21880}
21881
21882#[cfg(test)]
21883mod tests {
21884 use super::semantic_edit::{
21885 EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21886 markdown_section_spans,
21887 };
21888 use super::*;
21889 use tsift_memory::{MemoryEventKind, MemoryStore};
21890
21891 use std::cell::RefCell;
21892 use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21893
21894 #[test]
21895 fn graph_db_write_lock_serializes_concurrent_writers() {
21896 let dir = tempfile::tempdir().unwrap();
21897 let graph_db = dir.path().join(".tsift/graph.db");
21898 let short = Duration::from_millis(150);
21899
21900 let first = acquire_graph_db_write_lock_with_timeout(&graph_db, short)
21901 .expect("first writer acquires the lock");
21902 let second = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21905 assert!(
21906 second.is_err(),
21907 "a second writer must not acquire the graph-db write lock while it is held"
21908 );
21909 drop(first);
21910 let third = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21912 assert!(
21913 third.is_ok(),
21914 "graph-db write lock must be re-acquirable after release"
21915 );
21916 }
21917
21918 #[test]
21924 fn graph_db_compact_apply_blocks_on_held_write_lock() {
21925 let dir = setup_traversal_project();
21926 let session = dir.path().join("tasks/software/tsift.md");
21927 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
21928 let graph_db = graph_substrate_db_path(dir.path(), None);
21929
21930 let held = acquire_graph_db_write_lock(&graph_db).expect("hold writer lock");
21931
21932 let root = dir.path().to_path_buf();
21933 let handle = std::thread::Builder::new()
21934 .name("compact-apply".to_string())
21935 .stack_size(16 * 1024 * 1024)
21936 .spawn(move || {
21937 crate::commands::infra::cmd_graph_db_compact(
21938 &root,
21939 None,
21940 true,
21941 false,
21942 false,
21943 OutputFormat {
21944 json_output: true,
21945 compact: true,
21946 pretty: false,
21947 terse: false,
21948 ultra_terse: false,
21949 schema: false,
21950 envelope: false,
21951 },
21952 )
21953 })
21954 .unwrap();
21955
21956 std::thread::sleep(Duration::from_millis(300));
21960 assert!(
21961 !handle.is_finished(),
21962 "compact --apply must block on the held graph-db write lock, not run unguarded"
21963 );
21964
21965 drop(held);
21966 let result = handle.join().expect("compact thread joins");
21967 assert!(
21968 result.is_ok(),
21969 "compact --apply must succeed after the lock is released: {result:?}"
21970 );
21971 }
21972
21973 fn parse_cli<I, T>(itr: I) -> Cli
21974 where
21975 I: IntoIterator<Item = T> + Send + 'static,
21976 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21977 {
21978 std::thread::Builder::new()
21979 .name("cli-parse".to_string())
21980 .stack_size(16 * 1024 * 1024)
21981 .spawn(move || Cli::parse_from(itr))
21982 .unwrap()
21983 .join()
21984 .unwrap()
21985 }
21986
21987 fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21988 where
21989 I: IntoIterator<Item = T> + Send + 'static,
21990 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21991 {
21992 std::thread::Builder::new()
21993 .name("cli-try-parse".to_string())
21994 .stack_size(16 * 1024 * 1024)
21995 .spawn(move || Cli::try_parse_from(itr))
21996 .unwrap()
21997 .join()
21998 .unwrap()
21999 }
22000
22001 fn build_relative_search_budget_report(
22002 query: &str,
22003 strategy: &str,
22004 root: &Path,
22005 response: &sift::SearchResponse,
22006 symbol_hits: &[index::SymbolHit],
22007 budget: ResponseBudget,
22008 filters: &SearchFacetFilters,
22009 ) -> SearchBudgetReport {
22010 build_search_budget_report(SearchBudgetReportInput {
22011 query,
22012 strategy,
22013 root,
22014 response,
22015 symbol_hits,
22016 absolute: false,
22017 budget,
22018 filters,
22019 })
22020 }
22021
22022 #[derive(Default)]
22023 struct MemoryConvexGraphClient {
22024 nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
22025 edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
22026 }
22027
22028 impl ConvexGraphClient for MemoryConvexGraphClient {
22029 fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
22030 self.nodes
22031 .borrow_mut()
22032 .insert(row.external_id.clone(), row.clone());
22033 Ok(())
22034 }
22035
22036 fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
22037 self.edges
22038 .borrow_mut()
22039 .insert(row.edge_key.clone(), row.clone());
22040 Ok(())
22041 }
22042
22043 fn delete_node_row(&self, external_id: &str) -> Result<usize> {
22044 Ok(usize::from(
22045 self.nodes.borrow_mut().remove(external_id).is_some(),
22046 ))
22047 }
22048
22049 fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
22050 Ok(usize::from(
22051 self.edges.borrow_mut().remove(edge_key).is_some(),
22052 ))
22053 }
22054
22055 fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
22056 Ok(self.nodes.borrow().get(external_id).cloned())
22057 }
22058
22059 fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
22060 Ok(self.nodes.borrow().values().cloned().collect())
22061 }
22062
22063 fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
22064 Ok(self.edges.borrow().values().cloned().collect())
22065 }
22066
22067 fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
22068 Ok(self
22069 .nodes
22070 .borrow()
22071 .values()
22072 .filter(|row| row.kind == kind)
22073 .cloned()
22074 .collect())
22075 }
22076
22077 fn outgoing_edge_rows(
22078 &self,
22079 from_external_id: &str,
22080 kind: Option<&str>,
22081 ) -> Result<Vec<ConvexEdgeRow>> {
22082 Ok(self
22083 .edges
22084 .borrow()
22085 .values()
22086 .filter(|row| row.from_external_id == from_external_id)
22087 .filter(|row| kind.is_none_or(|kind| row.kind == kind))
22088 .cloned()
22089 .collect())
22090 }
22091 }
22092
22093 fn init_git_repo(path: &Path) {
22094 let status = std::process::Command::new("git")
22095 .args(["init"])
22096 .current_dir(path)
22097 .status()
22098 .unwrap();
22099 assert!(status.success(), "git init failed");
22100
22101 let status = std::process::Command::new("git")
22102 .args(["add", "."])
22103 .current_dir(path)
22104 .status()
22105 .unwrap();
22106 assert!(status.success(), "git add failed");
22107
22108 let status = std::process::Command::new("git")
22109 .args([
22110 "-c",
22111 "user.name=tsift-tests",
22112 "-c",
22113 "user.email=tsift-tests@example.com",
22114 "commit",
22115 "--quiet",
22116 "-m",
22117 "init",
22118 ])
22119 .current_dir(path)
22120 .status()
22121 .unwrap();
22122 assert!(status.success(), "git commit failed");
22123 }
22124
22125 fn write_empty_root_index(root: &Path) {
22126 let index_dir = root.join(".tsift");
22127 fs::create_dir_all(&index_dir).unwrap();
22128 fs::write(index_dir.join("index.db"), "").unwrap();
22129 }
22130
22131 fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
22132 if let Some(parent) = path.parent() {
22133 fs::create_dir_all(parent).unwrap();
22134 }
22135 let body = std::iter::repeat_n(line, lines)
22136 .collect::<Vec<_>>()
22137 .join("\n");
22138 fs::write(path, format!("{body}\n")).unwrap();
22139 path.to_path_buf()
22140 }
22141
22142 #[test]
22145 fn token_capped_preview_returns_all_lines_when_under_cap() {
22146 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 1", "}"];
22147 let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
22148 assert!(!result.was_capped);
22149 assert_eq!(result.preview.len(), 3);
22150 assert_eq!(result.capped_end, 3);
22151 }
22152
22153 #[test]
22154 fn token_capped_preview_truncates_when_over_cap() {
22155 let lines: Vec<&str> = (0..200)
22156 .map(|_| " let x = some_very_long_expression_here();")
22157 .collect();
22158 let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
22159 assert!(result.was_capped);
22160 assert!(result.preview.len() < 200);
22161 assert!(result.capped_end < 200);
22162 }
22163
22164 #[test]
22165 fn token_capped_preview_keeps_at_least_one_line() {
22166 let long_line: String = "x".repeat(8000);
22167 let lines: Vec<&str> = vec![&long_line];
22168 let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
22169 assert!(!result.was_capped);
22170 assert_eq!(result.preview.len(), 1);
22171 }
22172
22173 #[test]
22174 fn token_capped_preview_cap_at_boundary() {
22175 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22176 let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
22177 assert!(!result.was_capped);
22178 assert_eq!(result.preview.len(), 4);
22179 }
22180
22181 #[test]
22182 fn token_capped_preview_cap_just_over_boundary() {
22183 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22184 let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
22185 assert!(result.was_capped);
22186 assert_eq!(result.preview.len(), 3);
22187 assert_eq!(result.capped_end, 3);
22188 }
22189
22190 #[test]
22191 fn token_capped_preview_empty_lines() {
22192 let lines: Vec<&str> = vec![];
22193 let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
22194 assert!(!result.was_capped);
22195 assert!(result.preview.is_empty());
22196 }
22197
22198 #[test]
22199 fn token_capped_preview_per_line_truncation_applied() {
22200 let long_line = "x".repeat(500);
22201 let lines: Vec<&str> = vec![&long_line, "short"];
22202 let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
22203 assert!(!result.was_capped);
22204 assert_eq!(result.preview.len(), 2);
22205 assert!(result.preview[0].text.len() <= 23);
22206 assert!(result.preview[0].text.ends_with("..."));
22207 }
22208
22209 #[test]
22212 fn route_search_defaults_to_haiku() {
22213 let (tier, model) = classify_task("find all uses of authenticate");
22214 assert_eq!(tier, "haiku");
22215 assert!(
22216 model.contains("haiku"),
22217 "expected haiku model, got {}",
22218 model
22219 );
22220 }
22221
22222 #[test]
22223 fn route_edit_keywords_to_sonnet() {
22224 for kw in &[
22225 "edit the file",
22226 "fix the bug",
22227 "update the config",
22228 "remove dead code",
22229 "create a new module",
22230 ] {
22231 let (tier, _) = classify_task(kw);
22232 assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
22233 }
22234 }
22235
22236 #[test]
22237 fn route_architecture_keywords_to_opus() {
22238 for kw in &[
22239 "design the API",
22240 "architecture review",
22241 "plan the migration",
22242 "analyze the system",
22243 "evaluate trade-offs",
22244 ] {
22245 let (tier, _) = classify_task(kw);
22246 assert_eq!(tier, "opus", "expected opus for {:?}", kw);
22247 }
22248 }
22249
22250 #[test]
22251 fn route_architecture_beats_edit() {
22252 let (tier, _) = classify_task("design and implement the new auth service");
22254 assert_eq!(tier, "opus");
22255 }
22256
22257 #[test]
22258 fn cli_accepts_global_compact_flag() {
22259 let cli = parse_cli(["tsift", "--compact", "status"]);
22260 assert!(cli.compact);
22261 assert!(matches!(cli.command, Some(Commands::Status { .. })));
22262 }
22263
22264 #[test]
22265 fn summarize_diff_scope_matches_relative_directory() {
22266 let root = Path::new("/repo");
22267 let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
22268
22269 assert!(summarize_diff_matches_scope(
22270 Path::new("/repo/src/feature/main.rs"),
22271 &extract_scope
22272 ));
22273 assert!(!summarize_diff_matches_scope(
22274 Path::new("/repo/src/other/main.rs"),
22275 &extract_scope
22276 ));
22277 }
22278
22279 #[test]
22280 fn summarize_diff_scope_matches_relative_file() {
22281 let root = Path::new("/repo");
22282 let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
22283
22284 assert!(summarize_diff_matches_scope(
22285 Path::new("/repo/src/feature/main.rs"),
22286 &extract_scope
22287 ));
22288 assert!(!summarize_diff_matches_scope(
22289 Path::new("/repo/src/feature/lib.rs"),
22290 &extract_scope
22291 ));
22292 }
22293
22294 #[test]
22295 fn summarize_extract_scope_walks_relative_paths_from_root() {
22296 let dir = tempfile::tempdir().unwrap();
22297 let source_dir = dir.path().join("src");
22298 std::fs::create_dir_all(&source_dir).unwrap();
22299 let main_rs = source_dir.join("main.rs");
22300 std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
22301
22302 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
22303 let files = collect_source_files(&extract_scope).unwrap();
22304
22305 assert_eq!(files, vec![main_rs]);
22306 }
22307
22308 #[test]
22309 fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
22310 let dir = tempfile::tempdir().unwrap();
22311 let nested = dir.path().join("src/nested");
22312 std::fs::create_dir_all(&nested).unwrap();
22313 std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
22314 let nested_file = nested.join("main.rs");
22315 std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
22316
22317 let extract_base = resolve_extract_base(&nested).unwrap();
22318 let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
22319 let files = collect_source_files(&extract_scope).unwrap();
22320
22321 assert_eq!(extract_scope, nested);
22322 assert_eq!(files, vec![nested_file]);
22323 }
22324
22325 #[test]
22326 fn summarize_extract_base_uses_parent_of_file_path() {
22327 let dir = tempfile::tempdir().unwrap();
22328 let nested = dir.path().join("src/nested");
22329 std::fs::create_dir_all(&nested).unwrap();
22330 let file_path = nested.join("main.rs");
22331 std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
22332
22333 let extract_base = resolve_extract_base(&file_path).unwrap();
22334
22335 assert_eq!(extract_base, nested);
22336 }
22337
22338 #[test]
22339 fn summarize_extract_scope_normalizes_dotdot_segments() {
22340 let dir = tempfile::tempdir().unwrap();
22341 let source_dir = dir.path().join("src");
22342 std::fs::create_dir_all(&source_dir).unwrap();
22343
22344 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
22345
22346 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22347 assert!(summarize_diff_matches_scope(
22348 &source_dir.join("main.rs"),
22349 &extract_scope
22350 ));
22351 }
22352
22353 #[cfg(unix)]
22354 #[test]
22355 fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
22356 use std::os::unix::fs::symlink;
22357
22358 let dir = tempfile::tempdir().unwrap();
22359 let real_root = dir.path().join("real");
22360 let source_dir = real_root.join("src");
22361 std::fs::create_dir_all(&source_dir).unwrap();
22362 let symlink_scope = dir.path().join("scope-link");
22363 symlink(&source_dir, &symlink_scope).unwrap();
22364
22365 let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
22366
22367 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22368 assert!(summarize_diff_matches_scope(
22369 &source_dir.join("lib.rs"),
22370 &extract_scope
22371 ));
22372 }
22373
22374 #[test]
22375 fn summarize_diff_extract_includes_untracked_files() {
22376 let dir = tempfile::tempdir().unwrap();
22377 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22378 init_git_repo(dir.path());
22379
22380 let source_dir = dir.path().join("src");
22381 std::fs::create_dir_all(&source_dir).unwrap();
22382 let new_file = source_dir.join("new.rs");
22383 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22384
22385 let files = summarize::git_changed_files(dir.path()).unwrap();
22386
22387 assert_eq!(files.existing, vec![new_file]);
22388 assert!(files.deleted.is_empty());
22389 }
22390
22391 #[test]
22392 fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
22393 let dir = tempfile::tempdir().unwrap();
22394 let status = std::process::Command::new("git")
22395 .args(["init"])
22396 .current_dir(dir.path())
22397 .status()
22398 .unwrap();
22399 assert!(status.success(), "git init failed");
22400
22401 let source_dir = dir.path().join("src");
22402 std::fs::create_dir_all(&source_dir).unwrap();
22403 let new_file = source_dir.join("new.rs");
22404 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22405
22406 let files = summarize::git_changed_files(dir.path()).unwrap();
22407
22408 assert_eq!(files.existing, vec![new_file]);
22409 assert!(files.deleted.is_empty());
22410 }
22411
22412 #[test]
22413 fn summarize_diff_extract_tracks_deleted_files() {
22414 let dir = tempfile::tempdir().unwrap();
22415 let source_dir = dir.path().join("src");
22416 std::fs::create_dir_all(&source_dir).unwrap();
22417 let deleted_file = source_dir.join("gone.rs");
22418 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22419 init_git_repo(dir.path());
22420
22421 std::fs::remove_file(&deleted_file).unwrap();
22422
22423 let files = summarize::git_changed_files(dir.path()).unwrap();
22424
22425 assert!(files.existing.is_empty());
22426 assert_eq!(files.deleted, vec![deleted_file]);
22427 }
22428
22429 #[test]
22430 fn summarize_diff_extract_tracks_git_renames() {
22431 let dir = tempfile::tempdir().unwrap();
22432 let source_dir = dir.path().join("src");
22433 std::fs::create_dir_all(&source_dir).unwrap();
22434 let old_file = source_dir.join("old.rs");
22435 let new_file = source_dir.join("new.rs");
22436 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22437 init_git_repo(dir.path());
22438
22439 let status = std::process::Command::new("git")
22440 .args(["mv", "src/old.rs", "src/new.rs"])
22441 .current_dir(dir.path())
22442 .status()
22443 .unwrap();
22444 assert!(status.success(), "git mv failed");
22445
22446 let files = summarize::git_changed_files(dir.path()).unwrap();
22447
22448 assert_eq!(files.existing, vec![new_file]);
22449 assert_eq!(files.deleted, vec![old_file]);
22450 }
22451
22452 #[test]
22453 fn summarize_diff_extract_deletes_removed_summary_rows() {
22454 let dir = tempfile::tempdir().unwrap();
22455 let source_dir = dir.path().join("src");
22456 std::fs::create_dir_all(&source_dir).unwrap();
22457 let deleted_file = source_dir.join("gone.rs");
22458 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22459 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22460 init_git_repo(dir.path());
22461
22462 let summary_db =
22463 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22464 summary_db
22465 .insert(&summarize::Summary {
22466 id: 0,
22467 symbol_name: "stale".to_string(),
22468 file_path: "src/gone.rs".to_string(),
22469 content_hash: "hash1".to_string(),
22470 summary: "stale summary".to_string(),
22471 entities: None,
22472 relationships: None,
22473 concept_labels: None,
22474 extracted_at: "1700000000".to_string(),
22475 model: "test".to_string(),
22476 tokens_input: Some(100),
22477 tokens_output: Some(50),
22478 })
22479 .unwrap();
22480
22481 std::fs::remove_file(&deleted_file).unwrap();
22482
22483 cmd_summarize(
22484 None,
22485 None,
22486 Some(PathBuf::from("src")),
22487 true,
22488 false,
22489 dir.path(),
22490 false,
22491 true,
22492 false,
22493 false,
22494 false,
22495 None,
22496 )
22497 .unwrap();
22498
22499 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22500 }
22501
22502 #[test]
22503 fn summarize_diff_extract_deletes_renamed_summary_rows() {
22504 let dir = tempfile::tempdir().unwrap();
22505 let source_dir = dir.path().join("src");
22506 std::fs::create_dir_all(&source_dir).unwrap();
22507 let old_file = source_dir.join("old.rs");
22508 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22509 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22510 init_git_repo(dir.path());
22511
22512 let summary_db =
22513 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22514 summary_db
22515 .insert(&summarize::Summary {
22516 id: 0,
22517 symbol_name: "stale".to_string(),
22518 file_path: "src/old.rs".to_string(),
22519 content_hash: "hash1".to_string(),
22520 summary: "stale summary".to_string(),
22521 entities: None,
22522 relationships: None,
22523 concept_labels: None,
22524 extracted_at: "1700000000".to_string(),
22525 model: "test".to_string(),
22526 tokens_input: Some(100),
22527 tokens_output: Some(50),
22528 })
22529 .unwrap();
22530
22531 let status = std::process::Command::new("git")
22532 .args(["mv", "src/old.rs", "src/new.rs"])
22533 .current_dir(dir.path())
22534 .status()
22535 .unwrap();
22536 assert!(status.success(), "git mv failed");
22537
22538 cmd_summarize(
22539 None,
22540 None,
22541 Some(PathBuf::from("src")),
22542 true,
22543 false,
22544 dir.path(),
22545 false,
22546 true,
22547 false,
22548 false,
22549 false,
22550 None,
22551 )
22552 .unwrap();
22553
22554 assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
22555 }
22556
22557 #[test]
22558 fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
22559 let dir = tempfile::tempdir().unwrap();
22560 let source_dir = dir.path().join("src");
22561 std::fs::create_dir_all(&source_dir).unwrap();
22562 let deleted_file = source_dir.join("gone.rs");
22563 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22564
22565 let summary_db =
22566 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22567 summary_db
22568 .insert(&summarize::Summary {
22569 id: 0,
22570 symbol_name: "stale".to_string(),
22571 file_path: "src/gone.rs".to_string(),
22572 content_hash: "hash1".to_string(),
22573 summary: "stale summary".to_string(),
22574 entities: None,
22575 relationships: None,
22576 concept_labels: None,
22577 extracted_at: "1700000000".to_string(),
22578 model: "test".to_string(),
22579 tokens_input: Some(100),
22580 tokens_output: Some(50),
22581 })
22582 .unwrap();
22583
22584 std::fs::remove_file(&deleted_file).unwrap();
22585
22586 cmd_summarize(
22587 None,
22588 None,
22589 Some(PathBuf::from("src")),
22590 false,
22591 false,
22592 dir.path(),
22593 false,
22594 true,
22595 false,
22596 false,
22597 false,
22598 None,
22599 )
22600 .unwrap();
22601
22602 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22603 }
22604
22605 #[test]
22606 fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
22607 let dir = tempfile::tempdir().unwrap();
22608 let source_dir = dir.path().join("src");
22609 std::fs::create_dir_all(&source_dir).unwrap();
22610 let file = source_dir.join("lib.rs");
22611 std::fs::write(&file, "fn helper() {}\n").unwrap();
22612
22613 let content = std::fs::read(&file).unwrap();
22614 let summary_db =
22615 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22616 summary_db
22617 .insert(&summarize::Summary {
22618 id: 0,
22619 symbol_name: "lib.rs".to_string(),
22620 file_path: "src/lib.rs".to_string(),
22621 content_hash: summarize::content_hash(&content),
22622 summary: "cached summary".to_string(),
22623 entities: None,
22624 relationships: None,
22625 concept_labels: None,
22626 extracted_at: "1700000000".to_string(),
22627 model: "test".to_string(),
22628 tokens_input: Some(100),
22629 tokens_output: Some(50),
22630 })
22631 .unwrap();
22632 drop(summary_db);
22633
22634 let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
22635 let _lock = hold_writer_lock(&lock_path);
22636
22637 let err = cmd_summarize(
22638 None,
22639 None,
22640 Some(PathBuf::from("src")),
22641 false,
22642 false,
22643 dir.path(),
22644 false,
22645 true,
22646 false,
22647 false,
22648 false,
22649 None,
22650 )
22651 .unwrap_err();
22652 let message = err.to_string();
22653
22654 assert!(message.contains("another tsift summarize extractor is already active"));
22655 assert!(message.contains("tsift summarize --extract"));
22656 }
22657
22658 #[test]
22659 fn summarize_stats_fails_closed_when_cache_missing() {
22660 let dir = tempfile::tempdir().unwrap();
22661 let err = cmd_summarize(
22662 None,
22663 None,
22664 None,
22665 false,
22666 true,
22667 dir.path(),
22668 false,
22669 false,
22670 false,
22671 false,
22672 false,
22673 None,
22674 )
22675 .unwrap_err();
22676
22677 assert!(
22678 err.to_string().contains("no summaries.db found"),
22679 "got: {err}"
22680 );
22681 assert!(!dir.path().join(".tsift/summaries.db").exists());
22682 }
22683
22684 #[test]
22685 fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22686 let dir = tempfile::tempdir().unwrap();
22687 let summary_db =
22688 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22689 summary_db
22690 .insert(&summarize::Summary {
22691 id: 0,
22692 symbol_name: "alpha_helper".to_string(),
22693 file_path: "src/lib.rs".to_string(),
22694 content_hash: "hash1".to_string(),
22695 summary: "cached summary".to_string(),
22696 entities: None,
22697 relationships: None,
22698 concept_labels: None,
22699 extracted_at: "1700000000".to_string(),
22700 model: "claude-haiku-4-5-20251001".to_string(),
22701 tokens_input: Some(100),
22702 tokens_output: Some(40),
22703 })
22704 .unwrap();
22705 drop(summary_db);
22706 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22707
22708 let result = cmd_summarize(
22709 None,
22710 None,
22711 None,
22712 false,
22713 true,
22714 dir.path(),
22715 false,
22716 false,
22717 false,
22718 false,
22719 false,
22720 None,
22721 );
22722
22723 assert!(result.is_ok());
22724 }
22725
22726 #[test]
22727 fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22728 let dir = tempfile::tempdir().unwrap();
22729 let summary_db =
22730 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22731 summary_db
22732 .insert(&summarize::Summary {
22733 id: 0,
22734 symbol_name: "alpha_helper".to_string(),
22735 file_path: "src/lib.rs".to_string(),
22736 content_hash: "hash1".to_string(),
22737 summary: "cached summary".to_string(),
22738 entities: None,
22739 relationships: None,
22740 concept_labels: None,
22741 extracted_at: "1700000000".to_string(),
22742 model: "claude-haiku-4-5-20251001".to_string(),
22743 tokens_input: Some(100),
22744 tokens_output: Some(40),
22745 })
22746 .unwrap();
22747 drop(summary_db);
22748 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22749
22750 let result = cmd_summarize(
22751 Some("alpha_helper".to_string()),
22752 None,
22753 None,
22754 false,
22755 false,
22756 dir.path(),
22757 false,
22758 true,
22759 false,
22760 false,
22761 false,
22762 None,
22763 );
22764
22765 assert!(result.is_ok());
22766 }
22767
22768 #[test]
22769 fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
22770 let dir = tempfile::tempdir().unwrap();
22771 let nested = dir.path().join("src/nested");
22772 std::fs::create_dir_all(&nested).unwrap();
22773
22774 let summary_db =
22775 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22776 summary_db
22777 .insert(&summarize::Summary {
22778 id: 0,
22779 symbol_name: "alpha_helper".to_string(),
22780 file_path: "src/lib.rs".to_string(),
22781 content_hash: "hash1".to_string(),
22782 summary: "cached summary".to_string(),
22783 entities: None,
22784 relationships: None,
22785 concept_labels: None,
22786 extracted_at: "1700000000".to_string(),
22787 model: "claude-haiku-4-5-20251001".to_string(),
22788 tokens_input: Some(100),
22789 tokens_output: Some(40),
22790 })
22791 .unwrap();
22792
22793 let result = cmd_summarize(
22794 Some("alpha_helper".to_string()),
22795 None,
22796 None,
22797 false,
22798 false,
22799 &nested,
22800 false,
22801 true,
22802 false,
22803 false,
22804 false,
22805 None,
22806 );
22807
22808 assert!(result.is_ok());
22809 assert!(!nested.join(".tsift/summaries.db").exists());
22810 }
22811
22812 #[test]
22813 fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
22814 let dir = tempfile::tempdir().unwrap();
22815 std::fs::write(
22816 dir.path().join(".gitmodules"),
22817 r#"[submodule "src/alpha"]
22818 path = src/alpha
22819 url = https://example.com/alpha
22820[submodule "src/beta"]
22821 path = src/beta
22822 url = https://example.com/beta
22823"#,
22824 )
22825 .unwrap();
22826
22827 let alpha_root = dir.path().join("src/alpha");
22828 let beta_root = dir.path().join("src/beta");
22829 std::fs::create_dir_all(alpha_root.join("src")).unwrap();
22830 std::fs::create_dir_all(beta_root.join("src")).unwrap();
22831 std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
22832 std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
22833 std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
22834 let beta_file = beta_root.join("src/lib.rs");
22835 std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
22836 std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
22837 std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
22838
22839 let context = find_symbols_db_for_file(dir.path(), &beta_file)
22840 .unwrap()
22841 .expect("expected matching scoped index");
22842
22843 assert_eq!(
22844 context.db_path,
22845 dir.path().join(".tsift/indexes/beta/index.db")
22846 );
22847 assert_eq!(context.source_root, beta_root);
22848 }
22849
22850 fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
22853 EditOp {
22854 file: PathBuf::from("dummy.txt"),
22855 old: old.to_string(),
22856 new: new.to_string(),
22857 replace_all,
22858 }
22859 }
22860
22861 #[test]
22862 fn edit_replaces_single_occurrence() {
22863 let content = "hello world";
22864 let op = make_op("world", "rust", false);
22865 let (result, count) = apply_edit_op(content, &op).unwrap();
22866 assert_eq!(result, "hello rust");
22867 assert_eq!(count, 1);
22868 }
22869
22870 #[test]
22871 fn edit_replace_all_replaces_every_occurrence() {
22872 let content = "foo foo foo";
22873 let op = make_op("foo", "bar", true);
22874 let (result, count) = apply_edit_op(content, &op).unwrap();
22875 assert_eq!(result, "bar bar bar");
22876 assert_eq!(count, 3);
22877 }
22878
22879 #[test]
22880 fn edit_fails_when_old_not_found() {
22881 let content = "hello world";
22882 let op = make_op("missing", "x", false);
22883 assert!(apply_edit_op(content, &op).is_err());
22884 }
22885
22886 #[test]
22887 fn edit_fails_when_ambiguous_without_replace_all() {
22888 let content = "foo foo";
22889 let op = make_op("foo", "bar", false);
22890 let err = apply_edit_op(content, &op).unwrap_err();
22891 assert!(err.to_string().contains("2 times"), "got: {}", err);
22892 }
22893
22894 #[test]
22895 fn edit_fails_when_old_equals_new() {
22896 let content = "hello";
22897 let op = make_op("hello", "hello", false);
22898 assert!(apply_edit_op(content, &op).is_err());
22899 }
22900
22901 #[test]
22902 fn edit_batch_rolls_back_when_later_swap_fails() {
22903 let dir = tempfile::tempdir().unwrap();
22904 let alpha = dir.path().join("alpha.txt");
22905 let beta = dir.path().join("beta.txt");
22906 fs::write(&alpha, "alpha old\n").unwrap();
22907 fs::write(&beta, "beta old\n").unwrap();
22908
22909 let batch = EditBatch {
22910 edits: vec![
22911 EditOp {
22912 file: alpha.clone(),
22913 old: "old".to_string(),
22914 new: "new".to_string(),
22915 replace_all: false,
22916 },
22917 EditOp {
22918 file: beta.clone(),
22919 old: "old".to_string(),
22920 new: "new".to_string(),
22921 replace_all: false,
22922 },
22923 ],
22924 };
22925
22926 let plan = build_edit_plan(&batch).unwrap();
22927 let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
22928 if commit_index == 1 {
22929 bail!("simulated swap failure");
22930 }
22931 Ok(())
22932 }) {
22933 Ok(_) => panic!("expected simulated swap failure"),
22934 Err(err) => err,
22935 };
22936
22937 assert!(err.to_string().contains("simulated swap failure"));
22938 assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22939 assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22940 }
22941
22942 fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22945 let tmp = tempfile::NamedTempFile::new().unwrap();
22946 let conn = Connection::open(tmp.path()).unwrap();
22947 conn.execute_batch(
22948 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22949 INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22950 INSERT INTO users VALUES (2, 'Bob', NULL);
22951 CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22952 FOREIGN KEY(user_id) REFERENCES users(id));
22953 INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22954 INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22955 INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22956 ).unwrap();
22957 (tmp, conn)
22958 }
22959
22960 #[test]
22963 fn rewrite_rg_simple_pattern() {
22964 let result = rewrite_command("rg authenticate");
22965 assert_eq!(
22966 result,
22967 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22968 );
22969 }
22970
22971 #[test]
22972 fn rewrite_rg_with_path() {
22973 let result = rewrite_command("rg authenticate src/");
22974 assert_eq!(
22975 result,
22976 Some(
22977 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22978 .to_string()
22979 )
22980 );
22981 }
22982
22983 #[test]
22984 fn rewrite_rg_with_flags_ignored() {
22985 let result = rewrite_command("rg -i authenticate src/");
22986 assert_eq!(
22987 result,
22988 Some(
22989 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22990 .to_string()
22991 )
22992 );
22993 }
22994
22995 #[test]
22996 fn rewrite_rg_with_type_flag() {
22997 let result = rewrite_command("rg -t rs authenticate");
22999 assert_eq!(
23000 result,
23001 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
23002 );
23003 }
23004
23005 #[test]
23006 fn rewrite_rg_pipe_passthrough() {
23007 let result = rewrite_command("rg authenticate | head -5");
23009 assert_eq!(result, None);
23010 }
23011
23012 #[test]
23013 fn rewrite_rg_files_passthrough() {
23014 let result = rewrite_command("rg --files src/tsift .agent-doc logs");
23015 assert_eq!(result, None);
23016 }
23017
23018 #[test]
23019 fn rewrite_find_passthrough() {
23020 let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
23021 assert_eq!(result, None);
23022 }
23023
23024 #[test]
23025 fn rewrite_grep_recursive() {
23026 let result = rewrite_command("grep -r authenticate src/");
23027 assert_eq!(
23028 result,
23029 Some(
23030 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
23031 .to_string()
23032 )
23033 );
23034 }
23035
23036 #[test]
23037 fn rewrite_grep_non_recursive_passthrough() {
23038 let result = rewrite_command("grep authenticate file.txt");
23039 assert_eq!(result, None);
23040 }
23041
23042 #[test]
23043 fn rewrite_tsift_passthrough() {
23044 let result = rewrite_command("tsift search \"foo\"");
23045 assert_eq!(result, Some("tsift search \"foo\"".to_string()));
23046 }
23047
23048 #[test]
23049 fn rewrite_run_tsift_search_disables_timeout_by_default() {
23050 let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
23051 assert_eq!(
23052 result,
23053 "tsift search hookcaps --exact --path /tmp/x --timeout 0"
23054 );
23055 }
23056
23057 #[test]
23058 fn rewrite_run_preserves_explicit_search_timeout() {
23059 let result = effective_rewrite_run_command(
23060 "tsift search hookcaps --exact --path /tmp/x --timeout 5",
23061 );
23062 assert_eq!(
23063 result,
23064 "tsift search hookcaps --exact --path /tmp/x --timeout 5"
23065 );
23066 }
23067
23068 #[test]
23069 fn rewrite_unrelated_passthrough() {
23070 let result = rewrite_command("echo cargo build");
23071 assert_eq!(result, None);
23072 }
23073
23074 #[test]
23075 fn rewrite_rg_quoted_pattern() {
23076 let result = rewrite_command("rg \"fn main\"");
23077 assert_eq!(
23078 result,
23079 Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
23080 );
23081 }
23082
23083 #[test]
23084 fn rewrite_git_diff_to_diff_digest() {
23085 let result = rewrite_command("git diff");
23086 assert_eq!(result, Some("tsift diff-digest .".to_string()));
23087 }
23088
23089 #[test]
23090 fn rewrite_git_diff_cached_to_diff_digest() {
23091 let result = rewrite_command("git diff --cached");
23092 assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
23093 }
23094
23095 #[test]
23096 fn rewrite_git_diff_with_path_to_diff_digest() {
23097 let result = rewrite_command("git diff -- src/");
23098 assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
23099 }
23100
23101 #[test]
23102 fn rewrite_git_diff_with_revision_passthrough() {
23103 let result = rewrite_command("git diff HEAD~1");
23104 assert_eq!(result, None);
23105 }
23106
23107 #[test]
23108 fn rewrite_git_show_to_revision_diff_digest() {
23109 let result = rewrite_command("git show HEAD~1");
23110 assert_eq!(
23111 result,
23112 Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
23113 );
23114 }
23115
23116 #[test]
23117 fn rewrite_git_log_patch_history_to_revision_diff_digest() {
23118 let result = rewrite_command("git log -p -1 HEAD~2");
23119 assert_eq!(
23120 result,
23121 Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
23122 );
23123 }
23124
23125 #[test]
23126 fn rewrite_cat_long_agent_doc_session_to_session_digest() {
23127 let dir = tempfile::tempdir().unwrap();
23128 let session = dir.path().join("tsift.md");
23129 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23130 for index in 0..90 {
23131 body.push_str(&format!("❯ prompt {index}?\n"));
23132 }
23133 fs::write(&session, body).unwrap();
23134
23135 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23136 assert_eq!(
23137 result,
23138 Some(format!(
23139 "tsift session-digest --path {} --input {} --source markdown",
23140 shell_quote(&resolve_digest_context_path(&session)),
23141 shell_quote(session.to_str().unwrap())
23142 ))
23143 );
23144 }
23145
23146 #[test]
23147 fn rewrite_head_long_claude_jsonl_to_session_digest() {
23148 let dir = tempfile::tempdir().unwrap();
23149 let session = dir.path().join("session.jsonl");
23150 let line =
23151 r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
23152 let body = std::iter::repeat_n(line, 120)
23153 .collect::<Vec<_>>()
23154 .join("\n");
23155 fs::write(&session, format!("{body}\n")).unwrap();
23156
23157 let result = rewrite_command(&format!(
23158 "head -n 120 {}",
23159 shell_quote(session.to_str().unwrap())
23160 ));
23161 assert_eq!(
23162 result,
23163 Some(format!(
23164 "tsift session-digest --path {} --input {} --source claude-jsonl",
23165 shell_quote(&resolve_digest_context_path(&session)),
23166 shell_quote(session.to_str().unwrap())
23167 ))
23168 );
23169 }
23170
23171 #[test]
23172 fn rewrite_head_long_codex_jsonl_to_session_digest() {
23173 let dir = tempfile::tempdir().unwrap();
23174 let session = dir.path().join("codex.jsonl");
23175 let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
23176 let body = std::iter::repeat_n(line, 120)
23177 .collect::<Vec<_>>()
23178 .join("\n");
23179 fs::write(&session, format!("{body}\n")).unwrap();
23180
23181 let result = rewrite_command(&format!(
23182 "head -n 120 {}",
23183 shell_quote(session.to_str().unwrap())
23184 ));
23185 assert_eq!(
23186 result,
23187 Some(format!(
23188 "tsift session-digest --path {} --input {} --source codex-jsonl",
23189 shell_quote(&resolve_digest_context_path(&session)),
23190 shell_quote(session.to_str().unwrap())
23191 ))
23192 );
23193 }
23194
23195 #[test]
23196 fn rewrite_small_transcript_window_passthrough() {
23197 let dir = tempfile::tempdir().unwrap();
23198 let session = dir.path().join("session.jsonl");
23199 let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
23200 let body = std::iter::repeat_n(line, 120)
23201 .collect::<Vec<_>>()
23202 .join("\n");
23203 fs::write(&session, format!("{body}\n")).unwrap();
23204
23205 let result = rewrite_command(&format!(
23206 "tail -n 20 {}",
23207 shell_quote(session.to_str().unwrap())
23208 ));
23209 assert_eq!(result, None);
23210 }
23211
23212 #[test]
23213 fn rewrite_sed_large_agent_doc_range_to_session_digest() {
23214 let dir = tempfile::tempdir().unwrap();
23215 let session = dir.path().join("tsift.md");
23216 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23217 for index in 0..120 {
23218 body.push_str(&format!("### Re: topic {index}\n"));
23219 }
23220 fs::write(&session, body).unwrap();
23221
23222 let result = rewrite_command(&format!(
23223 "sed -n '1,120p' {}",
23224 shell_quote(session.to_str().unwrap())
23225 ));
23226 assert_eq!(
23227 result,
23228 Some(format!(
23229 "tsift session-digest --path {} --input {} --source markdown",
23230 shell_quote(&resolve_digest_context_path(&session)),
23231 shell_quote(session.to_str().unwrap())
23232 ))
23233 );
23234 }
23235
23236 #[test]
23237 fn rewrite_cat_large_agent_doc_log_to_session_digest() {
23238 let dir = tempfile::tempdir().unwrap();
23239 let session = dir.path().join("tsift.log");
23240 let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
23241 let body = std::iter::repeat_n(line, 120)
23242 .collect::<Vec<_>>()
23243 .join("\n");
23244 fs::write(&session, format!("{body}\n")).unwrap();
23245
23246 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23247 assert_eq!(
23248 result,
23249 Some(format!(
23250 "tsift session-digest --path {} --input {} --source agent-doc-log",
23251 shell_quote(&resolve_digest_context_path(&session)),
23252 shell_quote(session.to_str().unwrap())
23253 ))
23254 );
23255 }
23256
23257 #[test]
23258 fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
23259 let dir = tempfile::tempdir().unwrap();
23260 fs::write(
23261 dir.path().join(".gitmodules"),
23262 r#"[submodule "src/tsift"]
23263 path = src/tsift
23264 url = https://example.com/tsift
23265"#,
23266 )
23267 .unwrap();
23268 let submodule = dir.path().join("src/tsift");
23269 fs::create_dir_all(submodule.join("tasks")).unwrap();
23270 fs::write(
23271 submodule.join(".git"),
23272 "gitdir: ../../.git/modules/src/tsift\n",
23273 )
23274 .unwrap();
23275 let session = submodule.join("tasks/plan.md");
23276 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23277 for index in 0..90 {
23278 body.push_str(&format!("❯ prompt {index}?\n"));
23279 }
23280 fs::write(&session, body).unwrap();
23281
23282 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23283
23284 assert_eq!(
23285 result,
23286 Some(format!(
23287 "tsift session-digest --path {} --input {} --source markdown",
23288 shell_quote(submodule.to_str().unwrap()),
23289 shell_quote(session.to_str().unwrap())
23290 ))
23291 );
23292 }
23293
23294 #[test]
23295 fn rewrite_regular_markdown_read_passthrough() {
23296 let dir = tempfile::tempdir().unwrap();
23297 let readme = dir.path().join("README.md");
23298 let body = std::iter::repeat_n("plain markdown", 120)
23299 .collect::<Vec<_>>()
23300 .join("\n");
23301 fs::write(&readme, format!("{body}\n")).unwrap();
23302
23303 let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
23304 assert_eq!(result, None);
23305 }
23306
23307 #[test]
23308 fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
23309 let dir = tempfile::tempdir().unwrap();
23310 write_empty_root_index(dir.path());
23311 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23312
23313 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23314
23315 assert_eq!(
23316 result,
23317 Some(format!(
23318 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
23319 shell_quote(&dir.path().to_string_lossy())
23320 ))
23321 );
23322 }
23323
23324 #[test]
23325 fn rewrite_head_small_source_window_passthrough() {
23326 let dir = tempfile::tempdir().unwrap();
23327 write_empty_root_index(dir.path());
23328 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23329
23330 let result = rewrite_command(&format!(
23331 "head -n 20 {}",
23332 shell_quote(source.to_str().unwrap())
23333 ));
23334
23335 assert_eq!(result, None);
23336 }
23337
23338 #[test]
23339 fn rewrite_sed_large_source_range_to_source_read() {
23340 let dir = tempfile::tempdir().unwrap();
23341 write_empty_root_index(dir.path());
23342 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23343
23344 let result = rewrite_command(&format!(
23345 "sed -n '40,160p' {}",
23346 shell_quote(source.to_str().unwrap())
23347 ));
23348
23349 assert_eq!(
23350 result,
23351 Some(format!(
23352 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
23353 shell_quote(&dir.path().to_string_lossy())
23354 ))
23355 );
23356 }
23357
23358 #[test]
23359 fn rewrite_tail_large_source_window_preserves_tail_anchor() {
23360 let dir = tempfile::tempdir().unwrap();
23361 write_empty_root_index(dir.path());
23362 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23363
23364 let result = rewrite_command(&format!(
23365 "tail -n 120 {}",
23366 shell_quote(source.to_str().unwrap())
23367 ));
23368
23369 assert_eq!(
23370 result,
23371 Some(format!(
23372 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
23373 shell_quote(&dir.path().to_string_lossy())
23374 ))
23375 );
23376 }
23377
23378 #[test]
23379 fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
23380 let dir = tempfile::tempdir().unwrap();
23381 write_empty_root_index(dir.path());
23382 let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
23383
23384 let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
23385
23386 assert_eq!(result, None);
23387 }
23388
23389 #[test]
23390 fn rewrite_large_source_read_passthrough_without_index() {
23391 let dir = tempfile::tempdir().unwrap();
23392 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23393
23394 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23395
23396 assert_eq!(result, None);
23397 }
23398
23399 #[test]
23400 fn rewrite_cargo_test_to_digest_runner() {
23401 let result = rewrite_command("cargo test --lib");
23402 assert_eq!(
23403 result,
23404 Some(
23405 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
23406 )
23407 );
23408 }
23409
23410 #[test]
23411 fn rewrite_pytest_to_digest_runner() {
23412 let result = rewrite_command("pytest -q tests/test_cli.py");
23413 assert_eq!(
23414 result,
23415 Some(
23416 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
23417 )
23418 );
23419 }
23420
23421 #[test]
23422 fn rewrite_python_m_pytest_to_digest_runner() {
23423 let result = rewrite_command("python -m pytest tests/test_cli.py");
23424 assert_eq!(
23425 result,
23426 Some(
23427 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
23428 )
23429 );
23430 }
23431
23432 #[test]
23433 fn rewrite_cargo_build_to_log_digest_runner() {
23434 let result = rewrite_command("cargo build --release");
23435 assert_eq!(
23436 result,
23437 Some(
23438 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
23439 )
23440 );
23441 }
23442
23443 #[test]
23444 fn rewrite_cargo_install_to_log_digest_runner() {
23445 let result = rewrite_command("cargo install --path . --force");
23446 assert_eq!(
23447 result,
23448 Some(
23449 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
23450 )
23451 );
23452 }
23453
23454 #[test]
23455 fn rewrite_metacharacter_command_passthrough() {
23456 let result = rewrite_command("cargo test | head");
23457 assert_eq!(result, None);
23458 }
23459
23460 #[test]
23461 fn rewrite_output_cap_detects_search_even_with_global_flag() {
23462 let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
23463 assert_eq!(cap.max_lines, 50);
23464 assert_eq!(cap.strip_prefix, Some("Strategy:"));
23465 }
23466
23467 #[test]
23468 fn rewrite_output_cap_skips_structured_output() {
23469 assert!(rewrite_output_cap("tsift search foo --json").is_none());
23470 assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
23471 assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
23472 }
23473
23474 #[test]
23475 fn rewrite_output_format_forwards_envelope_to_digest_runner() {
23476 let command = rewrite_command("cargo test --lib").expect("rewrite");
23477 let forwarded = apply_rewrite_output_format(
23478 &command,
23479 OutputFormat {
23480 json_output: true,
23481 compact: false,
23482 pretty: false,
23483 terse: false,
23484 ultra_terse: false,
23485 schema: false,
23486 envelope: true,
23487 },
23488 );
23489 assert_eq!(
23490 forwarded,
23491 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
23492 );
23493 }
23494
23495 #[test]
23496 fn rewrite_output_format_forwards_json_when_requested() {
23497 let command = rewrite_command("cargo build --release").expect("rewrite");
23498 let forwarded = apply_rewrite_output_format(
23499 &command,
23500 OutputFormat {
23501 json_output: true,
23502 compact: false,
23503 pretty: true,
23504 terse: false,
23505 ultra_terse: false,
23506 schema: false,
23507 envelope: false,
23508 },
23509 );
23510 assert_eq!(
23511 forwarded,
23512 "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
23513 );
23514 }
23515
23516 #[test]
23517 fn output_cap_strips_search_header_and_truncates() {
23518 let capped = apply_output_cap(
23519 b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
23520 OutputCap {
23521 max_lines: 2,
23522 strip_prefix: Some("Strategy:"),
23523 },
23524 );
23525 assert_eq!(
23526 capped,
23527 "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
23528 );
23529 }
23530
23531 #[test]
23532 fn sql_schema_overview_lists_tables() {
23533 let (_tmp, conn) = setup_test_db();
23534 let tables = schema_overview(&conn).unwrap();
23535 let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
23536 assert_eq!(names, &["posts", "users"]);
23537 }
23538
23539 #[test]
23540 fn sql_schema_overview_row_counts() {
23541 let (_tmp, conn) = setup_test_db();
23542 let tables = schema_overview(&conn).unwrap();
23543 let users = tables.iter().find(|t| t.name == "users").unwrap();
23544 let posts = tables.iter().find(|t| t.name == "posts").unwrap();
23545 assert_eq!(users.row_count, 2);
23546 assert_eq!(posts.row_count, 3);
23547 }
23548
23549 #[test]
23550 fn sql_table_columns_metadata() {
23551 let (_tmp, conn) = setup_test_db();
23552 let cols = table_columns(&conn, "users").unwrap();
23553 assert_eq!(cols.len(), 3);
23554 assert_eq!(cols[0].name, "id");
23555 assert!(cols[0].pk);
23556 assert_eq!(cols[1].name, "name");
23557 assert!(cols[1].notnull);
23558 assert_eq!(cols[2].name, "email");
23559 assert!(!cols[2].notnull);
23560 }
23561
23562 #[test]
23563 fn sql_execute_query_returns_rows() {
23564 let (_tmp, conn) = setup_test_db();
23565 let (columns, rows) =
23566 execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
23567 assert_eq!(columns, &["name", "email"]);
23568 assert_eq!(rows.len(), 2);
23569 assert_eq!(rows[0][0], serde_json::json!("Alice"));
23570 assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
23571 assert_eq!(rows[1][1], serde_json::Value::Null);
23572 }
23573
23574 #[test]
23575 fn sql_execute_query_aggregate() {
23576 let (_tmp, conn) = setup_test_db();
23577 let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
23578 assert_eq!(columns, &["cnt"]);
23579 assert_eq!(rows[0][0], serde_json::json!(3));
23580 }
23581
23582 #[test]
23583 fn sql_execute_query_join() {
23584 let (_tmp, conn) = setup_test_db();
23585 let (_cols, rows) = execute_query(
23586 &conn,
23587 "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
23588 )
23589 .unwrap();
23590 assert_eq!(rows.len(), 3);
23591 assert_eq!(rows[0][0], serde_json::json!("Alice"));
23592 assert_eq!(rows[2][0], serde_json::json!("Bob"));
23593 }
23594
23595 #[test]
23596 fn sql_open_db_read_only() {
23597 let (tmp, _conn) = setup_test_db();
23598 drop(_conn);
23599 let ro_conn = open_db(tmp.path()).unwrap();
23600 let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
23601 assert!(result.is_err(), "read-only connection should reject writes");
23602 }
23603
23604 #[test]
23605 fn sql_empty_table_schema() {
23606 let tmp = tempfile::NamedTempFile::new().unwrap();
23607 let conn = Connection::open(tmp.path()).unwrap();
23608 conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
23609 .unwrap();
23610 let tables = schema_overview(&conn).unwrap();
23611 assert_eq!(tables[0].row_count, 0);
23612 assert_eq!(tables[0].columns.len(), 2);
23613 }
23614
23615 fn setup_graph_index() -> tempfile::TempDir {
23618 let dir = tempfile::tempdir().unwrap();
23619 std::fs::write(
23620 dir.path().join("main.rs"),
23621 "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
23622 )
23623 .unwrap();
23624 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23625 db.apply_changes(dir.path()).unwrap();
23626 dir
23627 }
23628
23629 fn setup_traversal_project() -> tempfile::TempDir {
23630 let dir = setup_graph_index();
23631 let task_dir = dir.path().join("tasks/software");
23632 std::fs::create_dir_all(&task_dir).unwrap();
23633 std::fs::write(
23634 task_dir.join("tsift.md"),
23635 r#"---
23636agent_doc_session: tsift-v0.1
23637agent_doc_format: template
23638---
23639
23640## Exchange
23641
23642<!-- agent:exchange patch=append -->
23643❯ do [#kgnv]
23644Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
23645<!-- /agent:exchange -->
23646
23647<!-- agent:queue -->
23648dispatch #spec-test-build-install-commit-push
23649- do [#kgnv]
23650<!-- /agent:queue -->
23651
23652## Backlog
23653
23654<!-- agent:backlog -->
23655- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
23656<!-- /agent:backlog -->
23657"#,
23658 )
23659 .unwrap();
23660 dir
23661 }
23662
23663 fn resolve_ast_span_node<'a>(
23664 graph: &'a TraversalGraphBuild,
23665 label: &str,
23666 symbol_kind: &str,
23667 ) -> &'a TraversalNode {
23668 graph
23669 .nodes
23670 .values()
23671 .find(|node| {
23672 node.kind == "ast_span"
23673 && node.label == label
23674 && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
23675 })
23676 .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
23677 }
23678
23679 fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
23680 let dir = tempfile::tempdir().unwrap();
23681 std::fs::write(
23682 dir.path().join("rust.rs"),
23683 r#"mod fixture_nav_rust_mod {
23684 pub fn fixture_nav_rust_helper() {}
23685 pub fn fixture_nav_rust_entry() {
23686 fixture_nav_rust_helper();
23687 }
23688}
23689"#,
23690 )
23691 .unwrap();
23692 std::fs::write(
23693 dir.path().join("python.py"),
23694 r#"def fixture_nav_python_helper():
23695 return 1
23696
23697def fixture_nav_python_entry():
23698 return fixture_nav_python_helper()
23699"#,
23700 )
23701 .unwrap();
23702 std::fs::write(
23703 dir.path().join("typescript.ts"),
23704 r#"export function fixture_nav_typescript_entry(): number {
23705 return fixtureNavTsHelper();
23706}
23707
23708function fixtureNavTsHelper(): number {
23709 return 1;
23710}
23711"#,
23712 )
23713 .unwrap();
23714 std::fs::write(
23715 dir.path().join("javascript.js"),
23716 r#"function fixture_nav_javascript_entry() {
23717 return fixtureNavJsHelper();
23718}
23719
23720function fixtureNavJsHelper() {
23721 return 1;
23722}
23723"#,
23724 )
23725 .unwrap();
23726 std::fs::write(
23727 dir.path().join("kotlin.kt"),
23728 r#"fun fixture_nav_kotlin_entry(): Int {
23729 return fixtureNavKotlinHelper()
23730}
23731
23732fun fixtureNavKotlinHelper(): Int = 1
23733"#,
23734 )
23735 .unwrap();
23736 std::fs::write(
23737 dir.path().join("zig.zig"),
23738 r#"pub fn fixture_nav_zig_entry() i32 {
23739 return fixtureNavZigHelper();
23740}
23741
23742fn fixtureNavZigHelper() i32 {
23743 return 1;
23744}
23745"#,
23746 )
23747 .unwrap();
23748 std::fs::write(
23749 dir.path().join("gdscript.gd"),
23750 "extends Node\n\nfunc fixture_nav_gdscript_entry():\n\treturn fixture_nav_gdscript_helper()\n\nfunc fixture_nav_gdscript_helper():\n\treturn 1\n",
23751 )
23752 .unwrap();
23753 std::fs::write(
23754 dir.path().join("bash.sh"),
23755 r#"#!/usr/bin/env bash
23756fixture_nav_bash_entry() {
23757 fixture_nav_bash_helper
23758}
23759
23760fixture_nav_bash_helper() {
23761 echo ok
23762}
23763
23764alias fixture_nav_bash_alias='echo alias'
23765"#,
23766 )
23767 .unwrap();
23768 std::fs::write(
23769 dir.path().join("README.md"),
23770 r#"# Fixture Guide
23771
23772## Fixture Section
23773
23774- Fixture step
23775 - Nested fixture step
23776
23777```python
23778def fixture_nav_markdown_embedded():
23779 return 1
23780```
23781"#,
23782 )
23783 .unwrap();
23784
23785 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23786 db.apply_changes(dir.path()).unwrap();
23787 dir
23788 }
23789
23790 fn assert_cli_expand_command_parses(command: &str) {
23791 let args = shell_split(command)
23792 .into_iter()
23793 .map(str::to_string)
23794 .collect::<Vec<_>>();
23795 assert!(
23796 try_parse_cli(args).is_ok(),
23797 "expand command should parse as a tsift CLI command: {command}"
23798 );
23799 }
23800
23801 fn setup_multiplicity_project() -> tempfile::TempDir {
23802 let dir = tempfile::tempdir().unwrap();
23803 std::fs::write(
23804 dir.path().join("Cargo.toml"),
23805 r#"[workspace]
23806members = ["crates/core-lib", "crates/cli-app"]
23807"#,
23808 )
23809 .unwrap();
23810 std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
23811 std::fs::write(
23812 dir.path().join("crates/core-lib/Cargo.toml"),
23813 r#"[package]
23814name = "core-lib"
23815
23816[lib]
23817name = "core_lib"
23818
23819[features]
23820default = []
23821"#,
23822 )
23823 .unwrap();
23824 std::fs::write(
23825 dir.path().join("crates/core-lib/src/lib.rs"),
23826 "pub fn run() {}\n",
23827 )
23828 .unwrap();
23829 std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
23830 std::fs::write(
23831 dir.path().join("crates/cli-app/Cargo.toml"),
23832 r#"[package]
23833name = "cli-app"
23834
23835[[bin]]
23836name = "cli-app"
23837
23838[dependencies]
23839core-lib = { path = "../core-lib" }
23840"#,
23841 )
23842 .unwrap();
23843 std::fs::write(
23844 dir.path().join("crates/cli-app/src/main.rs"),
23845 "use core_lib::run;\nfn main() { run(); }\n",
23846 )
23847 .unwrap();
23848 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23849 db.apply_changes(dir.path()).unwrap();
23850
23851 let task_dir = dir.path().join("tasks/software");
23852 std::fs::create_dir_all(&task_dir).unwrap();
23853 std::fs::write(
23854 task_dir.join("tsift.md"),
23855 r#"---
23856agent_doc_session: tsift-multiplicity
23857agent_doc_format: template
23858---
23859
23860## Backlog
23861
23862<!-- agent:backlog -->
23863- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
23864<!-- /agent:backlog -->
23865"#,
23866 )
23867 .unwrap();
23868 init_git_repo(dir.path());
23869 dir
23870 }
23871
23872 fn setup_dependency_dag_project() -> tempfile::TempDir {
23873 let dir = tempfile::tempdir().unwrap();
23874 std::fs::write(
23875 dir.path().join("main.rs"),
23876 "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
23877 )
23878 .unwrap();
23879 std::fs::write(
23880 dir.path().join("Cargo.toml"),
23881 "[package]\nname = \"dag-fixture\"\n",
23882 )
23883 .unwrap();
23884 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23885 db.apply_changes(dir.path()).unwrap();
23886
23887 let task_dir = dir.path().join("tasks/software");
23888 std::fs::create_dir_all(&task_dir).unwrap();
23889 std::fs::write(
23890 task_dir.join("tsift.md"),
23891 r#"---
23892agent_doc_session: tsift-dag
23893agent_doc_format: template
23894---
23895
23896## Exchange
23897
23898<!-- agent:exchange patch=append -->
23899Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
23900<!-- /agent:exchange -->
23901
23902## Backlog
23903
23904<!-- agent:backlog -->
23905- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
23906- [ ] [#alpha] Update shared_helper in main.rs after #prep.
23907- [ ] [#beta] Refactor shared_helper tests in main.rs.
23908- [ ] [#gamma] Follow-up review for graph navigation.
23909<!-- /agent:backlog -->
23910"#,
23911 )
23912 .unwrap();
23913 dir
23914 }
23915
23916 fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
23917 let dir = setup_graph_index();
23918 let task_dir = dir.path().join("tasks/software");
23919 std::fs::create_dir_all(&task_dir).unwrap();
23920 std::fs::write(
23921 task_dir.join("tsift.md"),
23922 r#"---
23923agent_doc_session: tsift-dag-cycle
23924agent_doc_format: template
23925---
23926
23927## Backlog
23928
23929<!-- agent:backlog -->
23930- [ ] [#left] Left side depends on #right.
23931- [ ] [#right] Right side depends on #left.
23932<!-- /agent:backlog -->
23933"#,
23934 )
23935 .unwrap();
23936 dir
23937 }
23938
23939 fn seed_traversal_semantic_summaries(dir: &Path) {
23940 let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23941 summary_db
23942 .insert(&summarize::Summary {
23943 id: 0,
23944 symbol_name: "helper".to_string(),
23945 file_path: "main.rs".to_string(),
23946 content_hash: "hash-main".to_string(),
23947 summary: "helper builds graph navigation handles for traversal.".to_string(),
23948 entities: Some(vec![
23949 summarize::Entity {
23950 name: "helper".to_string(),
23951 kind: "function".to_string(),
23952 description: "Builds graph navigation handles.".to_string(),
23953 },
23954 summarize::Entity {
23955 name: "TraversalGraph".to_string(),
23956 kind: "type".to_string(),
23957 description: "Carries GraphStore-backed traversal rows.".to_string(),
23958 },
23959 ]),
23960 relationships: Some(vec![summarize::Relationship {
23961 from: "helper".to_string(),
23962 to: "TraversalGraph".to_string(),
23963 kind: "uses".to_string(),
23964 }]),
23965 concept_labels: Some(vec![
23966 "graph navigation".to_string(),
23967 "semantic extraction".to_string(),
23968 ]),
23969 extracted_at: "1700000000".to_string(),
23970 model: "test-model".to_string(),
23971 tokens_input: Some(10),
23972 tokens_output: Some(5),
23973 })
23974 .unwrap();
23975 }
23976
23977 fn seed_tsift_memory_graph_db(dir: &Path) {
23978 let db = dir.join(".tsift").join("memory.db");
23979 let store = MemoryStore::open_or_create(&db).unwrap();
23980 let project = dir.to_string_lossy().to_string();
23981 let observation = MemoryEvent::new(
23982 MemoryEventKind::ImportedObservation,
23983 "claude-mem:observations:1",
23984 [
23985 "Graph memory adapter",
23986 "read-only projection",
23987 "graph-db should retrieve tsift memory observations",
23988 "Project memory is queried from .tsift/memory.db",
23989 "graph memory, tsift memory, semantic query",
23990 ]
23991 .join("\n\n"),
23992 )
23993 .with_session_id("claude-session-a")
23994 .with_observed_at_unix(1_700_000_000)
23995 .with_import("claude-mem", "observations:1")
23996 .with_metadata("project", project.clone())
23997 .with_metadata("observation_type", "fact")
23998 .with_metadata("prompt_number", "7")
23999 .with_metadata("discovery_tokens", "42")
24000 .with_metadata("content_hash", "hash-observation-1");
24001 store.insert_event(&observation).unwrap();
24002
24003 let summary = MemoryEvent::new(
24004 MemoryEventKind::ImportedSessionSummary,
24005 "claude-mem:session_summaries:2",
24006 [
24007 "Query old memory from graph-db",
24008 "Read-only tsift memory SQLite projection",
24009 "Semantic graph rows can point at existing memory",
24010 "Projected source and session nodes",
24011 "Keep capture ownership inside tsift-memory",
24012 "summary note",
24013 ]
24014 .join("\n\n"),
24015 )
24016 .with_session_id("claude-session-a")
24017 .with_observed_at_unix(1_700_000_010)
24018 .with_import("claude-mem", "session_summaries:2")
24019 .with_metadata("project", project)
24020 .with_metadata("prompt_number", "8")
24021 .with_metadata("discovery_tokens", "36");
24022 store.insert_event(&summary).unwrap();
24023
24024 let prompt = MemoryEvent::new(
24025 MemoryEventKind::ImportedUserPrompt,
24026 "claude-mem:user_prompts:3",
24027 "How can graph-db query tsift memory semantic history?",
24028 )
24029 .with_session_id("claude-session-a")
24030 .with_observed_at_unix(1_700_000_020)
24031 .with_import("claude-mem", "user_prompts:3")
24032 .with_metadata("prompt_number", "9");
24033 store.insert_event(&prompt).unwrap();
24034 }
24035
24036 #[test]
24037 fn graph_callers_query() {
24038 let dir = setup_graph_index();
24039 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24040 let callers = db.callers_of("helper").unwrap();
24041 assert_eq!(callers.len(), 1);
24042 assert_eq!(callers[0].caller_name, "main");
24043 }
24044
24045 #[test]
24046 fn graph_callees_query() {
24047 let dir = setup_graph_index();
24048 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24049 let callees = db.callees_of("main").unwrap();
24050 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
24051 assert!(names.contains(&"helper"));
24052 assert!(names.contains(&"new"));
24053 }
24054
24055 #[test]
24056 fn graph_no_callers_returns_empty() {
24057 let dir = setup_graph_index();
24058 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24059 let callers = db.callers_of("nonexistent").unwrap();
24060 assert!(callers.is_empty());
24061 }
24062
24063 #[test]
24064 fn graph_cmd_autoindexes_missing_index_by_default() {
24065 let dir = tempfile::tempdir().unwrap();
24066 std::fs::write(
24067 dir.path().join("main.rs"),
24068 "fn helper() {}\nfn main() { helper(); }\n",
24069 )
24070 .unwrap();
24071 let result = cmd_graph(
24072 "helper",
24073 dir.path(),
24074 true,
24075 false,
24076 None,
24077 20,
24078 false,
24079 true,
24080 false,
24081 false,
24082 false,
24083 false,
24084 false,
24085 TagpathSearchOpts::default(),
24086 );
24087
24088 assert!(result.is_ok());
24089 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
24090 let summary = db.compute_changes(dir.path()).unwrap();
24091 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
24092 }
24093
24094 #[test]
24095 fn traversal_graph_has_stable_typed_handles() {
24096 let dir = setup_traversal_project();
24097 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24098 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24099
24100 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
24101 let symbol = resolve_traversal_node(&graph, "helper").unwrap();
24102 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24103 let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
24104
24105 assert!(file.handle.starts_with("gfil-"));
24106 assert!(symbol.handle.starts_with("gsym-"));
24107 assert!(backlog.handle.starts_with("gbak-"));
24108 assert!(session.handle.starts_with("gses-"));
24109
24110 assert_eq!(
24111 symbol.handle,
24112 resolve_traversal_node(&graph_again, "helper")
24113 .unwrap()
24114 .handle
24115 );
24116 assert_eq!(
24117 backlog.handle,
24118 resolve_traversal_node(&graph_again, "#kgnv")
24119 .unwrap()
24120 .handle
24121 );
24122 }
24123
24124 #[test]
24125 fn traversal_graph_links_backlog_items_to_code_tokens() {
24126 let dir = setup_traversal_project();
24127 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24128 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24129 let helper = resolve_traversal_node(&graph, "helper").unwrap();
24130
24131 assert!(graph.edges.iter().any(|edge| {
24132 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24133 }));
24134 }
24135
24136 #[test]
24137 fn session_hinted_traversal_skips_global_call_edges() {
24138 let dir = setup_traversal_project();
24139 let session = dir.path().join("tasks/software/tsift.md");
24140 let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
24141 let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
24142 let helper = resolve_traversal_node(&bounded, "helper").unwrap();
24143
24144 assert!(bounded.edges.iter().any(|edge| {
24145 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24146 }));
24147 assert!(
24148 !bounded.edges.iter().any(|edge| edge.relation == "calls"),
24149 "session-hinted graph-db projections should not materialize unrelated global call edges"
24150 );
24151
24152 let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24153 assert!(
24154 full.edges.iter().any(|edge| edge.relation == "calls"),
24155 "root/full projections still carry the complete indexed call graph"
24156 );
24157 }
24158
24159 #[test]
24160 fn agent_doc_task_path_infers_matching_workspace_scope() {
24161 let dir = tempfile::tempdir().unwrap();
24162 std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
24163 std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
24164 std::fs::write(
24165 dir.path().join(".gitmodules"),
24166 "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
24167 )
24168 .unwrap();
24169 let task = dir.path().join("tasks/software/tsift.md");
24170 std::fs::write(&task, "# tsift\n").unwrap();
24171
24172 let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
24173 let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
24174 let cfg = config::Config::load(dir.path()).unwrap();
24175
24176 assert_eq!(targets.len(), 1);
24177 assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
24178 assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
24179 assert!(
24180 targets[0]
24181 .db_path
24182 .ends_with(".tsift/indexes/tsift/index.db")
24183 );
24184 assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
24185 }
24186
24187 #[test]
24188 fn cargo_package_scope_selector_indexes_package_db() {
24189 let dir = setup_multiplicity_project();
24190 let targets =
24191 resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
24192
24193 assert_eq!(targets.len(), 1);
24194 assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
24195 assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
24196 assert!(
24197 targets[0]
24198 .db_path
24199 .ends_with(".tsift/indexes/cargo/core-lib/index.db")
24200 );
24201
24202 cmd_index(
24203 dir.path(),
24204 false,
24205 false,
24206 false,
24207 false,
24208 true,
24209 false,
24210 Some("core_lib"),
24211 false,
24212 true,
24213 false,
24214 false,
24215 false,
24216 false,
24217 )
24218 .unwrap();
24219 assert!(targets[0].db_path.exists());
24220 }
24221
24222 #[test]
24223 fn source_read_symbols_build_cargo_package_index_on_demand() {
24224 let dir = setup_multiplicity_project();
24230 let cargo_index = dir.path().join(".tsift/indexes/cargo/core-lib/index.db");
24231 assert!(
24232 !cargo_index.exists(),
24233 "core-lib cargo index should not exist before the first source-read"
24234 );
24235
24236 let file_abs = dir.path().join("crates/core-lib/src/lib.rs");
24237 let source = std::fs::read(&file_abs).unwrap();
24238 let mut warnings = Vec::new();
24239 let symbols = load_source_symbols(
24240 dir.path(),
24241 &file_abs,
24242 "crates/core-lib/src/lib.rs",
24243 &source,
24244 None,
24245 1,
24246 usize::MAX,
24247 10,
24248 4096,
24249 &mut warnings,
24250 );
24251
24252 assert!(
24253 warnings.is_empty(),
24254 "source-read must build the index on demand instead of warning: {warnings:?}"
24255 );
24256 let symbol_names = symbols
24257 .iter()
24258 .map(|symbol| symbol.name.as_str())
24259 .collect::<Vec<_>>();
24260 assert!(
24261 symbol_names.contains(&"run"),
24262 "source-read should resolve `run` from the on-demand-built cargo index: {symbol_names:?}"
24263 );
24264 assert!(
24265 cargo_index.exists(),
24266 "source-read should have built the core-lib cargo index on demand"
24267 );
24268 }
24269
24270 #[test]
24271 fn path_inference_prefers_nested_cargo_package_without_submodule() {
24272 let dir = setup_multiplicity_project();
24273 let source = dir.path().join("crates/cli-app/src/main.rs");
24274 let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
24275
24276 assert_eq!(targets.len(), 1);
24277 assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
24278 assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
24279 }
24280
24281 #[test]
24282 fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
24283 let dir = setup_multiplicity_project();
24284 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24285 let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
24286 let core = resolve_traversal_node(&graph, "core-lib").unwrap();
24287 let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
24288 let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
24289
24290 assert_eq!(workspace.kind, "cargo_workspace");
24291 assert_eq!(core.kind, "cargo_package");
24292 assert_eq!(
24293 core.properties.get("features"),
24294 Some(&"default".to_string())
24295 );
24296 assert!(graph.edges.iter().any(|edge| {
24297 edge.from == workspace.handle
24298 && edge.to == core.handle
24299 && edge.relation == "contains_package"
24300 }));
24301 assert!(graph.edges.iter().any(|edge| {
24302 edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
24303 }));
24304 assert!(graph.edges.iter().any(|edge| {
24305 edge.from == cli.handle
24306 && edge.to == core.handle
24307 && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
24308 }));
24309 }
24310
24311 #[test]
24312 fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
24313 let dir = setup_multiplicity_project();
24314 let session = dir.path().join("tasks/software/tsift.md");
24315 let report =
24316 build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
24317 .unwrap();
24318
24319 assert!(report.per_target_fail_closed.is_empty());
24320 let candidate = report
24321 .candidates
24322 .iter()
24323 .find(|candidate| candidate.target == "corepkg")
24324 .unwrap();
24325 assert!(
24326 candidate
24327 .owned_files
24328 .iter()
24329 .any(|file| file == "crates/core-lib/Cargo.toml"),
24330 "{:?}",
24331 candidate.owned_files
24332 );
24333 }
24334
24335 #[test]
24336 fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
24337 let dir = setup_traversal_project();
24338 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24339 let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
24340 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24341
24342 assert_eq!(job.kind, "job_packet");
24343 assert!(job.handle.starts_with("gjob-"));
24344 assert!(graph.edges.iter().any(|edge| {
24345 edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
24346 }));
24347
24348 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24349 let jobs = store.nodes_by_kind("job_packet").unwrap();
24350 assert!(
24351 jobs.iter()
24352 .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24353 "expected queued job packet in graph store, got {jobs:?}"
24354 );
24355 }
24356
24357 #[test]
24358 fn traversal_graph_includes_routes_and_handler_edges() {
24359 let dir = tempfile::tempdir().unwrap();
24360 std::fs::write(
24361 dir.path().join("api.py"),
24362 r#"@router.get("/items")
24363def list_items():
24364 return []
24365"#,
24366 )
24367 .unwrap();
24368 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24369 db.apply_changes(dir.path()).unwrap();
24370
24371 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24372 let route = resolve_traversal_node(&graph, "/items").unwrap();
24373 let handler = resolve_traversal_node(&graph, "list_items").unwrap();
24374
24375 assert_eq!(route.kind, "route");
24376 assert!(graph.edges.iter().any(|edge| {
24377 edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
24378 }));
24379 }
24380
24381 #[test]
24382 fn traversal_graph_projects_rust_ast_navigation_edges() {
24383 let dir = tempfile::tempdir().unwrap();
24384 std::fs::write(
24385 dir.path().join("main.rs"),
24386 r#"mod api {
24387 pub fn helper() {}
24388 pub fn handler() { helper(); }
24389}
24390
24391fn main() { api::handler(); }
24392"#,
24393 )
24394 .unwrap();
24395 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24396 db.apply_changes(dir.path()).unwrap();
24397
24398 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24399 let api = resolve_ast_span_node(&graph, "api", "mod");
24400 let helper = resolve_ast_span_node(&graph, "helper", "function");
24401 let handler = resolve_ast_span_node(&graph, "handler", "function");
24402
24403 assert_eq!(helper.kind, "ast_span");
24404 assert!(helper.handle.starts_with("span-"));
24405 assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
24406 assert!(graph.edges.iter().any(|edge| {
24407 edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
24408 }));
24409 assert!(graph.edges.iter().any(|edge| {
24410 edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
24411 }));
24412 assert!(graph.edges.iter().any(|edge| {
24413 edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
24414 }));
24415 assert!(graph.edges.iter().any(|edge| {
24416 edge.from == helper.handle
24417 && edge.to == handler.handle
24418 && edge.relation == "next_sibling"
24419 }));
24420 assert!(graph.edges.iter().any(|edge| {
24421 edge.from == handler.handle
24422 && edge.to == helper.handle
24423 && edge.relation == "previous_sibling"
24424 }));
24425 assert!(graph.edges.iter().any(|edge| {
24426 edge.from == helper.handle
24427 && edge.to == api.handle
24428 && edge.relation == "enclosing_module"
24429 }));
24430 assert!(graph.edges.iter().any(|edge| {
24431 edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
24432 }));
24433
24434 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24435 let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
24436 assert!(
24437 ast_nodes.iter().any(|node| node.id == helper.handle
24438 && node.properties.get("symbol_kind") == Some(&"function".to_string())),
24439 "expected helper AST span in graph store, got {ast_nodes:?}"
24440 );
24441 assert!(
24442 store
24443 .outgoing_edges(&helper.handle, Some("parent"))
24444 .unwrap()
24445 .iter()
24446 .any(|edge| edge.to_id == api.handle),
24447 "expected persisted AST parent edge"
24448 );
24449 }
24450
24451 #[test]
24452 fn traversal_graph_projects_markdown_section_block_edges() {
24453 let dir = tempfile::tempdir().unwrap();
24454 std::fs::write(
24455 dir.path().join("README.md"),
24456 "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
24457 )
24458 .unwrap();
24459 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24460 db.apply_changes(dir.path()).unwrap();
24461
24462 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24463 let guide = resolve_ast_span_node(&graph, "Guide", "heading");
24464 let code = resolve_ast_span_node(&graph, "rust", "code_block");
24465 let embedded = resolve_ast_span_node(&graph, "demo", "function");
24466 let list_item = graph
24467 .nodes
24468 .values()
24469 .find(|node| {
24470 node.kind == "ast_span"
24471 && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
24472 && node.properties.get("section_handle") == Some(&guide.handle)
24473 })
24474 .expect("missing Markdown list item AST span");
24475
24476 assert_eq!(
24477 code.properties.get("markdown_block_kind"),
24478 Some(&"fenced_code_block".to_string())
24479 );
24480 assert_eq!(
24481 guide.properties.get("heading_level"),
24482 Some(&"1".to_string())
24483 );
24484 assert_eq!(
24485 embedded.properties.get("embedded"),
24486 Some(&"true".to_string())
24487 );
24488 assert_eq!(
24489 embedded.properties.get("language"),
24490 Some(&"rust".to_string())
24491 );
24492 assert_eq!(
24493 embedded.properties.get("markdown_block_handle"),
24494 Some(&code.handle)
24495 );
24496 assert!(graph.edges.iter().any(|edge| {
24497 edge.from == guide.handle
24498 && edge.to == code.handle
24499 && edge.relation == "contains_markdown_block"
24500 }));
24501 assert!(graph.edges.iter().any(|edge| {
24502 edge.from == code.handle
24503 && edge.to == guide.handle
24504 && edge.relation == "enclosing_section"
24505 }));
24506 assert!(graph.edges.iter().any(|edge| {
24507 edge.from == guide.handle
24508 && edge.to == list_item.handle
24509 && edge.relation == "contains_markdown_block"
24510 }));
24511 assert!(graph.edges.iter().any(|edge| {
24512 edge.from == code.handle
24513 && edge.to == embedded.handle
24514 && edge.relation == "contains_embedded_symbol"
24515 }));
24516 assert!(graph.edges.iter().any(|edge| {
24517 edge.from == embedded.handle
24518 && edge.to == code.handle
24519 && edge.relation == "embedded_in_fence"
24520 }));
24521 assert!(graph.edges.iter().any(|edge| {
24522 edge.from == guide.handle
24523 && edge.to == embedded.handle
24524 && edge.relation == "contains_embedded_code"
24525 }));
24526
24527 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24528 assert!(
24529 store
24530 .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
24531 .unwrap()
24532 .iter()
24533 .any(|edge| edge.to_id == code.handle),
24534 "expected persisted Markdown section/block edge"
24535 );
24536 assert!(
24537 store
24538 .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
24539 .unwrap()
24540 .iter()
24541 .any(|edge| edge.to_id == embedded.handle),
24542 "expected persisted Markdown fence/embedded symbol edge"
24543 );
24544 }
24545
24546 #[test]
24547 fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
24548 let dir = setup_multilingual_ast_navigation_project();
24549 let db =
24550 index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
24551 let symbols = db.all_symbols().unwrap();
24552 let expected_symbols = [
24553 ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
24554 (
24555 "python",
24556 "fixture_nav_python_entry",
24557 "function",
24558 "python.py",
24559 ),
24560 (
24561 "typescript",
24562 "fixture_nav_typescript_entry",
24563 "function",
24564 "typescript.ts",
24565 ),
24566 (
24567 "javascript",
24568 "fixture_nav_javascript_entry",
24569 "function",
24570 "javascript.js",
24571 ),
24572 (
24573 "kotlin",
24574 "fixture_nav_kotlin_entry",
24575 "function",
24576 "kotlin.kt",
24577 ),
24578 ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
24579 (
24580 "gdscript",
24581 "fixture_nav_gdscript_entry",
24582 "function",
24583 "gdscript.gd",
24584 ),
24585 ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
24586 ("markdown", "Fixture Section", "heading", "README.md"),
24587 ("markdown", "Fixture step", "list_item", "README.md"),
24588 ("markdown", "python", "code_block", "README.md"),
24589 ];
24590
24591 for (language, name, kind, file) in expected_symbols {
24592 let symbol = symbols
24593 .iter()
24594 .find(|symbol| {
24595 symbol.language == language
24596 && symbol.name == name
24597 && symbol.kind == kind
24598 && symbol.file.ends_with(file)
24599 })
24600 .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
24601 assert!(
24602 symbol.start_byte.is_some() && symbol.end_byte.is_some(),
24603 "{language} {name} should carry AST byte spans"
24604 );
24605 }
24606
24607 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24608 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24609 let expected_ast_nodes = [
24610 ("fixture_nav_rust_entry", "function", "rust"),
24611 ("fixture_nav_python_entry", "function", "python"),
24612 ("fixture_nav_typescript_entry", "function", "typescript"),
24613 ("fixture_nav_javascript_entry", "function", "javascript"),
24614 ("fixture_nav_kotlin_entry", "function", "kotlin"),
24615 ("fixture_nav_zig_entry", "function", "zig"),
24616 ("fixture_nav_gdscript_entry", "function", "gdscript"),
24617 ("fixture_nav_bash_entry", "function", "bash"),
24618 ("Fixture Section", "heading", "markdown"),
24619 ("Fixture step", "list_item", "markdown"),
24620 ("python", "code_block", "markdown"),
24621 ("fixture_nav_markdown_embedded", "function", "python"),
24622 ];
24623
24624 for (name, kind, language) in expected_ast_nodes {
24625 let node = resolve_ast_span_node(&graph, name, kind);
24626 let repeated = resolve_ast_span_node(&graph_again, name, kind);
24627 assert!(
24628 node.handle.starts_with("span-"),
24629 "{name} handle: {}",
24630 node.handle
24631 );
24632 assert_eq!(
24633 node.handle, repeated.handle,
24634 "{language} {name} handle drifted"
24635 );
24636 assert_eq!(
24637 node.properties.get("language"),
24638 Some(&language.to_string()),
24639 "{name} should keep its language label"
24640 );
24641 }
24642
24643 let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
24644 let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
24645 let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
24646 assert!(graph.edges.iter().any(|edge| {
24647 edge.from == markdown_section.handle
24648 && edge.to == markdown_code.handle
24649 && edge.relation == "contains_markdown_block"
24650 }));
24651 assert!(graph.edges.iter().any(|edge| {
24652 edge.from == markdown_code.handle
24653 && edge.to == embedded.handle
24654 && edge.relation == "contains_embedded_symbol"
24655 }));
24656 assert!(
24657 graph.nodes.len() <= 80,
24658 "multilingual AST fixture should stay bounded, got {} nodes",
24659 graph.nodes.len()
24660 );
24661 assert!(
24662 graph.edges.len() <= 180,
24663 "multilingual AST fixture should stay bounded, got {} edges",
24664 graph.edges.len()
24665 );
24666
24667 let response = empty_search_response(dir.path(), "lexical");
24668 let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
24669 let report = build_relative_search_budget_report(
24670 "fixture_nav_python_entry",
24671 "lexical",
24672 dir.path(),
24673 &response,
24674 &symbol_hits,
24675 ResponseBudget::new(Some(8), Some(120)),
24676 &SearchFacetFilters::default(),
24677 );
24678 let report_again = build_relative_search_budget_report(
24679 "fixture_nav_python_entry",
24680 "lexical",
24681 dir.path(),
24682 &response,
24683 &symbol_hits,
24684 ResponseBudget::new(Some(8), Some(120)),
24685 &SearchFacetFilters::default(),
24686 );
24687
24688 let top = report
24689 .ranked
24690 .first()
24691 .expect("ranked preview should not be empty");
24692 assert_eq!(top.source, "symbol_span");
24693 assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
24694 assert!(top.handle.starts_with("srnk-"));
24695 assert_eq!(top.handle, report_again.ranked[0].handle);
24696 assert!(
24697 top.reasons.iter().any(|reason| reason == "ast_span"),
24698 "expected AST span ranking reason, got {:?}",
24699 top.reasons
24700 );
24701 assert!(report.ranked.len() <= 8);
24702 assert!(report.symbols.len() <= 8);
24703
24704 let symbol = report
24705 .symbols
24706 .iter()
24707 .find(|symbol| symbol.name == "fixture_nav_python_entry")
24708 .expect("missing search preview symbol");
24709 assert_cli_expand_command_parses(&symbol.expand);
24710 let ast = symbol
24711 .ast
24712 .as_ref()
24713 .expect("search symbol should expose AST");
24714 assert_cli_expand_command_parses(&ast.expand.source_window);
24715 assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
24716 assert_cli_expand_command_parses(&ast.expand.symbol_read);
24717
24718 let markdown_hits = db.symbol_search("python", 20).unwrap();
24719 let markdown_report = build_relative_search_budget_report(
24720 "python",
24721 "lexical",
24722 dir.path(),
24723 &response,
24724 &markdown_hits,
24725 ResponseBudget::new(Some(8), Some(120)),
24726 &SearchFacetFilters::default(),
24727 );
24728 let markdown_symbol = markdown_report
24729 .symbols
24730 .iter()
24731 .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
24732 .expect("missing Markdown code-block symbol");
24733 let markdown_ast = markdown_symbol
24734 .ast
24735 .as_ref()
24736 .expect("Markdown code block should expose AST");
24737 assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
24738 assert_eq!(
24739 markdown_ast
24740 .span
24741 .markdown
24742 .as_ref()
24743 .unwrap()
24744 .embedded_symbols[0]
24745 .name,
24746 "fixture_nav_markdown_embedded"
24747 );
24748 }
24749
24750 #[test]
24751 fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
24752 let edges = vec![
24753 TraversalEdge {
24754 from: "origin".to_string(),
24755 to: "aaa_low".to_string(),
24756 relation: "unknown".to_string(),
24757 label: None,
24758 weight: 1,
24759 },
24760 TraversalEdge {
24761 from: "origin".to_string(),
24762 to: "zzz_high".to_string(),
24763 relation: "mentions".to_string(),
24764 label: None,
24765 weight: 1,
24766 },
24767 ];
24768
24769 let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
24770
24771 assert!(handles.contains("origin"));
24772 assert!(handles.contains("zzz_high"), "{handles:?}");
24773 assert!(!handles.contains("aaa_low"), "{handles:?}");
24774 }
24775
24776 #[test]
24777 fn traversal_materializes_provider_neutral_sqlite_graph() {
24778 let dir = setup_traversal_project();
24779 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24780 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24781
24782 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24783 let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
24784 assert!(
24785 backlog_nodes.iter().any(|node| node.id == backlog.handle
24786 && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24787 "expected materialized backlog node, got {backlog_nodes:?}"
24788 );
24789 assert!(
24790 store
24791 .all_nodes()
24792 .unwrap()
24793 .iter()
24794 .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
24795 && node.properties.get("projection_version")
24796 == Some(&GRAPH_PROJECTION_VERSION.to_string())),
24797 "expected projection metadata node"
24798 );
24799 let source_handles = store.nodes_by_kind("source_handle").unwrap();
24800 assert!(
24801 source_handles
24802 .iter()
24803 .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
24804 "expected bounded source_handle rows, got {source_handles:?}"
24805 );
24806 let worker_context = store.nodes_by_kind("worker_context").unwrap();
24807 assert!(
24808 worker_context
24809 .iter()
24810 .any(|node| node.properties.get("target")
24811 == Some(&"tasks/software/tsift.md".to_string())),
24812 "expected bounded worker_context rows, got {worker_context:?}"
24813 );
24814 let worker_results = store.nodes_by_kind("worker_result").unwrap();
24815 assert!(
24816 worker_results.iter().any(|node| {
24817 node.properties.get("ref_id") == Some(&"kgnv".to_string())
24818 && node.properties.get("status") == Some(&"completed".to_string())
24819 && node.properties.get("touched_files") == Some(&"main.rs".to_string())
24820 && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
24821 }),
24822 "expected worker_result rows, got {worker_results:?}"
24823 );
24824 }
24825
24826 #[test]
24827 fn traversal_projection_materializes_cached_semantic_rows() {
24828 let dir = setup_traversal_project();
24829 seed_traversal_semantic_summaries(dir.path());
24830 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24831 let helper = resolve_traversal_node(&graph, "helper").unwrap();
24832 let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
24833 let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
24834
24835 assert_eq!(concept.kind, "semantic_concept");
24836 assert_eq!(entity.kind, "semantic_entity");
24837 assert!(concept.handle.starts_with("gcon-"));
24838 assert!(entity.handle.starts_with("gent-"));
24839
24840 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24841 assert!(
24842 store
24843 .nodes_by_kind("semantic_concept")
24844 .unwrap()
24845 .iter()
24846 .any(|node| node.label == "semantic extraction"
24847 && node.properties.contains_key("embedding")),
24848 "expected persisted concept embeddings"
24849 );
24850 assert!(
24851 store
24852 .outgoing_edges(&helper.handle, Some("mentions_concept"))
24853 .unwrap()
24854 .iter()
24855 .any(|edge| edge.to_id == concept.handle),
24856 "expected helper symbol to link to cached summary concept"
24857 );
24858 assert!(
24859 store
24860 .outgoing_edges(
24861 &semantic_entity_handle("helper", "function"),
24862 Some("semantic_relation")
24863 )
24864 .unwrap()
24865 .iter()
24866 .any(|edge| edge.to_id == entity.handle
24867 && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
24868 "expected LLM relationship rows projected into GraphStore"
24869 );
24870 }
24871
24872 #[test]
24873 fn traversal_projection_materializes_tsift_memory_rows() {
24874 let dir = setup_traversal_project();
24875 seed_tsift_memory_graph_db(dir.path());
24876 let memory_db = dir.path().join(".tsift").join("memory.db");
24877 let store = MemoryStore::open_or_create(&memory_db).unwrap();
24878 for summary in ["first closeout", "second closeout"] {
24879 let event = MemoryEvent::new(
24880 MemoryEventKind::ResponseSummary,
24881 "tasks/software/tsift.md",
24882 summary,
24883 )
24884 .with_session_id("tasks/software/tsift.md")
24885 .with_observed_at_unix(1_700_000_100);
24886 store.insert_event(&event).unwrap();
24887 }
24888 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24889 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24890
24891 let native_sources = store
24892 .nodes_by_kind("source_handle")
24893 .unwrap()
24894 .into_iter()
24895 .filter(|node| {
24896 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24897 && node.properties.get("source_ref")
24898 == Some(&"tasks/software/tsift.md".to_string())
24899 })
24900 .collect::<Vec<_>>();
24901 assert_eq!(
24902 native_sources.len(),
24903 2,
24904 "same-source native memory events must get distinct source handles"
24905 );
24906
24907 let source = store
24908 .nodes_by_kind("source_handle")
24909 .unwrap()
24910 .into_iter()
24911 .find(|node| {
24912 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24913 })
24914 .expect("expected tsift-memory source handle");
24915 let session = store
24916 .nodes_by_kind("memory_session")
24917 .unwrap()
24918 .into_iter()
24919 .find(|node| {
24920 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24921 && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
24922 })
24923 .expect("expected tsift-memory session node");
24924 let event = store
24925 .nodes_by_kind("memory_event")
24926 .unwrap()
24927 .into_iter()
24928 .find(|node| {
24929 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24930 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24931 && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
24932 })
24933 .expect("expected tsift-memory event node");
24934 let concept = store
24935 .nodes_by_kind("semantic_concept")
24936 .unwrap()
24937 .into_iter()
24938 .find(|node| {
24939 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24940 && node.label.contains("Graph memory adapter")
24941 && node.properties.contains_key("embedding")
24942 })
24943 .expect("expected tsift-memory semantic concept");
24944
24945 assert!(
24946 store
24947 .outgoing_edges(&session.id, Some("records_memory_source"))
24948 .unwrap()
24949 .iter()
24950 .any(|edge| edge.to_id == source.id),
24951 "expected session to link to source handle"
24952 );
24953 assert!(
24954 store
24955 .outgoing_edges(&session.id, Some("records_memory_event"))
24956 .unwrap()
24957 .iter()
24958 .any(|edge| edge.to_id == event.id),
24959 "expected session to link to memory event"
24960 );
24961 assert!(
24962 store
24963 .outgoing_edges(&event.id, Some("projects_source"))
24964 .unwrap()
24965 .iter()
24966 .any(|edge| edge.to_id == source.id),
24967 "expected memory event to project source handle"
24968 );
24969 assert!(
24970 store
24971 .outgoing_edges(&source.id, Some("mentions_concept"))
24972 .unwrap()
24973 .iter()
24974 .any(|edge| edge.to_id == concept.id),
24975 "expected source handle to seed semantic concept"
24976 );
24977
24978 let related = semantic_related_report_from_store(
24979 dir.path(),
24980 None,
24981 "tsift memory graph adapter",
24982 5,
24983 SemanticRelatedKind::Concept,
24984 &store,
24985 )
24986 .unwrap();
24987 assert!(
24988 related
24989 .items
24990 .iter()
24991 .any(|item| item.handle == concept.id && item.score > 0.0),
24992 "expected semantic query to retrieve tsift-memory concept, got {:?}",
24993 related.items
24994 );
24995
24996 let graph_related = graph_db_report_from_store(
24997 dir.path(),
24998 None,
24999 "sqlite",
25000 GraphDbQuery::Related {
25001 query: "tsift memory graph adapter".to_string(),
25002 kind: SemanticRelatedKind::Concept,
25003 depth: 1,
25004 seed_limit: 5,
25005 limit: 20,
25006 },
25007 &store,
25008 sqlite_graph_freshness(&store, "root").unwrap(),
25009 Vec::new(),
25010 )
25011 .unwrap();
25012 assert_eq!(
25013 graph_related
25014 .readiness
25015 .as_ref()
25016 .map(|readiness| readiness.status.as_str()),
25017 Some("ready"),
25018 "tsift-memory semantic rows should satisfy graph-db related readiness"
25019 );
25020 assert!(
25021 graph_related.nodes.iter().any(|node| {
25022 node.kind == "semantic_concept"
25023 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
25024 }),
25025 "expected related graph output to include tsift-memory semantic rows"
25026 );
25027 }
25028
25029 #[test]
25030 fn semantic_related_query_uses_persisted_graph_embeddings() {
25031 let dir = setup_traversal_project();
25032 seed_traversal_semantic_summaries(dir.path());
25033 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25034 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25035 let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
25036 .unwrap()
25037 .query_row(
25038 "SELECT COUNT(*) FROM graph_node_semantic_vectors",
25039 [],
25040 |row| row_usize(row, 0),
25041 )
25042 .unwrap();
25043 assert!(semantic_vector_rows > 0);
25044
25045 let report = semantic_related_report_from_store(
25046 dir.path(),
25047 None,
25048 "graph navigation",
25049 5,
25050 SemanticRelatedKind::Concept,
25051 &store,
25052 )
25053 .unwrap();
25054
25055 assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
25056 assert!(
25057 report
25058 .items
25059 .iter()
25060 .any(|item| item.label == "graph navigation"
25061 && item.kind == "semantic_concept"
25062 && item.score > 0.9),
25063 "expected nearest concept match from graph embeddings, got {:?}",
25064 report.items
25065 );
25066 }
25067
25068 #[test]
25069 fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
25070 let dir = setup_traversal_project();
25071 seed_traversal_semantic_summaries(dir.path());
25072 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25073 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25074
25075 let report = graph_db_report_from_store(
25076 dir.path(),
25077 None,
25078 "sqlite",
25079 GraphDbQuery::Related {
25080 query: "graph navigation".to_string(),
25081 kind: SemanticRelatedKind::All,
25082 depth: 1,
25083 seed_limit: 2,
25084 limit: 20,
25085 },
25086 &store,
25087 sqlite_graph_freshness(&store, "root").unwrap(),
25088 Vec::new(),
25089 )
25090 .unwrap();
25091
25092 let knowledge = report.knowledge_retrieval.as_ref().unwrap();
25093 assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
25094 assert_eq!(knowledge.seed_kind, "all");
25095 assert_eq!(knowledge.depth, 1);
25096 assert_eq!(
25097 report
25098 .readiness
25099 .as_ref()
25100 .map(|readiness| readiness.status.as_str()),
25101 Some("ready")
25102 );
25103 assert!(
25104 knowledge
25105 .diagnostics
25106 .iter()
25107 .any(|diagnostic| diagnostic.contains("incident"))
25108 );
25109 assert!(
25110 report
25111 .semantic_related
25112 .iter()
25113 .any(|item| item.label == "graph navigation"
25114 && item.kind == "semantic_concept"
25115 && item.score > 0.9),
25116 "expected natural-language query to seed the graph navigation concept, got {:?}",
25117 report.semantic_related
25118 );
25119 assert!(
25120 report
25121 .nodes
25122 .iter()
25123 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
25124 );
25125 assert!(
25126 report
25127 .nodes
25128 .iter()
25129 .any(|node| node.kind == "symbol" && node.label == "helper"),
25130 "incident expansion from semantic seed should recover source symbols, got {:?}",
25131 report
25132 .nodes
25133 .iter()
25134 .map(|node| (&node.kind, &node.label))
25135 .collect::<Vec<_>>()
25136 );
25137 assert!(
25138 report
25139 .edges
25140 .iter()
25141 .any(|edge| edge.kind == "mentions_concept")
25142 );
25143 assert!(
25144 report.output_budget.as_ref().is_some_and(|budget| budget
25145 .diagnostics
25146 .iter()
25147 .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
25148 "expected related output budget diagnostics, got {:?}",
25149 report.output_budget
25150 );
25151 }
25152
25153 #[test]
25154 fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
25155 let dir = setup_graph_index();
25156 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25157 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25158
25159 let report = graph_db_report_from_store(
25160 dir.path(),
25161 None,
25162 "sqlite",
25163 GraphDbQuery::Related {
25164 query: "graph navigation".to_string(),
25165 kind: SemanticRelatedKind::All,
25166 depth: 1,
25167 seed_limit: 2,
25168 limit: 20,
25169 },
25170 &store,
25171 sqlite_graph_freshness(&store, "root").unwrap(),
25172 Vec::new(),
25173 )
25174 .unwrap();
25175
25176 let readiness = report.readiness.as_ref().unwrap();
25177 assert_eq!(readiness.status, "blocked");
25178 assert_eq!(readiness.reason, "summary_cache_empty");
25179 assert!(readiness.fail_closed);
25180 assert_eq!(
25181 readiness.next_commands,
25182 vec![
25183 "tsift summarize --extract .".to_string(),
25184 graph_db_refresh_command(dir.path(), None)
25185 ]
25186 );
25187 assert!(
25188 report
25189 .knowledge_retrieval
25190 .as_ref()
25191 .unwrap()
25192 .diagnostics
25193 .iter()
25194 .any(|diagnostic| diagnostic.contains("summary cache empty")
25195 && diagnostic.contains("graph-db materialized code/session rows")),
25196 "expected related diagnostics to carry readiness gate, got {:?}",
25197 report.knowledge_retrieval.as_ref().unwrap().diagnostics
25198 );
25199 }
25200
25201 #[test]
25202 fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
25203 let mut nodes = vec![
25204 SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
25205 SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
25206 ];
25207 let mut edges = vec![SubstrateGraphEdge::new(
25208 "zzz_high",
25209 "seed",
25210 "mentions_concept",
25211 )];
25212 for idx in 0..24 {
25213 let id = format!("aaa_low_{idx:02}");
25214 nodes.push(SubstrateGraphNode::new(
25215 id.clone(),
25216 "note",
25217 format!("low {idx}"),
25218 ));
25219 edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
25220 }
25221 let mut store = SqliteGraphStore::in_memory().unwrap();
25222 store
25223 .replace_projection(&GraphProjection { nodes, edges })
25224 .unwrap();
25225
25226 let subgraph =
25227 graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
25228
25229 assert_eq!(subgraph.nodes.len(), 3);
25230 assert_eq!(subgraph.nodes[0].id, "seed");
25231 assert_eq!(
25232 subgraph.nodes[1].id, "zzz_high",
25233 "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
25234 subgraph.nodes
25235 );
25236 assert!(subgraph.truncated);
25237 assert!(
25238 subgraph
25239 .diagnostics
25240 .iter()
25241 .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
25242 "{:?}",
25243 subgraph.diagnostics
25244 );
25245 assert!(
25246 subgraph
25247 .diagnostics
25248 .iter()
25249 .any(|diagnostic| diagnostic.contains("skipped")),
25250 "{:?}",
25251 subgraph.diagnostics
25252 );
25253 }
25254
25255 #[test]
25256 fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
25257 let dir = setup_traversal_project();
25258 seed_traversal_semantic_summaries(dir.path());
25259 init_git_repo(dir.path());
25260 let session = dir.path().join("tasks/software/tsift.md");
25261 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25262 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25263 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25264 let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25265 root: dir.path(),
25266 scope: None,
25267 backend: "sqlite",
25268 target: "kgnv",
25269 preferred_path: None,
25270 depth: 4,
25271 limit: 8,
25272 cursor: None,
25273 store: &store,
25274 freshness,
25275 warnings: Vec::new(),
25276 })
25277 .unwrap();
25278 assert!(
25279 evidence
25280 .semantic_related
25281 .iter()
25282 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
25283 "expected semantic evidence rows, got {:?}",
25284 evidence
25285 .semantic_related
25286 .iter()
25287 .map(|node| (&node.kind, &node.label))
25288 .collect::<Vec<_>>()
25289 );
25290 assert!(
25291 evidence
25292 .output_budget
25293 .as_ref()
25294 .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
25295 diagnostic.contains("semantic_match")
25296 && diagnostic.contains("source_handle_coverage")
25297 })),
25298 "expected evidence output budget diagnostics, got {:?}",
25299 evidence.output_budget
25300 );
25301
25302 let cached_diff = diff_digest::compute(
25303 dir.path(),
25304 diff_digest::DiffDigestOptions {
25305 cached: true,
25306 revision: None,
25307 max_parsed_files: None,
25308 },
25309 )
25310 .unwrap();
25311 let impact_report = impact::compute(
25312 dir.path(),
25313 impact::ImpactOptions {
25314 cached: true,
25315 revision: None,
25316 scope: None,
25317 limit: 10,
25318 },
25319 )
25320 .unwrap();
25321 let graph_nodes = store.all_nodes().unwrap();
25322 let graph_index = conflict_matrix_graph_index(&graph_nodes);
25323 let semantic_candidate = conflict_matrix_candidate_from_evidence(
25324 dir.path(),
25325 &evidence,
25326 &graph_index,
25327 &cached_diff,
25328 &impact_report,
25329 );
25330 assert!(semantic_candidate.semantic_dispatch_score > 0);
25331 assert!(
25332 semantic_candidate
25333 .semantic_dispatch_reasons
25334 .iter()
25335 .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
25336 "expected semantic ranking explanations, got {:?}",
25337 semantic_candidate.semantic_dispatch_reasons
25338 );
25339 assert!(
25340 semantic_candidate
25341 .semantic_related
25342 .iter()
25343 .any(|item| item.label == "graph navigation")
25344 );
25345
25346 let mut plain_candidate = semantic_candidate.clone();
25347 plain_candidate.target = "plain".to_string();
25348 plain_candidate.semantic_related.clear();
25349 plain_candidate.semantic_dispatch_score = 0;
25350 plain_candidate.semantic_dispatch_reasons.clear();
25351 let mut ranked = [plain_candidate, semantic_candidate];
25352 ranked.sort_by(|left, right| {
25353 left.risk
25354 .cmp(&right.risk)
25355 .then_with(|| left.risk_score.cmp(&right.risk_score))
25356 .then_with(|| {
25357 right
25358 .semantic_dispatch_score
25359 .cmp(&left.semantic_dispatch_score)
25360 })
25361 .then_with(|| left.target.cmp(&right.target))
25362 });
25363 assert_eq!(ranked[0].target, "kgnv");
25364 }
25365
25366 #[test]
25367 fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
25368 let dir = setup_dependency_dag_project();
25369 let session = dir.path().join("tasks/software/tsift.md");
25370 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25371
25372 assert_eq!(report.contract_version, "dependency-dag-v1");
25373 assert_eq!(
25374 report.targets,
25375 vec![
25376 "prep".to_string(),
25377 "alpha".to_string(),
25378 "beta".to_string(),
25379 "gamma".to_string()
25380 ]
25381 );
25382 assert!(report.edges.iter().any(|edge| {
25383 edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
25384 }));
25385 assert!(report.edges.iter().any(|edge| {
25386 edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
25387 }));
25388 assert!(report.edges.iter().any(|edge| {
25389 edge.from == "alpha"
25390 && edge.to == "beta"
25391 && edge.kind == "shared_resource"
25392 && edge.shared_files.contains(&"main.rs".to_string())
25393 && edge.shared_symbols.contains(&"shared_helper".to_string())
25394 }));
25395 assert!(
25396 !report.cycle_diagnostics.has_cycles,
25397 "{:?}",
25398 report.cycle_diagnostics
25399 );
25400 assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
25401 assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
25402 assert!(
25403 report.replay_commands[0].contains("dependency-dag"),
25404 "{:?}",
25405 report.replay_commands
25406 );
25407
25408 cmd_dependency_dag(
25409 &session,
25410 None,
25411 &["alpha".to_string(), "beta".to_string()],
25412 4,
25413 12,
25414 OutputFormat {
25415 json_output: true,
25416 compact: false,
25417 pretty: false,
25418 terse: false,
25419 ultra_terse: false,
25420 schema: false,
25421 envelope: false,
25422 },
25423 )
25424 .unwrap();
25425 }
25426
25427 #[test]
25428 fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
25429 let dir = setup_dependency_dag_cycle_project();
25430 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25431
25432 assert!(report.cycle_diagnostics.has_cycles);
25433 assert_eq!(
25434 report.cycle_diagnostics.blocked_nodes,
25435 vec!["left".to_string(), "right".to_string()]
25436 );
25437 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25438 edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
25439 }));
25440 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25441 edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
25442 }));
25443 }
25444
25445 #[test]
25446 fn traversal_projection_queries_match_sqlite_and_convex_stores() {
25447 let dir = setup_traversal_project();
25448 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
25449 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
25450
25451 let mut sqlite = SqliteGraphStore::in_memory().unwrap();
25452 sqlite.replace_projection(&projection).unwrap();
25453 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25454 projection.upsert_into(&convex).unwrap();
25455
25456 let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
25457 let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
25458 assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
25459 assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
25460
25461 let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
25462 let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
25463 assert!(convex_graph.edges.iter().any(|edge| {
25464 edge.from == sqlite_backlog.handle
25465 && edge.to == convex_helper.handle
25466 && edge.relation == "mentions"
25467 }));
25468 }
25469
25470 #[test]
25471 fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
25472 let dir = setup_traversal_project();
25473 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25474 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25475 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25476 assert_eq!(freshness.status, "current");
25477
25478 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25479 let report = graph_db_report_from_store(
25480 dir.path(),
25481 None,
25482 "sqlite",
25483 GraphDbQuery::Neighborhood {
25484 id: backlog.handle.clone(),
25485 depth: 1,
25486 edge_kind: Some("mentions".to_string()),
25487 cursor: None,
25488 limit: None,
25489 property_filters: Vec::new(),
25490 },
25491 &store,
25492 freshness,
25493 Vec::new(),
25494 )
25495 .unwrap();
25496 assert!(
25497 report
25498 .edges
25499 .iter()
25500 .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
25501 "expected backlog mention edge, got {:?}",
25502 report.edges
25503 );
25504 assert!(
25505 report.ranked_neighbors.iter().any(|neighbor| {
25506 neighbor.depth == Some(1)
25507 && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
25508 && neighbor.node_id != backlog.handle
25509 && neighbor.handle_coverage_pct >= 95.0
25510 && neighbor.duplicate_name_precision >= 0.99
25511 }),
25512 "expected ranked neighborhood neighbors with quality scores, got {:?}",
25513 report.ranked_neighbors
25514 );
25515 assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
25516 let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
25517 assert!(!ranking_gate.ranked_output_default);
25518 assert_eq!(ranking_gate.default_order, "stable_node_id");
25519 assert!(
25520 ranking_gate
25521 .diagnostics
25522 .iter()
25523 .any(|diagnostic| diagnostic.contains("score-capped")),
25524 "{ranking_gate:?}"
25525 );
25526 assert!(
25527 ranking_gate
25528 .required_metrics
25529 .iter()
25530 .any(|metric| metric == "handle_coverage_pct")
25531 );
25532 assert!(
25533 ranking_gate
25534 .required_metrics
25535 .iter()
25536 .any(|metric| metric == "duplicate_name_precision")
25537 );
25538 assert!(
25539 report
25540 .page
25541 .as_ref()
25542 .unwrap()
25543 .diagnostics
25544 .iter()
25545 .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
25546 "expected SQLite neighborhood query plan diagnostics, got {:?}",
25547 report.page.as_ref().unwrap().diagnostics
25548 );
25549 let edges_report = graph_db_report_from_store(
25550 dir.path(),
25551 None,
25552 "sqlite",
25553 GraphDbQuery::Edges {
25554 edge_kind: Some("mentions".to_string()),
25555 cursor: None,
25556 limit: Some(2),
25557 property_filters: Vec::new(),
25558 },
25559 &store,
25560 sqlite_graph_freshness(&store, "root").unwrap(),
25561 Vec::new(),
25562 )
25563 .unwrap();
25564 let edge_id = edges_report
25565 .edges
25566 .first()
25567 .map(|edge| edge.id.clone())
25568 .expect("expected at least one paged mentions edge");
25569 assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
25570 assert_eq!(
25571 edges_report.page.as_ref().unwrap().returned_edges,
25572 edges_report.edges.len()
25573 );
25574
25575 let edge_report = graph_db_report_from_store(
25576 dir.path(),
25577 None,
25578 "sqlite",
25579 GraphDbQuery::Edge {
25580 id: edge_id.clone(),
25581 },
25582 &store,
25583 sqlite_graph_freshness(&store, "root").unwrap(),
25584 Vec::new(),
25585 )
25586 .unwrap();
25587 assert_eq!(
25588 edge_report
25589 .edge
25590 .as_ref()
25591 .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
25592 Some(edge_id.clone())
25593 );
25594
25595 let incident_report = graph_db_report_from_store(
25596 dir.path(),
25597 None,
25598 "sqlite",
25599 GraphDbQuery::Incident {
25600 id: backlog.handle.clone(),
25601 edge_kind: Some("mentions".to_string()),
25602 cursor: None,
25603 limit: Some(1),
25604 property_filters: Vec::new(),
25605 },
25606 &store,
25607 sqlite_graph_freshness(&store, "root").unwrap(),
25608 Vec::new(),
25609 )
25610 .unwrap();
25611 assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
25612 assert!(
25613 incident_report
25614 .edges
25615 .iter()
25616 .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
25617 "{:?}",
25618 incident_report.edges
25619 );
25620
25621 let schema_report = graph_db_report_from_store(
25622 dir.path(),
25623 None,
25624 "sqlite",
25625 GraphDbQuery::Schema,
25626 &store,
25627 sqlite_graph_freshness(&store, "root").unwrap(),
25628 Vec::new(),
25629 )
25630 .unwrap();
25631 assert!(
25632 schema_report
25633 .schema
25634 .unwrap()
25635 .operations
25636 .iter()
25637 .any(|operation| operation.command.starts_with("neighborhood"))
25638 );
25639 }
25640
25641 #[test]
25642 fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
25643 let mut nodes = vec![SubstrateGraphNode::new(
25644 "origin",
25645 "backlog",
25646 "#budgeted-neighborhood",
25647 )];
25648 let mut edges = Vec::new();
25649 for idx in 0..32 {
25650 let id = format!("src-{idx:02}");
25651 nodes.push(
25652 SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
25653 .with_property("source_ref", format!("fixture:{idx}"))
25654 .with_property("detail", "x".repeat(600)),
25655 );
25656 edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
25657 }
25658 let store = SqliteGraphStore::in_memory().unwrap();
25659 GraphProjection { nodes, edges }
25660 .upsert_into(&store)
25661 .unwrap();
25662
25663 let report = graph_db_report_from_store(
25664 Path::new("."),
25665 None,
25666 "fixture",
25667 GraphDbQuery::Neighborhood {
25668 id: "origin".to_string(),
25669 depth: 1,
25670 edge_kind: None,
25671 cursor: None,
25672 limit: None,
25673 property_filters: Vec::new(),
25674 },
25675 &store,
25676 current_graph_db_freshness(),
25677 Vec::new(),
25678 )
25679 .unwrap();
25680 let budget = report.output_budget.as_ref().unwrap();
25681 assert!(budget.selected_nodes < budget.candidate_nodes);
25682 assert!(
25683 budget.dropped_by_budget.iter().any(|drop| {
25684 drop.item == "node"
25685 && drop.kind == "source_handle"
25686 && drop.reason == "per_kind_quota"
25687 }),
25688 "expected source_handle budget drops, got {:?}",
25689 budget.dropped_by_budget
25690 );
25691 assert!(report.page.as_ref().unwrap().truncated);
25692 assert!(
25693 report
25694 .page
25695 .as_ref()
25696 .unwrap()
25697 .diagnostics
25698 .iter()
25699 .any(|diagnostic| diagnostic.contains("budget ranking signals")),
25700 "{:?}",
25701 report.page
25702 );
25703 }
25704
25705 #[test]
25706 fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
25707 let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
25708 let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
25709 for idx in 0..8 {
25710 let id = format!("far-{idx:02}");
25711 nodes.push(SubstrateGraphNode::new(
25712 id.clone(),
25713 "note",
25714 format!("aaa deeper row {idx}"),
25715 ));
25716 depth_by_id.insert(id, 6);
25717 }
25718
25719 let origin_ids = vec!["target".to_string()];
25720 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
25721 &origin_ids,
25722 &BTreeMap::new(),
25723 nodes,
25724 Vec::new(),
25725 Some(3),
25726 Some(&depth_by_id),
25727 None,
25728 );
25729
25730 assert!(
25731 budgeted.nodes.iter().any(|node| node.id == "near"),
25732 "expected the shallow evidence row to outrank deeper rows, got {:?}",
25733 budgeted
25734 .nodes
25735 .iter()
25736 .map(|node| (&node.id, &node.label))
25737 .collect::<Vec<_>>()
25738 );
25739 assert!(
25740 budgeted.report.dropped_by_budget.iter().any(|drop| {
25741 drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
25742 }),
25743 "expected node quota drops, got {:?}",
25744 budgeted.report.dropped_by_budget
25745 );
25746 assert!(
25747 budgeted
25748 .report
25749 .diagnostics
25750 .iter()
25751 .any(|diagnostic| diagnostic.contains("depth")),
25752 "{:?}",
25753 budgeted.report.diagnostics
25754 );
25755 }
25756
25757 #[test]
25758 fn evidence_pagination_returns_next_cursor_when_truncated() {
25759 let mut nodes = vec![SubstrateGraphNode::new(
25760 "target".to_string(),
25761 "backlog_item",
25762 "target item".to_string(),
25763 )];
25764 let mut depth_by_id = BTreeMap::new();
25765 depth_by_id.insert("target".to_string(), 0);
25766 for idx in 0..20 {
25767 let id = format!("ev-{idx}");
25768 nodes.push(
25769 SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
25770 .with_property("detail", "x".repeat(400)),
25771 );
25772 depth_by_id.insert(id, 1);
25773 }
25774 let origin_ids = vec!["target".to_string()];
25775 let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
25776 &origin_ids,
25777 &BTreeMap::new(),
25778 nodes.clone(),
25779 Vec::new(),
25780 Some(3),
25781 Some(&depth_by_id),
25782 None,
25783 );
25784 assert!(
25785 first_page.truncated,
25786 "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
25787 first_page.nodes.len(),
25788 first_page.report.candidate_nodes
25789 );
25790 assert!(
25791 first_page.next_cursor.is_some(),
25792 "expected next_cursor when truncated"
25793 );
25794 let cursor = first_page.next_cursor.unwrap();
25795 assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
25796 let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
25797 let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
25798 &origin_ids,
25799 &BTreeMap::new(),
25800 nodes.clone(),
25801 Vec::new(),
25802 Some(3),
25803 Some(&depth_by_id),
25804 Some(&cursor),
25805 );
25806 let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
25807 let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
25808 assert!(
25809 overlap.is_empty(),
25810 "pages should not overlap, but found shared ids: {overlap:?}"
25811 );
25812 assert!(
25813 second_page
25814 .report
25815 .diagnostics
25816 .iter()
25817 .any(|d| d.contains("cursor skipped")),
25818 "expected cursor skip diagnostic, got {:?}",
25819 second_page.report.diagnostics
25820 );
25821 }
25822
25823 #[test]
25824 fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
25825 let mut nodes = vec![SubstrateGraphNode::new(
25826 "target".to_string(),
25827 "backlog_item",
25828 "target item".to_string(),
25829 )];
25830 let mut depth_by_id = BTreeMap::new();
25831 depth_by_id.insert("target".to_string(), 0);
25832 for idx in 0..3 {
25833 let id = format!("ev-{idx}");
25834 nodes.push(SubstrateGraphNode::new(
25835 id.clone(),
25836 "source_handle",
25837 format!("evidence row {idx}"),
25838 ));
25839 depth_by_id.insert(id, 1);
25840 }
25841 let origin_ids = vec!["target".to_string()];
25842 let result = graph_db_apply_output_budget_with_depths_and_cursor(
25843 &origin_ids,
25844 &BTreeMap::new(),
25845 nodes,
25846 Vec::new(),
25847 None,
25848 Some(&depth_by_id),
25849 None,
25850 );
25851 assert!(
25852 !result.truncated,
25853 "expected no truncation with small candidate set and default budget"
25854 );
25855 assert!(
25856 result.next_cursor.is_none(),
25857 "expected no next_cursor when not truncated"
25858 );
25859 }
25860
25861 #[test]
25862 fn evidence_pagination_invalid_cursor_returns_first_page() {
25863 let mut nodes = vec![SubstrateGraphNode::new(
25864 "target".to_string(),
25865 "backlog_item",
25866 "target item".to_string(),
25867 )];
25868 let mut depth_by_id = BTreeMap::new();
25869 depth_by_id.insert("target".to_string(), 0);
25870 for idx in 0..5 {
25871 let id = format!("ev-{idx}");
25872 nodes.push(SubstrateGraphNode::new(
25873 id.clone(),
25874 "source_handle",
25875 format!("evidence row {idx}"),
25876 ));
25877 depth_by_id.insert(id, 1);
25878 }
25879 let origin_ids = vec!["target".to_string()];
25880 let result = graph_db_apply_output_budget_with_depths_and_cursor(
25881 &origin_ids,
25882 &BTreeMap::new(),
25883 nodes.clone(),
25884 Vec::new(),
25885 None,
25886 Some(&depth_by_id),
25887 Some("nonexistent-id"),
25888 );
25889 assert!(
25890 result
25891 .report
25892 .diagnostics
25893 .iter()
25894 .any(|d| d.contains("cursor skipped 0")),
25895 "invalid cursor should skip 0 candidates, got {:?}",
25896 result.report.diagnostics
25897 );
25898 }
25899
25900 #[test]
25901 fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
25902 let dir = setup_traversal_project();
25903 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25904 let graph_db = dir.path().join(".tsift/graph.db");
25905 let _lock = hold_rollback_journal_lock(&graph_db);
25906
25907 let report =
25908 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25909 .unwrap();
25910
25911 assert_eq!(report.status, "current");
25912 assert_eq!(
25913 report.recovery,
25914 Some(index::ReadOnlyRecovery::SnapshotFallback)
25915 );
25916 assert!(
25917 report
25918 .warnings
25919 .iter()
25920 .any(|warning| warning.contains("rollback-journal lock")),
25921 "expected rollback-journal recovery warning, got {:?}",
25922 report.warnings
25923 );
25924 }
25925
25926 #[test]
25927 fn graph_db_status_copies_wal_sidecars_when_locked() {
25928 let dir = setup_traversal_project();
25929 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25930 let graph_db = dir.path().join(".tsift/graph.db");
25931 let _lock = hold_wal_database_lock(&graph_db);
25932
25933 let report =
25934 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25935 .unwrap();
25936
25937 assert_eq!(report.status, "current");
25938 assert_eq!(
25939 report.recovery,
25940 Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
25941 );
25942 assert!(
25943 report
25944 .warnings
25945 .iter()
25946 .any(|warning| warning.contains("WAL-aware snapshot fallback")),
25947 "expected WAL recovery warning, got {:?}",
25948 report.warnings
25949 );
25950 }
25951
25952 #[test]
25953 fn graph_db_doctor_reports_snapshot_fallback_when_rollback_journal_is_locked() {
25954 let dir = setup_traversal_project();
25955 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25956 let graph_db = dir.path().join(".tsift/graph.db");
25957 let _lock = hold_rollback_journal_lock(&graph_db);
25958
25959 let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25960 append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25961 report.finalize();
25962
25963 assert_eq!(report.status, "ok");
25964 assert!(!report.fail_closed);
25965 let recovery_check = report
25966 .checks
25967 .iter()
25968 .find(|check| check.name == "sqlite_graph_db_read_recovery")
25969 .expect("doctor should include read recovery diagnostic");
25970 assert_eq!(recovery_check.status, "recovered");
25971 assert!(
25972 recovery_check
25973 .diagnostics
25974 .iter()
25975 .any(|diagnostic| diagnostic.contains("rollback-journal lock")),
25976 "expected rollback-journal recovery diagnostic, got {:?}",
25977 recovery_check.diagnostics
25978 );
25979 }
25980
25981 #[test]
25982 fn graph_db_doctor_reports_wal_snapshot_fallback_when_locked() {
25983 let dir = setup_traversal_project();
25984 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25985 let graph_db = dir.path().join(".tsift/graph.db");
25986 let _lock = hold_wal_database_lock(&graph_db);
25987
25988 let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25989 append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25990 report.finalize();
25991
25992 assert_eq!(report.status, "ok");
25993 assert!(!report.fail_closed);
25994 let recovery_check = report
25995 .checks
25996 .iter()
25997 .find(|check| check.name == "sqlite_graph_db_read_recovery")
25998 .expect("doctor should include WAL read recovery diagnostic");
25999 assert_eq!(recovery_check.status, "recovered");
26000 assert!(
26001 recovery_check
26002 .diagnostics
26003 .iter()
26004 .any(|diagnostic| diagnostic.contains("WAL-aware snapshot fallback")),
26005 "expected WAL recovery diagnostic, got {:?}",
26006 recovery_check.diagnostics
26007 );
26008 }
26009
26010 #[test]
26011 fn graph_db_snapshot_export_import_round_trip_preserves_projection_metadata() {
26012 let dir = setup_traversal_project();
26013 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26014 let artifact = dir.path().join("graph.db.gz");
26015
26016 let exported =
26017 commands::infra::graph_db_snapshot_export_report(dir.path(), None, &artifact, false)
26018 .unwrap();
26019 let exported_projection_version = exported.freshness.projection_version.clone();
26020 let exported_content_hash = exported.freshness.content_hash.clone();
26021 let exported_source_watermark = exported.freshness.source_watermark.clone();
26022 let exported_nodes = exported.counts.nodes;
26023 let exported_edges = exported.counts.edges;
26024 assert_eq!(exported.operation, "snapshot-export");
26025 assert!(exported.status.starts_with("exported"));
26026 assert!(artifact.exists());
26027 assert!(exported.artifact_bytes > 0);
26028 assert_eq!(exported.compression, "gzip");
26029
26030 fs::remove_file(dir.path().join(".tsift/graph.db")).unwrap();
26031
26032 let imported =
26033 commands::infra::graph_db_snapshot_import_report(dir.path(), None, &artifact, false)
26034 .unwrap();
26035 assert_eq!(imported.operation, "snapshot-import");
26036 assert!(imported.status.starts_with("imported"));
26037 assert_eq!(
26038 imported.freshness.projection_version,
26039 exported_projection_version
26040 );
26041 assert_eq!(imported.freshness.content_hash, exported_content_hash);
26042 assert_eq!(
26043 imported.freshness.source_watermark,
26044 exported_source_watermark
26045 );
26046 assert_eq!(imported.counts.nodes, exported_nodes);
26047 assert_eq!(imported.counts.edges, exported_edges);
26048 assert!(dir.path().join(".tsift/graph.db").exists());
26049 }
26050
26051 #[test]
26052 fn graph_db_snapshot_export_fails_closed_when_wal_lock_requires_recovery() {
26053 let dir = setup_traversal_project();
26054 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26055 let graph_db = dir.path().join(".tsift/graph.db");
26056 let _lock = hold_wal_database_lock(&graph_db);
26057
26058 let err = match commands::infra::graph_db_snapshot_export_report(
26059 dir.path(),
26060 None,
26061 &dir.path().join("graph.db.gz"),
26062 false,
26063 ) {
26064 Ok(report) => panic!("expected snapshot export to fail, got {}", report.status),
26065 Err(err) => err,
26066 };
26067
26068 let message = err.to_string();
26074 assert!(
26075 message.contains("recovered path")
26076 && message.contains("database is locked")
26077 && message.contains("wait for it to finish before retrying the export"),
26078 "expected unified live-lock recovery diagnostic, got {err:#}"
26079 );
26080 }
26081
26082 #[test]
26083 fn graph_db_snapshot_clean_export_maps_database_locked_to_live_lock_diagnostic() {
26084 let dir = setup_traversal_project();
26085 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26086 let graph_db = dir.path().join(".tsift/graph.db");
26087
26088 let blocker = Connection::open(&graph_db).unwrap();
26092 blocker
26093 .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26094 .unwrap();
26095 assert!(!substrate::rollback_journal_path(&graph_db).exists());
26096
26097 let clean_path = dir.path().join("graph-clean-export.db");
26098 let err = match commands::infra::graph_db_snapshot_clean_export_copy(&graph_db, &clean_path)
26099 {
26100 Ok(bytes) => panic!("expected export to fail under live lock, got {bytes} bytes"),
26101 Err(err) => err,
26102 };
26103
26104 let message = err.to_string();
26105 assert!(
26106 message.contains("concurrent graph-db refresh or snapshot-import is in progress"),
26107 "expected actionable live-lock diagnostic, got {err:#}"
26108 );
26109 assert!(
26110 message.contains("wait for it to finish before retrying"),
26111 "expected retry guidance, got {err:#}"
26112 );
26113 assert!(
26115 !message.contains("creating clean graph-db export copy"),
26116 "live-lock case must not surface the generic VACUUM context, got {err:#}"
26117 );
26118
26119 drop(blocker);
26120 }
26121
26122 #[test]
26123 fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
26124 let dir = setup_traversal_project();
26125 let session = dir.path().join("tasks/software/tsift.md");
26126 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
26127 let graph_db = dir.path().join(".tsift/graph.db");
26128 let _lock = hold_rollback_journal_lock(&graph_db);
26129
26130 let result = cmd_graph_db(
26131 &session,
26132 None,
26133 GraphDbBackend::Sqlite,
26134 None,
26135 GraphDbQuery::Evidence {
26136 target: "kgnv".to_string(),
26137 depth: 3,
26138 limit: 8,
26139 cursor: None,
26140 },
26141 OutputFormat {
26142 json_output: false,
26143 compact: true,
26144 pretty: false,
26145 terse: false,
26146 ultra_terse: false,
26147 schema: false,
26148 envelope: false,
26149 },
26150 );
26151
26152 assert!(result.is_ok());
26153 }
26154
26155 fn current_graph_db_freshness() -> GraphDbFreshnessReport {
26156 GraphDbFreshnessReport {
26157 status: "current".to_string(),
26158 fail_closed: false,
26159 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
26160 content_hash: Some("fixture".to_string()),
26161 source_watermark: None,
26162 diagnostics: Vec::new(),
26163 }
26164 }
26165
26166 #[test]
26167 fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
26168 let dir = setup_traversal_project();
26169 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26170 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
26171 let stale = GraphDbFreshnessReport {
26172 status: "stale".to_string(),
26173 fail_closed: true,
26174 projection_version: Some("old-v0".to_string()),
26175 content_hash: None,
26176 source_watermark: None,
26177 diagnostics: vec!["projection content hash is missing".to_string()],
26178 };
26179
26180 let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
26181 root: dir.path(),
26182 scope: None,
26183 backend: "sqlite",
26184 target: "kgnv",
26185 preferred_path: None,
26186 depth: 3,
26187 limit: 8,
26188 cursor: None,
26189 store: &store,
26190 freshness: stale,
26191 warnings: Vec::new(),
26192 }) {
26193 Ok(_) => panic!("stale graph freshness should fail closed"),
26194 Err(err) => err,
26195 };
26196 let message = err.to_string();
26197 assert!(message.contains("failed closed"), "{message}");
26198 assert!(message.contains("graph-db --path"), "{message}");
26199 assert!(message.contains("refresh --json"), "{message}");
26200 }
26201
26202 fn paged_graph_ids(
26203 store: &impl GraphStore,
26204 cursor: Option<&str>,
26205 ) -> (Vec<String>, GraphDbPageReport) {
26206 let report = graph_db_report_from_store(
26207 Path::new("."),
26208 None,
26209 "fixture",
26210 GraphDbQuery::Kind {
26211 kind: "backlog".to_string(),
26212 cursor: cursor.map(str::to_string),
26213 limit: Some(2),
26214 property_filters: vec!["phase=open".to_string()],
26215 },
26216 store,
26217 current_graph_db_freshness(),
26218 Vec::new(),
26219 )
26220 .unwrap();
26221 (
26222 report.nodes.iter().map(|node| node.id.clone()).collect(),
26223 report.page.unwrap(),
26224 )
26225 }
26226
26227 #[test]
26228 fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
26229 let nodes = (0..5)
26230 .map(|idx| {
26231 let phase = if idx == 1 { "closed" } else { "open" };
26232 SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
26233 .with_property("phase", phase)
26234 })
26235 .collect::<Vec<_>>();
26236 let projection = GraphProjection {
26237 nodes,
26238 edges: Vec::new(),
26239 };
26240 let sqlite = SqliteGraphStore::in_memory().unwrap();
26241 projection.upsert_into(&sqlite).unwrap();
26242 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
26243 projection.upsert_into(&convex).unwrap();
26244
26245 let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
26246 let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
26247 assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
26248 assert_eq!(sqlite_first_ids, convex_first_ids);
26249 assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
26250 assert!(sqlite_first_page.truncated);
26251 assert_eq!(
26252 sqlite_first_page.returned_nodes,
26253 convex_first_page.returned_nodes
26254 );
26255 assert_eq!(
26256 sqlite_first_page.property_filters,
26257 convex_first_page.property_filters
26258 );
26259 assert!(
26260 sqlite_first_page
26261 .diagnostics
26262 .iter()
26263 .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
26264 "expected SQLite kind query plan diagnostics, got {:?}",
26265 sqlite_first_page.diagnostics
26266 );
26267
26268 let cursor = sqlite_first_page.next_cursor.as_deref();
26269 let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
26270 let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
26271 assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
26272 assert_eq!(sqlite_next_ids, convex_next_ids);
26273 assert_eq!(sqlite_next_page.next_cursor, None);
26274 assert!(!sqlite_next_page.truncated);
26275 assert_eq!(
26276 sqlite_next_page.returned_nodes,
26277 convex_next_page.returned_nodes
26278 );
26279 assert_eq!(
26280 sqlite_next_page.property_filters,
26281 convex_next_page.property_filters
26282 );
26283 }
26284
26285 #[test]
26286 fn traversal_shortest_path_crosses_artifacts_and_symbols() {
26287 let dir = setup_traversal_project();
26288 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26289 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
26290 let main = resolve_traversal_node(&graph, "main").unwrap();
26291
26292 let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
26293 assert_eq!(path.first(), Some(&backlog.handle));
26294 assert_eq!(path.last(), Some(&main.handle));
26295 assert!(
26296 path.len() >= 3,
26297 "expected backlog -> symbol -> main, got {path:?}"
26298 );
26299 }
26300
26301 #[test]
26302 fn traversal_report_recommends_next_bugfix_nodes() {
26303 let dir = setup_traversal_project();
26304 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26305 let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
26306
26307 assert_eq!(report.mode, "neighborhood");
26308 assert!(
26309 report
26310 .recommendations
26311 .iter()
26312 .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
26313 "expected helper recommendation, got {:?}",
26314 report.recommendations
26315 );
26316 assert!(
26317 !report.exploration.source_windows.is_empty(),
26318 "expected exploration source windows"
26319 );
26320 assert!(
26321 report
26322 .exploration
26323 .no_reread_guidance
26324 .contains("avoid whole-file reads")
26325 );
26326 }
26327
26328 #[test]
26329 fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
26330 let dir = setup_traversal_project();
26331 std::thread::sleep(std::time::Duration::from_millis(50));
26332 std::fs::write(
26333 dir.path().join("main.rs"),
26334 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26335 )
26336 .unwrap();
26337
26338 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26339
26340 assert!(
26341 graph
26342 .warnings
26343 .iter()
26344 .any(|warning| warning.contains("index refreshed")
26345 && warning.contains("graph traversal packet")),
26346 "expected refresh diagnostic, got {:?}",
26347 graph.warnings
26348 );
26349 assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
26350
26351 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26352 let summary = db.compute_changes(dir.path()).unwrap();
26353 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26354 }
26355
26356 #[test]
26357 fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
26358 let dir = setup_traversal_project();
26359 let db_path = dir.path().join(".tsift/index.db");
26360 let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
26361 std::thread::sleep(std::time::Duration::from_millis(50));
26362 std::fs::write(
26363 dir.path().join("main.rs"),
26364 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26365 )
26366 .unwrap();
26367
26368 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26369 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
26370
26371 assert!(
26372 graph
26373 .warnings
26374 .iter()
26375 .any(|warning| warning.contains("falling back to raw source file nodes")),
26376 "expected raw-source fallback diagnostic, got {:?}",
26377 graph.warnings
26378 );
26379 assert!(
26380 file.detail
26381 .as_deref()
26382 .is_some_and(|detail| detail.contains("raw source fallback")),
26383 "expected raw-source detail, got {:?}",
26384 file.detail
26385 );
26386 assert!(
26387 file.expand.contains("source-read"),
26388 "expected source-read fallback command, got {}",
26389 file.expand
26390 );
26391 assert!(
26392 resolve_traversal_node(&graph, "helper").is_none(),
26393 "stale symbol evidence should be skipped when refresh is blocked"
26394 );
26395 }
26396
26397 #[test]
26398 fn traversal_cmd_supports_json_and_html_outputs() {
26399 let dir = setup_traversal_project();
26400 cmd_traverse(
26401 Some("#kgnv"),
26402 Some("main"),
26403 dir.path(),
26404 None,
26405 1,
26406 50,
26407 TraverseFormat::Json,
26408 false,
26409 false,
26410 false,
26411 None,
26412 )
26413 .unwrap();
26414 cmd_traverse(
26415 None,
26416 None,
26417 dir.path(),
26418 None,
26419 1,
26420 50,
26421 TraverseFormat::Html,
26422 false,
26423 false,
26424 false,
26425 None,
26426 )
26427 .unwrap();
26428 }
26429
26430 #[test]
26431 fn traversal_html_renders_inline_graph_visualization() {
26432 let dir = setup_traversal_project();
26433 seed_traversal_semantic_summaries(dir.path());
26434 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26435 let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
26436 let html = traversal_report_html(&report).unwrap();
26437
26438 assert!(html.contains("id=\"graph-canvas\""));
26439 assert!(html.contains("semantic_concept"));
26440 assert!(html.contains("graph navigation"));
26441 assert!(html.contains("JSON.parse"));
26442 }
26443
26444 #[test]
26445 fn compact_helpers_trim_scores_and_snippets() {
26446 assert_eq!(format_score(0.12345, true), "0.12");
26447 assert_eq!(format_score(0.12345, false), "0.1235");
26448 let snippet = compact_snippet(" first line with useful context\nsecond");
26449 assert_eq!(snippet.as_deref(), Some("first line with useful context"));
26450 }
26451
26452 #[test]
26453 fn compact_members_caps_list() {
26454 let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
26455 .iter()
26456 .map(|n| graph::CommunityMember::new(*n))
26457 .collect();
26458 assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
26459 }
26460
26461 #[test]
26462 fn abbreviate_kind_maps_common_kinds() {
26463 assert_eq!(abbreviate_kind("function"), "fn");
26464 assert_eq!(abbreviate_kind("method"), "meth");
26465 assert_eq!(abbreviate_kind("class"), "cls");
26466 assert_eq!(abbreviate_kind("interface"), "iface");
26467 assert_eq!(abbreviate_kind("type_alias"), "type");
26468 assert_eq!(abbreviate_kind("data_class"), "data_cls");
26469 assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
26470 assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
26471 assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
26472 assert_eq!(abbreviate_kind("object"), "obj");
26473 assert_eq!(abbreviate_kind("heading"), "h");
26474 assert_eq!(abbreviate_kind("code_block"), "code");
26475 assert_eq!(abbreviate_kind("struct"), "struct");
26477 assert_eq!(abbreviate_kind("trait"), "trait");
26478 assert_eq!(abbreviate_kind("enum"), "enum");
26479 assert_eq!(abbreviate_kind("const"), "const");
26480 assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
26481 }
26482
26483 #[test]
26484 fn abbreviate_match_type_maps_search_types() {
26485 assert_eq!(abbreviate_match_type("exact_name"), "exact");
26486 assert_eq!(abbreviate_match_type("partial_tags"), "partial");
26487 assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
26488 assert_eq!(abbreviate_match_type("other_type"), "other_type");
26489 }
26490
26491 #[test]
26492 fn explain_compact_groups_edges_by_file() {
26493 let edges = vec![
26494 index::StoredEdge {
26495 caller_file: "src/main.rs".to_string(),
26496 caller_name: "main".to_string(),
26497 caller_line: 1,
26498 callee_name: "helper".to_string(),
26499 call_site_line: 2,
26500 tagpath_handle: None,
26501 },
26502 index::StoredEdge {
26503 caller_file: "src/main.rs".to_string(),
26504 caller_name: "main".to_string(),
26505 caller_line: 1,
26506 callee_name: "render".to_string(),
26507 call_site_line: 3,
26508 tagpath_handle: None,
26509 },
26510 ];
26511 let lines = format_edge_groups(&edges, false);
26512 assert_eq!(lines, vec![" src/main.rs (2): helper, render"]);
26513 }
26514
26515 #[test]
26516 fn search_hit_groups_preserve_file_counts_and_samples() {
26517 let dir = tempfile::tempdir().unwrap();
26518 let root = dir.path();
26519 let main_rs = root.join("src/main.rs");
26520 fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
26521 fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
26522 let freshness = exact_search_file_timestamp(&main_rs);
26523 let hits = vec![
26524 sift::SearchHit {
26525 artifact_id: "a".to_string(),
26526 artifact_kind: sift::ContextArtifactKind::File,
26527 path: main_rs.display().to_string(),
26528 rank: 1,
26529 score: 10.0,
26530 confidence: sift::ScoreConfidence::High,
26531 location: Some("line 3".to_string()),
26532 snippet: "claudescore-3 anchor".to_string(),
26533 provenance: sift::ArtifactProvenance {
26534 adapter: sift::AcquisitionAdapterKind::FileSystem,
26535 source: "ripgrep -F".to_string(),
26536 synthetic: false,
26537 },
26538 freshness: freshness.clone(),
26539 budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
26540 },
26541 sift::SearchHit {
26542 artifact_id: "b".to_string(),
26543 artifact_kind: sift::ContextArtifactKind::File,
26544 path: main_rs.display().to_string(),
26545 rank: 2,
26546 score: 9.0,
26547 confidence: sift::ScoreConfidence::High,
26548 location: Some("line 7".to_string()),
26549 snippet: "claudescore-3 follow-up".to_string(),
26550 provenance: sift::ArtifactProvenance {
26551 adapter: sift::AcquisitionAdapterKind::FileSystem,
26552 source: "ripgrep -F".to_string(),
26553 synthetic: false,
26554 },
26555 freshness: freshness.clone(),
26556 budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
26557 },
26558 sift::SearchHit {
26559 artifact_id: "c".to_string(),
26560 artifact_kind: sift::ContextArtifactKind::File,
26561 path: main_rs.display().to_string(),
26562 rank: 3,
26563 score: 8.0,
26564 confidence: sift::ScoreConfidence::High,
26565 location: Some("line 9".to_string()),
26566 snippet: "claudescore-3 tail".to_string(),
26567 provenance: sift::ArtifactProvenance {
26568 adapter: sift::AcquisitionAdapterKind::FileSystem,
26569 source: "ripgrep -F".to_string(),
26570 synthetic: false,
26571 },
26572 freshness,
26573 budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
26574 },
26575 ];
26576
26577 let groups = group_search_hits(&hits, root, false);
26578 assert_eq!(groups.len(), 1);
26579 assert_eq!(groups[0].path, "src/main.rs");
26580 assert_eq!(groups[0].hits, 3);
26581 assert_eq!(
26582 groups[0].samples,
26583 vec![
26584 "line 3: claudescore-3 anchor".to_string(),
26585 "line 7: claudescore-3 follow-up".to_string()
26586 ]
26587 );
26588 assert!(should_collapse_search_hits(&hits, root, false));
26589 }
26590
26591 #[test]
26592 fn dense_edge_groups_trigger_collapse() {
26593 let edges = vec![
26594 index::StoredEdge {
26595 caller_file: "src/main.rs".to_string(),
26596 caller_name: "main".to_string(),
26597 caller_line: 1,
26598 callee_name: "helper".to_string(),
26599 call_site_line: 2,
26600 tagpath_handle: None,
26601 },
26602 index::StoredEdge {
26603 caller_file: "src/main.rs".to_string(),
26604 caller_name: "beta".to_string(),
26605 caller_line: 5,
26606 callee_name: "helper".to_string(),
26607 call_site_line: 6,
26608 tagpath_handle: None,
26609 },
26610 index::StoredEdge {
26611 caller_file: "src/main.rs".to_string(),
26612 caller_name: "gamma".to_string(),
26613 caller_line: 9,
26614 callee_name: "helper".to_string(),
26615 call_site_line: 10,
26616 tagpath_handle: None,
26617 },
26618 ];
26619 assert!(should_collapse_edge_groups(&edges));
26620 }
26621
26622 fn setup_workspace() -> tempfile::TempDir {
26625 let dir = tempfile::tempdir().unwrap();
26626 let root = dir.path();
26627 std::fs::write(
26628 root.join(".gitmodules"),
26629 r#"[submodule "src/alpha"]
26630 path = src/alpha
26631 url = https://example.com/alpha
26632[submodule "src/beta"]
26633 path = src/beta
26634 url = https://example.com/beta
26635"#,
26636 )
26637 .unwrap();
26638 let alpha = root.join("src/alpha");
26639 let beta = root.join("src/beta");
26640 std::fs::create_dir_all(&alpha).unwrap();
26641 std::fs::create_dir_all(&beta).unwrap();
26642 std::fs::write(
26643 alpha.join("lib.rs"),
26644 "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
26645 )
26646 .unwrap();
26647 std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
26648 dir
26649 }
26650
26651 fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
26652 let dir = tempfile::tempdir().unwrap();
26653 let root = dir.path();
26654 std::fs::write(
26655 root.join(".gitmodules"),
26656 r#"[submodule "pkg/app/foo"]
26657 path = pkg/app/foo
26658 url = https://example.com/pkg-app-foo
26659[submodule "vendor/foo"]
26660 path = vendor/foo
26661 url = https://example.com/vendor-foo
26662"#,
26663 )
26664 .unwrap();
26665 let pkg_foo = root.join("pkg/app/foo");
26666 let vendor_foo = root.join("vendor/foo");
26667 std::fs::create_dir_all(&pkg_foo).unwrap();
26668 std::fs::create_dir_all(&vendor_foo).unwrap();
26669 std::fs::write(
26670 pkg_foo.join("lib.rs"),
26671 "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
26672 )
26673 .unwrap();
26674 std::fs::write(
26675 vendor_foo.join("lib.rs"),
26676 "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
26677 )
26678 .unwrap();
26679 dir
26680 }
26681
26682 #[test]
26683 fn workspace_index_creates_per_submodule_dbs() {
26684 let dir = setup_workspace();
26685 cmd_index(
26686 dir.path(),
26687 false,
26688 false,
26689 false,
26690 false,
26691 false,
26692 true,
26693 None,
26694 false,
26695 false,
26696 false,
26697 false,
26698 false,
26699 false,
26700 )
26701 .unwrap();
26702 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26703 assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
26704 }
26705
26706 #[test]
26707 fn workspace_index_single_submodule() {
26708 let dir = setup_workspace();
26709 cmd_index(
26710 dir.path(),
26711 false,
26712 false,
26713 false,
26714 false,
26715 false,
26716 false,
26717 Some("alpha"),
26718 false,
26719 false,
26720 false,
26721 false,
26722 false,
26723 false,
26724 )
26725 .unwrap();
26726 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26727 assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
26728 }
26729
26730 #[test]
26731 fn workspace_index_single_submodule_errors_on_unknown_scope() {
26732 let dir = setup_workspace();
26733
26734 let err = cmd_index(
26735 dir.path(),
26736 false,
26737 false,
26738 false,
26739 false,
26740 false,
26741 false,
26742 Some("missing"),
26743 false,
26744 false,
26745 false,
26746 false,
26747 false,
26748 false,
26749 )
26750 .unwrap_err();
26751
26752 let msg = err.to_string();
26753 assert!(msg.contains("unknown scope `missing`"));
26754 assert!(msg.contains("Available scopes: alpha, beta"));
26755 assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
26756 }
26757
26758 #[test]
26759 fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
26760 let dir = setup_workspace_with_duplicate_leaf_names();
26761 cmd_index(
26762 dir.path(),
26763 false,
26764 false,
26765 false,
26766 false,
26767 false,
26768 true,
26769 None,
26770 false,
26771 false,
26772 false,
26773 false,
26774 false,
26775 false,
26776 )
26777 .unwrap();
26778
26779 assert!(
26780 dir.path()
26781 .join(".tsift/indexes/pkg/app/foo/index.db")
26782 .exists()
26783 );
26784 assert!(
26785 dir.path()
26786 .join(".tsift/indexes/vendor/foo/index.db")
26787 .exists()
26788 );
26789 }
26790
26791 #[test]
26792 fn federated_search_across_submodules() {
26793 let dir = setup_workspace();
26794 cmd_index(
26795 dir.path(),
26796 false,
26797 false,
26798 false,
26799 false,
26800 false,
26801 true,
26802 None,
26803 false,
26804 false,
26805 false,
26806 false,
26807 false,
26808 false,
26809 )
26810 .unwrap();
26811 let (hits, _diag) = federated_symbol_search(
26812 dir.path(),
26813 "alpha_helper",
26814 10,
26815 &TagpathSearchOpts {
26816 no_tagpath: true,
26817 strict: false,
26818 },
26819 )
26820 .unwrap();
26821 assert!(
26822 !hits.is_empty(),
26823 "should find alpha_helper via federated search"
26824 );
26825 }
26826
26827 #[test]
26828 fn federated_search_respects_isolation() {
26829 let dir = setup_workspace();
26830 let tsift_dir = dir.path().join(".tsift");
26831 std::fs::create_dir_all(&tsift_dir).unwrap();
26832 std::fs::write(
26833 tsift_dir.join("config.toml"),
26834 r#"
26835[overrides.alpha]
26836tier = "isolated"
26837"#,
26838 )
26839 .unwrap();
26840 cmd_index(
26841 dir.path(),
26842 false,
26843 false,
26844 false,
26845 false,
26846 false,
26847 true,
26848 None,
26849 false,
26850 false,
26851 false,
26852 false,
26853 false,
26854 false,
26855 )
26856 .unwrap();
26857 let (hits, _diag) = federated_symbol_search(
26858 dir.path(),
26859 "alpha_helper",
26860 10,
26861 &TagpathSearchOpts {
26862 no_tagpath: true,
26863 strict: false,
26864 },
26865 )
26866 .unwrap();
26867 assert!(
26868 hits.is_empty(),
26869 "isolated submodule should not appear in federated search"
26870 );
26871 }
26872
26873 #[test]
26874 fn federated_lexical_search_respects_isolation() {
26875 let dir = setup_workspace();
26876 let tsift_dir = dir.path().join(".tsift");
26877 std::fs::create_dir_all(&tsift_dir).unwrap();
26878 std::fs::write(
26879 tsift_dir.join("config.toml"),
26880 r#"
26881[overrides.alpha]
26882tier = "isolated"
26883"#,
26884 )
26885 .unwrap();
26886 cmd_index(
26887 dir.path(),
26888 false,
26889 false,
26890 false,
26891 false,
26892 false,
26893 true,
26894 None,
26895 false,
26896 false,
26897 false,
26898 false,
26899 false,
26900 false,
26901 )
26902 .unwrap();
26903
26904 let response = federated_sift_search(
26905 dir.path(),
26906 &dir.path().join(".tsift/search-cache"),
26907 "fn",
26908 10,
26909 0,
26910 "lexical",
26911 None,
26912 )
26913 .unwrap();
26914
26915 assert!(
26916 !response.hits.is_empty(),
26917 "shared scopes should still contribute lexical hits"
26918 );
26919 assert!(
26920 response
26921 .hits
26922 .iter()
26923 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26924 "isolated scope should not leak lexical hits: {:?}",
26925 response.hits
26926 );
26927 }
26928
26929 #[test]
26930 fn federated_lexical_search_respects_private_tier() {
26931 let dir = setup_workspace();
26932 let tsift_dir = dir.path().join(".tsift");
26933 std::fs::create_dir_all(&tsift_dir).unwrap();
26934 std::fs::write(
26935 tsift_dir.join("config.toml"),
26936 r#"
26937[overrides.alpha]
26938tier = "private"
26939"#,
26940 )
26941 .unwrap();
26942 cmd_index(
26943 dir.path(),
26944 false,
26945 false,
26946 false,
26947 false,
26948 false,
26949 true,
26950 None,
26951 false,
26952 false,
26953 false,
26954 false,
26955 false,
26956 false,
26957 )
26958 .unwrap();
26959
26960 let response = federated_sift_search(
26961 dir.path(),
26962 &dir.path().join(".tsift/search-cache"),
26963 "fn",
26964 10,
26965 0,
26966 "lexical",
26967 None,
26968 )
26969 .unwrap();
26970
26971 assert!(
26972 !response.hits.is_empty(),
26973 "shared scopes should still contribute lexical hits"
26974 );
26975 assert!(
26976 response
26977 .hits
26978 .iter()
26979 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26980 "private scope should not leak lexical hits: {:?}",
26981 response.hits
26982 );
26983 }
26984
26985 #[test]
26986 fn scoped_search_finds_submodule_symbols() {
26987 let dir = setup_workspace();
26988 cmd_index(
26989 dir.path(),
26990 false,
26991 false,
26992 false,
26993 false,
26994 false,
26995 true,
26996 None,
26997 false,
26998 false,
26999 false,
27000 false,
27001 false,
27002 false,
27003 )
27004 .unwrap();
27005 let cfg = config::Config::load(dir.path()).unwrap();
27006 let db_path = cfg.db_path_for(dir.path(), "alpha");
27007 let db = index::IndexDb::open(&db_path).unwrap();
27008 let hits = db.symbol_search("alpha_main", 10).unwrap();
27009 assert!(!hits.is_empty());
27010 assert_eq!(hits[0].name, "alpha_main");
27011 }
27012
27013 #[test]
27014 fn scoped_search_cmd_errors_on_unknown_scope() {
27015 let dir = setup_workspace();
27016
27017 let err = cmd_search(
27018 "alpha_main".to_string(),
27019 Some(dir.path().to_path_buf()),
27020 5,
27021 Some("lexical".to_string()),
27022 Some("missing".to_string()),
27023 false,
27024 false,
27025 false,
27026 0,
27027 false,
27028 false,
27029 false,
27030 false,
27031 false,
27032 false,
27033 false,
27034 )
27035 .unwrap_err();
27036
27037 let msg = err.to_string();
27038 assert!(msg.contains("unknown scope `missing`"));
27039 assert!(msg.contains("Available scopes: alpha, beta"));
27040 }
27041
27042 #[test]
27043 fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
27044 let dir = setup_workspace_with_duplicate_leaf_names();
27045 cmd_index(
27046 dir.path(),
27047 false,
27048 false,
27049 false,
27050 false,
27051 false,
27052 true,
27053 None,
27054 false,
27055 false,
27056 false,
27057 false,
27058 false,
27059 false,
27060 )
27061 .unwrap();
27062
27063 let err = cmd_search(
27064 "vendor_only".to_string(),
27065 Some(dir.path().to_path_buf()),
27066 5,
27067 Some("lexical".to_string()),
27068 Some("foo".to_string()),
27069 false,
27070 false,
27071 false,
27072 0,
27073 false,
27074 false,
27075 false,
27076 false,
27077 false,
27078 false,
27079 false,
27080 )
27081 .unwrap_err();
27082
27083 let msg = err.to_string();
27084 assert!(msg.contains("ambiguous scope `foo`"));
27085 assert!(msg.contains("pkg/app/foo"));
27086 assert!(msg.contains("vendor/foo"));
27087 }
27088
27089 #[test]
27090 fn scoped_graph_query() {
27091 let dir = setup_workspace();
27092 cmd_index(
27093 dir.path(),
27094 false,
27095 false,
27096 false,
27097 false,
27098 false,
27099 true,
27100 None,
27101 false,
27102 false,
27103 false,
27104 false,
27105 false,
27106 false,
27107 )
27108 .unwrap();
27109 let cfg = config::Config::load(dir.path()).unwrap();
27110 let db_path = cfg.db_path_for(dir.path(), "alpha");
27111 let db = index::IndexDb::open(&db_path).unwrap();
27112 let callees = db.callees_of("alpha_main").unwrap();
27113 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
27114 assert!(names.contains(&"alpha_helper"));
27115 }
27116
27117 fn assert_workspace_query_requires_scope(err: anyhow::Error) {
27118 let msg = err.to_string();
27119 assert!(msg.contains("require `--scope <scope>`"), "{msg}");
27120 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27121 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27122 assert!(
27123 !msg.contains("no index found at"),
27124 "workspace query should fail with scope guidance, got: {msg}"
27125 );
27126 }
27127
27128 fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
27129 let msg = err.to_string();
27130 assert!(
27131 msg.contains("requires `--scope <scope>` or `--federated`"),
27132 "{msg}"
27133 );
27134 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27135 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27136 assert!(
27137 !msg.contains("autoindexing index"),
27138 "workspace search should fail before creating a shared root index: {msg}"
27139 );
27140 }
27141
27142 #[test]
27143 fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
27144 let dir = setup_workspace();
27145 cmd_index(
27146 dir.path(),
27147 false,
27148 false,
27149 false,
27150 false,
27151 false,
27152 true,
27153 None,
27154 false,
27155 false,
27156 false,
27157 false,
27158 false,
27159 false,
27160 )
27161 .unwrap();
27162
27163 let err = cmd_graph(
27164 "alpha_main",
27165 dir.path(),
27166 false,
27167 false,
27168 None,
27169 20,
27170 false,
27171 false,
27172 false,
27173 false,
27174 false,
27175 false,
27176 false,
27177 TagpathSearchOpts::default(),
27178 )
27179 .unwrap_err();
27180
27181 assert_workspace_query_requires_scope(err);
27182 }
27183
27184 #[test]
27185 fn graph_cmd_infers_scope_from_nested_workspace_path() {
27186 let dir = setup_workspace();
27187 cmd_index(
27188 dir.path(),
27189 false,
27190 false,
27191 false,
27192 false,
27193 false,
27194 true,
27195 None,
27196 false,
27197 false,
27198 false,
27199 false,
27200 false,
27201 false,
27202 )
27203 .unwrap();
27204 let nested = dir.path().join("src/alpha/nested");
27205 std::fs::create_dir_all(&nested).unwrap();
27206
27207 let result = cmd_graph(
27208 "alpha_main",
27209 &nested,
27210 false,
27211 false,
27212 None,
27213 20,
27214 false,
27215 false,
27216 false,
27217 false,
27218 false,
27219 false,
27220 false,
27221 TagpathSearchOpts::default(),
27222 );
27223
27224 assert!(result.is_ok());
27225 }
27226
27227 #[test]
27228 fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
27229 let dir = setup_workspace();
27230 cmd_index(
27231 dir.path(),
27232 false,
27233 false,
27234 false,
27235 false,
27236 false,
27237 true,
27238 None,
27239 false,
27240 false,
27241 false,
27242 false,
27243 false,
27244 false,
27245 )
27246 .unwrap();
27247
27248 let err = cmd_communities(
27249 dir.path(),
27250 None,
27251 1,
27252 10,
27253 false,
27254 false,
27255 false,
27256 false,
27257 false,
27258 false,
27259 TagpathSearchOpts::default(),
27260 )
27261 .unwrap_err();
27262
27263 assert_workspace_query_requires_scope(err);
27264 }
27265
27266 #[test]
27267 fn communities_cmd_infers_scope_from_nested_workspace_path() {
27268 let dir = setup_workspace();
27269 cmd_index(
27270 dir.path(),
27271 false,
27272 false,
27273 false,
27274 false,
27275 false,
27276 true,
27277 None,
27278 false,
27279 false,
27280 false,
27281 false,
27282 false,
27283 false,
27284 )
27285 .unwrap();
27286 let nested = dir.path().join("src/alpha/nested");
27287 std::fs::create_dir_all(&nested).unwrap();
27288
27289 let result = cmd_communities(
27290 &nested,
27291 None,
27292 1,
27293 10,
27294 false,
27295 false,
27296 false,
27297 false,
27298 false,
27299 false,
27300 TagpathSearchOpts::default(),
27301 );
27302
27303 assert!(result.is_ok());
27304 }
27305
27306 #[test]
27307 fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
27308 let dir = setup_workspace();
27309 cmd_index(
27310 dir.path(),
27311 false,
27312 false,
27313 false,
27314 false,
27315 false,
27316 true,
27317 None,
27318 false,
27319 false,
27320 false,
27321 false,
27322 false,
27323 false,
27324 )
27325 .unwrap();
27326
27327 let err = cmd_path(
27328 "alpha_main",
27329 "alpha_helper",
27330 dir.path(),
27331 None,
27332 false,
27333 false,
27334 false,
27335 false,
27336 false,
27337 TagpathSearchOpts::default(),
27338 )
27339 .unwrap_err();
27340
27341 assert_workspace_query_requires_scope(err);
27342 }
27343
27344 #[test]
27345 fn path_cmd_infers_scope_from_nested_workspace_path() {
27346 let dir = setup_workspace();
27347 cmd_index(
27348 dir.path(),
27349 false,
27350 false,
27351 false,
27352 false,
27353 false,
27354 true,
27355 None,
27356 false,
27357 false,
27358 false,
27359 false,
27360 false,
27361 false,
27362 )
27363 .unwrap();
27364 let nested = dir.path().join("src/alpha/nested");
27365 std::fs::create_dir_all(&nested).unwrap();
27366
27367 let result = cmd_path(
27368 "alpha_main",
27369 "alpha_helper",
27370 &nested,
27371 None,
27372 false,
27373 false,
27374 false,
27375 false,
27376 false,
27377 TagpathSearchOpts::default(),
27378 );
27379
27380 assert!(result.is_ok());
27381 }
27382
27383 #[test]
27384 fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27385 let dir = setup_graph_index();
27386 let db_path = dir.path().join(".tsift/index.db");
27387 let _lock = hold_rollback_journal_lock(&db_path);
27388
27389 let result = cmd_path(
27390 "main",
27391 "helper",
27392 dir.path(),
27393 None,
27394 false,
27395 false,
27396 false,
27397 false,
27398 false,
27399 TagpathSearchOpts::default(),
27400 );
27401
27402 assert!(result.is_ok());
27403 }
27404
27405 #[test]
27406 fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
27407 let dir = setup_workspace();
27408 cmd_index(
27409 dir.path(),
27410 false,
27411 false,
27412 false,
27413 false,
27414 false,
27415 true,
27416 None,
27417 false,
27418 false,
27419 false,
27420 false,
27421 false,
27422 false,
27423 )
27424 .unwrap();
27425
27426 let err = cmd_explain(
27427 "alpha_main",
27428 dir.path(),
27429 None,
27430 15,
27431 false,
27432 false,
27433 false,
27434 false,
27435 false,
27436 false,
27437 false,
27438 false,
27439 )
27440 .unwrap_err();
27441
27442 assert_workspace_query_requires_scope(err);
27443 }
27444
27445 #[test]
27446 fn explain_cmd_infers_scope_from_nested_workspace_path() {
27447 let dir = setup_workspace();
27448 cmd_index(
27449 dir.path(),
27450 false,
27451 false,
27452 false,
27453 false,
27454 false,
27455 true,
27456 None,
27457 false,
27458 false,
27459 false,
27460 false,
27461 false,
27462 false,
27463 )
27464 .unwrap();
27465 let nested = dir.path().join("src/alpha/nested");
27466 std::fs::create_dir_all(&nested).unwrap();
27467
27468 let result = cmd_explain(
27469 "alpha_main",
27470 &nested,
27471 None,
27472 15,
27473 false,
27474 false,
27475 false,
27476 false,
27477 false,
27478 false,
27479 false,
27480 false,
27481 );
27482
27483 assert!(result.is_ok());
27484 }
27485
27486 #[test]
27487 fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27488 let dir = setup_graph_index();
27489 let db_path = dir.path().join(".tsift/index.db");
27490 let _lock = hold_rollback_journal_lock(&db_path);
27491
27492 let result = cmd_explain(
27493 "main",
27494 dir.path(),
27495 None,
27496 15,
27497 false,
27498 false,
27499 false,
27500 false,
27501 false,
27502 false,
27503 false,
27504 false,
27505 );
27506
27507 assert!(result.is_ok());
27508 }
27509
27510 #[test]
27513 fn community_detection_groups_related() {
27514 let dir = setup_graph_index();
27515 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27516 let edges = db.all_edges().unwrap();
27517 let result = graph::detect_communities(&edges);
27518 assert!(result.node_count > 0);
27519 assert!(!result.communities.is_empty());
27520 }
27521
27522 #[test]
27523 fn community_cmd_autoindexes_missing_index_by_default() {
27524 let dir = tempfile::tempdir().unwrap();
27525 let result = cmd_communities(
27526 dir.path(),
27527 None,
27528 2,
27529 10,
27530 false,
27531 false,
27532 false,
27533 false,
27534 false,
27535 false,
27536 TagpathSearchOpts::default(),
27537 );
27538
27539 assert!(result.is_ok());
27540 assert!(dir.path().join(".tsift/index.db").exists());
27541 }
27542
27543 #[test]
27546 fn path_finds_connected_symbols() {
27547 let dir = setup_graph_index();
27548 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27549 let edges = db.all_edges().unwrap();
27550 let result = graph::shortest_path(&edges, "main", "helper");
27551 assert!(result.is_some());
27552 let path = result.unwrap();
27553 assert_eq!(path.hops, 1);
27554 }
27555
27556 #[test]
27557 fn path_returns_none_for_unknown() {
27558 let dir = setup_graph_index();
27559 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27560 let edges = db.all_edges().unwrap();
27561 assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
27562 }
27563
27564 #[test]
27565 fn path_cmd_autoindexes_missing_index_by_default() {
27566 let dir = tempfile::tempdir().unwrap();
27567 let result = cmd_path(
27568 "a",
27569 "b",
27570 dir.path(),
27571 None,
27572 false,
27573 false,
27574 false,
27575 false,
27576 false,
27577 TagpathSearchOpts::default(),
27578 );
27579
27580 assert!(result.is_ok());
27581 assert!(dir.path().join(".tsift/index.db").exists());
27582 }
27583
27584 #[test]
27587 fn explain_shows_symbol_info() {
27588 let dir = setup_graph_index();
27589 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27590 let symbols = db.symbol_info("main").unwrap();
27591 assert!(!symbols.is_empty());
27592 assert_eq!(symbols[0].name, "main");
27593 assert_eq!(symbols[0].kind, "function");
27594 }
27595
27596 #[test]
27597 fn explain_cmd_autoindexes_missing_index_by_default() {
27598 let dir = tempfile::tempdir().unwrap();
27599 let result = cmd_explain(
27600 "main",
27601 dir.path(),
27602 None,
27603 15,
27604 false,
27605 false,
27606 false,
27607 false,
27608 false,
27609 false,
27610 false,
27611 false,
27612 );
27613
27614 assert!(result.is_ok());
27615 assert!(dir.path().join(".tsift/index.db").exists());
27616 }
27617
27618 fn hold_write_lock(db_path: &std::path::Path) -> Connection {
27619 let conn = Connection::open(db_path).unwrap();
27620 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
27621 conn
27622 }
27623
27624 fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
27625 use fs4::fs_std::FileExt;
27626 use std::io::Write;
27627
27628 let mut file = std::fs::OpenOptions::new()
27629 .read(true)
27630 .write(true)
27631 .create(true)
27632 .truncate(false)
27633 .open(lock_path)
27634 .unwrap();
27635 assert!(file.try_lock_exclusive().unwrap());
27636 writeln!(file, "{}", std::process::id()).unwrap();
27637 file
27638 }
27639
27640 fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
27641 let conn = Connection::open(db_path).unwrap();
27642 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
27643 .unwrap();
27644 std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
27645 conn
27646 }
27647
27648 fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
27649 let conn = Connection::open(db_path).unwrap();
27650 conn.execute_batch(
27651 "PRAGMA journal_mode=WAL;
27652 PRAGMA wal_autocheckpoint=0;
27653 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
27654 INSERT INTO wal_lock_probe DEFAULT VALUES;
27655 PRAGMA locking_mode=EXCLUSIVE;
27656 BEGIN EXCLUSIVE;",
27657 )
27658 .unwrap();
27659 assert!(substrate::wal_sidecar_path(db_path).exists());
27660 conn
27661 }
27662
27663 #[test]
27664 fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
27665 let dir = setup_graph_index();
27666 let db_path = dir.path().join(".tsift/index.db");
27667 let _lock = hold_wal_database_lock(&db_path);
27668
27669 let err = cmd_index(
27670 dir.path(),
27671 false,
27672 false,
27673 false,
27674 false,
27675 false,
27676 false,
27677 None,
27678 false,
27679 false,
27680 false,
27681 false,
27682 false,
27683 false,
27684 )
27685 .unwrap_err();
27686
27687 let msg = err.to_string();
27688 assert!(msg.contains("indexing"));
27689 assert!(msg.contains("lock diagnostics:"));
27690 assert!(msg.contains("lock: absent"));
27691 assert!(msg.contains("wal: present") || msg.contains("shm: present"));
27692 assert!(msg.contains("wedged writer holding live WAL sidecars"));
27693 assert!(msg.contains("snapshot fallback"));
27694 }
27695
27696 #[test]
27697 fn search_cmd_succeeds_while_writer_lock_is_held() {
27698 let dir = setup_graph_index();
27699 let db_path = dir.path().join(".tsift/index.db");
27700 let _lock = hold_write_lock(&db_path);
27701
27702 let result = cmd_search(
27703 "main".to_string(),
27704 Some(dir.path().to_path_buf()),
27705 5,
27706 Some("lexical".to_string()),
27707 None,
27708 false,
27709 false,
27710 false,
27711 0,
27712 true,
27713 false,
27714 false,
27715 false,
27716 false,
27717 false,
27718 false,
27719 );
27720
27721 assert!(result.is_ok());
27722 }
27723
27724 #[test]
27725 fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
27726 let dir = setup_graph_index();
27727 let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
27728
27729 let result = cmd_search(
27730 "main".to_string(),
27731 Some(dir.path().to_path_buf()),
27732 5,
27733 Some("lexical".to_string()),
27734 None,
27735 false,
27736 false,
27737 false,
27738 0,
27739 true,
27740 false,
27741 false,
27742 false,
27743 false,
27744 false,
27745 false,
27746 );
27747
27748 assert!(result.is_ok());
27749 }
27750
27751 #[test]
27752 fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
27753 let dir = setup_graph_index();
27754 let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
27755
27756 let result = cmd_search(
27757 "main".to_string(),
27758 Some(dir.path().to_path_buf()),
27759 5,
27760 Some("lexical".to_string()),
27761 None,
27762 false,
27763 false,
27764 false,
27765 0,
27766 true,
27767 false,
27768 false,
27769 false,
27770 false,
27771 false,
27772 false,
27773 );
27774
27775 assert!(result.is_ok());
27776 }
27777
27778 #[test]
27779 fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
27780 let dir = setup_graph_index();
27781 std::thread::sleep(std::time::Duration::from_millis(50));
27782 std::fs::write(
27783 dir.path().join("main.rs"),
27784 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27785 )
27786 .unwrap();
27787
27788 let err = cmd_search(
27789 "helper".to_string(),
27790 Some(dir.path().to_path_buf()),
27791 5,
27792 Some("lexical".to_string()),
27793 None,
27794 false,
27795 false,
27796 false,
27797 0,
27798 false,
27799 false,
27800 false,
27801 false,
27802 false,
27803 false,
27804 false,
27805 )
27806 .unwrap_err();
27807
27808 assert!(err.to_string().contains("search aborted"));
27809 assert!(err.to_string().contains("index is stale"));
27810 assert!(err.to_string().contains("--no-autoindex"));
27811 }
27812
27813 #[test]
27814 fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
27815 let dir = setup_graph_index();
27816 std::thread::sleep(std::time::Duration::from_millis(50));
27817 std::fs::write(
27818 dir.path().join("main.rs"),
27819 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27820 )
27821 .unwrap();
27822 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27823
27824 let err = cmd_search(
27825 "helper".to_string(),
27826 Some(dir.path().to_path_buf()),
27827 5,
27828 Some("lexical".to_string()),
27829 None,
27830 false,
27831 false,
27832 false,
27833 0,
27834 false,
27835 false,
27836 false,
27837 false,
27838 false,
27839 false,
27840 false,
27841 )
27842 .unwrap_err();
27843
27844 assert!(err.to_string().contains("search aborted"));
27845 assert!(err.to_string().contains("index is stale"));
27846 assert!(!err.to_string().contains("database is locked"));
27847 }
27848
27849 #[test]
27850 fn search_cmd_autoindexes_stale_index_by_default() {
27851 let dir = setup_graph_index();
27852 std::thread::sleep(std::time::Duration::from_millis(50));
27853 std::fs::write(
27854 dir.path().join("main.rs"),
27855 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27856 )
27857 .unwrap();
27858
27859 let result = cmd_search(
27860 "helper".to_string(),
27861 Some(dir.path().to_path_buf()),
27862 5,
27863 Some("lexical".to_string()),
27864 None,
27865 false,
27866 false,
27867 true,
27868 0,
27869 false,
27870 false,
27871 false,
27872 false,
27873 false,
27874 false,
27875 false,
27876 );
27877
27878 assert!(result.is_ok());
27879
27880 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27881 let summary = db.compute_changes(dir.path()).unwrap();
27882 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27883 }
27884
27885 #[test]
27886 fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
27887 let dir = setup_graph_index();
27888 std::thread::sleep(std::time::Duration::from_millis(50));
27889 std::fs::write(
27890 dir.path().join("main.rs"),
27891 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27892 )
27893 .unwrap();
27894 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27895
27896 let result = cmd_search(
27897 "helper".to_string(),
27898 Some(dir.path().to_path_buf()),
27899 5,
27900 Some("lexical".to_string()),
27901 None,
27902 false,
27903 false,
27904 true,
27905 0,
27906 false,
27907 false,
27908 false,
27909 false,
27910 false,
27911 false,
27912 false,
27913 );
27914
27915 assert!(result.is_ok());
27916
27917 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27918 let summary = db.compute_changes(dir.path()).unwrap();
27919 assert_eq!(summary.modified, 1);
27920 }
27921
27922 #[test]
27923 fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
27924 let dir = setup_graph_index();
27925 std::thread::sleep(std::time::Duration::from_millis(50));
27926 std::fs::write(
27927 dir.path().join("main.rs"),
27928 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27929 )
27930 .unwrap();
27931 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27932
27933 let err = cmd_search(
27934 "helper".to_string(),
27935 Some(dir.path().to_path_buf()),
27936 5,
27937 Some("lexical".to_string()),
27938 None,
27939 false,
27940 false,
27941 true,
27942 0,
27943 false,
27944 false,
27945 false,
27946 false,
27947 false,
27948 false,
27949 false,
27950 )
27951 .unwrap_err();
27952
27953 let msg = err.to_string();
27954 assert!(msg.contains("autoindexing index"));
27955 assert!(msg.contains("lock diagnostics:"));
27956 assert!(msg.contains("journal: present"));
27957 assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
27958 }
27959
27960 #[test]
27961 fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
27962 let dir = setup_graph_index();
27963 let nested = dir.path().join("src/nested");
27964 std::fs::create_dir_all(&nested).unwrap();
27965
27966 let result = cmd_search(
27967 "helper".to_string(),
27968 Some(nested.clone()),
27969 5,
27970 Some("lexical".to_string()),
27971 None,
27972 false,
27973 false,
27974 true,
27975 0,
27976 false,
27977 false,
27978 false,
27979 false,
27980 false,
27981 false,
27982 false,
27983 );
27984
27985 assert!(result.is_ok());
27986 assert!(!nested.join(".tsift/index.db").exists());
27987 }
27988
27989 #[test]
27990 fn exact_search_returns_literal_matches() {
27991 let dir = tempfile::tempdir().unwrap();
27992 std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
27993
27994 let response = run_exact_search_with_timeout(
27995 std::slice::from_ref(&dir.path().to_path_buf()),
27996 "claudescore-3",
27997 5,
27998 0,
27999 )
28000 .unwrap();
28001
28002 assert_eq!(response.strategy, "exact");
28003 assert_eq!(response.hits.len(), 1);
28004 assert!(response.hits[0].path.ends_with("notes.txt"));
28005 assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
28006 assert!(response.hits[0].snippet.contains("claudescore-3"));
28007 }
28008
28009 #[test]
28010 fn exact_search_skips_stale_index_precheck() {
28011 let dir = setup_graph_index();
28012 std::thread::sleep(std::time::Duration::from_millis(50));
28013 std::fs::write(
28014 dir.path().join("main.rs"),
28015 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
28016 )
28017 .unwrap();
28018
28019 let result = cmd_search(
28020 "println!(\"updated\")".to_string(),
28021 Some(dir.path().to_path_buf()),
28022 5,
28023 Some("exact".to_string()),
28024 None,
28025 false,
28026 false,
28027 false,
28028 0,
28029 false,
28030 false,
28031 false,
28032 false,
28033 false,
28034 false,
28035 false,
28036 );
28037
28038 assert!(result.is_ok());
28039 }
28040
28041 #[test]
28042 fn workspace_exact_search_does_not_require_shared_root_index() {
28043 let dir = setup_workspace();
28044 cmd_index(
28045 dir.path(),
28046 false,
28047 false,
28048 false,
28049 false,
28050 false,
28051 true,
28052 None,
28053 false,
28054 false,
28055 false,
28056 false,
28057 false,
28058 false,
28059 )
28060 .unwrap();
28061
28062 let result = cmd_search(
28063 "alpha_helper".to_string(),
28064 Some(dir.path().to_path_buf()),
28065 5,
28066 Some("exact".to_string()),
28067 None,
28068 false,
28069 false,
28070 false,
28071 0,
28072 false,
28073 false,
28074 false,
28075 false,
28076 false,
28077 false,
28078 false,
28079 );
28080
28081 assert!(result.is_ok());
28082 assert!(!dir.path().join(".tsift/index.db").exists());
28083 }
28084
28085 #[test]
28086 fn identifier_like_query_prefers_exact_search() {
28087 assert!(query_prefers_exact_search("claudescore-3"));
28088 assert!(query_prefers_exact_search("alpha_helper"));
28089 assert!(query_prefers_exact_search("src/main.rs"));
28090 assert!(query_prefers_exact_search("crate::module"));
28091 assert!(!query_prefers_exact_search("authenticate"));
28092 assert!(!query_prefers_exact_search("fn main"));
28093 assert!(!query_prefers_exact_search("."));
28094 }
28095
28096 #[test]
28097 fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
28098 assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
28099 assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
28100 assert_eq!(
28101 resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
28102 "hybrid"
28103 );
28104 }
28105
28106 #[test]
28107 fn workspace_identifier_like_search_auto_uses_exact_backend() {
28108 let dir = setup_workspace();
28109 cmd_index(
28110 dir.path(),
28111 false,
28112 false,
28113 false,
28114 false,
28115 false,
28116 true,
28117 None,
28118 false,
28119 false,
28120 false,
28121 false,
28122 false,
28123 false,
28124 )
28125 .unwrap();
28126
28127 let result = cmd_search(
28128 "alpha_helper".to_string(),
28129 Some(dir.path().to_path_buf()),
28130 5,
28131 None,
28132 None,
28133 false,
28134 false,
28135 false,
28136 0,
28137 false,
28138 false,
28139 false,
28140 false,
28141 false,
28142 false,
28143 false,
28144 );
28145
28146 assert!(result.is_ok());
28147 assert!(!dir.path().join(".tsift/index.db").exists());
28148 }
28149
28150 #[test]
28151 fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
28152 let dir = setup_graph_index();
28153 let nested = dir.path().join("src/nested");
28154 std::fs::create_dir_all(&nested).unwrap();
28155 std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
28156
28157 let result = cmd_index(
28158 &nested, false, false, false, false, false, false, None, false, false, false, false,
28159 false, false,
28160 );
28161
28162 assert!(result.is_ok());
28163 assert!(dir.path().join(".tsift/index.db").exists());
28164 assert!(!nested.join(".tsift/index.db").exists());
28165 }
28166
28167 #[test]
28168 fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
28169 let dir = setup_workspace();
28170 let nested = dir.path().join("docs/nested");
28171 std::fs::create_dir_all(&nested).unwrap();
28172
28173 let result = cmd_index(
28174 &nested, false, false, false, false, false, true, None, false, false, false, false,
28175 false, false,
28176 );
28177
28178 let cfg = config::Config::load(dir.path()).unwrap();
28179
28180 assert!(result.is_ok());
28181 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28182 assert!(cfg.db_path_for(dir.path(), "beta").exists());
28183 }
28184
28185 #[test]
28186 fn status_cmd_autoindexes_missing_workspace_scopes() {
28187 let dir = setup_workspace();
28188 let cfg = config::Config::load(dir.path()).unwrap();
28189 let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
28190 let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
28191 let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
28192 alpha_db.apply_changes(&alpha.source_root).unwrap();
28193
28194 let beta_db_path = cfg.db_path_for(dir.path(), "beta");
28195 assert!(!beta_db_path.exists());
28196
28197 cmd_status(
28198 dir.path(),
28199 StatusCommandOptions {
28200 fix: false,
28201 no_fix: false,
28202 json_output: true,
28203 compact: false,
28204 pretty: false,
28205 terse: false,
28206 schema: false,
28207 },
28208 )
28209 .unwrap();
28210
28211 assert!(beta_db_path.exists());
28212 let report = status::check_status(dir.path()).unwrap();
28213 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28214 }
28215
28216 #[test]
28217 fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
28218 let dir = setup_workspace();
28219 let cfg = config::Config::load(dir.path()).unwrap();
28220
28221 cmd_status(
28222 dir.path(),
28223 StatusCommandOptions {
28224 fix: false,
28225 no_fix: false,
28226 json_output: true,
28227 compact: false,
28228 pretty: false,
28229 terse: false,
28230 schema: false,
28231 },
28232 )
28233 .unwrap();
28234
28235 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28236 assert!(cfg.db_path_for(dir.path(), "beta").exists());
28237 let report = status::check_status(dir.path()).unwrap();
28238 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28239 }
28240
28241 #[test]
28242 fn status_fix_targets_only_stale_workspace_scopes() {
28243 let dir = setup_workspace();
28244 let cfg = config::Config::load(dir.path()).unwrap();
28245 for scope_id in ["alpha", "beta"] {
28246 let scope = config::Config::resolve_submodule(dir.path(), scope_id).unwrap();
28247 let db = index::IndexDb::open(&cfg.db_path_for(dir.path(), scope_id)).unwrap();
28248 db.apply_changes(&scope.source_root).unwrap();
28249 }
28250
28251 std::thread::sleep(std::time::Duration::from_millis(50));
28252 std::fs::write(
28253 dir.path().join("src/alpha/lib.rs"),
28254 "fn alpha_helper() { println!(\"updated\"); }\n",
28255 )
28256 .unwrap();
28257
28258 let report = status::check_status(dir.path()).unwrap();
28259 let scope_ids = status_workspace_scope_ids_needing_fix(&report);
28260 assert_eq!(scope_ids, std::collections::HashSet::from(["alpha"]));
28261
28262 cmd_status(
28263 dir.path(),
28264 StatusCommandOptions {
28265 fix: false,
28266 no_fix: false,
28267 json_output: true,
28268 compact: false,
28269 pretty: false,
28270 terse: false,
28271 schema: false,
28272 },
28273 )
28274 .unwrap();
28275
28276 let report = status::check_status(dir.path()).unwrap();
28277 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28278 }
28279
28280 #[test]
28281 fn status_cmd_fix_refreshes_stale_index() {
28282 let dir = setup_graph_index();
28283 std::thread::sleep(std::time::Duration::from_millis(50));
28284 std::fs::write(
28285 dir.path().join("main.rs"),
28286 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28287 )
28288 .unwrap();
28289
28290 let report = status::check_status(dir.path()).unwrap();
28291 assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
28292
28293 cmd_status(
28294 dir.path(),
28295 StatusCommandOptions {
28296 fix: false,
28297 no_fix: false,
28298 json_output: true,
28299 compact: false,
28300 pretty: false,
28301 terse: false,
28302 schema: false,
28303 },
28304 )
28305 .unwrap();
28306
28307 let report = status::check_status(dir.path()).unwrap();
28308 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28309 }
28310
28311 #[test]
28312 fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
28313 let dir = setup_graph_index();
28314 let db_path = dir.path().join(".tsift/index.db");
28315 let _lock = hold_wal_database_lock(&db_path);
28316
28317 cmd_status(
28318 dir.path(),
28319 StatusCommandOptions {
28320 fix: false,
28321 no_fix: false,
28322 json_output: true,
28323 compact: false,
28324 pretty: false,
28325 terse: false,
28326 schema: false,
28327 },
28328 )
28329 .unwrap();
28330
28331 let report = status::check_status(dir.path()).unwrap();
28332 assert!(matches!(
28333 report.index,
28334 status::IndexStatus::Fresh {
28335 recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
28336 ..
28337 }
28338 ));
28339 let locks = status::check_locks(dir.path(), None, None).unwrap();
28340 assert!(matches!(
28341 locks.writer_lock,
28342 status::WriterLockStatus::Absent { .. }
28343 ));
28344 assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
28345 assert!(
28346 locks
28347 .recommended_action
28348 .contains("wedged writer holding live WAL sidecars")
28349 );
28350 }
28351
28352 #[test]
28353 fn locks_report_uses_ancestor_project_root_for_nested_paths() {
28354 let dir = setup_graph_index();
28355 let nested = dir.path().join("src/nested");
28356 std::fs::create_dir_all(&nested).unwrap();
28357
28358 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28359 let report = status::check_locks(&root, Some(&nested), None).unwrap();
28360
28361 assert_eq!(report.source_root, dir.path());
28362 assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
28363 }
28364
28365 #[test]
28366 fn workspace_locks_report_infers_scope_from_nested_path() {
28367 let dir = setup_workspace();
28368 cmd_index(
28369 dir.path(),
28370 false,
28371 false,
28372 false,
28373 false,
28374 false,
28375 true,
28376 None,
28377 false,
28378 false,
28379 false,
28380 false,
28381 false,
28382 false,
28383 )
28384 .unwrap();
28385 let nested = dir.path().join("src/alpha/nested");
28386 std::fs::create_dir_all(&nested).unwrap();
28387
28388 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28389 let report = status::check_locks(&root, Some(&nested), None).unwrap();
28390 let cfg = config::Config::load(dir.path()).unwrap();
28391
28392 assert_eq!(report.label, "submodule `alpha` index");
28393 assert_eq!(report.source_root, dir.path().join("src/alpha"));
28394 assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
28395 assert_eq!(
28396 report.reindex_command,
28397 format!("tsift index --submodule alpha {}", dir.path().display())
28398 );
28399 }
28400
28401 #[test]
28402 fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
28403 let dir = setup_workspace();
28404 cmd_index(
28405 dir.path(),
28406 false,
28407 false,
28408 false,
28409 false,
28410 false,
28411 true,
28412 None,
28413 false,
28414 false,
28415 false,
28416 false,
28417 false,
28418 false,
28419 )
28420 .unwrap();
28421
28422 let alpha = dir.path().join("src/alpha/lib.rs");
28423 std::thread::sleep(std::time::Duration::from_millis(50));
28424 std::fs::write(
28425 &alpha,
28426 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28427 )
28428 .unwrap();
28429
28430 let result = cmd_search(
28431 "alpha_helper".to_string(),
28432 Some(dir.path().to_path_buf()),
28433 5,
28434 Some("lexical".to_string()),
28435 Some("alpha".to_string()),
28436 false,
28437 false,
28438 true,
28439 0,
28440 false,
28441 false,
28442 false,
28443 false,
28444 false,
28445 false,
28446 false,
28447 );
28448
28449 assert!(result.is_ok());
28450
28451 let cfg = config::Config::load(dir.path()).unwrap();
28452 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28453 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28454 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28455 }
28456
28457 #[test]
28458 fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28459 let dir = setup_workspace();
28460 cmd_index(
28461 dir.path(),
28462 false,
28463 false,
28464 false,
28465 false,
28466 false,
28467 true,
28468 None,
28469 false,
28470 false,
28471 false,
28472 false,
28473 false,
28474 false,
28475 )
28476 .unwrap();
28477
28478 let alpha = dir.path().join("src/alpha/lib.rs");
28479 std::thread::sleep(std::time::Duration::from_millis(50));
28480 std::fs::write(
28481 &alpha,
28482 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28483 )
28484 .unwrap();
28485
28486 let cfg = config::Config::load(dir.path()).unwrap();
28487 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28488
28489 let err = cmd_search(
28490 "alpha_helper".to_string(),
28491 Some(dir.path().to_path_buf()),
28492 5,
28493 Some("lexical".to_string()),
28494 Some("alpha".to_string()),
28495 false,
28496 false,
28497 false,
28498 0,
28499 false,
28500 false,
28501 false,
28502 false,
28503 false,
28504 false,
28505 false,
28506 )
28507 .unwrap_err();
28508
28509 assert!(err.to_string().contains("search aborted"));
28510 assert!(err.to_string().contains("submodule `alpha` index"));
28511 assert!(!err.to_string().contains("database is locked"));
28512 }
28513
28514 #[test]
28515 fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
28516 let dir = setup_workspace();
28517 cmd_index(
28518 dir.path(),
28519 false,
28520 false,
28521 false,
28522 false,
28523 false,
28524 true,
28525 None,
28526 false,
28527 false,
28528 false,
28529 false,
28530 false,
28531 false,
28532 )
28533 .unwrap();
28534
28535 let alpha = dir.path().join("src/alpha/lib.rs");
28536 std::thread::sleep(std::time::Duration::from_millis(50));
28537 std::fs::write(
28538 &alpha,
28539 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28540 )
28541 .unwrap();
28542
28543 let result = cmd_search(
28544 "alpha_helper".to_string(),
28545 Some(dir.path().to_path_buf()),
28546 5,
28547 Some("lexical".to_string()),
28548 None,
28549 true,
28550 false,
28551 true,
28552 0,
28553 false,
28554 false,
28555 false,
28556 false,
28557 false,
28558 false,
28559 false,
28560 );
28561
28562 assert!(result.is_ok());
28563
28564 let cfg = config::Config::load(dir.path()).unwrap();
28565 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28566 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28567 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28568 }
28569
28570 #[test]
28571 fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28572 let dir = setup_workspace();
28573 cmd_index(
28574 dir.path(),
28575 false,
28576 false,
28577 false,
28578 false,
28579 false,
28580 true,
28581 None,
28582 false,
28583 false,
28584 false,
28585 false,
28586 false,
28587 false,
28588 )
28589 .unwrap();
28590
28591 let alpha = dir.path().join("src/alpha/lib.rs");
28592 std::thread::sleep(std::time::Duration::from_millis(50));
28593 std::fs::write(
28594 &alpha,
28595 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28596 )
28597 .unwrap();
28598
28599 let cfg = config::Config::load(dir.path()).unwrap();
28600 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28601
28602 let err = cmd_search(
28603 "alpha_helper".to_string(),
28604 Some(dir.path().to_path_buf()),
28605 5,
28606 Some("lexical".to_string()),
28607 None,
28608 true,
28609 false,
28610 false,
28611 30,
28612 false,
28613 false,
28614 false,
28615 false,
28616 false,
28617 false,
28618 false,
28619 )
28620 .unwrap_err();
28621
28622 assert!(err.to_string().contains("stale"));
28623 assert!(err.to_string().contains("submodule `alpha` index"));
28624 assert!(!err.to_string().contains("database is locked"));
28625 }
28626
28627 #[test]
28628 fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
28629 let dir = setup_workspace();
28630 cmd_index(
28631 dir.path(),
28632 false,
28633 false,
28634 false,
28635 false,
28636 false,
28637 true,
28638 None,
28639 false,
28640 false,
28641 false,
28642 false,
28643 false,
28644 false,
28645 )
28646 .unwrap();
28647
28648 let err = cmd_search(
28649 "alpha_helper".to_string(),
28650 Some(dir.path().to_path_buf()),
28651 5,
28652 Some("lexical".to_string()),
28653 None,
28654 false,
28655 false,
28656 true,
28657 0,
28658 false,
28659 false,
28660 false,
28661 false,
28662 false,
28663 false,
28664 false,
28665 )
28666 .unwrap_err();
28667
28668 assert_workspace_search_requires_explicit_target(err);
28669 assert!(!dir.path().join(".tsift/index.db").exists());
28670 }
28671
28672 #[test]
28673 fn workspace_search_cmd_infers_scope_from_nested_path() {
28674 let dir = setup_workspace();
28675 cmd_index(
28676 dir.path(),
28677 false,
28678 false,
28679 false,
28680 false,
28681 false,
28682 true,
28683 None,
28684 false,
28685 false,
28686 false,
28687 false,
28688 false,
28689 false,
28690 )
28691 .unwrap();
28692 let nested = dir.path().join("src/alpha/nested");
28693 std::fs::create_dir_all(&nested).unwrap();
28694
28695 let result = cmd_search(
28696 "alpha_helper".to_string(),
28697 Some(nested),
28698 5,
28699 Some("lexical".to_string()),
28700 None,
28701 false,
28702 false,
28703 false,
28704 0,
28705 false,
28706 false,
28707 false,
28708 false,
28709 false,
28710 false,
28711 false,
28712 );
28713
28714 assert!(result.is_ok());
28715 }
28716
28717 #[test]
28718 fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
28719 let dir = setup_workspace_with_duplicate_leaf_names();
28720 cmd_index(
28721 dir.path(),
28722 false,
28723 false,
28724 false,
28725 false,
28726 false,
28727 true,
28728 None,
28729 false,
28730 false,
28731 false,
28732 false,
28733 false,
28734 false,
28735 )
28736 .unwrap();
28737 let nested = dir.path().join("vendor/foo/nested");
28738 std::fs::create_dir_all(&nested).unwrap();
28739
28740 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28741 let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
28742 let cfg = config::Config::load(dir.path()).unwrap();
28743
28744 assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
28745 }
28746
28747 #[test]
28748 fn graph_cmd_succeeds_while_writer_lock_is_held() {
28749 let dir = setup_graph_index();
28750 let db_path = dir.path().join(".tsift/index.db");
28751 let _lock = hold_write_lock(&db_path);
28752
28753 let result = cmd_graph(
28754 "main",
28755 dir.path(),
28756 false,
28757 false,
28758 None,
28759 20,
28760 false,
28761 true,
28762 false,
28763 false,
28764 false,
28765 false,
28766 false,
28767 TagpathSearchOpts::default(),
28768 );
28769
28770 assert!(result.is_ok());
28771 }
28772
28773 #[test]
28774 fn graph_cmd_autoindexes_stale_index_by_default() {
28775 let dir = setup_graph_index();
28776 std::thread::sleep(std::time::Duration::from_millis(50));
28777 std::fs::write(
28778 dir.path().join("main.rs"),
28779 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28780 )
28781 .unwrap();
28782
28783 let result = cmd_graph(
28784 "helper",
28785 dir.path(),
28786 true,
28787 false,
28788 None,
28789 20,
28790 false,
28791 true,
28792 false,
28793 false,
28794 false,
28795 false,
28796 false,
28797 TagpathSearchOpts::default(),
28798 );
28799
28800 assert!(result.is_ok());
28801 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
28802 let summary = db.compute_changes(dir.path()).unwrap();
28803 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28804 }
28805
28806 #[test]
28807 fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28808 let dir = setup_graph_index();
28809 let db_path = dir.path().join(".tsift/index.db");
28810 let _lock = hold_rollback_journal_lock(&db_path);
28811
28812 let result = cmd_graph(
28813 "main",
28814 dir.path(),
28815 false,
28816 false,
28817 None,
28818 20,
28819 false,
28820 true,
28821 false,
28822 false,
28823 false,
28824 false,
28825 false,
28826 TagpathSearchOpts::default(),
28827 );
28828
28829 assert!(result.is_ok());
28830 }
28831
28832 #[test]
28833 fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
28834 let dir = setup_graph_index();
28835 let nested = dir.path().join("src/nested");
28836 std::fs::create_dir_all(&nested).unwrap();
28837
28838 let result = cmd_graph(
28839 "helper",
28840 &nested,
28841 true,
28842 false,
28843 None,
28844 20,
28845 false,
28846 false,
28847 false,
28848 false,
28849 false,
28850 false,
28851 false,
28852 TagpathSearchOpts::default(),
28853 );
28854
28855 assert!(result.is_ok());
28856 }
28857
28858 #[test]
28859 fn communities_cmd_succeeds_while_writer_lock_is_held() {
28860 let dir = setup_graph_index();
28861 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
28862
28863 let result = cmd_communities(
28864 dir.path(),
28865 None,
28866 1,
28867 10,
28868 false,
28869 false,
28870 false,
28871 false,
28872 false,
28873 false,
28874 TagpathSearchOpts::default(),
28875 );
28876
28877 assert!(result.is_ok());
28878 }
28879
28880 #[test]
28881 fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28882 let dir = setup_graph_index();
28883 let db_path = dir.path().join(".tsift/index.db");
28884 let _lock = hold_rollback_journal_lock(&db_path);
28885
28886 let result = cmd_communities(
28887 dir.path(),
28888 None,
28889 1,
28890 10,
28891 false,
28892 false,
28893 false,
28894 false,
28895 false,
28896 false,
28897 TagpathSearchOpts::default(),
28898 );
28899
28900 assert!(result.is_ok());
28901 }
28902
28903 #[test]
28904 fn lint_finds_entities_from_project_root_index_db() {
28905 let dir = tempfile::tempdir().unwrap();
28906 std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
28907 std::fs::write(
28908 dir.path().join("README.md"),
28909 "alpha_helper should be backticked.\n",
28910 )
28911 .unwrap();
28912 cmd_index(
28913 dir.path(),
28914 false,
28915 false,
28916 false,
28917 false,
28918 false,
28919 false,
28920 None,
28921 false,
28922 false,
28923 false,
28924 false,
28925 false,
28926 false,
28927 )
28928 .unwrap();
28929
28930 let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
28931 .unwrap()
28932 .unwrap();
28933 let entities = lint::collect_entities_from_index_path(&root).unwrap();
28934 let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
28935
28936 assert!(
28937 result
28938 .annotations
28939 .iter()
28940 .any(|ann| ann.text == "alpha_helper")
28941 );
28942 }
28943
28944 #[test]
28947 fn search_direct_runs_ok() {
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 = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical", None);
28953 assert!(result.is_ok(), "direct search should succeed");
28954 assert!(
28955 cache_dir.exists(),
28956 "search should create the configured cache dir"
28957 );
28958 }
28959
28960 #[test]
28961 fn search_timeout_zero_disables_timeout() {
28962 let dir = tempfile::tempdir().unwrap();
28963 let search_dir = dir.path().to_path_buf();
28964 let cache_dir = search_dir.join(".tsift/search-cache");
28965 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28966 let result =
28967 run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[], None);
28968 assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
28969 assert!(
28970 cache_dir.exists(),
28971 "timeout=0 should keep using the stable search cache dir"
28972 );
28973 }
28974
28975 #[test]
28976 fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
28977 let dir = tempfile::tempdir().unwrap();
28978 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
28979 cmd_index(
28980 dir.path(),
28981 false,
28982 false,
28983 false,
28984 false,
28985 false,
28986 false,
28987 None,
28988 false,
28989 false,
28990 false,
28991 false,
28992 false,
28993 false,
28994 )
28995 .unwrap();
28996 let db_path = dir.path().join(".tsift/index.db");
28997 std::fs::remove_file(&db_path).unwrap();
28998 let search_target = SearchIndexTarget {
28999 label: "index".to_string(),
29000 db_path,
29001 source_root: dir.path().to_path_buf(),
29002 scope_name: None,
29003 reindex_cmd: format!("tsift index {}", dir.path().display()),
29004 };
29005
29006 let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
29007
29008 assert!(message.contains("timed out after 1s"));
29009 assert!(message.contains("index is missing"));
29010 assert!(message.contains("Run `tsift index"));
29011 assert!(!message.contains("search root looks fresh"));
29012 }
29013
29014 #[test]
29015 fn search_worker_output_path_uses_json_suffix() {
29016 let path = next_search_worker_output_path();
29017 assert!(path.extension().is_some_and(|ext| ext == "json"));
29018 }
29019
29020 #[test]
29021 fn fts_search_flag_value_parses_falsy_escape_hatch() {
29022 for falsy in ["0", "false", "FALSE", " no ", "Off"] {
29024 assert!(
29025 fts_flag_value_disabled(falsy),
29026 "{falsy:?} should force the legacy TokenIndex path"
29027 );
29028 }
29029 for keeps_default in ["", "1", "true", "yes", "on", "maybe"] {
29030 assert!(
29031 !fts_flag_value_disabled(keeps_default),
29032 "{keeps_default:?} should keep the FTS5 default"
29033 );
29034 }
29035 }
29036
29037 #[test]
29038 fn run_sift_search_defaults_to_fts_when_index_db_present() {
29039 let dir = tempfile::tempdir().unwrap();
29042 let root = dir.path();
29043 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29044 index::IndexDb::open(&root.join(".tsift/index.db"))
29045 .unwrap()
29046 .apply_changes(root)
29047 .unwrap();
29048 let cache_dir = root.join(".tsift/search-cache");
29049
29050 if fts_search_forced_off() {
29052 return;
29053 }
29054 let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
29055 assert_eq!(response.strategy, "fts");
29056 assert!(response.hits.iter().any(|h| h.path.ends_with("alpha.rs")));
29057 }
29058
29059 #[test]
29060 fn run_sift_search_falls_back_to_lexical_without_index_db() {
29061 let dir = tempfile::tempdir().unwrap();
29064 let root = dir.path();
29065 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29066 let cache_dir = root.join(".tsift/search-cache");
29067
29068 let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
29069 assert_eq!(response.strategy, "lexical");
29070 }
29071
29072 #[test]
29073 fn run_sift_search_honors_threaded_freshness_verdict() {
29074 let dir = tempfile::tempdir().unwrap();
29078 let root = dir.path();
29079 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29080 index::IndexDb::open(&root.join(".tsift/index.db"))
29081 .unwrap()
29082 .apply_changes(root)
29083 .unwrap();
29084 let cache_dir = root.join(".tsift/search-cache");
29085
29086 if fts_search_forced_off() {
29087 return;
29088 }
29089 let fresh =
29090 run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(true)).unwrap();
29091 assert_eq!(fresh.strategy, "fts");
29092
29093 let stale =
29094 run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(false)).unwrap();
29095 assert_eq!(stale.strategy, "lexical");
29096 }
29097
29098 #[test]
29101 fn index_quiet_suppresses_file_list() {
29102 let dir = setup_graph_index();
29103 let result = cmd_index(
29104 dir.path(),
29105 false,
29106 true,
29107 false,
29108 false,
29109 true,
29110 false,
29111 None,
29112 false,
29113 false,
29114 false,
29115 false,
29116 false,
29117 false,
29118 );
29119 assert!(result.is_ok());
29120 }
29121
29122 #[test]
29123 fn index_exit_code_implies_quiet() {
29124 let dir = setup_graph_index();
29125 let result = cmd_index(
29126 dir.path(),
29127 false,
29128 true,
29129 false,
29130 false,
29131 false,
29132 false,
29133 None,
29134 false,
29135 false,
29136 false,
29137 false,
29138 false,
29139 false,
29140 );
29141 assert!(result.is_ok());
29142 }
29143
29144 #[test]
29145 fn index_quiet_json_omits_changes() {
29146 let dir = setup_graph_index();
29147 let result = cmd_index(
29148 dir.path(),
29149 false,
29150 true,
29151 false,
29152 false,
29153 true,
29154 false,
29155 None,
29156 true,
29157 false,
29158 false,
29159 false,
29160 false,
29161 false,
29162 );
29163 assert!(result.is_ok());
29164 }
29165
29166 #[test]
29167 fn cli_workflow_defaults_to_search_topic() {
29168 let cli = parse_cli(["tsift", "workflow"]);
29169 match cli.command {
29170 Some(Commands::Workflow { topic, json }) => {
29171 assert_eq!(topic, "search");
29172 assert!(!json);
29173 }
29174 _ => panic!("expected Workflow command"),
29175 }
29176 }
29177
29178 #[test]
29179 fn search_workflow_recipe_preserves_handles_across_expansions() {
29180 let recipe = workflow::search_workflow_recipe();
29181 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29182 assert_eq!(
29183 step_names,
29184 vec![
29185 "exact-anchor",
29186 "semantic-search",
29187 "explain-symbol",
29188 "summarize-selection",
29189 "digest-expansion"
29190 ]
29191 );
29192 assert!(
29193 recipe
29194 .handle_contract
29195 .iter()
29196 .any(|item| item.contains("originating command"))
29197 );
29198 assert!(
29199 recipe.steps[1]
29200 .preserves
29201 .iter()
29202 .any(|item| item.contains("sfam-*"))
29203 );
29204 assert!(
29205 recipe.steps[2]
29206 .preserves
29207 .iter()
29208 .any(|item| item.contains("ecall-*"))
29209 );
29210 assert!(
29211 recipe.steps[4]
29212 .preserves
29213 .iter()
29214 .any(|item| item.contains("artifact handles"))
29215 );
29216 }
29217
29218 #[test]
29219 fn kg_workflow_recipe_covers_extract_to_evidence() {
29220 let recipe = workflow::kg_workflow_recipe();
29221 assert_eq!(recipe.topic, "kg");
29222 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29223 assert_eq!(
29224 step_names,
29225 vec!["smoke-check", "extract", "status", "refresh", "evidence"]
29226 );
29227 let evidence = recipe.steps.last().unwrap();
29229 assert!(evidence.command.contains("kg evidence --symbol"));
29230 assert!(!evidence.command.contains("--budget"));
29231 assert!(
29233 recipe
29234 .handle_contract
29235 .iter()
29236 .any(|item| item.contains("Extract once"))
29237 );
29238 }
29239
29240 #[test]
29243 fn to_json_compact_default() {
29244 let val = serde_json::json!({"a": 1, "b": [2, 3]});
29245 let compact = to_json(&val, false, false).unwrap();
29246 assert!(!compact.contains('\n'));
29247 assert!(
29248 compact.contains("\"a\":1")
29249 || compact.contains("\"a\": 1")
29250 || compact.contains("\"a\":")
29251 );
29252 }
29253
29254 #[test]
29255 fn to_json_pretty_indents() {
29256 let val = serde_json::json!({"a": 1, "b": [2, 3]});
29257 let pretty = to_json(&val, true, false).unwrap();
29258 assert!(pretty.contains('\n'));
29259 assert!(pretty.contains(" "));
29260 }
29261
29262 #[test]
29263 fn to_json_compact_is_shorter() {
29264 let val =
29265 serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
29266 let compact = to_json(&val, false, false).unwrap();
29267 let pretty = to_json(&val, true, false).unwrap();
29268 assert!(compact.len() < pretty.len());
29269 }
29270
29271 #[test]
29272 fn terse_renames_keys() {
29273 let val =
29274 serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
29275 let result = to_json(&val, false, true).unwrap();
29276 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29277 assert!(parsed["_s"].is_object());
29278 let d = &parsed["d"];
29279 assert_eq!(d["cf"], "a.rs");
29280 assert_eq!(d["cn"], "main");
29281 assert_eq!(d["csl"], 10);
29282 }
29283
29284 #[test]
29285 fn terse_schema_only_includes_used_keys() {
29286 let val = serde_json::json!({"name": "test", "score": 0.5});
29287 let result = to_json(&val, false, true).unwrap();
29288 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29289 let schema = parsed["_s"].as_object().unwrap();
29290 assert_eq!(schema["n"], "name");
29291 assert_eq!(schema["sc"], "score");
29292 assert!(!schema.contains_key("cf"));
29293 }
29294
29295 #[test]
29296 fn terse_nested_arrays() {
29297 let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
29298 let result = to_json(&val, false, true).unwrap();
29299 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29300 let d = &parsed["d"];
29301 assert_eq!(d["crs"][0]["cn"], "a");
29302 assert_eq!(d["crs"][0]["cf"], "b.rs");
29303 }
29304
29305 #[test]
29306 fn terse_preserves_unknown_keys() {
29307 let val = serde_json::json!({"custom_field": "value", "name": "test"});
29308 let result = to_json(&val, false, true).unwrap();
29309 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29310 let d = &parsed["d"];
29311 assert_eq!(d["custom_field"], "value");
29312 assert_eq!(d["n"], "test");
29313 }
29314
29315 #[test]
29318 fn ultra_terse_strips_properties_from_graph_nodes() {
29319 let val = serde_json::json!({
29320 "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
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 node = &parsed["d"]["nodes"][0];
29325 assert_eq!(node["id"], "fn:main");
29326 assert_eq!(node["k"], "fn");
29327 assert_eq!(node["n"], "main");
29328 assert!(node.get("properties").is_none());
29329 }
29330
29331 #[test]
29332 fn ultra_terse_strips_properties_from_graph_edges() {
29333 let val = serde_json::json!({
29334 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
29335 });
29336 let result = to_json_schema(&val, false, true, true, false).unwrap();
29337 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29338 let edge = &parsed["d"]["edges"][0];
29339 assert_eq!(edge["from_id"], "a");
29340 assert_eq!(edge["to_id"], "b");
29341 assert_eq!(edge["k"], "c");
29342 assert!(edge.get("properties").is_none());
29343 }
29344
29345 #[test]
29346 fn ultra_terse_abbreviates_edge_kinds() {
29347 let val = serde_json::json!({
29348 "edges": [
29349 {"from_id": "a", "to_id": "b", "kind": "defines"},
29350 {"from_id": "a", "to_id": "c", "kind": "contains"},
29351 {"from_id": "a", "to_id": "d", "kind": "imports"},
29352 {"from_id": "a", "to_id": "e", "kind": "mentions"},
29353 {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
29354 {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
29355 {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
29356 {"from_id": "a", "to_id": "i", "kind": "uses"},
29357 {"from_id": "a", "to_id": "j", "kind": "parent"},
29358 {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
29359 ]
29360 });
29361 let result = to_json_schema(&val, false, true, true, false).unwrap();
29362 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29363 let edges = &parsed["d"]["edges"].as_array().unwrap();
29364 assert_eq!(edges[0]["k"], "d");
29365 assert_eq!(edges[1]["k"], "ct");
29366 assert_eq!(edges[2]["k"], "i");
29367 assert_eq!(edges[3]["k"], "m");
29368 assert_eq!(edges[4]["k"], "sr");
29369 assert_eq!(edges[5]["k"], "bt");
29370 assert_eq!(edges[6]["k"], "sctx");
29371 assert_eq!(edges[7]["k"], "u");
29372 assert_eq!(edges[8]["k"], "p");
29373 assert_eq!(edges[9]["k"], "unknown_edge");
29374 }
29375
29376 #[test]
29377 fn ultra_terse_strips_provenance_freshness_from_edges() {
29378 let val = serde_json::json!({
29379 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
29380 });
29381 let result = to_json_schema(&val, false, true, true, false).unwrap();
29382 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29383 let edge = &parsed["d"]["edges"][0];
29384 assert!(edge.get("provenance").is_none());
29385 assert!(edge.get("freshness").is_none());
29386 assert_eq!(edge["k"], "c");
29387 }
29388
29389 #[test]
29390 fn ultra_terse_truncates_snippets() {
29391 let long_snippet = "x".repeat(120);
29392 let val = serde_json::json!({"snippet": long_snippet});
29393 let result = to_json_schema(&val, false, true, true, false).unwrap();
29394 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29395 let snipped = parsed["d"]["sn"].as_str().unwrap();
29396 assert_eq!(snipped.len(), 80);
29397 assert!(snipped.ends_with("..."));
29398 }
29399
29400 #[test]
29401 fn ultra_terse_truncates_abbreviated_snippet_key() {
29402 let long_snippet = "y".repeat(100);
29403 let val = serde_json::json!({"snippet": long_snippet});
29404 let result = to_json_schema(&val, false, true, true, false).unwrap();
29405 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29406 let snipped = parsed["d"]["sn"].as_str().unwrap();
29407 assert_eq!(snipped.len(), 80);
29408 assert!(snipped.ends_with("..."));
29409 }
29410
29411 #[test]
29412 fn ultra_terse_compacts_coverage_snapshot() {
29413 let val = serde_json::json!({
29414 "mode": "incremental",
29415 "total_sector_count": 10,
29416 "dirty_sector_count": 2,
29417 "active_rebuild": Some("rebuild-1"),
29418 "completed_dirty_sector_count": 1,
29419 "mounted_sector_count": 8,
29420 "rebuilding_sector_count": 1,
29421 "resumed_sector_count": 3,
29422 "reused_sector_count": 5
29423 });
29424 let result = to_json_schema(&val, false, true, true, false).unwrap();
29425 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29426 let d = &parsed["d"];
29427 assert_eq!(d["mode"], "incremental");
29428 assert_eq!(d["total_sector_count"], 10);
29429 assert_eq!(d["dirty_sector_count"], 2);
29430 assert!(d.get("active_rebuild").is_none());
29431 assert!(d.get("completed_dirty_sector_count").is_none());
29432 assert!(d.get("mounted_sector_count").is_none());
29433 assert!(d.get("rebuilding_sector_count").is_none());
29434 assert!(d.get("resumed_sector_count").is_none());
29435 assert!(d.get("reused_sector_count").is_none());
29436 }
29437
29438 #[test]
29439 fn ultra_terse_short_snippet_unchanged() {
29440 let val = serde_json::json!({"snippet": "short text"});
29441 let result = to_json_schema(&val, false, true, true, false).unwrap();
29442 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29443 assert_eq!(parsed["d"]["sn"], "short text");
29444 }
29445
29446 #[test]
29447 fn ultra_terse_non_graph_object_properties_preserved() {
29448 let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
29449 let result = to_json_schema(&val, false, true, true, false).unwrap();
29450 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29451 assert!(parsed["d"]["config"]["properties"].is_object());
29452 }
29453
29454 #[test]
29457 fn schema_converts_homogeneous_arrays() {
29458 let val = serde_json::json!({"symbols": [
29459 {"name": "foo", "kind": "fn", "line": 10},
29460 {"name": "bar", "kind": "fn", "line": 20}
29461 ]});
29462 let result = to_json_schema(&val, false, false, false, true).unwrap();
29463 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29464 let syms = &parsed["symbols"];
29465 let columns = syms["_c"]
29466 .as_array()
29467 .unwrap()
29468 .iter()
29469 .map(|value| value.as_str().unwrap())
29470 .collect::<Vec<_>>();
29471 let row0 = syms["_r"][0].as_array().unwrap();
29472 let row1 = syms["_r"][1].as_array().unwrap();
29473 let name_index = columns.iter().position(|column| *column == "name").unwrap();
29474 let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
29475 let line_index = columns.iter().position(|column| *column == "line").unwrap();
29476 assert_eq!(row0[name_index], "foo");
29477 assert_eq!(row0[kind_index], "fn");
29478 assert_eq!(row0[line_index], 10);
29479 assert_eq!(row1[name_index], "bar");
29480 assert_eq!(row1[kind_index], "fn");
29481 assert_eq!(row1[line_index], 20);
29482 }
29483
29484 #[test]
29485 fn schema_skips_short_arrays() {
29486 let val = serde_json::json!({"items": [{"name": "only"}]});
29487 let result = to_json_schema(&val, false, false, false, true).unwrap();
29488 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29489 assert!(parsed["items"].is_array());
29490 assert_eq!(parsed["items"][0]["name"], "only");
29491 }
29492
29493 #[test]
29494 fn schema_skips_heterogeneous_arrays() {
29495 let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
29496 let result = to_json_schema(&val, false, false, false, true).unwrap();
29497 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29498 assert!(parsed["items"].is_array());
29499 assert_eq!(parsed["items"][0]["a"], 1);
29500 }
29501
29502 #[test]
29503 fn schema_with_terse_combines() {
29504 let val = serde_json::json!({"callers": [
29505 {"caller_name": "a", "caller_file": "x.rs"},
29506 {"caller_name": "b", "caller_file": "y.rs"}
29507 ]});
29508 let result = to_json_schema(&val, false, true, false, true).unwrap();
29509 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29510 assert!(parsed["_s"].is_object());
29511 let d = &parsed["d"];
29512 let crs = &d["crs"];
29513 assert!(crs["_c"].is_array());
29514 assert!(crs["_r"].is_array());
29515 let columns = crs["_c"]
29516 .as_array()
29517 .unwrap()
29518 .iter()
29519 .map(|value| value.as_str().unwrap())
29520 .collect::<Vec<_>>();
29521 let row = crs["_r"][0].as_array().unwrap();
29522 let name_index = columns.iter().position(|column| *column == "cn").unwrap();
29523 let file_index = columns.iter().position(|column| *column == "cf").unwrap();
29524 assert_eq!(row[name_index], "a");
29525 assert_eq!(row[file_index], "x.rs");
29526 }
29527
29528 #[test]
29529 fn schema_preserves_non_object_arrays() {
29530 let val = serde_json::json!({"tags": ["a", "b", "c"]});
29531 let result = to_json_schema(&val, false, false, false, true).unwrap();
29532 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29533 assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
29534 }
29535
29536 #[test]
29537 fn cli_accepts_global_schema_flag() {
29538 let cli = parse_cli(["tsift", "--schema", "search", "test"]);
29539 assert!(cli.schema);
29540 assert!(matches!(cli.command, Some(Commands::Search { .. })));
29541 }
29542
29543 #[test]
29544 fn cli_accepts_global_envelope_flag() {
29545 let cli = parse_cli([
29546 "tsift",
29547 "--envelope",
29548 "context-pack",
29549 "tasks/software/tsift.md",
29550 ]);
29551 assert!(cli.envelope);
29552 assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
29553 }
29554
29555 #[test]
29556 fn cli_accepts_locks_command() {
29557 let cli = parse_cli(["tsift", "locks"]);
29558 assert!(matches!(cli.command, Some(Commands::Locks { .. })));
29559 }
29560
29561 #[test]
29562 fn cli_parses_memory_budget_guard_command() {
29563 let cli = parse_cli([
29564 "tsift",
29565 "memory",
29566 "budget-guard",
29567 "--file",
29568 "tool.log",
29569 "--budget-tokens",
29570 "1000",
29571 "--json",
29572 ]);
29573 match cli.command {
29574 Some(Commands::Memory {
29575 command:
29576 crate::cli::MemoryCommand::BudgetGuard {
29577 file,
29578 budget_tokens,
29579 json,
29580 ..
29581 },
29582 }) => {
29583 assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
29584 assert_eq!(budget_tokens, 1000);
29585 assert!(json);
29586 }
29587 _ => panic!("expected memory budget-guard command"),
29588 }
29589 }
29590
29591 #[test]
29592 fn cli_parses_memory_capture_agent_doc_closeout_command() {
29593 let cli = parse_cli([
29594 "tsift",
29595 "memory",
29596 "capture-agent-doc-closeout",
29597 ".",
29598 "--session-path",
29599 "tasks/software/tsift.md",
29600 "--prompt-target",
29601 "do [#tsiftmemhooks]",
29602 "--response-summary",
29603 "wired closeout capture",
29604 "--commit-hash",
29605 "abc123",
29606 "--session-check-status",
29607 "clean",
29608 "--json",
29609 ]);
29610 match cli.command {
29611 Some(Commands::Memory {
29612 command:
29613 crate::cli::MemoryCommand::CaptureAgentDocCloseout {
29614 path,
29615 session_path,
29616 prompt_target,
29617 response_summary,
29618 commit_hash,
29619 session_check_status,
29620 json,
29621 },
29622 }) => {
29623 assert_eq!(path, std::path::PathBuf::from("."));
29624 assert_eq!(
29625 session_path,
29626 std::path::PathBuf::from("tasks/software/tsift.md")
29627 );
29628 assert_eq!(prompt_target, "do [#tsiftmemhooks]");
29629 assert_eq!(response_summary, "wired closeout capture");
29630 assert_eq!(commit_hash.as_deref(), Some("abc123"));
29631 assert_eq!(session_check_status, "clean");
29632 assert!(json);
29633 }
29634 _ => panic!("expected memory capture-agent-doc-closeout command"),
29635 }
29636 }
29637
29638 #[test]
29639 fn cli_parses_memory_project_graph_read_policy() {
29640 let cli = parse_cli([
29641 "tsift",
29642 "memory",
29643 "project-graph",
29644 ".",
29645 "--read-policy",
29646 "query-relevant",
29647 "--query",
29648 "semantic memory",
29649 "--limit",
29650 "7",
29651 "--json",
29652 ]);
29653 match cli.command {
29654 Some(Commands::Memory {
29655 command:
29656 crate::cli::MemoryCommand::ProjectGraph {
29657 read_policy,
29658 query,
29659 limit,
29660 json,
29661 ..
29662 },
29663 }) => {
29664 assert_eq!(
29665 read_policy,
29666 crate::cli::MemoryProjectReadPolicy::QueryRelevant
29667 );
29668 assert_eq!(query.as_deref(), Some("semantic memory"));
29669 assert_eq!(limit, 7);
29670 assert!(json);
29671 }
29672 _ => panic!("expected memory project-graph command"),
29673 }
29674 }
29675
29676 #[test]
29677 fn cli_locks_accepts_scope_flag() {
29678 let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
29679 match cli.command {
29680 Some(Commands::Locks { scope, .. }) => {
29681 assert_eq!(scope.as_deref(), Some("alpha"));
29682 }
29683 _ => panic!("expected Locks command"),
29684 }
29685 }
29686
29687 #[test]
29688 fn cli_search_accepts_autoindex_flag() {
29689 let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
29690 match cli.command {
29691 Some(Commands::Search {
29692 autoindex,
29693 no_autoindex,
29694 ..
29695 }) => {
29696 assert!(autoindex);
29697 assert!(!no_autoindex);
29698 }
29699 _ => panic!("expected Search command"),
29700 }
29701 }
29702
29703 #[test]
29704 fn cli_search_accepts_exact_flag() {
29705 let cli = parse_cli(["tsift", "search", "test", "--exact"]);
29706 match cli.command {
29707 Some(Commands::Search {
29708 exact, strategy, ..
29709 }) => {
29710 assert!(exact);
29711 assert!(strategy.is_none());
29712 }
29713 _ => panic!("expected Search command"),
29714 }
29715 }
29716
29717 #[test]
29718 fn cli_parses_diff_digest_command() {
29719 let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
29720 match cli.command {
29721 Some(Commands::DiffDigest {
29722 json,
29723 path,
29724 cached,
29725 revision,
29726 max_parsed_files,
29727 }) => {
29728 assert!(json);
29729 assert_eq!(path, PathBuf::from("."));
29730 assert!(!cached);
29731 assert!(revision.is_none());
29732 assert_eq!(max_parsed_files, 25);
29733 }
29734 _ => panic!("expected DiffDigest command"),
29735 }
29736 }
29737
29738 #[test]
29739 fn cli_rejects_conflicting_diff_digest_modes() {
29740 match try_parse_cli([
29741 "tsift",
29742 "diff-digest",
29743 "--cached",
29744 "--revision",
29745 "HEAD",
29746 ".",
29747 ]) {
29748 Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
29749 Err(err) => {
29750 assert!(err.to_string().contains("--cached"));
29751 assert!(err.to_string().contains("--revision"));
29752 }
29753 }
29754 }
29755
29756 #[test]
29757 fn cli_parses_test_digest_command() {
29758 let cli = parse_cli([
29759 "tsift",
29760 "test-digest",
29761 "--path",
29762 ".",
29763 "--input",
29764 "target/test.log",
29765 "--runner",
29766 "cargo",
29767 "--json",
29768 ]);
29769 match cli.command {
29770 Some(Commands::TestDigest {
29771 json,
29772 path,
29773 input,
29774 runner,
29775 }) => {
29776 assert!(json);
29777 assert_eq!(path, PathBuf::from("."));
29778 assert_eq!(input, Some(PathBuf::from("target/test.log")));
29779 assert_eq!(runner.as_deref(), Some("cargo"));
29780 }
29781 _ => panic!("expected TestDigest command"),
29782 }
29783 }
29784
29785 #[test]
29786 fn cli_parses_log_digest_command() {
29787 let cli = parse_cli([
29788 "tsift",
29789 "log-digest",
29790 "--path",
29791 ".",
29792 "--input",
29793 "target/build.log",
29794 "--json",
29795 ]);
29796 match cli.command {
29797 Some(Commands::LogDigest {
29798 json,
29799 path,
29800 input,
29801 fixture,
29802 fail_under,
29803 }) => {
29804 assert!(json);
29805 assert_eq!(path, PathBuf::from("."));
29806 assert_eq!(input, Some(PathBuf::from("target/build.log")));
29807 assert!(fixture.is_none());
29808 assert!(!fail_under);
29809 }
29810 _ => panic!("expected LogDigest command"),
29811 }
29812 }
29813
29814 #[test]
29815 fn cli_parses_metric_digest_command() {
29816 let cli = parse_cli([
29817 "tsift",
29818 "metric-digest",
29819 "--input",
29820 "target/runs.json",
29821 "--baseline",
29822 "target/prior.json",
29823 "--metric",
29824 "session_mae",
29825 "--lower-is-better",
29826 "session_mae",
29827 "--history",
29828 "4",
29829 "--top",
29830 "2",
29831 "--json",
29832 ]);
29833 match cli.command {
29834 Some(Commands::MetricDigest {
29835 input,
29836 baseline,
29837 metrics,
29838 lower_is_better,
29839 history,
29840 top,
29841 json,
29842 ..
29843 }) => {
29844 assert!(json);
29845 assert_eq!(input, Some(PathBuf::from("target/runs.json")));
29846 assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
29847 assert_eq!(metrics, vec!["session_mae"]);
29848 assert_eq!(lower_is_better, vec!["session_mae"]);
29849 assert_eq!(history, 4);
29850 assert_eq!(top, 2);
29851 }
29852 _ => panic!("expected MetricDigest command"),
29853 }
29854 }
29855
29856 #[test]
29857 fn cli_parses_dci_benchmark_command() {
29858 let cli = parse_cli([
29859 "tsift",
29860 "dci-benchmark",
29861 "--fixture",
29862 "fixtures/dci-search-benchmark.json",
29863 "--json",
29864 ]);
29865 match cli.command {
29866 Some(Commands::DciBenchmark { fixture, json }) => {
29867 assert!(json);
29868 assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
29869 }
29870 _ => panic!("expected DciBenchmark command"),
29871 }
29872 }
29873
29874 #[test]
29875 fn cli_parses_session_digest_command() {
29876 let cli = parse_cli([
29877 "tsift",
29878 "session-digest",
29879 "--path",
29880 ".",
29881 "--input",
29882 "target/session.md",
29883 "--source",
29884 "markdown",
29885 "--json",
29886 ]);
29887 match cli.command {
29888 Some(Commands::SessionDigest {
29889 json,
29890 path,
29891 input,
29892 source,
29893 }) => {
29894 assert!(json);
29895 assert_eq!(path, PathBuf::from("."));
29896 assert_eq!(input, Some(PathBuf::from("target/session.md")));
29897 assert_eq!(source.as_deref(), Some("markdown"));
29898 }
29899 _ => panic!("expected SessionDigest command"),
29900 }
29901 }
29902
29903 #[test]
29904 fn cli_parses_session_cost_command() {
29905 let cli = parse_cli([
29906 "tsift",
29907 "session-cost",
29908 "--input",
29909 "target/session.jsonl",
29910 "--source",
29911 "codex-jsonl",
29912 "--json",
29913 ]);
29914 match cli.command {
29915 Some(Commands::SessionCost {
29916 json,
29917 input,
29918 fixture,
29919 fail_under,
29920 source,
29921 }) => {
29922 assert!(json);
29923 assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
29924 assert_eq!(fixture, None);
29925 assert!(!fail_under);
29926 assert_eq!(source.as_deref(), Some("codex-jsonl"));
29927 }
29928 _ => panic!("expected SessionCost command"),
29929 }
29930
29931 let cli = parse_cli([
29932 "tsift",
29933 "session-cost",
29934 "--fixture",
29935 "fixtures/real-session-prompt-cache-effectiveness.json",
29936 "--fail-under",
29937 "--json",
29938 ]);
29939 match cli.command {
29940 Some(Commands::SessionCost {
29941 json,
29942 input,
29943 fixture,
29944 fail_under,
29945 source,
29946 }) => {
29947 assert!(json);
29948 assert_eq!(input, None);
29949 assert_eq!(
29950 fixture,
29951 Some(PathBuf::from(
29952 "fixtures/real-session-prompt-cache-effectiveness.json"
29953 ))
29954 );
29955 assert!(fail_under);
29956 assert_eq!(source, None);
29957 }
29958 _ => panic!("expected SessionCost command"),
29959 }
29960 }
29961
29962 #[test]
29963 fn cli_parses_session_review_command() {
29964 let cli = parse_cli([
29965 "tsift",
29966 "session-review",
29967 "tasks/software/tsift.md",
29968 "--next-context",
29969 "--json",
29970 ]);
29971 match cli.command {
29972 Some(Commands::SessionReview {
29973 json,
29974 next_context,
29975 path,
29976 ..
29977 }) => {
29978 assert!(json);
29979 assert!(next_context);
29980 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29981 }
29982 _ => panic!("expected SessionReview command"),
29983 }
29984 }
29985
29986 #[test]
29987 fn cli_search_accepts_budget_flags() {
29988 let cli = parse_cli([
29989 "tsift",
29990 "search",
29991 "alpha_helper",
29992 "--max-items",
29993 "3",
29994 "--max-bytes",
29995 "96",
29996 ]);
29997 match cli.command {
29998 Some(Commands::Search {
29999 max_items,
30000 max_bytes,
30001 ..
30002 }) => {
30003 assert_eq!(max_items, Some(3));
30004 assert_eq!(max_bytes, Some(96));
30005 }
30006 _ => panic!("expected Search command"),
30007 }
30008 }
30009
30010 #[test]
30011 fn cli_search_accepts_budget_preset() {
30012 let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
30013 match cli.command {
30014 Some(Commands::Search { budget, .. }) => {
30015 assert_eq!(budget, Some(ResponseBudgetPreset::Small));
30016 }
30017 _ => panic!("expected Search command"),
30018 }
30019 }
30020
30021 #[test]
30022 fn cli_search_accepts_ast_facet_filters() {
30023 let cli = parse_cli([
30024 "tsift",
30025 "search",
30026 "setup",
30027 "--lang",
30028 "markdown",
30029 "--kind",
30030 "list_item",
30031 "--node-kind",
30032 "list_item",
30033 "--section",
30034 "Install",
30035 "--parent",
30036 "Run setup.",
30037 "--child",
30038 "Confirm setup.",
30039 "--fence-language",
30040 "rust",
30041 "--list-depth",
30042 "1",
30043 "--heading-level",
30044 "2",
30045 ]);
30046 match cli.command {
30047 Some(Commands::Search {
30048 lang,
30049 kind,
30050 node_kind,
30051 section,
30052 parent,
30053 child,
30054 fence_language,
30055 list_depth,
30056 heading_level,
30057 ..
30058 }) => {
30059 assert_eq!(lang, vec!["markdown"]);
30060 assert_eq!(kind, vec!["list_item"]);
30061 assert_eq!(node_kind, vec!["list_item"]);
30062 assert_eq!(section, vec!["Install"]);
30063 assert_eq!(parent, vec!["Run setup."]);
30064 assert_eq!(child, vec!["Confirm setup."]);
30065 assert_eq!(fence_language, vec!["rust"]);
30066 assert_eq!(list_depth, vec![1]);
30067 assert_eq!(heading_level, vec![2]);
30068 }
30069 _ => panic!("expected Search command"),
30070 }
30071 }
30072
30073 #[test]
30074 fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
30075 let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
30076 assert_eq!(small.preview_items(), 3);
30077 assert_eq!(small.preview_bytes(), 120);
30078 assert_eq!(small.follow_up_items(), 4);
30079
30080 let overridden =
30081 ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
30082 assert_eq!(overridden.preview_items(), 7);
30083 assert_eq!(overridden.preview_bytes(), 120);
30084 assert_eq!(overridden.follow_up_items(), 7);
30085
30086 let envelope_default = ResponseBudget::from_cli(None, None, None, true);
30087 assert!(envelope_default.is_active());
30088 }
30089
30090 #[test]
30091 fn cli_explain_accepts_budget_flags() {
30092 let cli = parse_cli([
30093 "tsift",
30094 "explain",
30095 "alpha_helper",
30096 "--max-items",
30097 "2",
30098 "--max-bytes",
30099 "80",
30100 ]);
30101 match cli.command {
30102 Some(Commands::Explain {
30103 max_items,
30104 max_bytes,
30105 ..
30106 }) => {
30107 assert_eq!(max_items, Some(2));
30108 assert_eq!(max_bytes, Some(80));
30109 }
30110 _ => panic!("expected Explain command"),
30111 }
30112 }
30113
30114 #[test]
30115 fn cli_session_review_accepts_budget_flags() {
30116 let cli = parse_cli([
30117 "tsift",
30118 "session-review",
30119 "tasks/software/tsift.md",
30120 "--max-items",
30121 "4",
30122 "--max-bytes",
30123 "120",
30124 ]);
30125 match cli.command {
30126 Some(Commands::SessionReview {
30127 max_items,
30128 max_bytes,
30129 ..
30130 }) => {
30131 assert_eq!(max_items, Some(4));
30132 assert_eq!(max_bytes, Some(120));
30133 }
30134 _ => panic!("expected SessionReview command"),
30135 }
30136 }
30137
30138 #[test]
30139 fn cli_parses_context_pack_command() {
30140 let cli = parse_cli([
30141 "tsift",
30142 "context-pack",
30143 "tasks/software/tsift.md",
30144 "--test-input",
30145 "target/test.log",
30146 "--runner",
30147 "cargo",
30148 "--log-input",
30149 "target/build.log",
30150 "--max-items",
30151 "3",
30152 "--max-bytes",
30153 "96",
30154 "--json",
30155 ]);
30156 match cli.command {
30157 Some(Commands::ContextPack {
30158 path,
30159 test_input,
30160 runner,
30161 log_input,
30162 json,
30163 max_items,
30164 max_bytes,
30165 budget,
30166 convex_snapshot,
30167 }) => {
30168 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
30169 assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
30170 assert_eq!(runner.as_deref(), Some("cargo"));
30171 assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
30172 assert!(json);
30173 assert_eq!(max_items, Some(3));
30174 assert_eq!(max_bytes, Some(96));
30175 assert!(budget.is_none());
30176 assert!(convex_snapshot.is_none());
30177 }
30178 _ => panic!("expected ContextPack command"),
30179 }
30180 }
30181
30182 #[test]
30183 fn cli_parses_token_savings_command() {
30184 let cli = parse_cli([
30185 "tsift",
30186 "token-savings",
30187 "--fixture",
30188 "fixtures/tsift-token-savings.json",
30189 "--fail-under",
30190 "--json",
30191 ]);
30192 match cli.command {
30193 Some(Commands::TokenSavings {
30194 fixture,
30195 fail_under,
30196 json,
30197 }) => {
30198 assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
30199 assert!(fail_under);
30200 assert!(json);
30201 }
30202 _ => panic!("expected TokenSavings command"),
30203 }
30204 }
30205
30206 #[test]
30207 fn token_savings_report_records_fixture_thresholds() {
30208 let raw_symbols = [
30209 "validate_user",
30210 "validateUser",
30211 "ValidateUser",
30212 "validate-user",
30213 "VALIDATE_USER",
30214 "Validate_User",
30215 "raw_symbol",
30216 "rawSymbol",
30217 "RawSymbol",
30218 "raw-symbol",
30219 "RAW_SYMBOL",
30220 "Raw_Symbol",
30221 ]
30222 .iter()
30223 .enumerate()
30224 .map(|(idx, identifier)| TokenSavingsRawSymbol {
30225 identifier: (*identifier).to_string(),
30226 file: format!("src/example_{idx}.rs"),
30227 line: (idx + 1) as u64,
30228 context: "function".to_string(),
30229 })
30230 .collect();
30231 let fixture = TokenSavingsFixture {
30232 schema_version: 1,
30233 description: "fixture".to_string(),
30234 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30235 cases: vec![TokenSavingsFixtureCase {
30236 name: "search-preview".to_string(),
30237 surface: "search".to_string(),
30238 minimum_savings_percent: 40.0,
30239 raw_symbols,
30240 tagpath_families: vec![
30241 TokenSavingsFamily {
30242 canonical: "validate_user".to_string(),
30243 count: 6,
30244 aliases: BTreeMap::new(),
30245 },
30246 TokenSavingsFamily {
30247 canonical: "raw_symbol".to_string(),
30248 count: 6,
30249 aliases: BTreeMap::new(),
30250 },
30251 ],
30252 context_pack_inputs: None,
30253 session_review_inputs: None,
30254 source_read_inputs: None,
30255 markdown_projection_inputs: None,
30256 }],
30257 };
30258
30259 let report = build_token_savings_report(&fixture).unwrap();
30260
30261 assert!(report.pass);
30262 assert_eq!(report.cases[0].raw_symbol_count, 12);
30263 assert_eq!(report.cases[0].family_count, 2);
30264 assert_eq!(report.cases[0].status, "pass");
30265 assert!(report.cases[0].byte_delta > 0);
30266 assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
30267 assert!(report.cases[0].savings_percent >= 40.0);
30268 }
30269
30270 #[test]
30271 fn token_savings_source_read_inputs_preserve_required_anchors() {
30272 let fixture = TokenSavingsFixture {
30273 schema_version: 1,
30274 description: "fixture".to_string(),
30275 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30276 cases: vec![TokenSavingsFixtureCase {
30277 name: "source-read".to_string(),
30278 surface: "source-read".to_string(),
30279 minimum_savings_percent: 40.0,
30280 raw_symbols: Vec::new(),
30281 tagpath_families: Vec::new(),
30282 context_pack_inputs: None,
30283 session_review_inputs: None,
30284 source_read_inputs: Some(TokenSavingsSourceReadInputs {
30285 reads: vec![TokenSavingsSourceReadInput {
30286 command: "sed -n '40,160p' src/main.rs".to_string(),
30287 file: "src/main.rs".to_string(),
30288 raw_start: 40,
30289 raw_lines: 121,
30290 raw_excerpt: "line 40\n".repeat(121),
30291 envelope_start: 40,
30292 envelope_lines: 121,
30293 required_line_anchors: vec![40, 120, 160],
30294 }],
30295 }),
30296 markdown_projection_inputs: None,
30297 }],
30298 };
30299
30300 let report = build_token_savings_report(&fixture).unwrap();
30301
30302 assert!(report.pass);
30303 assert_eq!(report.cases[0].surface, "source-read");
30304 assert!(report.cases[0].savings_percent >= 40.0);
30305 }
30306
30307 #[test]
30308 fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
30309 let fixture = TokenSavingsFixture {
30310 schema_version: 1,
30311 description: "fixture".to_string(),
30312 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30313 cases: vec![TokenSavingsFixtureCase {
30314 name: "source-read".to_string(),
30315 surface: "source-read".to_string(),
30316 minimum_savings_percent: 40.0,
30317 raw_symbols: Vec::new(),
30318 tagpath_families: Vec::new(),
30319 context_pack_inputs: None,
30320 session_review_inputs: None,
30321 source_read_inputs: Some(TokenSavingsSourceReadInputs {
30322 reads: vec![TokenSavingsSourceReadInput {
30323 command: "cat src/main.rs".to_string(),
30324 file: "src/main.rs".to_string(),
30325 raw_start: 1,
30326 raw_lines: 200,
30327 raw_excerpt: "line\n".repeat(200),
30328 envelope_start: 1,
30329 envelope_lines: 80,
30330 required_line_anchors: vec![120],
30331 }],
30332 }),
30333 markdown_projection_inputs: None,
30334 }],
30335 };
30336
30337 let err = match build_token_savings_report(&fixture) {
30338 Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
30339 Err(err) => err,
30340 };
30341
30342 assert!(err.to_string().contains("hides required line anchor 120"));
30343 }
30344
30345 #[test]
30346 fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
30347 let fixture = TokenSavingsFixture {
30348 schema_version: 1,
30349 description: "fixture".to_string(),
30350 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30351 cases: vec![TokenSavingsFixtureCase {
30352 name: "markdown-projection".to_string(),
30353 surface: "context-pack".to_string(),
30354 minimum_savings_percent: 40.0,
30355 raw_symbols: Vec::new(),
30356 tagpath_families: Vec::new(),
30357 context_pack_inputs: None,
30358 session_review_inputs: None,
30359 source_read_inputs: None,
30360 markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
30361 documents: vec![TokenSavingsMarkdownProjectionInput {
30362 command: "context-pack markdown body".to_string(),
30363 file: "tasks/software/tsift.md".to_string(),
30364 raw_markdown: "# Heading\n\n".repeat(120),
30365 outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
30366 selected_nodes: vec!["mdast-selected".to_string()],
30367 expand:
30368 "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
30369 .to_string(),
30370 }],
30371 }),
30372 }],
30373 };
30374
30375 let report = build_token_savings_report(&fixture).unwrap();
30376
30377 assert!(report.pass);
30378 assert_eq!(report.cases[0].surface, "context-pack");
30379 assert!(report.cases[0].savings_percent >= 40.0);
30380 }
30381
30382 #[test]
30383 fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
30384 let mut content = String::from("# Cache Root\n\n");
30385 for idx in 0..96 {
30386 content.push_str(&format!(
30387 "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
30388 ));
30389 }
30390
30391 let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30392 assert!(!first.cache_hit);
30393 assert!(first.nodes.len() > 200);
30394
30395 let sections = markdown_section_spans(&content).unwrap();
30396 let list_items = markdown_block_spans(&content, "list_item").unwrap();
30397 let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
30398 let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30399
30400 assert!(second.cache_hit);
30401 assert_eq!(second.nodes.len(), first.nodes.len());
30402 assert_eq!(sections.len(), 97);
30403 assert_eq!(list_items.len(), 96);
30404 assert_eq!(code_blocks.len(), 96);
30405 let first_code = first
30406 .nodes
30407 .iter()
30408 .find(|node| node.kind == "code_block")
30409 .expect("expected a Markdown code block");
30410 let first_code_node = markdown_ast_node(
30411 Path::new("/repo"),
30412 "semantic-edit",
30413 first_code,
30414 content.as_bytes(),
30415 &first.nodes,
30416 8,
30417 );
30418 assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
30419 assert_eq!(
30420 first_code_node.metadata.embedded_symbols[0].name,
30421 "sample_0"
30422 );
30423 assert_eq!(
30424 first_code_node.metadata.embedded_symbols[0].language,
30425 "rust"
30426 );
30427 }
30428
30429 #[test]
30430 fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
30431 let response = empty_search_response(Path::new("/repo"), "lexical");
30432 let symbol_hits = vec![index::SymbolHit {
30433 name: "alpha_helper_with_a_long_name".to_string(),
30434 kind: "function".to_string(),
30435 language: "rust".to_string(),
30436 file: "/repo/src/lib.rs".to_string(),
30437 line: 12,
30438 end_line: None,
30439 node_kind: None,
30440 start_byte: None,
30441 end_byte: None,
30442 body_start_byte: None,
30443 body_end_byte: None,
30444 tags: None,
30445 score: 0.98,
30446 match_type: "exact_name".to_string(),
30447 tagpath_handle: None,
30448 }];
30449
30450 let report = build_relative_search_budget_report(
30451 "alpha_helper_with_a_long_name",
30452 "lexical",
30453 Path::new("/repo"),
30454 &response,
30455 &symbol_hits,
30456 ResponseBudget::new(Some(1), Some(12)),
30457 &SearchFacetFilters::default(),
30458 );
30459
30460 assert_eq!(report.symbols.len(), 1);
30461 assert!(report.symbols[0].handle.starts_with("sfam-"));
30462 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
30463 assert_eq!(report.symbols[0].name, "alpha_hel...");
30464 assert_eq!(report.symbols[0].file, "src/lib.rs");
30465 assert!(report.symbols[0].expand.contains("tsift search"));
30466 }
30467
30468 #[test]
30469 fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
30470 let dir = tempfile::tempdir().unwrap();
30471 let src_dir = dir.path().join("src");
30472 fs::create_dir_all(&src_dir).unwrap();
30473 let source = "fn alpha_helper() {\n beta();\n}\n";
30474 let file = src_dir.join("lib.rs");
30475 fs::write(&file, source).unwrap();
30476 let body_start = source.find("{\n").unwrap() + 1;
30477 let body_end = source.rfind("\n}").unwrap() + 1;
30478
30479 let response = empty_search_response(dir.path(), "lexical");
30480 let symbol_hits = vec![index::SymbolHit {
30481 name: "alpha_helper".to_string(),
30482 kind: "function".to_string(),
30483 language: "rust".to_string(),
30484 file: file.to_string_lossy().to_string(),
30485 line: 0,
30486 end_line: Some(2),
30487 node_kind: Some("function_item".to_string()),
30488 start_byte: Some(0),
30489 end_byte: Some(i64::try_from(source.len()).unwrap()),
30490 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30491 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30492 tags: Some("alpha,helper".to_string()),
30493 score: 0.98,
30494 match_type: "exact_name".to_string(),
30495 tagpath_handle: None,
30496 }];
30497
30498 let report = build_relative_search_budget_report(
30499 "alpha helper",
30500 "lexical",
30501 dir.path(),
30502 &response,
30503 &symbol_hits,
30504 ResponseBudget::new(Some(5), Some(96)),
30505 &SearchFacetFilters::default(),
30506 );
30507
30508 let symbol = &report.symbols[0];
30509 assert_eq!(symbol.language, "rust");
30510 assert_eq!(symbol.end_line, Some(2));
30511 let ast = symbol
30512 .ast
30513 .as_ref()
30514 .expect("search symbol preview should expose an AST span artifact");
30515 assert_eq!(ast.artifact_kind, "ast_span");
30516 assert!(ast.span.handle.starts_with("span-"));
30517 assert_eq!(ast.span.node_kind, "function_item");
30518 assert_eq!(ast.span.start_byte, 0);
30519 assert_eq!(ast.span.end_byte, source.len());
30520 assert_eq!(ast.span.body_start_byte, Some(body_start));
30521 assert_eq!(ast.span.body_end_byte, Some(body_end));
30522 assert!(ast.expand.source_window.contains("source-read"));
30523 assert!(
30524 ast.expand
30525 .source_body
30526 .as_ref()
30527 .unwrap()
30528 .contains("source-read")
30529 );
30530 assert!(ast.expand.symbol_read.contains("symbol-read"));
30531 assert!(ast.expand.markdown_ast.is_none());
30532 }
30533
30534 #[test]
30535 fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
30536 let dir = tempfile::tempdir().unwrap();
30537 let source = "# Guide\n\n## Install\n\n- Run setup.\n";
30538 let file = dir.path().join("README.md");
30539 fs::write(&file, source).unwrap();
30540 let heading_start = source.find("## Install").unwrap();
30541 let heading_end = source.len();
30542
30543 let response = empty_search_response(dir.path(), "lexical");
30544 let symbol_hits = vec![index::SymbolHit {
30545 name: "Install".to_string(),
30546 kind: "heading".to_string(),
30547 language: "markdown".to_string(),
30548 file: file.to_string_lossy().to_string(),
30549 line: 2,
30550 end_line: Some(4),
30551 node_kind: Some("atx_heading".to_string()),
30552 start_byte: Some(i64::try_from(heading_start).unwrap()),
30553 end_byte: Some(i64::try_from(heading_end).unwrap()),
30554 body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
30555 body_end_byte: Some(i64::try_from(heading_end).unwrap()),
30556 tags: Some("install".to_string()),
30557 score: 1.0,
30558 match_type: "exact_name".to_string(),
30559 tagpath_handle: None,
30560 }];
30561
30562 let report = build_relative_search_budget_report(
30563 "Install",
30564 "lexical",
30565 dir.path(),
30566 &response,
30567 &symbol_hits,
30568 ResponseBudget::new(Some(5), Some(96)),
30569 &SearchFacetFilters::default(),
30570 );
30571
30572 let ast = report.symbols[0]
30573 .ast
30574 .as_ref()
30575 .expect("Markdown search symbol should expose an AST span artifact");
30576 assert_eq!(ast.span.node_kind, "atx_heading");
30577 assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
30578 let markdown_ast = ast
30579 .expand
30580 .markdown_ast
30581 .as_ref()
30582 .expect("Markdown symbols should include markdown-ast expansion");
30583 assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
30584 assert!(markdown_ast.contains("--node"), "{markdown_ast}");
30585 assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
30586 assert!(ast.expand.source_window.contains("source-read"));
30587 assert!(ast.expand.symbol_read.contains("symbol-read"));
30588 }
30589
30590 #[test]
30591 fn search_budget_report_exposes_markdown_embedded_code_symbols() {
30592 let dir = tempfile::tempdir().unwrap();
30593 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30594 let file = dir.path().join("README.md");
30595 fs::write(&file, source).unwrap();
30596 let fence_start = source.find("```rust").unwrap();
30597 let body_start = source.find("fn sample").unwrap();
30598 let body_end = body_start + "fn sample() {}\n".len();
30599
30600 let response = empty_search_response(dir.path(), "lexical");
30601 let symbol_hits = vec![index::SymbolHit {
30602 name: "rust".to_string(),
30603 kind: "code_block".to_string(),
30604 language: "markdown".to_string(),
30605 file: file.to_string_lossy().to_string(),
30606 line: 2,
30607 end_line: Some(4),
30608 node_kind: Some("fenced_code_block".to_string()),
30609 start_byte: Some(i64::try_from(fence_start).unwrap()),
30610 end_byte: Some(i64::try_from(source.len()).unwrap()),
30611 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30612 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30613 tags: Some("rust".to_string()),
30614 score: 1.0,
30615 match_type: "exact_name".to_string(),
30616 tagpath_handle: None,
30617 }];
30618
30619 let report = build_relative_search_budget_report(
30620 "rust",
30621 "lexical",
30622 dir.path(),
30623 &response,
30624 &symbol_hits,
30625 ResponseBudget::new(Some(5), Some(96)),
30626 &SearchFacetFilters::default(),
30627 );
30628
30629 let embedded = &report.symbols[0]
30630 .ast
30631 .as_ref()
30632 .unwrap()
30633 .span
30634 .markdown
30635 .as_ref()
30636 .unwrap()
30637 .embedded_symbols;
30638 assert_eq!(embedded.len(), 1);
30639 assert_eq!(embedded[0].name, "sample");
30640 assert_eq!(embedded[0].kind, "function");
30641 assert_eq!(embedded[0].language, "rust");
30642 assert_eq!(embedded[0].node_kind, "function_item");
30643 assert!(embedded[0].handle.starts_with("span-"));
30644 assert_eq!(embedded[0].start_byte, body_start);
30645 assert_eq!(embedded[0].start_line, 4);
30646 }
30647
30648 fn test_lexical_search_hit(
30649 path: &Path,
30650 rank: usize,
30651 score: f64,
30652 snippet: &str,
30653 ) -> sift::SearchHit {
30654 sift::SearchHit {
30655 artifact_id: format!("hit-{rank}"),
30656 artifact_kind: sift::ContextArtifactKind::File,
30657 budget: sift::ArtifactBudget::from_text(snippet, 1),
30658 confidence: sift::ScoreConfidence::High,
30659 freshness: sift::ArtifactFreshness {
30660 modified_unix_secs: None,
30661 observed_unix_secs: 0,
30662 },
30663 location: Some("line 1".to_string()),
30664 path: path.to_string_lossy().to_string(),
30665 provenance: sift::ArtifactProvenance {
30666 adapter: sift::AcquisitionAdapterKind::FileSystem,
30667 source: "test lexical hit".to_string(),
30668 synthetic: false,
30669 },
30670 rank,
30671 score,
30672 snippet: snippet.to_string(),
30673 }
30674 }
30675
30676 fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
30677 summarize::Summary {
30678 id: 0,
30679 symbol_name: symbol_name.to_string(),
30680 file_path: file_path.to_string(),
30681 content_hash: "hash".to_string(),
30682 summary: summary.to_string(),
30683 entities: None,
30684 relationships: None,
30685 concept_labels: None,
30686 extracted_at: "2026-06-02T00:00:00Z".to_string(),
30687 model: "test".to_string(),
30688 tokens_input: None,
30689 tokens_output: None,
30690 }
30691 }
30692
30693 #[test]
30694 fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
30695 let dir = tempfile::tempdir().unwrap();
30696 let src_dir = dir.path().join("src");
30697 fs::create_dir_all(&src_dir).unwrap();
30698 let source = "fn alpha_helper() {}\n";
30699 let file = src_dir.join("lib.rs");
30700 let broad_file = dir.path().join("README.md");
30701 fs::write(&file, source).unwrap();
30702 fs::write(
30703 &broad_file,
30704 "alpha helper alpha helper alpha helper in prose\n",
30705 )
30706 .unwrap();
30707
30708 let mut response = empty_search_response(dir.path(), "lexical");
30709 response.hits.push(test_lexical_search_hit(
30710 &broad_file,
30711 1,
30712 240.0,
30713 "alpha helper alpha helper alpha helper in prose",
30714 ));
30715 let symbol_hits = vec![index::SymbolHit {
30716 name: "alpha_helper".to_string(),
30717 kind: "function".to_string(),
30718 language: "rust".to_string(),
30719 file: file.to_string_lossy().to_string(),
30720 line: 0,
30721 end_line: Some(0),
30722 node_kind: Some("function_item".to_string()),
30723 start_byte: Some(0),
30724 end_byte: Some(i64::try_from(source.len()).unwrap()),
30725 body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30726 body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30727 tags: Some("alpha,helper".to_string()),
30728 score: 0.8,
30729 match_type: "all_tags".to_string(),
30730 tagpath_handle: None,
30731 }];
30732
30733 let report = build_relative_search_budget_report(
30734 "alpha helper",
30735 "lexical",
30736 dir.path(),
30737 &response,
30738 &symbol_hits,
30739 ResponseBudget::new(Some(5), Some(128)),
30740 &SearchFacetFilters::default(),
30741 );
30742
30743 assert_eq!(report.ranked[0].source, "symbol_span");
30744 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30745 assert!(report.ranked[0].score > report.ranked[1].score);
30746 assert_eq!(report.ranked[1].source, "lexical_file");
30747 }
30748
30749 #[test]
30750 fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
30751 let dir = tempfile::tempdir().unwrap();
30752 let src_dir = dir.path().join("src");
30753 fs::create_dir_all(&src_dir).unwrap();
30754 let source = "fn alpha_helper() {\n let needle = \"needle\";\n}\n\nfn other() {}\n";
30755 let file = src_dir.join("lib.rs");
30756 fs::write(&file, source).unwrap();
30757
30758 let mut response = empty_search_response(dir.path(), "exact");
30759 let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
30760 hit.location = Some("line 2".to_string());
30761 response.hits.push(hit);
30762
30763 let symbol_hits = vec![index::SymbolHit {
30764 name: "alpha_helper".to_string(),
30765 kind: "function".to_string(),
30766 language: "rust".to_string(),
30767 file: file.to_string_lossy().to_string(),
30768 line: 0,
30769 end_line: Some(2),
30770 node_kind: Some("function_item".to_string()),
30771 start_byte: Some(0),
30772 end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
30773 body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
30774 body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
30775 tags: Some("alpha,helper".to_string()),
30776 score: 0.9,
30777 match_type: "all_tags".to_string(),
30778 tagpath_handle: None,
30779 }];
30780
30781 let report = build_relative_search_budget_report(
30782 "needle",
30783 "exact",
30784 dir.path(),
30785 &response,
30786 &symbol_hits,
30787 ResponseBudget::new(Some(5), Some(128)),
30788 &SearchFacetFilters::default(),
30789 );
30790
30791 let hit = &report.hits[0];
30792 assert_eq!(hit.line, Some(2));
30793 let source_handle = hit
30794 .source_handle
30795 .as_ref()
30796 .expect("exact hit should expose a bounded source_handle window");
30797 assert!(source_handle.handle.starts_with("xwin-"));
30798 assert_eq!(source_handle.kind, "source_handle");
30799 assert_eq!(source_handle.file, "src/lib.rs");
30800 assert_eq!(source_handle.start_line, 1);
30801 assert_eq!(source_handle.end_line, 3);
30802 assert!(source_handle.expand.contains("source-read"));
30803
30804 let containing_symbol = hit
30805 .containing_symbol
30806 .as_ref()
30807 .expect("exact hit should expose its containing symbol when indexed");
30808 assert_eq!(containing_symbol.name, "alpha_helper");
30809 assert_eq!(containing_symbol.kind, "function");
30810 assert_eq!(containing_symbol.line, 1);
30811 assert_eq!(containing_symbol.end_line, Some(3));
30812 assert!(containing_symbol.expand.contains("symbol-read"));
30813
30814 let lexical_rank = report
30815 .ranked
30816 .iter()
30817 .find(|item| item.source == "lexical_file")
30818 .expect("ranked preview should retain the lexical retrieval handle");
30819 assert!(
30820 lexical_rank
30821 .reasons
30822 .iter()
30823 .any(|reason| reason == "source_handle")
30824 );
30825 assert!(
30826 lexical_rank
30827 .reasons
30828 .iter()
30829 .any(|reason| reason == "containing_symbol")
30830 );
30831 }
30832
30833 #[test]
30834 fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
30835 let dir = tempfile::tempdir().unwrap();
30836 let src_dir = dir.path().join("src");
30837 let tests_dir = dir.path().join("tests");
30838 fs::create_dir_all(&src_dir).unwrap();
30839 fs::create_dir_all(&tests_dir).unwrap();
30840 let source_file = src_dir.join("lib.rs");
30841 let test_file = tests_dir.join("alpha_test.rs");
30842 fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
30843 fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
30844
30845 let response = empty_search_response(dir.path(), "lexical");
30846 let symbol_hits = vec![
30847 index::SymbolHit {
30848 name: "alpha_helper_test".to_string(),
30849 kind: "function".to_string(),
30850 language: "rust".to_string(),
30851 file: test_file.to_string_lossy().to_string(),
30852 line: 1,
30853 end_line: Some(1),
30854 node_kind: Some("function_item".to_string()),
30855 start_byte: Some(8),
30856 end_byte: Some(33),
30857 body_start_byte: Some(31),
30858 body_end_byte: Some(31),
30859 tags: Some("alpha,helper,test".to_string()),
30860 score: 1.0,
30861 match_type: "exact_name".to_string(),
30862 tagpath_handle: None,
30863 },
30864 index::SymbolHit {
30865 name: "alpha_helper".to_string(),
30866 kind: "function".to_string(),
30867 language: "rust".to_string(),
30868 file: source_file.to_string_lossy().to_string(),
30869 line: 0,
30870 end_line: Some(0),
30871 node_kind: Some("function_item".to_string()),
30872 start_byte: Some(0),
30873 end_byte: Some(20),
30874 body_start_byte: Some(18),
30875 body_end_byte: Some(18),
30876 tags: Some("alpha,helper".to_string()),
30877 score: 0.78,
30878 match_type: "all_tags".to_string(),
30879 tagpath_handle: None,
30880 },
30881 ];
30882
30883 let report = build_relative_search_budget_report(
30884 "alpha helper",
30885 "lexical",
30886 dir.path(),
30887 &response,
30888 &symbol_hits,
30889 ResponseBudget::new(Some(5), Some(128)),
30890 &SearchFacetFilters::default(),
30891 );
30892
30893 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30894 assert_eq!(report.ranked[0].path, "src/lib.rs");
30895 assert!(
30896 report.ranked[0]
30897 .reasons
30898 .iter()
30899 .any(|reason| reason == "definition_kind")
30900 );
30901 assert!(
30902 report.ranked[0]
30903 .reasons
30904 .iter()
30905 .any(|reason| reason == "source_path")
30906 );
30907 let test_rank = report
30908 .ranked
30909 .iter()
30910 .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
30911 .expect("test symbol should still be present in the ranked preview");
30912 assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
30913 }
30914
30915 #[test]
30916 fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
30917 let dir = tempfile::tempdir().unwrap();
30918 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30919 let file = dir.path().join("README.md");
30920 fs::write(&file, source).unwrap();
30921 let summary_db =
30922 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
30923 summary_db
30924 .insert(&test_summary(
30925 "rust",
30926 "README.md",
30927 "Rust fence contains a sample function.",
30928 ))
30929 .unwrap();
30930
30931 let fence_start = source.find("```rust").unwrap();
30932 let body_start = source.find("fn sample").unwrap();
30933 let body_end = body_start + "fn sample() {}\n".len();
30934 let response = empty_search_response(dir.path(), "lexical");
30935 let symbol_hits = vec![index::SymbolHit {
30936 name: "rust".to_string(),
30937 kind: "code_block".to_string(),
30938 language: "markdown".to_string(),
30939 file: file.to_string_lossy().to_string(),
30940 line: 2,
30941 end_line: Some(4),
30942 node_kind: Some("fenced_code_block".to_string()),
30943 start_byte: Some(i64::try_from(fence_start).unwrap()),
30944 end_byte: Some(i64::try_from(source.len()).unwrap()),
30945 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30946 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30947 tags: Some("rust".to_string()),
30948 score: 1.0,
30949 match_type: "exact_name".to_string(),
30950 tagpath_handle: None,
30951 }];
30952
30953 let report = build_relative_search_budget_report(
30954 "rust",
30955 "lexical",
30956 dir.path(),
30957 &response,
30958 &symbol_hits,
30959 ResponseBudget::new(Some(5), Some(128)),
30960 &SearchFacetFilters::default(),
30961 );
30962
30963 let symbol = &report.symbols[0];
30964 assert_eq!(symbol.summary_refs, 1);
30965 assert_eq!(symbol.graph_neighbors, 1);
30966 assert!(
30967 report.ranked[0]
30968 .reasons
30969 .iter()
30970 .any(|reason| reason == "summary_refs:1")
30971 );
30972 assert!(
30973 report.ranked[0]
30974 .reasons
30975 .iter()
30976 .any(|reason| reason == "graph_neighbors:1")
30977 );
30978 }
30979
30980 fn markdown_search_facet_fixture() -> tempfile::TempDir {
30981 let dir = tempfile::tempdir().unwrap();
30982 let source = r#"# Guide
30983
30984## Install
30985
30986- Run setup.
30987 - Confirm setup.
30988
30989```rust
30990fn sample() {}
30991```
30992"#;
30993 fs::write(dir.path().join("README.md"), source).unwrap();
30994 let index_dir = dir.path().join(".tsift");
30995 fs::create_dir_all(&index_dir).unwrap();
30996 run_index_update(
30997 &index_dir.join("index.db"),
30998 dir.path(),
30999 "indexing markdown search facet fixture".to_string(),
31000 dir.path(),
31001 None,
31002 false,
31003 false,
31004 )
31005 .unwrap();
31006 dir
31007 }
31008
31009 fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
31010 let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
31011 db.symbol_search(query, 20).unwrap()
31012 }
31013
31014 #[test]
31015 fn search_facet_filters_match_scalar_symbol_fields() {
31016 let dir = tempfile::tempdir().unwrap();
31017 let hits = vec![
31018 index::SymbolHit {
31019 name: "alpha_helper".to_string(),
31020 kind: "function".to_string(),
31021 language: "rust".to_string(),
31022 file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
31023 line: 0,
31024 end_line: None,
31025 node_kind: Some("function_item".to_string()),
31026 start_byte: None,
31027 end_byte: None,
31028 body_start_byte: None,
31029 body_end_byte: None,
31030 tags: None,
31031 score: 1.0,
31032 match_type: "exact_name".to_string(),
31033 tagpath_handle: None,
31034 },
31035 index::SymbolHit {
31036 name: "Install".to_string(),
31037 kind: "heading".to_string(),
31038 language: "markdown".to_string(),
31039 file: dir.path().join("README.md").to_string_lossy().to_string(),
31040 line: 0,
31041 end_line: None,
31042 node_kind: Some("atx_heading".to_string()),
31043 start_byte: None,
31044 end_byte: None,
31045 body_start_byte: None,
31046 body_end_byte: None,
31047 tags: None,
31048 score: 0.9,
31049 match_type: "exact_name".to_string(),
31050 tagpath_handle: None,
31051 },
31052 ];
31053
31054 let filtered = apply_search_facet_filters(
31055 dir.path(),
31056 hits,
31057 &SearchFacetFilters {
31058 languages: vec!["rust".to_string()],
31059 kinds: vec!["function".to_string()],
31060 node_kinds: vec!["function_item".to_string()],
31061 ..SearchFacetFilters::default()
31062 },
31063 );
31064
31065 assert_eq!(filtered.len(), 1);
31066 assert_eq!(filtered[0].name, "alpha_helper");
31067 }
31068
31069 #[test]
31070 fn search_facet_filters_match_markdown_sections_and_block_metadata() {
31071 let dir = markdown_search_facet_fixture();
31072
31073 let nested_list = apply_search_facet_filters(
31074 dir.path(),
31075 markdown_search_facet_hits(dir.path(), "setup"),
31076 &SearchFacetFilters {
31077 sections: vec!["Install".to_string()],
31078 parents: vec!["Run setup.".to_string()],
31079 list_depths: vec![1],
31080 ..SearchFacetFilters::default()
31081 },
31082 );
31083 assert_eq!(nested_list.len(), 1);
31084 assert_eq!(nested_list[0].name, "Confirm setup.");
31085
31086 let parent_list = apply_search_facet_filters(
31087 dir.path(),
31088 markdown_search_facet_hits(dir.path(), "setup"),
31089 &SearchFacetFilters {
31090 children: vec!["Confirm setup.".to_string()],
31091 ..SearchFacetFilters::default()
31092 },
31093 );
31094 assert_eq!(parent_list.len(), 1);
31095 assert_eq!(parent_list[0].name, "Run setup.");
31096
31097 let heading = apply_search_facet_filters(
31098 dir.path(),
31099 markdown_search_facet_hits(dir.path(), "Install"),
31100 &SearchFacetFilters {
31101 heading_levels: vec![2],
31102 node_kinds: vec!["atx_heading".to_string()],
31103 ..SearchFacetFilters::default()
31104 },
31105 );
31106 assert_eq!(heading.len(), 1);
31107 assert_eq!(heading[0].name, "Install");
31108
31109 let fence = apply_search_facet_filters(
31110 dir.path(),
31111 markdown_search_facet_hits(dir.path(), "rust"),
31112 &SearchFacetFilters {
31113 fence_languages: vec!["rust".to_string()],
31114 kinds: vec!["code_block".to_string()],
31115 ..SearchFacetFilters::default()
31116 },
31117 );
31118 assert_eq!(fence.len(), 1);
31119 assert_eq!(fence[0].kind, "code_block");
31120
31121 let embedded_child = apply_search_facet_filters(
31122 dir.path(),
31123 markdown_search_facet_hits(dir.path(), "rust"),
31124 &SearchFacetFilters {
31125 children: vec!["sample".to_string()],
31126 kinds: vec!["code_block".to_string()],
31127 ..SearchFacetFilters::default()
31128 },
31129 );
31130 assert_eq!(embedded_child.len(), 1);
31131 assert_eq!(embedded_child[0].name, "rust");
31132 }
31133
31134 #[test]
31135 fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
31136 let response = empty_search_response(Path::new("/repo"), "lexical");
31137 let symbol_hits = vec![
31138 index::SymbolHit {
31139 name: "alpha_helper".to_string(),
31140 kind: "function".to_string(),
31141 language: "rust".to_string(),
31142 file: "/repo/src/lib.rs".to_string(),
31143 line: 12,
31144 end_line: None,
31145 node_kind: None,
31146 start_byte: None,
31147 end_byte: None,
31148 body_start_byte: None,
31149 body_end_byte: None,
31150 tags: Some("alpha,helper".to_string()),
31151 score: 0.98,
31152 match_type: "exact_name".to_string(),
31153 tagpath_handle: None,
31154 },
31155 index::SymbolHit {
31156 name: "alphaHelper".to_string(),
31157 kind: "method".to_string(),
31158 language: "rust".to_string(),
31159 file: "/repo/src/main.rs".to_string(),
31160 line: 34,
31161 end_line: None,
31162 node_kind: None,
31163 start_byte: None,
31164 end_byte: None,
31165 body_start_byte: None,
31166 body_end_byte: None,
31167 tags: Some("alpha,helper".to_string()),
31168 score: 0.93,
31169 match_type: "tag_overlap".to_string(),
31170 tagpath_handle: None,
31171 },
31172 index::SymbolHit {
31173 name: "alpha_helper".to_string(),
31174 kind: "function".to_string(),
31175 language: "rust".to_string(),
31176 file: "/repo/src/worker.rs".to_string(),
31177 line: 56,
31178 end_line: None,
31179 node_kind: None,
31180 start_byte: None,
31181 end_byte: None,
31182 body_start_byte: None,
31183 body_end_byte: None,
31184 tags: Some("alpha,helper".to_string()),
31185 score: 0.91,
31186 match_type: "tag_overlap".to_string(),
31187 tagpath_handle: None,
31188 },
31189 ];
31190
31191 let report = build_relative_search_budget_report(
31192 "alpha helper",
31193 "lexical",
31194 Path::new("/repo"),
31195 &response,
31196 &symbol_hits,
31197 ResponseBudget::new(Some(5), Some(48)),
31198 &SearchFacetFilters::default(),
31199 );
31200
31201 assert_eq!(report.symbol_total, 1);
31202 assert_eq!(report.raw_symbol_total, 3);
31203 assert_eq!(report.symbols.len(), 1);
31204 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
31205 assert_eq!(report.symbols[0].match_count, 3);
31206 assert_eq!(report.symbols[0].surface_count, 2);
31207 assert_eq!(report.symbols[0].file_count, 3);
31208 assert_eq!(
31209 report.symbols[0].surface_examples,
31210 vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
31211 );
31212 assert!(report.symbols[0].name.contains("(+1 variant)"));
31213 assert!(report.symbols[0].file.contains("(+2 files)"));
31214 assert!(report.symbols[0].expand.contains("tsift search"));
31215 assert!(report.symbols[0].expand.contains("alpha helper"));
31216 }
31217
31218 #[test]
31219 fn search_budget_report_carries_active_filters() {
31220 let response = empty_search_response(Path::new("/repo"), "lexical");
31221 let symbol_hits = vec![index::SymbolHit {
31222 name: "alpha_helper".to_string(),
31223 kind: "function".to_string(),
31224 language: "rust".to_string(),
31225 file: "/repo/src/lib.rs".to_string(),
31226 line: 12,
31227 end_line: None,
31228 node_kind: Some("function_item".to_string()),
31229 start_byte: None,
31230 end_byte: None,
31231 body_start_byte: None,
31232 body_end_byte: None,
31233 tags: Some("alpha,helper".to_string()),
31234 score: 0.98,
31235 match_type: "exact_name".to_string(),
31236 tagpath_handle: None,
31237 }];
31238 let filters = SearchFacetFilters {
31239 languages: vec!["rust".to_string()],
31240 kinds: vec!["function".to_string()],
31241 node_kinds: vec!["function_item".to_string()],
31242 ..SearchFacetFilters::default()
31243 };
31244
31245 let report = build_relative_search_budget_report(
31246 "alpha helper",
31247 "lexical",
31248 Path::new("/repo"),
31249 &response,
31250 &symbol_hits,
31251 ResponseBudget::new(Some(5), Some(48)),
31252 &filters,
31253 );
31254
31255 assert_eq!(report.filters, filters);
31256 assert_eq!(
31257 search_facet_filters_summary(&report.filters),
31258 "lang=rust kind=function node-kind=function_item"
31259 );
31260 }
31261
31262 #[test]
31263 fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
31264 let mut response = empty_search_response(Path::new("/repo"), "lexical");
31265 response.indexed_artifacts = 450;
31266 let symbol_hits = vec![
31267 index::SymbolHit {
31268 name: "alpha_helper".to_string(),
31269 kind: "function".to_string(),
31270 language: "rust".to_string(),
31271 file: "/repo/src/lib.rs".to_string(),
31272 line: 12,
31273 end_line: None,
31274 node_kind: None,
31275 start_byte: None,
31276 end_byte: None,
31277 body_start_byte: None,
31278 body_end_byte: None,
31279 tags: Some("alpha,helper".to_string()),
31280 score: 0.98,
31281 match_type: "exact_name".to_string(),
31282 tagpath_handle: None,
31283 },
31284 index::SymbolHit {
31285 name: "beta_helper".to_string(),
31286 kind: "function".to_string(),
31287 language: "rust".to_string(),
31288 file: "/repo/src/beta.rs".to_string(),
31289 line: 21,
31290 end_line: None,
31291 node_kind: None,
31292 start_byte: None,
31293 end_byte: None,
31294 body_start_byte: None,
31295 body_end_byte: None,
31296 tags: Some("beta,helper".to_string()),
31297 score: 0.92,
31298 match_type: "tag_overlap".to_string(),
31299 tagpath_handle: None,
31300 },
31301 ];
31302
31303 let report = build_relative_search_budget_report(
31304 "helper",
31305 "lexical",
31306 Path::new("/repo"),
31307 &response,
31308 &symbol_hits,
31309 ResponseBudget::new(Some(1), Some(64)),
31310 &SearchFacetFilters::default(),
31311 );
31312
31313 let guard = report
31314 .scale_guard
31315 .as_ref()
31316 .expect("broad previews should emit a scale guard");
31317 assert_eq!(guard.level, "high-hit");
31318 assert_eq!(guard.signals.indexed_artifacts, 450);
31319 assert_eq!(guard.signals.raw_symbol_matches, 2);
31320 assert!(
31321 guard
31322 .narrow_commands
31323 .iter()
31324 .any(|command| command.contains("--exact"))
31325 );
31326 assert!(
31327 guard
31328 .narrow_commands
31329 .iter()
31330 .any(|command| command.contains("alpha helper"))
31331 );
31332 assert!(
31333 guard
31334 .narrow_commands
31335 .last()
31336 .unwrap()
31337 .contains("workflow search")
31338 );
31339 }
31340
31341 #[test]
31342 fn explain_budget_report_limits_edges_and_members() {
31343 let symbols = vec![index::StoredSymbol {
31344 name: "alpha_helper".to_string(),
31345 kind: "function".to_string(),
31346 language: "rust".to_string(),
31347 signature: None,
31348 file: "src/lib.rs".to_string(),
31349 line: 10,
31350 end_line: None,
31351 node_kind: None,
31352 start_byte: None,
31353 end_byte: None,
31354 body_start_byte: None,
31355 body_end_byte: None,
31356 parent_module: None,
31357 visibility: None,
31358 tags: None,
31359 tagpath_handle: None,
31360 }];
31361 let callers = vec![
31362 index::StoredEdge {
31363 caller_file: "src/main.rs".to_string(),
31364 caller_name: "main".to_string(),
31365 caller_line: 1,
31366 callee_name: "alpha_helper".to_string(),
31367 call_site_line: 3,
31368 tagpath_handle: None,
31369 },
31370 index::StoredEdge {
31371 caller_file: "src/worker.rs".to_string(),
31372 caller_name: "worker".to_string(),
31373 caller_line: 5,
31374 callee_name: "alpha_helper".to_string(),
31375 call_site_line: 8,
31376 tagpath_handle: None,
31377 },
31378 ];
31379 let community = graph::Community {
31380 id: 1,
31381 members: vec![
31382 graph::CommunityMember::new("alpha_helper"),
31383 graph::CommunityMember::new("main"),
31384 graph::CommunityMember::new("worker"),
31385 ],
31386 modularity_contribution: 0.5,
31387 };
31388
31389 let report = build_explain_budget_report(
31390 "alpha_helper",
31391 Path::new("/repo"),
31392 &symbols,
31393 &callers,
31394 2,
31395 false,
31396 &[],
31397 0,
31398 false,
31399 Some(&community),
31400 ResponseBudget::new(Some(1), Some(24)),
31401 );
31402
31403 assert_eq!(report.definitions.len(), 1);
31404 assert_eq!(report.callers.len(), 1);
31405 assert!(report.truncated);
31406 assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
31407 assert_eq!(
31408 report.definitions[0].tag_alias.as_deref(),
31409 Some("alpha/helper")
31410 );
31411 assert!(report.callers[0].handle.starts_with("ecall-"));
31412 assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
31413 }
31414
31415 #[test]
31416 fn session_review_next_context_budget_limits_lists() {
31417 let report = session_review::SessionReviewReport {
31418 root: "/repo".to_string(),
31419 target: "tasks/software/tsift.md".to_string(),
31420 target_kind: "file".to_string(),
31421 sessions_considered: 1,
31422 sessions_matched: 1,
31423 claude_sessions: 1,
31424 codex_sessions: 0,
31425 agent_doc_logs: 0,
31426 prompt_target_count: 2,
31427 command_groups: 0,
31428 file_groups: 2,
31429 symbol_groups: 1,
31430 failure_groups: 1,
31431 runtime_event_groups: 0,
31432 restart_churn_groups: 0,
31433 closeout_groups: 0,
31434 usage_samples: 1,
31435 prompt_tokens: 120,
31436 cached_input_tokens: 80,
31437 cache_creation_input_tokens: 0,
31438 output_tokens: 40,
31439 reasoning_output_tokens: 0,
31440 total_tokens: 240,
31441 cached_input_ratio: Some(40.0),
31442 largest_turn_total_tokens: 240,
31443 aggregate_cost: session_review::SessionReviewCostSummary {
31444 scope: "bounded_matched_sessions".to_string(),
31445 sessions: 1,
31446 usage_samples: 1,
31447 prompt_tokens: 120,
31448 cached_input_tokens: 80,
31449 cache_creation_input_tokens: 0,
31450 output_tokens: 40,
31451 reasoning_output_tokens: 0,
31452 total_tokens: 240,
31453 cached_input_ratio: Some(40.0),
31454 largest_turn_total_tokens: 240,
31455 },
31456 latest_session_cost: Some(session_review::SessionReviewCostSummary {
31457 scope: "latest_matched_session".to_string(),
31458 sessions: 1,
31459 usage_samples: 1,
31460 prompt_tokens: 120,
31461 cached_input_tokens: 80,
31462 cache_creation_input_tokens: 0,
31463 output_tokens: 40,
31464 reasoning_output_tokens: 0,
31465 total_tokens: 240,
31466 cached_input_ratio: Some(66.67),
31467 largest_turn_total_tokens: 240,
31468 }),
31469 prompt_cache_cross_run: None,
31470 prompt_cache_roi_scorecard: vec![],
31471 guardrails: vec![
31472 session_cost::SessionCostGuardrail {
31473 kind: "cache_resend".to_string(),
31474 severity: "warn".to_string(),
31475 message: "cached input ratio was high".to_string(),
31476 guidance: "compact or restart the session".to_string(),
31477 },
31478 session_cost::SessionCostGuardrail {
31479 kind: "prompt_budget".to_string(),
31480 severity: "warn".to_string(),
31481 message: "largest prompt turn reached 999999 tokens".to_string(),
31482 guidance: "compact the session before another large turn".to_string(),
31483 },
31484 session_cost::SessionCostGuardrail {
31485 kind: "restart_loop".to_string(),
31486 severity: "warn".to_string(),
31487 message: "restart churn detected".to_string(),
31488 guidance: "restart cleanly".to_string(),
31489 },
31490 session_cost::SessionCostGuardrail {
31491 kind: "noop_closeout".to_string(),
31492 severity: "warn".to_string(),
31493 message: "commit_already_current appeared 8 times".to_string(),
31494 guidance: "avoid reopening without new edits".to_string(),
31495 },
31496 ],
31497 loop_clusters: vec![session_cost::SessionCostLoopCluster {
31498 kind: "command_bundle".to_string(),
31499 label: "cargo test -> cargo build --release".to_string(),
31500 occurrences: 2,
31501 max_consecutive: 2,
31502 }],
31503 file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
31504 path: "src/lib.rs".to_string(),
31505 range: "12-40".to_string(),
31506 occurrences: 3,
31507 estimated_tokens: 1200,
31508 duplicate_estimated_tokens: 800,
31509 follow_up_commands: vec![
31510 "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
31511 .to_string(),
31512 ],
31513 }],
31514 prompt_targets: vec![
31515 session_review::SessionReviewPromptTarget {
31516 text: "do one".to_string(),
31517 occurrences: 1,
31518 },
31519 session_review::SessionReviewPromptTarget {
31520 text: "do two".to_string(),
31521 occurrences: 1,
31522 },
31523 ],
31524 commands: vec![],
31525 touched_files: vec![],
31526 touched_symbols: vec![],
31527 failures: vec![],
31528 runtime_events: vec![],
31529 restart_churn: vec![],
31530 closeout: vec![],
31531 largest_turns: vec![],
31532 sessions: vec![session_review::SessionReviewSession {
31533 source: "claude_jsonl".to_string(),
31534 path: "/tmp/session.jsonl".to_string(),
31535 matched_by: vec!["path".to_string()],
31536 modified_unix_secs: None,
31537 prompt_target_count: 2,
31538 command_groups: 0,
31539 file_groups: 2,
31540 symbol_groups: 1,
31541 failure_groups: 1,
31542 runtime_event_groups: 0,
31543 restart_churn_groups: 0,
31544 closeout_groups: 0,
31545 usage_samples: 1,
31546 prompt_tokens: 120,
31547 cached_input_tokens: 80,
31548 cache_creation_input_tokens: 0,
31549 output_tokens: 40,
31550 reasoning_output_tokens: 0,
31551 total_tokens: 240,
31552 largest_turn_total_tokens: 240,
31553 }],
31554 next_context: session_review::SessionReviewNextContext {
31555 target: "tasks/software/tsift.md".to_string(),
31556 active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
31557 last_verification: session_review::SessionReviewVerificationState {
31558 status: "green".to_string(),
31559 detail: "cargo test".to_string(),
31560 },
31561 touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
31562 touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
31563 unresolved_failures: vec![session_review::SessionReviewFailure {
31564 kind: "timeout".to_string(),
31565 message: "search timed out".to_string(),
31566 occurrences: 1,
31567 command: None,
31568 session_path: None,
31569 }],
31570 agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
31571 active_queue_prompt: Some(
31572 "[#one] do one with enough detail to truncate".to_string(),
31573 ),
31574 live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
31575 backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
31576 review_rows: vec![
31577 "[#review] review one".to_string(),
31578 "[#review2] review two".to_string(),
31579 ],
31580 prompt_presets: vec![
31581 "#spec-test-build-install-commit-push: update spec + tests"
31582 .to_string(),
31583 "#next-steps: collect follow-ups".to_string(),
31584 ],
31585 expansion_handles: vec![
31586 session_review::SessionReviewAgentDocExpansionHandle {
31587 handle: "adq-next-context".to_string(),
31588 label: "refresh next-context".to_string(),
31589 expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
31590 },
31591 session_review::SessionReviewAgentDocExpansionHandle {
31592 handle: "adq-context-pack".to_string(),
31593 label: "refresh context-pack".to_string(),
31594 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
31595 },
31596 ],
31597 }),
31598 prompt_cache_health: None,
31599 next_digest_commands: vec![
31600 "tsift session-review --next-context tasks/software/tsift.md".to_string(),
31601 "tsift diff-digest .".to_string(),
31602 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
31603 "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
31604 ],
31605 },
31606 warnings: vec![],
31607 };
31608
31609 let budget_report = build_session_review_next_context_budget_report(
31610 &report,
31611 ResponseBudget::new(Some(1), Some(12)),
31612 None,
31613 );
31614
31615 assert!(budget_report.truncated);
31616 assert_eq!(budget_report.prompt_targets, vec!["do one"]);
31617 assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
31618 assert!(
31619 budget_report.touched_symbol_refs[0]
31620 .handle
31621 .starts_with("ncsym-")
31622 );
31623 assert_eq!(
31624 budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
31625 Some("alpha/helper")
31626 );
31627 assert!(
31628 budget_report.unresolved_failures[0]
31629 .handle
31630 .starts_with("snf-")
31631 );
31632 assert_eq!(budget_report.next_digest_commands.len(), 4);
31633 assert_eq!(
31634 budget_report.next_digest_commands[2],
31635 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
31636 );
31637 let queue = budget_report
31638 .agent_doc_queue
31639 .as_ref()
31640 .expect("agent-doc queue budget profile should be present");
31641 assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
31642 assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
31643 assert_eq!(queue.review_row_total, 2);
31644 assert_eq!(queue.prompt_presets.len(), 1);
31645 assert_eq!(queue.expansion_handles.len(), 2);
31646 assert!(queue.truncated);
31647 assert_eq!(budget_report.next_token_actions.len(), 1);
31648 assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
31649
31650 let full_action_report = build_session_review_next_context_budget_report(
31651 &report,
31652 ResponseBudget::new(Some(6), Some(120)),
31653 None,
31654 );
31655 assert_eq!(
31656 full_action_report
31657 .next_token_actions
31658 .iter()
31659 .map(|action| action.kind.as_str())
31660 .collect::<Vec<_>>(),
31661 vec![
31662 "prompt_budget",
31663 "cache_resend",
31664 "repeated_raw_read",
31665 "repeated_command_bundle",
31666 "restart_loop",
31667 "noop_closeout"
31668 ]
31669 );
31670 assert_eq!(
31671 full_action_report.next_token_actions[0]
31672 .compact_command
31673 .as_deref(),
31674 Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
31675 );
31676 assert_eq!(
31677 full_action_report.next_token_actions[0]
31678 .restart_command
31679 .as_deref(),
31680 Some("agent-doc start \"tasks/software/tsift.md\"")
31681 );
31682 assert!(
31683 full_action_report.next_token_actions[0]
31684 .digest_commands
31685 .iter()
31686 .any(|command| command
31687 == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
31688 );
31689 let raw_read_action = full_action_report
31690 .next_token_actions
31691 .iter()
31692 .find(|action| action.kind == "repeated_raw_read")
31693 .expect("raw read action");
31694 assert!(
31695 raw_read_action.rewrite_commands.iter().any(
31696 |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
31697 ),
31698 "raw read rewrite commands: {:?}",
31699 raw_read_action.rewrite_commands
31700 );
31701 assert!(raw_read_action.rewrite_commands.iter().any(|command| command
31702 == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
31703 let command_bundle_action = full_action_report
31704 .next_token_actions
31705 .iter()
31706 .find(|action| action.kind == "repeated_command_bundle")
31707 .expect("command bundle action");
31708 assert!(
31709 command_bundle_action
31710 .rewrite_commands
31711 .iter()
31712 .any(|command| command == "tsift rewrite --run \"cargo test\"")
31713 );
31714 assert!(
31715 command_bundle_action
31716 .rewrite_commands
31717 .iter()
31718 .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
31719 );
31720 }
31721
31722 #[test]
31723 fn context_pack_diff_preview_limits_files_and_symbols() {
31724 let report = diff_digest::DiffDigestReport {
31725 root: "/repo".to_string(),
31726 mode: diff_digest::DiffDigestMode::WorkingTree,
31727 revision: None,
31728 files_changed: 2,
31729 files_with_current_summaries: 1,
31730 symbols_touched: 3,
31731 call_edges_added: 1,
31732 call_edges_removed: 0,
31733 files: vec![
31734 diff_digest::DiffDigestFile {
31735 path: "src/lib.rs".to_string(),
31736 status: diff_digest::DiffDigestFileStatus::Modified,
31737 touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
31738 summary_state: diff_digest::DiffDigestSummaryState::Current,
31739 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31740 symbol: "alpha_helper".to_string(),
31741 summary: "alpha helper handles the main alpha workflow".to_string(),
31742 }],
31743 added_call_edges: vec!["alpha->beta".to_string()],
31744 removed_call_edges: vec![],
31745 warnings: vec!["stale parse".to_string()],
31746 },
31747 diff_digest::DiffDigestFile {
31748 path: "src/main.rs".to_string(),
31749 status: diff_digest::DiffDigestFileStatus::Added,
31750 touched_symbols: vec!["main".to_string()],
31751 summary_state: diff_digest::DiffDigestSummaryState::Missing,
31752 current_summaries: vec![],
31753 added_call_edges: vec![],
31754 removed_call_edges: vec![],
31755 warnings: vec![],
31756 },
31757 ],
31758 };
31759
31760 let preview =
31761 build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
31762
31763 assert!(preview.truncated);
31764 assert_eq!(preview.files.len(), 1);
31765 assert_eq!(preview.files[0].path, "src/lib.rs");
31766 assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
31767 assert!(
31768 preview.files[0].touched_symbol_refs[0]
31769 .handle
31770 .starts_with("cdsym-")
31771 );
31772 assert_eq!(
31773 preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
31774 Some("alpha/he...")
31775 );
31776 assert!(
31777 preview.files[0].summary_refs[0]
31778 .handle
31779 .starts_with("cdsum-")
31780 );
31781 assert_eq!(
31782 preview.files[0].summary_refs[0].tag_alias.as_deref(),
31783 Some("alpha/he...")
31784 );
31785 assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
31786 assert_eq!(
31787 preview.files[0].summary_refs[0].expand,
31788 "tsift summarize --file \"src/lib.rs\""
31789 );
31790 assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
31791 }
31792
31793 #[test]
31794 fn context_pack_status_reminders_include_stale_index_state() {
31795 let dir = setup_graph_index();
31796 std::thread::sleep(std::time::Duration::from_millis(50));
31797 std::fs::write(
31798 dir.path().join("main.rs"),
31799 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
31800 )
31801 .unwrap();
31802
31803 let reminders = context_pack_status_reminders(dir.path());
31804
31805 assert_eq!(reminders.len(), 1);
31806 assert!(reminders[0].contains("index stale"));
31807 assert!(reminders[0].contains("tsift index ."));
31808 }
31809
31810 #[test]
31817 fn build_context_pack_reuses_inspect_within_scope() {
31818 let dir = setup_graph_index();
31819 init_git_repo(dir.path());
31820 let _guard = index::InspectScopeGuard::new();
31821 let _ = build_context_pack_report(
31822 dir.path(),
31823 None,
31824 None,
31825 None,
31826 ResponseBudget::new(Some(2), Some(96)),
31827 )
31828 .unwrap();
31829 let (hits, misses) = index::inspect_scope_stats();
31830 assert!(
31831 hits >= 1,
31832 "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
31833 );
31834 assert!(
31835 misses >= 1,
31836 "expected at least one initial inspect miss (hits={hits}, misses={misses})"
31837 );
31838 }
31839
31840 #[test]
31845 fn inspect_read_only_outside_scope_does_not_cache() {
31846 let dir = setup_graph_index();
31847 let db_path = dir.path().join(".tsift/index.db");
31848 let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31849 let (hits, misses) = index::inspect_scope_stats();
31850 assert_eq!(
31851 (hits, misses),
31852 (0, 0),
31853 "no scope guard => no hits/misses recorded"
31854 );
31855 let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31856 let (hits, _) = index::inspect_scope_stats();
31857 assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
31858 }
31859
31860 #[test]
31861 fn context_pack_refreshes_stale_index_before_handoff() {
31862 let dir = setup_graph_index();
31863 init_git_repo(dir.path());
31864 std::thread::sleep(std::time::Duration::from_millis(50));
31865 std::fs::write(
31866 dir.path().join("main.rs"),
31867 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
31868 )
31869 .unwrap();
31870
31871 let report = build_context_pack_report(
31872 dir.path(),
31873 None,
31874 None,
31875 None,
31876 ResponseBudget::new(Some(2), Some(96)),
31877 )
31878 .unwrap();
31879
31880 assert!(
31881 report
31882 .status_reminders
31883 .iter()
31884 .any(|reminder| reminder.contains("index refreshed")
31885 && reminder.contains("context-pack handoff")),
31886 "expected context-pack refresh diagnostic, got {:?}",
31887 report.status_reminders
31888 );
31889 assert!(
31890 !report
31891 .status_reminders
31892 .iter()
31893 .any(|reminder| reminder.contains("index stale")),
31894 "stale reminder should be gone after refresh: {:?}",
31895 report.status_reminders
31896 );
31897
31898 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
31899 let summary = db.compute_changes(dir.path()).unwrap();
31900 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
31901 }
31902
31903 #[test]
31904 fn context_pack_materializes_source_handles_into_graph_store() {
31905 let dir = tempfile::tempdir().unwrap();
31906 let packet = ExplorationPacket {
31907 budget: exploration_budget_for_counts(2, 1),
31908 relationship_map: vec![ExplorationRelation {
31909 from: "file:main.rs".to_string(),
31910 relation: "touches_symbol".to_string(),
31911 to: "symbol:helper".to_string(),
31912 label: Some("modified diff".to_string()),
31913 }],
31914 source_windows: vec![ExplorationSourceWindow {
31915 handle: "xwin-test".to_string(),
31916 file: "main.rs".to_string(),
31917 start: 1,
31918 end: 32,
31919 reason: "changed file".to_string(),
31920 expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
31921 }],
31922 worker_context: vec![ExplorationWorkerContext {
31923 handle: "xwrk-test".to_string(),
31924 target: "tasks/software/tsift.md".to_string(),
31925 summary: "do #kgnv".to_string(),
31926 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
31927 .to_string(),
31928 }],
31929 no_reread_guidance: "use windows".to_string(),
31930 };
31931
31932 let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
31933 assert_eq!(packet.source_windows[0].handle, "xwin-test");
31934
31935 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
31936 let source_handles = store.nodes_by_kind("source_handle").unwrap();
31937 assert_eq!(source_handles.len(), 1);
31938 assert_eq!(
31939 source_handles[0].properties.get("file"),
31940 Some(&"main.rs".to_string())
31941 );
31942 assert_eq!(
31943 store
31944 .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
31945 .unwrap()
31946 .len(),
31947 1
31948 );
31949 let worker_context = store.nodes_by_kind("worker_context").unwrap();
31950 assert_eq!(worker_context.len(), 1);
31951 assert_eq!(
31952 store
31953 .outgoing_edges("xwrk-test", Some("scopes_source"))
31954 .unwrap()
31955 .len(),
31956 1
31957 );
31958 }
31959
31960 #[test]
31961 fn context_pack_records_graph_orchestration_observability() {
31962 let dir = setup_traversal_project();
31963 init_git_repo(dir.path());
31964 let session = dir.path().join("tasks/software/tsift.md");
31965 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
31966
31967 let report = build_context_pack_report(
31968 &session,
31969 None,
31970 None,
31971 None,
31972 ResponseBudget::new(Some(4), Some(160)),
31973 )
31974 .unwrap();
31975
31976 assert_eq!(
31977 report.graph_orchestration.contract_version,
31978 CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
31979 );
31980 assert_eq!(
31981 report
31982 .graph_orchestration
31983 .projection_freshness
31984 .status
31985 .as_str(),
31986 "current"
31987 );
31988 assert!(!report.graph_orchestration.projection_hashes.is_empty());
31989 assert_eq!(report.graph_orchestration.readiness.status, "blocked");
31990 assert_eq!(
31991 report.graph_orchestration.readiness.reason,
31992 "summary_cache_empty"
31993 );
31994 assert!(report.graph_orchestration.readiness.fail_closed);
31995 assert!(
31996 report
31997 .graph_orchestration
31998 .readiness
31999 .next_commands
32000 .iter()
32001 .any(|command| command == "tsift summarize --extract ."),
32002 "{:?}",
32003 report.graph_orchestration.readiness.next_commands
32004 );
32005 assert!(
32006 report
32007 .graph_orchestration
32008 .evidence_packet_ids
32009 .iter()
32010 .all(|id| !id.starts_with("gevd-")),
32011 "evidence packet ids should be empty when readiness is blocked: {:?}",
32012 report.graph_orchestration.evidence_packet_ids
32013 );
32014 assert!(
32015 report
32016 .graph_orchestration
32017 .conflict_matrix_decisions
32018 .iter()
32019 .any(|decision| decision.contains("readiness blocked")),
32020 "conflict-matrix decisions should reference readiness block: {:?}",
32021 report.graph_orchestration.conflict_matrix_decisions
32022 );
32023 assert!(
32024 !report
32025 .graph_orchestration
32026 .follow_up_commands
32027 .iter()
32028 .any(|command| command.contains("conflict-matrix")),
32029 "conflict-matrix command should not appear when readiness is blocked: {:?}",
32030 report.graph_orchestration.follow_up_commands
32031 );
32032 assert!(
32033 report
32034 .graph_orchestration
32035 .follow_up_commands
32036 .iter()
32037 .any(|command| command == "tsift summarize --extract ."),
32038 "{:?}",
32039 report.graph_orchestration.follow_up_commands
32040 );
32041 assert!(
32042 !report
32043 .graph_orchestration
32044 .worker_ownership_blocks
32045 .is_empty()
32046 );
32047 }
32048
32049 #[test]
32050 fn convex_sync_report_chunks_upserts_and_tombstones() {
32051 let dir = setup_traversal_project();
32052 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
32053 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
32054 let mut snapshot = projection.to_convex_rows();
32055 snapshot.nodes.push(ConvexNodeRow {
32056 external_id: "stale-node".to_string(),
32057 kind: "backlog".to_string(),
32058 label: "stale".to_string(),
32059 properties: BTreeMap::new(),
32060 provenance: Vec::new(),
32061 freshness: None,
32062 });
32063 snapshot.edges.clear();
32064 snapshot.edges.push(ConvexEdgeRow {
32065 edge_key: "stale-edge".to_string(),
32066 from_external_id: "stale-node".to_string(),
32067 to_external_id: "stale-node".to_string(),
32068 kind: "mentions".to_string(),
32069 properties: BTreeMap::new(),
32070 provenance: Vec::new(),
32071 freshness: None,
32072 });
32073 let snapshot_path = dir.path().join("convex-snapshot.json");
32074 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
32075
32076 let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
32077
32078 assert_eq!(report.freshness.status, "stale");
32079 assert!(report.freshness.fail_closed);
32080 assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
32081 assert!(
32082 report.edge_upserts.len() > 1,
32083 "snapshot without edges should upsert local edges"
32084 );
32085 assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
32086 assert_eq!(
32087 report.chunks.first().map(|chunk| chunk.operation.as_str()),
32088 Some("delete_edges"),
32089 "edge tombstones should be planned before node tombstones"
32090 );
32091 assert!(
32092 report
32093 .chunks
32094 .iter()
32095 .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
32096 "expected chunked edge upserts, got {:?}",
32097 report.chunks
32098 );
32099 }
32100
32101 #[test]
32102 fn convex_snapshot_validation_fails_closed_when_stale() {
32103 let dir = setup_traversal_project();
32104 build_traversal_graph(dir.path(), dir.path(), None).unwrap();
32105 let snapshot = ConvexProjectionRows::default();
32106 let snapshot_path = dir.path().join("empty-convex-snapshot.json");
32107 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
32108
32109 let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
32110 assert!(
32111 err.to_string()
32112 .contains("Convex graph projection is not current"),
32113 "{err}"
32114 );
32115 }
32116
32117 #[test]
32118 fn convex_sync_report_marks_live_apply_mode_without_network() {
32119 let dir = setup_traversal_project();
32120 let report =
32121 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
32122
32123 assert!(!report.dry_run);
32124 assert!(
32125 !report
32126 .diagnostics
32127 .iter()
32128 .any(|diagnostic| diagnostic.contains("dry-run only")),
32129 "apply-mode report should not claim dry-run diagnostics"
32130 );
32131 assert!(
32132 report
32133 .chunks
32134 .iter()
32135 .any(|chunk| chunk.operation == "upsert_nodes"),
32136 "live apply mode should still expose chunked idempotent operations"
32137 );
32138 }
32139
32140 #[test]
32141 fn convex_sync_apply_round_trips_with_http_backend() {
32142 use std::net::TcpListener;
32143 use std::sync::{Arc, Mutex};
32144
32145 let dir = setup_traversal_project();
32146 let report =
32147 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
32148 let expected_chunks = report.chunks.len();
32149 assert!(expected_chunks > 0);
32150
32151 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
32152 let endpoint = format!("http://{}", listener.local_addr().unwrap());
32153 let operations = Arc::new(Mutex::new(Vec::<String>::new()));
32154 let server_operations = Arc::clone(&operations);
32155 let server = std::thread::spawn(move || {
32156 for _ in 0..expected_chunks {
32157 let (mut stream, _) = listener.accept().unwrap();
32158 let mut reader = BufReader::new(stream.try_clone().unwrap());
32159 let mut request_line = String::new();
32160 reader.read_line(&mut request_line).unwrap();
32161 assert!(request_line.starts_with("POST "));
32162
32163 let mut content_length = 0usize;
32164 loop {
32165 let mut line = String::new();
32166 reader.read_line(&mut line).unwrap();
32167 if line == "\r\n" {
32168 break;
32169 }
32170 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
32171 content_length = value.trim().parse().unwrap();
32172 }
32173 }
32174
32175 let mut body = vec![0u8; content_length];
32176 reader.read_exact(&mut body).unwrap();
32177 let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
32178 server_operations
32179 .lock()
32180 .unwrap()
32181 .push(request["operation"].as_str().unwrap().to_string());
32182
32183 let response = br#"{"status":"ok","message":"accepted"}"#;
32184 write!(
32185 stream,
32186 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
32187 response.len()
32188 )
32189 .unwrap();
32190 stream.write_all(response).unwrap();
32191 }
32192 });
32193
32194 cmd_convex_sync(
32195 ConvexSyncOptions {
32196 path: dir.path(),
32197 scope: None,
32198 snapshot: None,
32199 chunk_size: 100,
32200 remote_snapshot: false,
32201 apply: true,
32202 endpoint: Some(&endpoint),
32203 auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
32204 },
32205 OutputFormat {
32206 json_output: false,
32207 compact: true,
32208 pretty: false,
32209 terse: false,
32210 ultra_terse: false,
32211 schema: false,
32212 envelope: false,
32213 },
32214 )
32215 .unwrap();
32216 server.join().unwrap();
32217
32218 let operations = operations.lock().unwrap().clone();
32219 assert!(operations.contains(&"upsert_nodes".to_string()));
32220 assert!(operations.contains(&"upsert_edges".to_string()));
32221 }
32222
32223 #[test]
32224 fn context_pack_diff_preview_attaches_tag_ontology_refs() {
32225 let root = tempfile::tempdir().unwrap();
32226 fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
32227 fs::write(
32228 root.path().join(".naming/tags/alpha.md"),
32229 "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
32230 )
32231 .unwrap();
32232 let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
32233 let report = diff_digest::DiffDigestReport {
32234 root: root.path().display().to_string(),
32235 mode: diff_digest::DiffDigestMode::WorkingTree,
32236 revision: None,
32237 files_changed: 1,
32238 files_with_current_summaries: 1,
32239 symbols_touched: 1,
32240 call_edges_added: 0,
32241 call_edges_removed: 0,
32242 files: vec![diff_digest::DiffDigestFile {
32243 path: "src/lib.rs".to_string(),
32244 status: diff_digest::DiffDigestFileStatus::Modified,
32245 touched_symbols: vec!["alpha_helper".to_string()],
32246 summary_state: diff_digest::DiffDigestSummaryState::Current,
32247 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
32248 symbol: "alpha_helper".to_string(),
32249 summary: "alpha helper summary".to_string(),
32250 }],
32251 added_call_edges: vec![],
32252 removed_call_edges: vec![],
32253 warnings: vec![],
32254 }],
32255 };
32256
32257 let preview = build_context_pack_diff_preview(
32258 &report,
32259 ResponseBudget::new(Some(1), Some(80)),
32260 Some(&ontology),
32261 );
32262
32263 let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
32264 assert!(symbol_ref.handle.starts_with("tont-"));
32265 assert_eq!(symbol_ref.tag, "alpha");
32266 assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
32267 assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
32268 assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
32269 assert_eq!(
32270 preview.files[0].summary_refs[0].ontology_refs[0].path,
32271 ".naming/tags/alpha.md"
32272 );
32273 }
32274
32275 #[test]
32276 fn context_pack_test_preview_limits_failure_groups() {
32277 let report = test_digest::TestDigestReport {
32278 root: "/repo".to_string(),
32279 runner: "cargo".to_string(),
32280 failures: 2,
32281 grouped_failures: 2,
32282 counts: test_digest::TestDigestCounts {
32283 passed: Some(8),
32284 failed: Some(2),
32285 skipped: Some(1),
32286 },
32287 failure_groups: vec![
32288 test_digest::TestDigestFailure {
32289 tests: vec!["suite::alpha_failure".to_string()],
32290 message: "assertion failed".to_string(),
32291 path: Some("src/lib.rs".to_string()),
32292 line: Some(42),
32293 column: None,
32294 occurrences: 1,
32295 summary_state: test_digest::TestDigestSummaryState::Current,
32296 current_summaries: vec![test_digest::TestDigestSummarySnippet {
32297 symbol: "alpha_failure".to_string(),
32298 summary: "failure summary for alpha test".to_string(),
32299 }],
32300 },
32301 test_digest::TestDigestFailure {
32302 tests: vec!["suite::beta_failure".to_string()],
32303 message: "panic".to_string(),
32304 path: Some("src/main.rs".to_string()),
32305 line: Some(7),
32306 column: None,
32307 occurrences: 1,
32308 summary_state: test_digest::TestDigestSummaryState::Missing,
32309 current_summaries: vec![],
32310 },
32311 ],
32312 warnings: vec!["warning text".to_string()],
32313 };
32314
32315 let preview =
32316 build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32317
32318 assert!(preview.truncated);
32319 assert_eq!(preview.failure_groups.len(), 1);
32320 assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
32321 assert_eq!(preview.failure_groups[0].message, "assertion f...");
32322 assert!(
32323 preview.failure_groups[0].summary_refs[0]
32324 .handle
32325 .starts_with("ctsum-")
32326 );
32327 assert_eq!(
32328 preview.failure_groups[0].summary_refs[0].expand,
32329 "tsift summarize --file \"src/lib.rs\""
32330 );
32331 assert_eq!(preview.warnings, vec!["warning text"]);
32332 }
32333
32334 #[test]
32335 fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
32336 let dir = tempfile::tempdir().unwrap();
32337 let root = dir.path();
32338
32339 let small_input = "Compiling serde v1.0.130\n";
32341 let mut small = log_digest::compute(root, small_input).unwrap();
32342 maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
32343 assert!(small.raw_log_artifact.is_none());
32344 assert!(!root.join(".tsift/artifacts").exists());
32345
32346 let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
32348 let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
32349 maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
32350 let artifact = bulky
32351 .raw_log_artifact
32352 .expect("artifact attached for bulky log");
32353 assert!(artifact.handle.starts_with("logdg-"));
32354 assert_eq!(artifact.bytes, bulky_input.len());
32355 assert!(artifact.expand.contains("tsift log-digest"));
32356 assert!(artifact.expand.contains("--input"));
32357 let persisted = root.join(&artifact.path);
32358 assert!(persisted.exists(), "artifact file written to {persisted:?}");
32359 assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
32360 }
32361
32362 #[test]
32363 fn context_pack_log_preview_limits_signals_and_refs() {
32364 let report = log_digest::LogDigestReport {
32365 root: "/repo".to_string(),
32366 total_lines: 12,
32367 non_empty_lines: 10,
32368 signal_groups: 2,
32369 error_signal_groups: 1,
32370 repeated_line_groups: 2,
32371 repeated_line_occurrences: 3,
32372 line_family_groups: 0,
32373 file_ref_groups: 2,
32374 symbol_ref_groups: 2,
32375 stack_groups: 1,
32376 signals: vec![
32377 log_digest::LogDigestSignal {
32378 severity: "error".to_string(),
32379 message: "src/lib.rs:42 boom".to_string(),
32380 path: Some("src/lib.rs".to_string()),
32381 line: Some(42),
32382 column: None,
32383 occurrences: 2,
32384 summary_state: log_digest::LogDigestSummaryState::Current,
32385 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32386 symbol: "alpha_helper".to_string(),
32387 summary: "alpha helper cached log summary".to_string(),
32388 }],
32389 },
32390 log_digest::LogDigestSignal {
32391 severity: "warn".to_string(),
32392 message: "slow path".to_string(),
32393 path: None,
32394 line: None,
32395 column: None,
32396 occurrences: 1,
32397 summary_state: log_digest::LogDigestSummaryState::Unavailable,
32398 current_summaries: vec![],
32399 },
32400 ],
32401 repeated_lines: vec![
32402 log_digest::LogDigestRepeatedLine {
32403 line: "retrying work item alpha".to_string(),
32404 occurrences: 3,
32405 },
32406 log_digest::LogDigestRepeatedLine {
32407 line: "retrying work item beta".to_string(),
32408 occurrences: 2,
32409 },
32410 ],
32411 line_families: vec![],
32412 file_refs: vec![
32413 log_digest::LogDigestFileRef {
32414 path: "src/lib.rs".to_string(),
32415 line: Some(42),
32416 column: None,
32417 occurrences: 2,
32418 summary_state: log_digest::LogDigestSummaryState::Current,
32419 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32420 symbol: "alpha_helper".to_string(),
32421 summary: "alpha helper cached file summary".to_string(),
32422 }],
32423 },
32424 log_digest::LogDigestFileRef {
32425 path: "src/main.rs".to_string(),
32426 line: Some(7),
32427 column: None,
32428 occurrences: 1,
32429 summary_state: log_digest::LogDigestSummaryState::Missing,
32430 current_summaries: vec![],
32431 },
32432 ],
32433 symbol_refs: vec![
32434 log_digest::LogDigestSymbolRef {
32435 symbol: "alpha_helper".to_string(),
32436 occurrences: 2,
32437 summary_state: log_digest::LogDigestSummaryState::Current,
32438 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32439 symbol: "alpha_helper".to_string(),
32440 summary: "alpha helper cached symbol summary".to_string(),
32441 }],
32442 },
32443 log_digest::LogDigestSymbolRef {
32444 symbol: "beta_helper".to_string(),
32445 occurrences: 1,
32446 summary_state: log_digest::LogDigestSummaryState::Missing,
32447 current_summaries: vec![],
32448 },
32449 ],
32450 stack_traces: vec![log_digest::LogDigestStackGroup {
32451 frames: vec!["frame one".to_string()],
32452 occurrences: 1,
32453 }],
32454 raw_log_artifact: None,
32455 warnings: vec!["warning text".to_string()],
32456 };
32457
32458 let preview =
32459 build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32460
32461 assert!(preview.truncated);
32462 assert_eq!(preview.signals.len(), 1);
32463 assert_eq!(preview.signals[0].message, "src/lib.rs:...");
32464 assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
32465 assert_eq!(preview.file_refs.len(), 1);
32466 assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
32467 assert!(
32468 preview.signals[0].summary_refs[0]
32469 .handle
32470 .starts_with("clsum-")
32471 );
32472 assert!(
32473 preview.file_refs[0].summary_refs[0]
32474 .handle
32475 .starts_with("clfsum-")
32476 );
32477 assert!(
32478 preview.symbol_refs[0].summary_refs[0]
32479 .handle
32480 .starts_with("clssum-")
32481 );
32482 assert_eq!(
32483 preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
32484 Some("alpha/helper")
32485 );
32486 assert_eq!(
32487 preview.symbol_refs[0].summary_refs[0].expand,
32488 "tsift summarize \"alpha_helper\""
32489 );
32490 assert_eq!(preview.warnings, vec!["warning text"]);
32491 }
32492
32493 #[test]
32494 fn cli_search_rejects_exact_with_strategy_flag() {
32495 let cli = try_parse_cli([
32496 "tsift",
32497 "search",
32498 "test",
32499 "--exact",
32500 "--strategy",
32501 "lexical",
32502 ]);
32503 assert!(cli.is_err());
32504 }
32505
32506 #[test]
32507 fn cli_search_autoindexes_by_default() {
32508 let cli = parse_cli(["tsift", "search", "test"]);
32509 match cli.command {
32510 Some(Commands::Search {
32511 autoindex,
32512 no_autoindex,
32513 ..
32514 }) => {
32515 assert!(!autoindex);
32516 assert!(!no_autoindex);
32517 assert!(autoindex || !no_autoindex);
32518 }
32519 _ => panic!("expected Search command"),
32520 }
32521 }
32522
32523 #[test]
32524 fn cli_local_model_status_accepts_json_and_no_probe() {
32525 let cli = parse_cli(["tsift", "local-model", "status", "--json", "--no-probe"]);
32526 match cli.command {
32527 Some(Commands::LocalModel {
32528 command: LocalModelCommand::Status { json, no_probe },
32529 }) => {
32530 assert!(json);
32531 assert!(no_probe);
32532 }
32533 _ => panic!("expected LocalModel status command"),
32534 }
32535 }
32536
32537 #[test]
32538 fn cli_local_model_unload_accepts_probe_and_strict_flags() {
32539 let cli = parse_cli([
32540 "tsift",
32541 "local-model",
32542 "unload",
32543 "--profile",
32544 "qwen3-32b-q4",
32545 "--pre-used-mib",
32546 "200",
32547 "--post-used-mib",
32548 "800",
32549 "--provider-pid",
32550 "42",
32551 "--strict",
32552 "--json",
32553 ]);
32554 match cli.command {
32555 Some(Commands::LocalModel {
32556 command:
32557 LocalModelCommand::Unload {
32558 profile,
32559 provider_pid,
32560 pre_used_mib,
32561 post_used_mib,
32562 strict,
32563 json,
32564 ..
32565 },
32566 }) => {
32567 assert_eq!(profile, "qwen3-32b-q4");
32568 assert_eq!(provider_pid, Some(42));
32569 assert_eq!(pre_used_mib, Some(200));
32570 assert_eq!(post_used_mib, Some(800));
32571 assert!(strict);
32572 assert!(json);
32573 }
32574 _ => panic!("expected LocalModel unload command"),
32575 }
32576 }
32577
32578 #[test]
32579 fn cli_local_model_swap_parses_flags() {
32580 let cli = parse_cli([
32581 "tsift",
32582 "local-model",
32583 "swap",
32584 "--from",
32585 "qwen3-32b-q4",
32586 "--to",
32587 "qwen3-embedding-0.6b",
32588 "--provider-pid",
32589 "42",
32590 "--pre-used-mib",
32591 "200",
32592 "--post-used-mib",
32593 "180",
32594 "--strict",
32595 "--json",
32596 ]);
32597 match cli.command {
32598 Some(Commands::LocalModel {
32599 command:
32600 LocalModelCommand::Swap {
32601 from,
32602 to,
32603 provider_pid,
32604 pre_used_mib,
32605 post_used_mib,
32606 strict,
32607 json,
32608 ..
32609 },
32610 }) => {
32611 assert_eq!(from, "qwen3-32b-q4");
32612 assert_eq!(to, "qwen3-embedding-0.6b");
32613 assert_eq!(provider_pid, Some(42));
32614 assert_eq!(pre_used_mib, Some(200));
32615 assert_eq!(post_used_mib, Some(180));
32616 assert!(strict);
32617 assert!(json);
32618 }
32619 _ => panic!("expected LocalModel swap command"),
32620 }
32621 }
32622
32623 #[test]
32624 fn cli_local_model_resolve_parses_flags() {
32625 use cli::ResolveRole;
32626 let cli = parse_cli([
32627 "tsift",
32628 "local-model",
32629 "resolve",
32630 "--profile",
32631 "hash",
32632 "--role",
32633 "embed",
32634 "--no-probe",
32635 "--json",
32636 ]);
32637 match cli.command {
32638 Some(Commands::LocalModel {
32639 command:
32640 LocalModelCommand::Resolve {
32641 profile,
32642 role,
32643 no_probe,
32644 json,
32645 },
32646 }) => {
32647 assert_eq!(profile.as_deref(), Some("hash"));
32648 assert_eq!(role, ResolveRole::Embed);
32649 assert!(no_probe);
32650 assert!(json);
32651 }
32652 _ => panic!("expected LocalModel resolve command"),
32653 }
32654 }
32655
32656 #[test]
32657 fn cli_semantic_command_accepts_profile_flag() {
32658 let cli = parse_cli([
32659 "tsift",
32660 "semantic",
32661 "auth",
32662 "--profile",
32663 "qwen3-embedding-0.6b",
32664 "--json",
32665 ]);
32666 match cli.command {
32667 Some(Commands::Semantic { profile, query, .. }) => {
32668 assert_eq!(query, "auth");
32669 assert_eq!(profile.as_deref(), Some("qwen3-embedding-0.6b"));
32670 }
32671 _ => panic!("expected Semantic command"),
32672 }
32673 }
32674
32675 #[test]
32676 fn cli_summarize_command_accepts_profile_flag() {
32677 let cli = parse_cli([
32678 "tsift",
32679 "summarize",
32680 "--extract",
32681 "src",
32682 "--profile",
32683 "hash",
32684 "--json",
32685 ]);
32686 match cli.command {
32687 Some(Commands::Summarize {
32688 extract,
32689 profile,
32690 json,
32691 ..
32692 }) => {
32693 assert_eq!(extract.as_deref(), Some(std::path::Path::new("src")));
32694 assert_eq!(profile.as_deref(), Some("hash"));
32695 assert!(json);
32696 }
32697 _ => panic!("expected Summarize command"),
32698 }
32699 }
32700
32701 #[test]
32702 fn cli_local_model_lease_acquire_parses_flags() {
32703 let cli = parse_cli([
32704 "tsift",
32705 "local-model",
32706 "lease",
32707 "acquire",
32708 "--profile",
32709 "qwen3-32b-q4",
32710 "--holder-pid",
32711 "4242",
32712 "--holder-command",
32713 "corky",
32714 "--idle-ttl-seconds",
32715 "120",
32716 "--vram-baseline-mib",
32717 "200",
32718 "--lease-file",
32719 "/tmp/tsift-lease.json",
32720 "--strict",
32721 "--json",
32722 ]);
32723 match cli.command {
32724 Some(Commands::LocalModel {
32725 command:
32726 LocalModelCommand::Lease {
32727 command:
32728 LeaseCommand::Acquire {
32729 profile,
32730 holder_pid,
32731 holder_command,
32732 idle_ttl_seconds,
32733 vram_baseline_mib,
32734 lease_file,
32735 strict,
32736 json,
32737 ..
32738 },
32739 },
32740 }) => {
32741 assert_eq!(profile, "qwen3-32b-q4");
32742 assert_eq!(holder_pid, Some(4242));
32743 assert_eq!(holder_command, "corky");
32744 assert_eq!(idle_ttl_seconds, 120);
32745 assert_eq!(vram_baseline_mib, Some(200));
32746 assert_eq!(lease_file, Some(PathBuf::from("/tmp/tsift-lease.json")));
32747 assert!(strict);
32748 assert!(json);
32749 }
32750 _ => panic!("expected LocalModel lease acquire command"),
32751 }
32752 }
32753
32754 #[test]
32755 fn cli_local_model_lease_release_parses_flags() {
32756 let cli = parse_cli([
32757 "tsift",
32758 "local-model",
32759 "lease",
32760 "release",
32761 "--profile",
32762 "qwen3-embedding-0.6b",
32763 "--holder-pid",
32764 "999",
32765 "--json",
32766 ]);
32767 match cli.command {
32768 Some(Commands::LocalModel {
32769 command:
32770 LocalModelCommand::Lease {
32771 command:
32772 LeaseCommand::Release {
32773 profile,
32774 holder_pid,
32775 json,
32776 ..
32777 },
32778 },
32779 }) => {
32780 assert_eq!(profile, "qwen3-embedding-0.6b");
32781 assert_eq!(holder_pid, Some(999));
32782 assert!(json);
32783 }
32784 _ => panic!("expected LocalModel lease release command"),
32785 }
32786 }
32787
32788 #[test]
32789 fn cli_local_model_lease_show_parses_flags() {
32790 let cli = parse_cli([
32791 "tsift",
32792 "local-model",
32793 "lease",
32794 "show",
32795 "--include-stale",
32796 "--json",
32797 ]);
32798 match cli.command {
32799 Some(Commands::LocalModel {
32800 command:
32801 LocalModelCommand::Lease {
32802 command:
32803 LeaseCommand::Show {
32804 include_stale,
32805 json,
32806 ..
32807 },
32808 },
32809 }) => {
32810 assert!(include_stale);
32811 assert!(json);
32812 }
32813 _ => panic!("expected LocalModel lease show command"),
32814 }
32815 }
32816
32817 #[test]
32818 fn cli_search_accepts_no_autoindex_flag() {
32819 let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
32820 match cli.command {
32821 Some(Commands::Search {
32822 autoindex,
32823 no_autoindex,
32824 ..
32825 }) => {
32826 assert!(!autoindex);
32827 assert!(no_autoindex);
32828 }
32829 _ => panic!("expected Search command"),
32830 }
32831 }
32832
32833 #[test]
32834 fn cli_search_rejects_conflicting_autoindex_flags() {
32835 let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
32836 assert!(cli.is_err());
32837 }
32838
32839 #[test]
32842 fn cli_accepts_global_absolute_flag() {
32843 let cli = parse_cli(["tsift", "--absolute", "status"]);
32844 assert!(cli.absolute);
32845 assert!(matches!(cli.command, Some(Commands::Status { .. })));
32846 }
32847
32848 #[test]
32849 fn cli_accepts_global_tabular_flag() {
32850 let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
32851 assert!(cli.tabular);
32852 assert!(matches!(cli.command, Some(Commands::Search { .. })));
32853 }
32854
32855 #[test]
32856 fn cli_tabular_with_graph() {
32857 let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
32858 assert!(cli.tabular);
32859 assert!(matches!(cli.command, Some(Commands::Graph { .. })));
32860 }
32861
32862 #[test]
32863 fn cli_tabular_with_communities() {
32864 let cli = parse_cli(["tsift", "--tabular", "communities"]);
32865 assert!(cli.tabular);
32866 assert!(matches!(cli.command, Some(Commands::Communities { .. })));
32867 }
32868
32869 #[test]
32870 fn cli_tabular_with_explain() {
32871 let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
32872 assert!(cli.tabular);
32873 assert!(matches!(cli.command, Some(Commands::Explain { .. })));
32874 }
32875
32876 #[test]
32877 fn cli_traverse_accepts_path_target_and_html_format() {
32878 let cli = parse_cli([
32879 "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
32880 ]);
32881 match cli.command {
32882 Some(Commands::Traverse {
32883 node,
32884 to,
32885 path,
32886 format,
32887 ..
32888 }) => {
32889 assert_eq!(node.as_deref(), Some("#kgnv"));
32890 assert_eq!(to.as_deref(), Some("main"));
32891 assert_eq!(path, PathBuf::from("."));
32892 assert_eq!(format, TraverseFormat::Html);
32893 }
32894 _ => panic!("expected Traverse command"),
32895 }
32896 }
32897
32898 #[test]
32899 fn cli_parses_semantic_related_command() {
32900 let cli = parse_cli([
32901 "tsift",
32902 "semantic",
32903 "graph navigation",
32904 "--path",
32905 ".",
32906 "--kind",
32907 "all",
32908 "--limit",
32909 "3",
32910 "--json",
32911 ]);
32912 match cli.command {
32913 Some(Commands::Semantic {
32914 query,
32915 path,
32916 kind,
32917 limit,
32918 json,
32919 ..
32920 }) => {
32921 assert_eq!(query, "graph navigation");
32922 assert_eq!(path, PathBuf::from("."));
32923 assert_eq!(kind, SemanticRelatedKind::All);
32924 assert_eq!(limit, 3);
32925 assert!(json);
32926 }
32927 _ => panic!("expected Semantic command"),
32928 }
32929 }
32930
32931 #[test]
32932 fn cli_parses_convex_sync_command() {
32933 let cli = parse_cli([
32934 "tsift",
32935 "convex-sync",
32936 ".",
32937 "--snapshot",
32938 "rows.json",
32939 "--chunk-size",
32940 "25",
32941 "--json",
32942 ]);
32943 match cli.command {
32944 Some(Commands::ConvexSync {
32945 path,
32946 snapshot,
32947 chunk_size,
32948 json,
32949 ..
32950 }) => {
32951 assert_eq!(path, PathBuf::from("."));
32952 assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
32953 assert_eq!(chunk_size, 25);
32954 assert!(json);
32955 }
32956 _ => panic!("expected ConvexSync command"),
32957 }
32958 }
32959
32960 #[test]
32961 fn cli_parses_convex_sync_live_flags() {
32962 let cli = parse_cli([
32963 "tsift",
32964 "convex-sync",
32965 ".",
32966 "--remote-snapshot",
32967 "--apply",
32968 "--endpoint",
32969 "https://example.test/convex-graph",
32970 "--auth-token-env",
32971 "TSIFT_TEST_TOKEN",
32972 ]);
32973 match cli.command {
32974 Some(Commands::ConvexSync {
32975 remote_snapshot,
32976 apply,
32977 endpoint,
32978 auth_token_env,
32979 ..
32980 }) => {
32981 assert!(remote_snapshot);
32982 assert!(apply);
32983 assert_eq!(
32984 endpoint.as_deref(),
32985 Some("https://example.test/convex-graph")
32986 );
32987 assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
32988 }
32989 _ => panic!("expected ConvexSync command"),
32990 }
32991 }
32992
32993 #[test]
32994 fn cli_parses_graph_db_query() {
32995 let cli = parse_cli([
32996 "tsift",
32997 "graph-db",
32998 "--backend",
32999 "convex-snapshot",
33000 "--convex-snapshot",
33001 "rows.json",
33002 "--json",
33003 "neighborhood",
33004 "gbak-kgnv",
33005 "--depth",
33006 "2",
33007 "--edge-kind",
33008 "mentions",
33009 "--property",
33010 "path=tasks/software/tsift.md",
33011 "--cursor",
33012 "gbak-old",
33013 "--limit",
33014 "10",
33015 ]);
33016 match cli.command {
33017 Some(Commands::GraphDb {
33018 backend,
33019 convex_snapshot,
33020 json,
33021 query,
33022 ..
33023 }) => {
33024 assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
33025 assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
33026 assert!(json);
33027 match query {
33028 GraphDbQuery::Neighborhood {
33029 id,
33030 depth,
33031 edge_kind,
33032 cursor,
33033 limit,
33034 property_filters,
33035 } => {
33036 assert_eq!(id, "gbak-kgnv");
33037 assert_eq!(depth, 2);
33038 assert_eq!(edge_kind.as_deref(), Some("mentions"));
33039 assert_eq!(cursor.as_deref(), Some("gbak-old"));
33040 assert_eq!(limit, Some(10));
33041 assert_eq!(
33042 property_filters,
33043 vec!["path=tasks/software/tsift.md".to_string()]
33044 );
33045 }
33046 _ => panic!("expected graph-db neighborhood query"),
33047 }
33048 }
33049 _ => panic!("expected GraphDb command"),
33050 }
33051 }
33052
33053 #[test]
33054 fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
33055 let cli = parse_cli([
33056 "tsift",
33057 "graph-db",
33058 "--json",
33059 "backend-eval",
33060 "--candidate",
33061 "surrealdb",
33062 "--target",
33063 "gval",
33064 "--full-projection",
33065 ]);
33066 match cli.command {
33067 Some(Commands::GraphDb { json, query, .. }) => {
33068 assert!(json);
33069 match query {
33070 GraphDbQuery::BackendEval {
33071 candidates,
33072 targets,
33073 full_projection,
33074 } => {
33075 assert_eq!(candidates, vec!["surrealdb".to_string()]);
33076 assert_eq!(targets, vec!["gval".to_string()]);
33077 assert!(full_projection);
33078 }
33079 _ => panic!("expected graph-db backend-eval query"),
33080 }
33081 }
33082 _ => panic!("expected GraphDb command"),
33083 }
33084 }
33085
33086 #[test]
33087 fn cli_parses_graph_db_tokensave_backend() {
33088 let cli = parse_cli([
33089 "tsift",
33090 "graph-db",
33091 "--backend",
33092 "tokensave",
33093 "--json",
33094 "node",
33095 "fn:main",
33096 ]);
33097 match cli.command {
33098 Some(Commands::GraphDb {
33099 backend,
33100 json,
33101 query,
33102 ..
33103 }) => {
33104 assert_eq!(backend, GraphDbBackend::Tokensave);
33105 assert!(json);
33106 match query {
33107 GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
33108 _ => panic!("expected graph-db node query"),
33109 }
33110 }
33111 _ => panic!("expected GraphDb command"),
33112 }
33113 }
33114
33115 #[test]
33116 fn cli_parses_analyze_command() {
33117 let cli = parse_cli([
33118 "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
33119 "--limit", "7", "--json",
33120 ]);
33121 match cli.command {
33122 Some(Commands::Analyze {
33123 path,
33124 scope,
33125 entry_points,
33126 limit,
33127 json,
33128 }) => {
33129 assert_eq!(path, PathBuf::from("."));
33130 assert_eq!(scope.as_deref(), Some("core"));
33131 assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
33132 assert_eq!(limit, 7);
33133 assert!(json);
33134 }
33135 _ => panic!("expected Analyze command"),
33136 }
33137 }
33138
33139 #[test]
33140 fn cli_parses_graph_db_related_query() {
33141 let cli = parse_cli([
33142 "tsift",
33143 "graph-db",
33144 "--json",
33145 "related",
33146 "voice avatar memory retrieval",
33147 "--kind",
33148 "all",
33149 "--depth",
33150 "3",
33151 "--seed-limit",
33152 "4",
33153 "--limit",
33154 "12",
33155 ]);
33156 match cli.command {
33157 Some(Commands::GraphDb { json, query, .. }) => {
33158 assert!(json);
33159 match query {
33160 GraphDbQuery::Related {
33161 query,
33162 kind,
33163 depth,
33164 seed_limit,
33165 limit,
33166 } => {
33167 assert_eq!(query, "voice avatar memory retrieval");
33168 assert_eq!(kind, SemanticRelatedKind::All);
33169 assert_eq!(depth, 3);
33170 assert_eq!(seed_limit, 4);
33171 assert_eq!(limit, 12);
33172 }
33173 _ => panic!("expected graph-db related query"),
33174 }
33175 }
33176 _ => panic!("expected GraphDb command"),
33177 }
33178 }
33179
33180 #[test]
33181 fn cli_parses_graph_db_compact_query() {
33182 let cli = parse_cli([
33183 "tsift",
33184 "graph-db",
33185 "--path",
33186 ".",
33187 "compact",
33188 "--apply",
33189 "--prune-tombstones",
33190 "--confirmed-convex-reconciled",
33191 ]);
33192 match cli.command {
33193 Some(Commands::GraphDb { query, .. }) => match query {
33194 GraphDbQuery::Compact {
33195 apply,
33196 prune_tombstones,
33197 confirmed_convex_reconciled,
33198 } => {
33199 assert!(apply);
33200 assert!(prune_tombstones);
33201 assert!(confirmed_convex_reconciled);
33202 }
33203 _ => panic!("expected graph-db compact query"),
33204 },
33205 _ => panic!("expected GraphDb command"),
33206 }
33207 }
33208
33209 #[test]
33210 fn cli_parses_graph_db_snapshot_queries() {
33211 let export_cli = parse_cli([
33212 "tsift",
33213 "graph-db",
33214 "--json",
33215 "snapshot-export",
33216 "graph.db.gz",
33217 "--force",
33218 ]);
33219 match export_cli.command {
33220 Some(Commands::GraphDb { json, query, .. }) => {
33221 assert!(json);
33222 match query {
33223 GraphDbQuery::SnapshotExport { output, force } => {
33224 assert_eq!(output, PathBuf::from("graph.db.gz"));
33225 assert!(force);
33226 }
33227 _ => panic!("expected graph-db snapshot-export query"),
33228 }
33229 }
33230 _ => panic!("expected GraphDb command"),
33231 }
33232
33233 let import_cli = parse_cli([
33234 "tsift",
33235 "graph-db",
33236 "snapshot-import",
33237 "graph.db.gz",
33238 "--replace",
33239 ]);
33240 match import_cli.command {
33241 Some(Commands::GraphDb { query, .. }) => match query {
33242 GraphDbQuery::SnapshotImport { artifact, replace } => {
33243 assert_eq!(artifact, PathBuf::from("graph.db.gz"));
33244 assert!(replace);
33245 }
33246 _ => panic!("expected graph-db snapshot-import query"),
33247 },
33248 _ => panic!("expected GraphDb command"),
33249 }
33250 }
33251
33252 #[test]
33253 fn cli_parses_impact_command() {
33254 let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
33255 match cli.command {
33256 Some(Commands::Impact {
33257 path,
33258 cached,
33259 limit,
33260 ..
33261 }) => {
33262 assert_eq!(path, PathBuf::from("."));
33263 assert!(cached);
33264 assert_eq!(limit, 5);
33265 }
33266 _ => panic!("expected Impact command"),
33267 }
33268 }
33269
33270 #[test]
33271 fn cli_parses_conflict_matrix_command() {
33272 let cli = parse_cli([
33273 "tsift",
33274 "conflict-matrix",
33275 "--path",
33276 "tasks/software/tsift.md",
33277 "--depth",
33278 "4",
33279 "--limit",
33280 "12",
33281 "--impact-limit",
33282 "6",
33283 "--json",
33284 "pwcm",
33285 "#g6kf",
33286 ]);
33287 match cli.command {
33288 Some(Commands::ConflictMatrix {
33289 targets,
33290 path,
33291 depth,
33292 limit,
33293 impact_limit,
33294 json,
33295 ..
33296 }) => {
33297 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33298 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33299 assert_eq!(depth, 4);
33300 assert_eq!(limit, 12);
33301 assert_eq!(impact_limit, 6);
33302 assert!(json);
33303 }
33304 _ => panic!("expected ConflictMatrix command"),
33305 }
33306 }
33307
33308 #[test]
33309 fn cli_parses_dispatch_trace_command() {
33310 let cli = parse_cli([
33311 "tsift",
33312 "dispatch-trace",
33313 "--path",
33314 "tasks/software/tsift.md",
33315 "--format",
33316 "html",
33317 "--depth",
33318 "4",
33319 "pwcm",
33320 "#g6kf",
33321 ]);
33322 match cli.command {
33323 Some(Commands::DispatchTrace {
33324 targets,
33325 path,
33326 format,
33327 depth,
33328 ..
33329 }) => {
33330 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33331 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33332 assert_eq!(format, DispatchTraceFormat::Html);
33333 assert_eq!(depth, 4);
33334 }
33335 _ => panic!("expected DispatchTrace command"),
33336 }
33337 }
33338
33339 #[test]
33340 fn cli_parses_dependency_dag_command() {
33341 let cli = parse_cli([
33342 "tsift",
33343 "dependency-dag",
33344 "--path",
33345 "tasks/software/tsift.md",
33346 "--depth",
33347 "5",
33348 "--limit",
33349 "20",
33350 "--json",
33351 "alpha",
33352 "#beta",
33353 ]);
33354 match cli.command {
33355 Some(Commands::DependencyDag {
33356 targets,
33357 path,
33358 depth,
33359 limit,
33360 json,
33361 ..
33362 }) => {
33363 assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
33364 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33365 assert_eq!(depth, 5);
33366 assert_eq!(limit, 20);
33367 assert!(json);
33368 }
33369 _ => panic!("expected DependencyDag command"),
33370 }
33371 }
33372
33373 #[test]
33374 fn relativize_strips_root_prefix() {
33375 let root = std::path::Path::new("/home/user/project");
33376 assert_eq!(
33377 relativize("/home/user/project/src/main.rs", root),
33378 "src/main.rs"
33379 );
33380 }
33381
33382 #[test]
33383 fn relativize_leaves_non_matching_path() {
33384 let root = std::path::Path::new("/home/user/project");
33385 assert_eq!(
33386 relativize("/other/path/file.rs", root),
33387 "/other/path/file.rs"
33388 );
33389 }
33390
33391 #[test]
33392 fn relativize_leaves_already_relative() {
33393 let root = std::path::Path::new("/home/user/project");
33394 assert_eq!(relativize("src/main.rs", root), "src/main.rs");
33395 }
33396
33397 #[test]
33398 fn relativize_pathbuf_strips_prefix() {
33399 let root = std::path::Path::new("/home/user/project");
33400 let path = std::path::Path::new("/home/user/project/src/lib.rs");
33401 assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
33402 }
33403
33404 #[test]
33405 fn relativize_edges_strips_caller_file() {
33406 let root = std::path::Path::new("/tmp/proj");
33407 let mut edges = vec![index::StoredEdge {
33408 caller_file: "/tmp/proj/src/main.rs".to_string(),
33409 caller_name: "main".to_string(),
33410 caller_line: 1,
33411 callee_name: "helper".to_string(),
33412 call_site_line: 5,
33413 tagpath_handle: None,
33414 }];
33415 relativize_edges(&mut edges, root);
33416 assert_eq!(edges[0].caller_file, "src/main.rs");
33417 }
33418
33419 #[test]
33420 fn relativize_json_paths_strips_known_keys() {
33421 let root = std::path::Path::new("/tmp/proj");
33422 let mut val = serde_json::json!({
33423 "file": "/tmp/proj/src/main.rs",
33424 "path": "/tmp/proj/test.rs",
33425 "name": "/tmp/proj/not-a-path",
33426 "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
33427 });
33428 relativize_json_paths(&mut val, root);
33429 assert_eq!(val["file"], "src/main.rs");
33430 assert_eq!(val["path"], "test.rs");
33431 assert_eq!(val["name"], "/tmp/proj/not-a-path");
33432 assert_eq!(val["hits"][0]["path"], "nested.rs");
33433 }
33434
33435 #[test]
33438 fn cli_graph_accepts_limit_flag() {
33439 let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
33440 match cli.command {
33441 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
33442 _ => panic!("expected Graph command"),
33443 }
33444 }
33445
33446 #[test]
33447 fn cli_graph_default_limit_is_20() {
33448 let cli = parse_cli(["tsift", "graph", "main"]);
33449 match cli.command {
33450 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
33451 _ => panic!("expected Graph command"),
33452 }
33453 }
33454
33455 #[test]
33456 fn cli_communities_accepts_limit_flag() {
33457 let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
33458 match cli.command {
33459 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
33460 _ => panic!("expected Communities command"),
33461 }
33462 }
33463
33464 #[test]
33465 fn cli_communities_default_limit_is_10() {
33466 let cli = parse_cli(["tsift", "communities"]);
33467 match cli.command {
33468 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
33469 _ => panic!("expected Communities command"),
33470 }
33471 }
33472
33473 #[test]
33474 fn cli_explain_accepts_limit_flag() {
33475 let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
33476 match cli.command {
33477 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
33478 _ => panic!("expected Explain command"),
33479 }
33480 }
33481
33482 #[test]
33483 fn cli_explain_default_limit_is_15() {
33484 let cli = parse_cli(["tsift", "explain", "main"]);
33485 match cli.command {
33486 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
33487 _ => panic!("expected Explain command"),
33488 }
33489 }
33490
33491 #[test]
33492 fn cli_limit_zero_means_unlimited() {
33493 let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
33494 match cli.command {
33495 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
33496 _ => panic!("expected Graph command"),
33497 }
33498 }
33499
33500 #[test]
33501 fn graph_cmd_limit_runs_ok() {
33502 let dir = setup_graph_index();
33503 let result = cmd_graph(
33504 "main",
33505 dir.path(),
33506 false,
33507 false,
33508 None,
33509 1,
33510 false,
33511 false,
33512 false,
33513 false,
33514 false,
33515 false,
33516 false,
33517 TagpathSearchOpts::default(),
33518 );
33519 assert!(result.is_ok());
33520 }
33521
33522 #[test]
33523 fn graph_cmd_unlimited_runs_ok() {
33524 let dir = setup_graph_index();
33525 let result = cmd_graph(
33526 "main",
33527 dir.path(),
33528 false,
33529 false,
33530 None,
33531 0,
33532 false,
33533 false,
33534 false,
33535 false,
33536 false,
33537 false,
33538 false,
33539 TagpathSearchOpts::default(),
33540 );
33541 assert!(result.is_ok());
33542 }
33543
33544 #[test]
33545 fn graph_cmd_tabular_runs_ok() {
33546 let dir = setup_graph_index();
33547 let result = cmd_graph(
33548 "main",
33549 dir.path(),
33550 false,
33551 false,
33552 None,
33553 20,
33554 false,
33555 false,
33556 false,
33557 false,
33558 false,
33559 true,
33560 false,
33561 TagpathSearchOpts::default(),
33562 );
33563 assert!(result.is_ok());
33564 }
33565
33566 #[test]
33567 fn communities_cmd_tabular_runs_ok() {
33568 let dir = setup_graph_index();
33569 let result = cmd_communities(
33570 dir.path(),
33571 None,
33572 1,
33573 10,
33574 false,
33575 false,
33576 false,
33577 false,
33578 true,
33579 false,
33580 TagpathSearchOpts::default(),
33581 );
33582 assert!(result.is_ok());
33583 }
33584
33585 #[test]
33586 fn explain_cmd_tabular_runs_ok() {
33587 let dir = setup_graph_index();
33588 let result = cmd_explain(
33589 "main",
33590 dir.path(),
33591 None,
33592 15,
33593 false,
33594 false,
33595 false,
33596 false,
33597 false,
33598 true,
33599 false,
33600 false,
33601 );
33602 assert!(result.is_ok());
33603 }
33604
33605 #[test]
33606 fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
33607 let cases = [
33612 ".agent-doc",
33613 ".agent-doc/snapshots/abc.md",
33614 ".agent-doc/baselines/abc.md",
33615 ".agent-doc/archives/2026.md",
33616 ".agent-doc/runtime/run.jsonl",
33617 "src/foo/.agent-doc",
33618 "src/foo/.agent-doc/snapshots/x.md",
33619 "./.agent-doc/snapshots/x.md",
33620 ];
33621 for path in cases {
33622 assert!(
33623 traversal_relative_path_is_generated_artifact(path),
33624 "expected `{path}` to be excluded from source watermark"
33625 );
33626 }
33627 for path in [
33629 "src/main.rs",
33630 "tests/perf_gate.rs",
33631 "fixtures/x.json",
33632 "agent-doc/src/lib.rs", "src/.agent-doc-helper.rs",
33634 ] {
33635 assert!(
33636 !traversal_relative_path_is_generated_artifact(path),
33637 "expected `{path}` to be included in source watermark"
33638 );
33639 }
33640 }
33641
33642 #[test]
33643 fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
33644 let cases = [
33652 ".tsift",
33653 ".tsift/index.db",
33654 ".tsift/indexes/foo/index.db",
33655 ".tsift/conflict-matrix-cache/inputs/abc.json",
33656 ".tsift/summaries.db",
33657 "src/foo/.tsift",
33658 "src/foo/.tsift/graph.db",
33659 "./.tsift/index.db",
33660 "target",
33661 "target/debug/build/x",
33662 "target/release/tsift",
33663 "src/foo/target/debug/x",
33664 "./target/release/x",
33665 ];
33666 for path in cases {
33667 assert!(
33668 traversal_relative_path_is_generated_artifact(path),
33669 "expected `{path}` to be excluded from source watermark"
33670 );
33671 }
33672 for path in [
33674 "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
33675 "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
33676 "src/tsift-extras/lib.rs",
33677 "tsift/README.md",
33678 "src/targeting.rs",
33679 "src/.tsiftrc",
33680 "src/agent-doc-helper.rs",
33681 ] {
33682 assert!(
33683 !traversal_relative_path_is_generated_artifact(path),
33684 "expected `{path}` to be included in source watermark"
33685 );
33686 }
33687 }
33688
33689 #[test]
33690 fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
33691 let dir = tempfile::tempdir().unwrap();
33700 let root = dir.path();
33701 std::fs::create_dir_all(root.join("src")).unwrap();
33702 std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
33703 let hint = root.join("README.md");
33704 std::fs::write(&hint, "# stable\n").unwrap();
33705 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33707 std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
33708 std::fs::create_dir_all(root.join("target/debug")).unwrap();
33709 std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
33710
33711 let first = traversal_source_watermark(root, &hint, None, true)
33712 .expect("first watermark call must succeed")
33713 .expect("first watermark must produce a hash for hinted markdown");
33714 let second = traversal_source_watermark(root, &hint, None, true)
33715 .expect("second watermark call must succeed")
33716 .expect("second watermark must produce a hash for hinted markdown");
33717 assert_eq!(
33718 first, second,
33719 "watermark must be identical across back-to-back invocations on a quiescent root"
33720 );
33721
33722 std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
33724 std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
33725 let third = traversal_source_watermark(root, &hint, None, true)
33726 .expect("third watermark call must succeed")
33727 .expect("third watermark must produce a hash for hinted markdown");
33728 assert_eq!(
33729 first, third,
33730 "watermark must ignore mutations under .tsift/ and target/"
33731 );
33732
33733 std::thread::sleep(std::time::Duration::from_millis(20));
33738 std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
33739 let fourth = traversal_source_watermark(root, &hint, None, true)
33740 .expect("fourth watermark call must succeed")
33741 .expect("fourth watermark must produce a hash for hinted markdown");
33742 assert_ne!(
33743 first, fourth,
33744 "watermark must invalidate when the hinted markdown file changes"
33745 );
33746 }
33747
33748 #[test]
33749 fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
33750 let dir = tempfile::tempdir().unwrap();
33754 let root = dir.path();
33755 std::fs::write(root.join("README.md"), "# stable\n").unwrap();
33756 let summaries_db_path = root.join(".tsift/summaries.db");
33757 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33758 let mut summary = summarize::Summary {
33759 id: 0,
33760 symbol_name: "main".to_string(),
33761 file_path: "src/main.rs".to_string(),
33762 content_hash: "hash-main".to_string(),
33763 summary: "main wires the CLI".to_string(),
33764 entities: Some(vec![summarize::Entity {
33765 name: "Cli".to_string(),
33766 kind: "type".to_string(),
33767 description: "Command-line interface".to_string(),
33768 }]),
33769 relationships: None,
33770 concept_labels: Some(vec!["cli".to_string()]),
33771 extracted_at: "1700000000".to_string(),
33772 model: "test-model".to_string(),
33773 tokens_input: Some(10),
33774 tokens_output: Some(5),
33775 };
33776 summary_db.insert(&summary).unwrap();
33777 drop(summary_db);
33778
33779 let hint = root.join("README.md");
33780 let first = traversal_source_watermark(root, &hint, None, true)
33781 .expect("first watermark call must succeed")
33782 .expect("first watermark must produce a hash");
33783
33784 std::thread::sleep(std::time::Duration::from_millis(20));
33785 let conn = Connection::open(&summaries_db_path).unwrap();
33786 conn.pragma_update(None, "user_version", 1).unwrap();
33787 conn.pragma_update(None, "user_version", 0).unwrap();
33788 drop(conn);
33789
33790 let second = traversal_source_watermark(root, &hint, None, true)
33791 .expect("second watermark call must succeed")
33792 .expect("second watermark must produce a hash");
33793 assert_eq!(
33794 first, second,
33795 "metadata-only summaries.db churn must not invalidate the source watermark"
33796 );
33797
33798 summary.entities = Some(vec![summarize::Entity {
33799 name: "GraphCache".to_string(),
33800 kind: "type".to_string(),
33801 description: "Stable full-projection cache input".to_string(),
33802 }]);
33803 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33804 summary_db.delete_by_file("src/main.rs").unwrap();
33805 summary_db.insert(&summary).unwrap();
33806 drop(summary_db);
33807
33808 let third = traversal_source_watermark(root, &hint, None, true)
33809 .expect("third watermark call must succeed")
33810 .expect("third watermark must produce a hash");
33811 assert_ne!(
33812 first, third,
33813 "semantic summary row changes must invalidate the source watermark"
33814 );
33815 }
33816
33817 #[test]
33818 fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
33819 let dir = tempfile::tempdir().unwrap();
33823 let root = dir.path();
33824 std::fs::create_dir_all(root.join("src")).unwrap();
33825 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33826 let source = root.join("src/lib.rs");
33827 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33828 std::fs::write(&source, source_body).unwrap();
33829 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33830 db.rebuild(root).unwrap();
33831 drop(db);
33832
33833 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33834 .unwrap()
33835 .value;
33836 std::thread::sleep(std::time::Duration::from_millis(20));
33837 std::fs::write(&source, source_body).unwrap();
33838 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33839 db.apply_changes(root).unwrap();
33840 drop(db);
33841
33842 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33843 .unwrap()
33844 .value;
33845 assert_eq!(
33846 first, second,
33847 "mtime-only source index churn must not invalidate the full-projection cache"
33848 );
33849 }
33850
33851 #[test]
33852 fn full_projection_source_watermark_ignores_session_markdown_churn() {
33853 let dir = tempfile::tempdir().unwrap();
33858 let root = dir.path();
33859 std::fs::create_dir_all(root.join("src")).unwrap();
33860 std::fs::create_dir_all(root.join("tasks/software")).unwrap();
33861 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33862 std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
33863 let task_doc = root.join("tasks/software/tsift.md");
33864 std::fs::write(
33865 &task_doc,
33866 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
33867 )
33868 .unwrap();
33869 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33870 db.rebuild(root).unwrap();
33871 drop(db);
33872
33873 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33874 .unwrap()
33875 .value;
33876 std::fs::write(
33877 &task_doc,
33878 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
33879 )
33880 .unwrap();
33881 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33882 .unwrap()
33883 .value;
33884 assert_eq!(
33885 first, second,
33886 "session markdown churn must not invalidate the full-projection code/summary cache"
33887 );
33888 }
33889
33890 #[test]
33891 fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
33892 let dir = tempfile::tempdir().unwrap();
33896 let root = dir.path();
33897 std::fs::create_dir_all(root.join("src")).unwrap();
33898 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33899 let source = root.join("src/lib.rs");
33900 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33901 std::fs::write(&source, source_body).unwrap();
33902 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33903 db.rebuild(root).unwrap();
33904 drop(db);
33905
33906 let (_projection, _warnings, _phases, first_stats) =
33907 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33908 assert!(
33909 !first_stats.hit,
33910 "the first full-projection run should populate the cache"
33911 );
33912
33913 std::thread::sleep(std::time::Duration::from_millis(20));
33914 std::fs::write(&source, source_body).unwrap();
33915 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33916 db.apply_changes(root).unwrap();
33917 drop(db);
33918
33919 let (_projection, _warnings, phases, second_stats) =
33920 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33921 assert!(second_stats.hit, "mtime-only churn should still cache-hit");
33922 let source_graph_build = phases
33923 .iter()
33924 .find(|phase| phase.name == "full_projection.source_graph_build")
33925 .expect("cache hit must report source_graph_build");
33926 let projection_rows = phases
33927 .iter()
33928 .find(|phase| phase.name == "full_projection.projection_rows")
33929 .expect("cache hit must report projection_rows");
33930 assert_eq!(source_graph_build.duration_micros, 0);
33931 assert_eq!(projection_rows.duration_micros, 0);
33932 }
33933
33934 #[test]
33935 fn build_token_capped_preview_within_cap() {
33936 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 2", "}"];
33937 let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
33938 assert!(!capped.was_capped);
33939 assert_eq!(capped.preview.len(), 3);
33940 assert_eq!(capped.capped_end, 3);
33941 }
33942
33943 #[test]
33944 fn build_token_capped_preview_truncates_long_body() {
33945 let owned: Vec<String> = (0..200)
33946 .map(|i| format!(" let line_{i} = {i};"))
33947 .collect();
33948 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33949 let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
33950 assert!(capped.was_capped);
33951 assert!(capped.preview.len() < 200);
33952 assert!(capped.capped_end < 200);
33953 assert!(!capped.preview.is_empty());
33954 }
33955
33956 #[test]
33957 fn build_token_capped_preview_respects_start_offset() {
33958 let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
33959 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33960 let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
33961 assert!(capped.was_capped);
33962 assert!(capped.capped_end >= 50);
33963 assert!(capped.capped_end < 100);
33964 assert_eq!(capped.preview[0].line, 50);
33965 }
33966
33967 #[test]
33968 fn response_budget_body_token_cap_defaults() {
33969 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
33970 assert_eq!(budget.body_token_cap(), 1500);
33971
33972 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
33973 assert_eq!(budget.body_token_cap(), 500);
33974
33975 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
33976 assert_eq!(budget.body_token_cap(), 3000);
33977 }
33978
33979 #[test]
33980 fn build_token_capped_preview_empty_input() {
33981 let lines: Vec<&str> = vec![];
33982 let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
33983 assert!(!capped.was_capped);
33984 assert!(capped.preview.is_empty());
33985 }
33986
33987 #[test]
33988 fn build_token_capped_preview_single_long_line_fits() {
33989 let lines: Vec<&str> = vec!["short"];
33990 let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
33991 assert!(!capped.was_capped);
33992 assert_eq!(capped.preview.len(), 1);
33993 assert_eq!(capped.capped_end, 1);
33994 }
33995
33996 #[test]
33997 fn edge_index_replaces_from_id_to_id_with_positions() {
33998 let input = serde_json::json!({
33999 "nodes": [
34000 {"id": "symbol:src/lib.rs:foo"},
34001 {"id": "symbol:src/lib.rs:bar"},
34002 {"id": "symbol:src/lib.rs:baz"}
34003 ],
34004 "edges": [
34005 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
34006 {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
34007 ]
34008 });
34009 let result = edge_index_transform(input);
34010 let edges = result.get("edges").unwrap().as_array().unwrap();
34011 assert_eq!(edges.len(), 2);
34012 assert_eq!(edges[0]["from"], 0);
34013 assert_eq!(edges[0]["to"], 1);
34014 assert_eq!(edges[1]["from"], 1);
34015 assert_eq!(edges[1]["to"], 2);
34016 assert!(edges[0].get("from_id").is_none());
34017 assert!(edges[0].get("to_id").is_none());
34018 }
34019
34020 #[test]
34021 fn edge_index_preserves_unresolved_ids_as_strings() {
34022 let input = serde_json::json!({
34023 "nodes": [{"id": "symbol:src/lib.rs:foo"}],
34024 "edges": [
34025 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
34026 ]
34027 });
34028 let result = edge_index_transform(input);
34029 let edge = &result["edges"][0];
34030 assert_eq!(edge["from"], 0);
34031 assert_eq!(edge["to_id"], "symbol:other.rs:missing");
34032 }
34033
34034 #[test]
34035 fn edge_index_noop_without_nodes_and_edges() {
34036 let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
34037 let result = edge_index_transform(input);
34038 assert_eq!(result["report"]["entries"][0]["from_id"], "a");
34039 }
34040}
34041
34042#[derive(Serialize)]
34045struct TableInfo {
34046 name: String,
34047 columns: Vec<ColumnInfo>,
34048 row_count: i64,
34049}
34050
34051#[derive(Serialize)]
34052struct ColumnInfo {
34053 name: String,
34054 #[serde(rename = "type")]
34055 col_type: String,
34056 notnull: bool,
34057 pk: bool,
34058 #[serde(skip_serializing_if = "Option::is_none")]
34059 default_value: Option<String>,
34060}
34061
34062pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
34064 let conn = Connection::open_with_flags(
34065 path,
34066 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
34067 )
34068 .with_context(|| format!("opening database: {}", path.display()))?;
34069 Ok(conn)
34070}
34071
34072pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
34074 let mut stmt = conn.prepare(
34075 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
34076 )?;
34077 let table_names: Vec<String> = stmt
34078 .query_map([], |row| row.get(0))?
34079 .collect::<std::result::Result<Vec<_>, _>>()?;
34080
34081 let mut tables = Vec::new();
34082 for tbl in table_names {
34083 let columns = table_columns(conn, &tbl)?;
34084 let row_count: i64 =
34085 conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
34086 row.get(0)
34087 })?;
34088 tables.push(TableInfo {
34089 name: tbl,
34090 columns,
34091 row_count,
34092 });
34093 }
34094 Ok(tables)
34095}
34096
34097pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
34099 let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
34100 let cols = stmt
34101 .query_map([], |row| {
34102 Ok(ColumnInfo {
34103 name: row.get(1)?,
34104 col_type: row.get::<_, String>(2).unwrap_or_default(),
34105 notnull: row.get::<_, bool>(3).unwrap_or(false),
34106 pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
34107 default_value: row.get(4)?,
34108 })
34109 })?
34110 .collect::<std::result::Result<Vec<_>, _>>()?;
34111 Ok(cols)
34112}
34113
34114pub(crate) fn execute_query(
34116 conn: &Connection,
34117 sql: &str,
34118) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
34119 let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
34120 let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
34121 let col_count = col_names.len();
34122
34123 let mut rows = Vec::new();
34124 let mut query_rows = stmt.query([])?;
34125 while let Some(row) = query_rows.next()? {
34126 let mut vals = Vec::with_capacity(col_count);
34127 for i in 0..col_count {
34128 let val = match row.get_ref(i)? {
34129 rusqlite::types::ValueRef::Null => serde_json::Value::Null,
34130 rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
34131 rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
34132 rusqlite::types::ValueRef::Text(s) => {
34133 serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
34134 }
34135 rusqlite::types::ValueRef::Blob(b) => {
34136 serde_json::Value::String(format!("<blob {} bytes>", b.len()))
34137 }
34138 };
34139 vals.push(val);
34140 }
34141 rows.push(vals);
34142 }
34143 Ok((col_names, rows))
34144}
34145
34146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34147enum DigestRunnerKind {
34148 Test,
34149 Log,
34150}
34151
34152impl DigestRunnerKind {
34153 fn parse(raw: &str) -> Result<Self> {
34154 match raw.trim().to_ascii_lowercase().as_str() {
34155 "test" => Ok(Self::Test),
34156 "log" => Ok(Self::Log),
34157 other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
34158 }
34159 }
34160
34161 fn as_str(self) -> &'static str {
34162 match self {
34163 Self::Test => "test",
34164 Self::Log => "log",
34165 }
34166 }
34167}
34168
34169pub(crate) fn shell_split(s: &str) -> Vec<&str> {
34171 let mut parts = Vec::new();
34172 let mut i = 0;
34173 let bytes = s.as_bytes();
34174 while i < bytes.len() {
34175 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
34177 i += 1;
34178 }
34179 if i >= bytes.len() {
34180 break;
34181 }
34182 let start = i;
34183 if bytes[i] == b'"' || bytes[i] == b'\'' {
34184 let quote = bytes[i];
34185 i += 1;
34186 while i < bytes.len() && bytes[i] != quote {
34187 i += 1;
34188 }
34189 if i < bytes.len() {
34190 i += 1; }
34192 } else {
34193 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
34194 i += 1;
34195 }
34196 }
34197 parts.push(&s[start..i]);
34198 }
34199 parts
34200}
34201
34202pub(crate) fn shell_quote(s: &str) -> String {
34204 let unquoted =
34206 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
34207 &s[1..s.len() - 1]
34208 } else {
34209 s
34210 };
34211
34212 if unquoted
34213 .chars()
34214 .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
34215 {
34216 format!("\"{}\"", unquoted)
34217 } else {
34218 format!(
34219 "\"{}\"",
34220 unquoted.replace('\\', "\\\\").replace('"', "\\\"")
34221 )
34222 }
34223}
34224
34225fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
34226 sift::SearchCoverageSnapshot {
34227 mode: sift::SearchCoverageMode::Sealed,
34228 total_sector_count: 0,
34229 mounted_sector_count: 0,
34230 reused_sector_count: 0,
34231 dirty_sector_count: 0,
34232 completed_dirty_sector_count: 0,
34233 rebuilding_sector_count: 0,
34234 resumed_sector_count: 0,
34235 active_rebuild: None,
34236 }
34237}
34238
34239fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
34240 let total_sector_count = responses
34241 .iter()
34242 .map(|response| response.coverage.total_sector_count)
34243 .sum();
34244 let mounted_sector_count = responses
34245 .iter()
34246 .map(|response| response.coverage.mounted_sector_count)
34247 .sum();
34248 let reused_sector_count = responses
34249 .iter()
34250 .map(|response| response.coverage.reused_sector_count)
34251 .sum();
34252 let dirty_sector_count = responses
34253 .iter()
34254 .map(|response| response.coverage.dirty_sector_count)
34255 .sum();
34256 let completed_dirty_sector_count = responses
34257 .iter()
34258 .map(|response| response.coverage.completed_dirty_sector_count)
34259 .sum();
34260 let rebuilding_sector_count = responses
34261 .iter()
34262 .map(|response| response.coverage.rebuilding_sector_count)
34263 .sum();
34264 let resumed_sector_count = responses
34265 .iter()
34266 .map(|response| response.coverage.resumed_sector_count)
34267 .sum();
34268
34269 let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
34270 sift::SearchCoverageMode::Sealed
34271 } else if completed_dirty_sector_count > 0
34272 || rebuilding_sector_count > 0
34273 || resumed_sector_count > 0
34274 {
34275 sift::SearchCoverageMode::Converging
34276 } else {
34277 sift::SearchCoverageMode::Frontier
34278 };
34279
34280 sift::SearchCoverageSnapshot {
34281 mode,
34282 total_sector_count,
34283 mounted_sector_count,
34284 reused_sector_count,
34285 dirty_sector_count,
34286 completed_dirty_sector_count,
34287 rebuilding_sector_count,
34288 resumed_sector_count,
34289 active_rebuild: responses
34290 .iter()
34291 .find_map(|response| response.coverage.active_rebuild.clone()),
34292 }
34293}
34294
34295fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
34296 sift::SearchResponse {
34297 strategy: strategy.to_string(),
34298 root: root.display().to_string(),
34299 indexed_artifacts: 0,
34300 skipped_artifacts: 0,
34301 coverage: empty_search_coverage(),
34302 hits: Vec::new(),
34303 }
34304}
34305
34306fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
34307 for hit in &mut response.hits {
34308 let path = Path::new(&hit.path);
34309 if path.is_relative() {
34310 hit.path = search_root.join(path).display().to_string();
34311 }
34312 }
34313}
34314
34315fn merge_search_responses(
34316 root: &Path,
34317 strategy: &str,
34318 limit: usize,
34319 responses: Vec<sift::SearchResponse>,
34320) -> sift::SearchResponse {
34321 let indexed_artifacts = responses
34322 .iter()
34323 .map(|response| response.indexed_artifacts)
34324 .sum();
34325 let skipped_artifacts = responses
34326 .iter()
34327 .map(|response| response.skipped_artifacts)
34328 .sum();
34329 let coverage = if responses.is_empty() {
34330 empty_search_coverage()
34331 } else {
34332 aggregate_search_coverage(&responses)
34333 };
34334 let mut hits: Vec<sift::SearchHit> = responses
34335 .into_iter()
34336 .flat_map(|response| response.hits)
34337 .collect();
34338 hits.sort_by(|left, right| {
34339 right
34340 .score
34341 .partial_cmp(&left.score)
34342 .unwrap_or(Ordering::Equal)
34343 .then_with(|| left.path.cmp(&right.path))
34344 .then_with(|| left.location.cmp(&right.location))
34345 });
34346 hits.truncate(limit);
34347 for (rank, hit) in hits.iter_mut().enumerate() {
34348 hit.rank = rank + 1;
34349 }
34350
34351 sift::SearchResponse {
34352 strategy: strategy.to_string(),
34353 root: root.display().to_string(),
34354 indexed_artifacts,
34355 skipped_artifacts,
34356 coverage,
34357 hits,
34358 }
34359}
34360
34361pub(crate) fn federated_sift_search(
34362 root: &Path,
34363 cache_dir: &Path,
34364 query: &str,
34365 limit: usize,
34366 timeout_secs: u64,
34367 strategy: &str,
34368 fts_index_fresh: Option<bool>,
34369) -> Result<sift::SearchResponse> {
34370 let targets = resolve_search_index_targets(root, root, None, true)?;
34371 if targets.is_empty() {
34372 if config::Config::submodule_dirs(root)?.is_empty() {
34373 return run_search_with_timeout(
34374 root,
34375 cache_dir,
34376 query,
34377 limit,
34378 timeout_secs,
34379 strategy,
34380 &[],
34381 fts_index_fresh,
34382 );
34383 }
34384 return Ok(empty_search_response(root, strategy));
34385 }
34386
34387 let mut responses = Vec::with_capacity(targets.len());
34388 for target in &targets {
34389 let mut response = run_search_with_timeout(
34390 &target.source_root,
34391 cache_dir,
34392 query,
34393 limit,
34394 timeout_secs,
34395 strategy,
34396 std::slice::from_ref(target),
34397 fts_index_fresh,
34398 )?;
34399 absolutize_search_hit_paths(&mut response, &target.source_root);
34400 response.root = root.display().to_string();
34401 responses.push(response);
34402 }
34403
34404 Ok(merge_search_responses(root, strategy, limit, responses))
34405}
34406
34407pub(crate) fn federated_symbol_search(
34415 root: &std::path::Path,
34416 query: &str,
34417 limit: usize,
34418 tagpath_opts: &TagpathSearchOpts,
34419) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
34420 let cfg = config::Config::load(root)?;
34421 let submodules = config::Config::submodule_dirs(root)?;
34422 let mut all_hits: Vec<index::SymbolHit> = Vec::new();
34423 let mut combined = TagpathAnnotationDiagnostic::default();
34424 for scope in &submodules {
34425 if !cfg.federation_for_scope(scope) {
34426 continue;
34427 }
34428 let db_path = cfg.db_path_for(root, &scope.id);
34429 if !db_path.exists() {
34430 continue;
34431 }
34432 let db = index::IndexDb::open_read_only(&db_path)?;
34433 let mut hits = db.symbol_search(query, limit)?;
34434 let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
34435 combined.loaded |= diag.loaded;
34436 if diag.stale && !combined.stale {
34437 combined.stale = true;
34438 combined.reason = diag.reason;
34439 }
34440 all_hits.append(&mut hits);
34441 }
34442 all_hits.sort_by(|a, b| {
34443 b.score
34444 .partial_cmp(&a.score)
34445 .unwrap_or(std::cmp::Ordering::Equal)
34446 });
34447 all_hits.truncate(limit);
34448 Ok((all_hits, combined))
34449}
34450
34451#[derive(Debug, Deserialize)]
34452#[serde(tag = "type", rename_all = "lowercase")]
34453enum RipgrepJsonEvent {
34454 Match {
34455 data: RipgrepMatchData,
34456 },
34457 #[serde(other)]
34458 Other,
34459}
34460
34461#[derive(Debug, Deserialize)]
34462struct RipgrepMatchData {
34463 path: RipgrepTextField,
34464 lines: RipgrepTextField,
34465 line_number: Option<usize>,
34466}
34467
34468#[derive(Debug, Deserialize)]
34469struct RipgrepTextField {
34470 text: Option<String>,
34471}
34472
34473pub(crate) fn federated_exact_search(
34474 root: &Path,
34475 query: &str,
34476 limit: usize,
34477 timeout_secs: u64,
34478) -> Result<sift::SearchResponse> {
34479 let cfg = config::Config::load(root)?;
34480 let mut responses = Vec::new();
34481 for scope in config::Config::submodule_dirs(root)? {
34482 if !cfg.federation_for_scope(&scope) {
34483 continue;
34484 }
34485 let mut response =
34486 run_exact_search_with_timeout(std::slice::from_ref(&scope.source_root), query, limit, timeout_secs)?;
34487 absolutize_search_hit_paths(&mut response, &scope.source_root);
34488 response.root = root.display().to_string();
34489 responses.push(response);
34490 }
34491
34492 Ok(merge_search_responses(root, "exact", limit, responses))
34493}
34494
34495pub(crate) fn run_sift_search(
34496 search_path: &Path,
34497 cache_dir: &Path,
34498 query: &str,
34499 limit: usize,
34500 strategy: &str,
34501 fts_index_fresh: Option<bool>,
34510) -> Result<sift::SearchResponse> {
34511 if !fts_search_forced_off() {
34528 let db_path = search_path.join(".tsift/index.db");
34529 let use_fts = match fts_index_fresh {
34530 Some(fresh) => fresh && db_path.exists(),
34531 None => db_path.exists() && index_db_is_fresh_for_fts(&db_path, search_path),
34532 };
34533 if use_fts {
34534 return sift::fts_search(&db_path, search_path, query, limit)
34535 .context("index.db FTS5 search failed");
34536 }
34537 }
34538
34539 let engine = Sift::builder().with_cache_dir(cache_dir).build();
34540 let options = SearchOptions::default()
34541 .with_limit(limit)
34542 .with_strategy(strategy.to_string());
34543 let input = SearchInput::new(search_path, query).with_options(options);
34544 engine.search(input).context("sift search failed")
34545}
34546
34547fn index_db_is_fresh_for_fts(db_path: &Path, search_path: &Path) -> bool {
34556 match index::IndexDb::inspect_read_only(db_path, search_path, false) {
34557 Ok(inspection) => {
34558 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted == 0
34559 }
34560 Err(_) => false,
34561 }
34562}
34563
34564fn fts_search_forced_off() -> bool {
34569 std::env::var("TSIFT_FTS_SEARCH")
34570 .map(|value| fts_flag_value_disabled(&value))
34571 .unwrap_or(false)
34572}
34573
34574fn fts_flag_value_disabled(value: &str) -> bool {
34577 matches!(
34578 value.trim().to_ascii_lowercase().as_str(),
34579 "0" | "false" | "no" | "off"
34580 )
34581}
34582
34583fn exact_search_timeout_message(timeout_secs: u64) -> String {
34584 format!(
34585 "tsift search timed out after {}s (strategy: exact). \
34586 Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
34587 timeout_secs
34588 )
34589}
34590
34591fn exact_search_command(search_paths: &[PathBuf], query: &str) -> Command {
34592 let mut command = Command::new("rg");
34593 command
34594 .arg("--json")
34595 .arg("--fixed-strings")
34596 .arg("--line-number")
34597 .arg("--hidden")
34598 .arg("--")
34599 .arg(query);
34600 if search_paths.is_empty() {
34601 command.arg(Path::new("."));
34602 } else {
34603 command.args(search_paths);
34604 }
34605 command
34606}
34607
34608fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
34609 let observed_unix_secs = SystemTime::now()
34610 .duration_since(UNIX_EPOCH)
34611 .unwrap_or_default()
34612 .as_secs() as i64;
34613 let modified_unix_secs = fs::metadata(path)
34614 .ok()
34615 .and_then(|metadata| metadata.modified().ok())
34616 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
34617 .map(|duration| duration.as_secs() as i64);
34618 sift::ArtifactFreshness {
34619 observed_unix_secs,
34620 modified_unix_secs,
34621 }
34622}
34623
34624fn parse_exact_search_output(
34625 search_path: &Path,
34626 limit: usize,
34627 raw: &str,
34628) -> Result<sift::SearchResponse> {
34629 if limit == 0 {
34630 return Ok(sift::SearchResponse {
34631 strategy: "exact".to_string(),
34632 root: search_path.display().to_string(),
34633 indexed_artifacts: 0,
34634 skipped_artifacts: 0,
34635 coverage: empty_search_coverage(),
34636 hits: Vec::new(),
34637 });
34638 }
34639
34640 let mut hits = Vec::new();
34641 for line in raw.lines() {
34642 let event: RipgrepJsonEvent =
34643 serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
34644 let RipgrepJsonEvent::Match { data } = event else {
34645 continue;
34646 };
34647 let Some(path_text) = data.path.text else {
34648 continue;
34649 };
34650 let Some(lines_text) = data.lines.text else {
34651 continue;
34652 };
34653 let path = PathBuf::from(path_text);
34654 let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
34655 let rank = hits.len() + 1;
34656 hits.push(sift::SearchHit {
34657 artifact_id: format!(
34658 "exact:{}:{}:{}",
34659 path.display(),
34660 data.line_number.unwrap_or(0),
34661 rank
34662 ),
34663 artifact_kind: sift::ContextArtifactKind::File,
34664 path: path.display().to_string(),
34665 rank,
34666 score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
34667 confidence: sift::ScoreConfidence::High,
34668 location: data.line_number.map(|line| format!("line {}", line)),
34669 snippet: snippet.clone(),
34670 provenance: sift::ArtifactProvenance {
34671 adapter: sift::AcquisitionAdapterKind::FileSystem,
34672 source: "ripgrep -F".to_string(),
34673 synthetic: false,
34674 },
34675 freshness: exact_search_file_timestamp(&path),
34676 budget: sift::ArtifactBudget::from_text(&snippet, 1),
34677 });
34678 if hits.len() >= limit {
34679 break;
34680 }
34681 }
34682
34683 Ok(sift::SearchResponse {
34684 strategy: "exact".to_string(),
34685 root: search_path.display().to_string(),
34686 indexed_artifacts: hits.len(),
34687 skipped_artifacts: 0,
34688 coverage: empty_search_coverage(),
34689 hits,
34690 })
34691}
34692
34693fn exact_search_response_from_process(
34694 search_path: &Path,
34695 limit: usize,
34696 status: std::process::ExitStatus,
34697 stdout: &[u8],
34698 stderr: &[u8],
34699) -> Result<sift::SearchResponse> {
34700 if !status.success() && status.code() != Some(1) {
34701 let message = String::from_utf8_lossy(stderr);
34702 let trimmed = message.trim();
34703 if trimmed.is_empty() {
34704 bail!("ripgrep exact search exited with status {}", status);
34705 }
34706 bail!("{}", trimmed);
34707 }
34708
34709 let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
34710 parse_exact_search_output(search_path, limit, &raw)
34711}
34712
34713fn run_exact_search(search_paths: &[PathBuf], query: &str, limit: usize) -> Result<sift::SearchResponse> {
34714 let output = exact_search_command(search_paths, query)
34715 .output()
34716 .context("running exact search with ripgrep")?;
34717 let root_display = search_paths
34718 .first()
34719 .map(|p| p.as_path())
34720 .unwrap_or_else(|| Path::new("."));
34721 exact_search_response_from_process(
34722 root_display,
34723 limit,
34724 output.status,
34725 &output.stdout,
34726 &output.stderr,
34727 )
34728}
34729
34730pub(crate) fn run_exact_search_with_timeout(
34731 search_paths: &[PathBuf],
34732 query: &str,
34733 limit: usize,
34734 timeout_secs: u64,
34735) -> Result<sift::SearchResponse> {
34736 if timeout_secs == 0 {
34737 return run_exact_search(search_paths, query, limit);
34738 }
34739
34740 let mut child = exact_search_command(search_paths, query)
34741 .stdin(Stdio::null())
34742 .stdout(Stdio::piped())
34743 .stderr(Stdio::piped())
34744 .spawn()
34745 .context("spawning timed exact search worker")?;
34746
34747 let timeout = Duration::from_secs(timeout_secs);
34748 let status = wait_for_child_exit(&mut child, timeout)
34749 .context("waiting for timed exact search worker")?;
34750 if status.is_none() {
34751 let _ = child.kill();
34752 let _ = child.wait();
34753 bail!("{}", exact_search_timeout_message(timeout_secs));
34754 }
34755
34756 let status = status.unwrap();
34757 let stdout = read_child_stdout(&mut child)?;
34758 let stderr = read_child_stderr(&mut child)?;
34759 let root_display = search_paths
34760 .first()
34761 .map(|p| p.as_path())
34762 .unwrap_or_else(|| Path::new("."));
34763 exact_search_response_from_process(
34764 root_display,
34765 limit,
34766 status,
34767 stdout.as_bytes(),
34768 stderr.as_bytes(),
34769 )
34770}
34771
34772#[allow(clippy::too_many_arguments)]
34773pub(crate) fn run_search_with_timeout(
34774 search_path: &Path,
34775 cache_dir: &Path,
34776 query: &str,
34777 limit: usize,
34778 timeout_secs: u64,
34779 strategy: &str,
34780 search_targets: &[SearchIndexTarget],
34781 fts_index_fresh: Option<bool>,
34784) -> Result<sift::SearchResponse> {
34785 if timeout_secs == 0 {
34786 return run_sift_search(search_path, cache_dir, query, limit, strategy, fts_index_fresh);
34787 }
34788
34789 let output_path = next_search_worker_output_path();
34790 let mut command = Command::new(
34791 std::env::current_exe().context("resolving tsift executable for timed search")?,
34792 );
34793 command
34794 .arg("__search-worker")
34795 .arg("--path")
34796 .arg(search_path)
34797 .arg("--cache-dir")
34798 .arg(cache_dir)
34799 .arg("--query")
34800 .arg(query)
34801 .arg("--limit")
34802 .arg(limit.to_string())
34803 .arg("--strategy")
34804 .arg(strategy)
34805 .arg("--output")
34806 .arg(&output_path);
34807 if let Some(fresh) = fts_index_fresh {
34808 command.arg("--fts-index-fresh").arg(fresh.to_string());
34809 }
34810 let mut child = command
34811 .stdin(Stdio::null())
34812 .stdout(Stdio::null())
34813 .stderr(Stdio::piped())
34814 .spawn()
34815 .context("spawning timed sift search worker")?;
34816
34817 let timeout = Duration::from_secs(timeout_secs);
34818 let status =
34819 wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
34820 if status.is_none() {
34821 let _ = child.kill();
34822 let _ = child.wait();
34823 let _ = fs::remove_file(&output_path);
34824 bail!(
34825 "{}",
34826 search_timeout_message(timeout_secs, strategy, search_targets)?
34827 );
34828 }
34829
34830 let status = status.unwrap();
34831 let stderr = read_child_stderr(&mut child)?;
34832 if !status.success() {
34833 let _ = fs::remove_file(&output_path);
34834 let message = stderr.trim();
34835 if message.is_empty() {
34836 bail!("sift search worker exited with status {}", status);
34837 }
34838 bail!("{}", message);
34839 }
34840
34841 let raw = fs::read_to_string(&output_path)
34842 .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
34843 let _ = fs::remove_file(&output_path);
34844 serde_json::from_str(&raw).context("parsing search worker output")
34845}
34846
34847fn next_search_worker_output_path() -> PathBuf {
34848 let stamp = SystemTime::now()
34849 .duration_since(UNIX_EPOCH)
34850 .unwrap_or_default()
34851 .as_nanos();
34852 std::env::temp_dir().join(format!(
34853 "tsift-search-{}-{}.json",
34854 std::process::id(),
34855 stamp
34856 ))
34857}
34858
34859fn wait_for_child_exit(
34860 child: &mut std::process::Child,
34861 timeout: Duration,
34862) -> Result<Option<std::process::ExitStatus>> {
34863 let started = Instant::now();
34864 loop {
34865 if let Some(status) = child.try_wait()? {
34866 return Ok(Some(status));
34867 }
34868 if started.elapsed() >= timeout {
34869 return Ok(None);
34870 }
34871 let remaining = timeout.saturating_sub(started.elapsed());
34872 std::thread::sleep(remaining.min(Duration::from_millis(10)));
34873 }
34874}
34875
34876fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
34877 let mut stderr = String::new();
34878 if let Some(mut pipe) = child.stderr.take() {
34879 pipe.read_to_string(&mut stderr)
34880 .context("reading search worker stderr")?;
34881 }
34882 Ok(stderr)
34883}
34884
34885fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
34886 let mut stdout = String::new();
34887 if let Some(mut pipe) = child.stdout.take() {
34888 pipe.read_to_string(&mut stdout)
34889 .context("reading search worker stdout")?;
34890 }
34891 Ok(stdout)
34892}
34893
34894pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
34895 if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
34896 fs::write(&path, std::process::id().to_string())
34897 .with_context(|| format!("writing search worker pid file: {path}"))?;
34898 }
34899 if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
34900 let delay_ms = ms
34901 .parse::<u64>()
34902 .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
34903 std::thread::sleep(Duration::from_millis(delay_ms));
34904 }
34905 Ok(())
34906}
34907
34908#[cfg(test)]
34909thread_local! {
34910 static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
34911}
34912
34913#[cfg(test)]
34914enum SearchPostPrecheckLockMode {
34915 RollbackJournal,
34916 Wal,
34917}
34918
34919#[cfg(test)]
34920struct SearchPostPrecheckLockHook {
34921 db_path: PathBuf,
34922 mode: SearchPostPrecheckLockMode,
34923}
34924
34925#[cfg(test)]
34926struct SearchPostPrecheckLockGuard;
34927
34928#[cfg(test)]
34929impl Drop for SearchPostPrecheckLockGuard {
34930 fn drop(&mut self) {
34931 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34932 hook.borrow_mut().take();
34933 });
34934 }
34935}
34936
34937#[cfg(test)]
34938fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34939 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
34940}
34941
34942#[cfg(test)]
34943fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34944 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
34945}
34946
34947#[cfg(test)]
34948fn install_search_post_precheck_lock_hook(
34949 db_path: PathBuf,
34950 mode: SearchPostPrecheckLockMode,
34951) -> SearchPostPrecheckLockGuard {
34952 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34953 assert!(
34954 hook.borrow().is_none(),
34955 "search post-precheck lock hook already installed"
34956 );
34957 *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
34958 });
34959 SearchPostPrecheckLockGuard
34960}
34961
34962#[cfg(test)]
34963pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34964 let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
34965 return Ok(());
34966 };
34967 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
34968 std::thread::spawn(move || {
34969 let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
34970 match hook.mode {
34971 SearchPostPrecheckLockMode::RollbackJournal => {
34972 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
34973 .expect("acquiring rollback-journal hook lock");
34974 fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
34975 .expect("writing rollback journal marker");
34976 }
34977 SearchPostPrecheckLockMode::Wal => {
34978 conn.execute_batch(
34979 "PRAGMA journal_mode=WAL;
34980 PRAGMA wal_autocheckpoint=0;
34981 CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
34982 INSERT INTO search_wal_lock_probe DEFAULT VALUES;
34983 PRAGMA locking_mode=EXCLUSIVE;
34984 BEGIN EXCLUSIVE;",
34985 )
34986 .expect("acquiring WAL hook lock");
34987 assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
34988 }
34989 }
34990 ready_tx.send(()).expect("signaling search lock hook");
34991 std::thread::sleep(Duration::from_millis(200));
34992 drop(conn);
34993 let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
34994 });
34995 ready_rx
34996 .recv_timeout(Duration::from_secs(1))
34997 .context("waiting for search post-precheck lock hook")?;
34998 Ok(())
34999}
35000
35001#[cfg(not(test))]
35002pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
35003 Ok(())
35004}