1mod cli;
2mod commands;
3mod community_detection;
4mod conflict_matrix;
5mod context_pack;
6mod output;
7mod rewrite;
8mod search_budget;
9mod semantic_edit;
10mod session_review_budget;
11mod token_savings;
12mod workflow;
13
14pub(crate) use community_detection::{
15 CommunityDetectionReport, annotate_community_members_with_context,
16 community_tagpath_cache_part, community_tagpath_cache_part_for_loaded,
17 detect_communities_cached, file_communities_from_callers, graph_effectiveness_blocked,
18 graph_effectiveness_ready, resolve_tagpath_handle_for_callee_edge,
19 update_community_annotation_diagnostics,
20};
21#[allow(unused_imports)]
22pub(crate) use conflict_matrix::{
23 ConflictMatrixCandidate, ConflictMatrixGraphPreparedInputs, ConflictMatrixPreparedInputs,
24 ConflictMatrixReport, ConflictMatrixSemanticRef, ConflictMatrixSharedPreparationSummary,
25 ConflictMatrixWorkerFeedback, ConflictMatrixWorkerPromptPacket, build_conflict_matrix_report,
26 build_conflict_matrix_report_from_prepared_graph, cmd_conflict_matrix,
27 collect_conflict_matrix_evidence_packets, conflict_matrix_candidate_from_evidence,
28 conflict_matrix_graph_index, conflict_matrix_semantic_ref,
29 conflict_matrix_shared_preparation_summary, conflict_matrix_source_handle,
30 conflict_matrix_target_scoped_graph_snapshot, conflict_matrix_worker_feedback,
31 conflict_risk_label, extract_conflict_target_refs, hash_bytes_hex, is_planner_config_path,
32 normalize_conflict_target, prepare_conflict_matrix_graph_orchestration,
33 prepare_conflict_matrix_inputs, resolve_conflict_matrix_targets, sorted_intersection,
34 sorted_set,
35};
36#[allow(unused_imports)]
37pub(crate) use context_pack::{
38 ContextPackReport, ContextPackSummaryRefPreview, build_context_pack_diff_preview,
39 build_context_pack_log_preview, build_context_pack_report,
40 build_context_pack_report_with_profile, build_context_pack_test_preview,
41 context_pack_status_reminders, exploration_ref_id, materialize_context_pack_exploration_packet,
42 print_context_pack_human,
43};
44pub use rewrite::rewrite_command;
45pub(crate) use rewrite::{
46 apply_rewrite_output_format, execute_rewritten_command, no_rewrite_message,
47};
48#[cfg(test)]
49use search_budget::{SearchBudgetReport, search_facet_filters_summary};
50pub(crate) use search_budget::{
51 SearchBudgetReportInput, apply_search_facet_filters, build_search_budget_follow_up,
52 build_search_budget_report, print_search_budget_human,
53};
54pub(crate) use semantic_edit::{
55 AstSpanPreview, EditBatch, EditResult, EditStatus, MarkdownEmbeddedSymbol,
56 MarkdownSpanMetadata, MetricDigestOptions, SemanticEditVerifyOptions,
57 apply_edit_plan_atomically, build_edit_plan, cmd_edit_intents,
58};
59#[allow(unused_imports)]
60pub(crate) use session_review_budget::{
61 SessionReviewBudgetFailurePreview, SessionReviewBudgetReport,
62 SessionReviewNextContextBudgetReport, SessionReviewNextTokenAction,
63 build_session_review_budget_report, build_session_review_next_context_budget_report,
64 print_session_review_budget_human, print_session_review_next_context_budget_human,
65};
66
67#[cfg(test)]
68use rewrite::{
69 OutputCap, apply_output_cap, effective_rewrite_run_command, resolve_digest_context_path,
70 rewrite_output_cap,
71};
72#[cfg(test)]
73use std::io::{BufRead as _, BufReader};
74#[cfg(test)]
75use token_savings::{
76 TokenSavingsFamily, TokenSavingsFixture, TokenSavingsFixtureCase,
77 TokenSavingsMarkdownProjectionInput, TokenSavingsMarkdownProjectionInputs,
78 TokenSavingsRawSymbol, TokenSavingsSourceReadInput, TokenSavingsSourceReadInputs,
79 build_token_savings_report,
80};
81
82use anyhow::{Context, Result, bail};
83use clap::Parser;
84use cli::{Cli, Commands, DispatchTraceFormat, GraphDbQuery, SemanticRelatedKind, SourceReadStyle};
85#[cfg(test)]
86use cli::{GraphDbBackend, TraverseFormat};
87use commands::digests::{
88 cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
89 cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
90};
91#[cfg(test)]
92use commands::graph::cmd_explain;
93use commands::graph::{
94 cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
95};
96#[cfg(test)]
97use commands::index_search::cmd_search;
98use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
99use commands::infra::{
100 StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
101 cmd_rewrite, cmd_route, cmd_sql, cmd_status,
102};
103use commands::memory::cmd_memory;
104use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
105use commands::summarize::cmd_summarize;
106use flate2::{Compression, read::GzDecoder, write::GzEncoder};
107#[cfg(test)]
108use output::ResponseBudgetPreset;
109use output::tagpath::{
110 TagpathAnnotationDiagnostic, TagpathSearchOpts, annotate_communities_with_tagpath,
111 annotate_hits_with_tagpath, annotate_path_nodes_with_tagpath,
112 annotate_stored_edges_with_tagpath, annotate_stored_symbols_with_tagpath,
113};
114use output::{
115 OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric, ToolEnvelopeSummary,
116 TranscriptArtifactRef,
117};
118use rusqlite::{Connection, OptionalExtension};
119use serde::{Deserialize, Serialize};
120use sift::{SearchInput, SearchOptions, Sift};
121#[cfg(test)]
122use std::cell::RefCell;
123use std::cmp::Ordering;
124use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
125use std::env;
126use std::fs;
127use std::io::{Read as _, Write as _};
128use std::path::{Path, PathBuf};
129use std::process::{Command, Stdio};
130use std::sync::{Mutex, OnceLock};
131use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
132use substrate::{
133 ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
134 GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
135 GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
136 SqliteGraphStore, SqliteProjectionRefresh, TerseGraphEdge as SubstrateTerseGraphEdge,
137 TerseGraphNode as SubstrateTerseGraphNode,
138};
139use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
140#[cfg(test)]
141use tsift_agent_doc::session_cost;
142use tsift_agent_doc::session_markdown::{self, AgentDocQueueItem, AgentDocSessionDocument};
143#[cfg(test)]
144use tsift_agent_doc::session_review;
145use tsift_cache::cycle_packet_cache;
146use tsift_core::{
147 NeighborhoodScoring, RankedNeighborhoodOptions, SemanticSeededNeighborhoodOptions,
148};
149use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
150use tsift_graph as graph;
151use tsift_index::{config, index, init, multiplicity, walk};
152use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
153#[cfg(test)]
154use tsift_memory::MemoryEvent;
155use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
156use tsift_resolution as resolution;
157use tsift_search::{impact, sift};
158use tsift_sqlite as substrate;
159use tsift_status::status;
160use tsift_summarize::summarize;
161#[cfg(feature = "backend-surrealdb")]
162use tsift_surrealdb::SurrealdbGraphStore;
163use tsift_tokensave::TokensaveDb;
164
165#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
166pub(crate) enum GraphDbExperimentalBackend {
167 DuckdbDuckpgq,
168 Falkordb,
169 Ladybug,
170 Kuzu,
171 Surrealdb,
172}
173
174#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
175pub(crate) struct SearchFacetFilters {
176 #[serde(skip_serializing_if = "Vec::is_empty", default)]
177 pub(crate) languages: Vec<String>,
178 #[serde(skip_serializing_if = "Vec::is_empty", default)]
179 pub(crate) kinds: Vec<String>,
180 #[serde(skip_serializing_if = "Vec::is_empty", default)]
181 pub(crate) node_kinds: Vec<String>,
182 #[serde(skip_serializing_if = "Vec::is_empty", default)]
183 pub(crate) sections: Vec<String>,
184 #[serde(skip_serializing_if = "Vec::is_empty", default)]
185 pub(crate) parents: Vec<String>,
186 #[serde(skip_serializing_if = "Vec::is_empty", default)]
187 pub(crate) children: Vec<String>,
188 #[serde(skip_serializing_if = "Vec::is_empty", default)]
189 pub(crate) fence_languages: Vec<String>,
190 #[serde(skip_serializing_if = "Vec::is_empty", default)]
191 pub(crate) list_depths: Vec<usize>,
192 #[serde(skip_serializing_if = "Vec::is_empty", default)]
193 pub(crate) heading_levels: Vec<usize>,
194}
195
196impl SearchFacetFilters {
197 pub(crate) fn is_empty(&self) -> bool {
198 self.languages.is_empty()
199 && self.kinds.is_empty()
200 && self.node_kinds.is_empty()
201 && self.sections.is_empty()
202 && self.parents.is_empty()
203 && self.children.is_empty()
204 && self.fence_languages.is_empty()
205 && self.list_depths.is_empty()
206 && self.heading_levels.is_empty()
207 }
208
209 fn needs_ast_context(&self) -> bool {
210 !self.sections.is_empty()
211 || !self.parents.is_empty()
212 || !self.children.is_empty()
213 || !self.fence_languages.is_empty()
214 || !self.list_depths.is_empty()
215 || !self.heading_levels.is_empty()
216 }
217}
218
219#[derive(Serialize)]
220struct GraphDbBackendPromotionGate {
221 status: String,
222 native_adapter_required: bool,
223 required_checks: Vec<String>,
224}
225
226impl GraphDbExperimentalBackend {
227 fn name(self) -> &'static str {
228 match self {
229 Self::DuckdbDuckpgq => "duckdb-duckpgq",
230 Self::Falkordb => "falkordb",
231 Self::Ladybug => "ladybug",
232 Self::Kuzu => "kuzu",
233 Self::Surrealdb => "surrealdb",
234 }
235 }
236
237 fn adapter_label(self) -> &'static str {
238 match self {
239 Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
240 Self::Falkordb => "FalkorDB read-only prototype",
241 Self::Ladybug => "Ladybug read-only prototype",
242 Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
243 Self::Surrealdb => "SurrealDB read-only prototype",
244 }
245 }
246
247 fn projection_load(self) -> &'static str {
248 match self {
249 Self::Falkordb => {
250 "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"
251 }
252 Self::Kuzu => {
253 "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"
254 }
255 Self::Surrealdb => {
256 "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"
257 }
258 _ => {
259 "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
260 }
261 }
262 }
263
264 fn lock_behavior(self) -> &'static str {
265 match self {
266 Self::Falkordb => {
267 "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
268 }
269 Self::Kuzu => {
270 "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"
271 }
272 Self::Surrealdb => {
273 "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
274 }
275 _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
276 }
277 }
278
279 fn install_portability(self) -> &'static str {
280 match self {
281 Self::Falkordb => {
282 "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
283 }
284 Self::Kuzu => {
285 "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"
286 }
287 Self::Surrealdb => {
288 "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"
289 }
290 _ => {
291 "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
292 }
293 }
294 }
295
296 fn prototype_hold_reason(self) -> Option<&'static str> {
297 match self {
298 Self::DuckdbDuckpgq => Some(
299 "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
300 ),
301 Self::Falkordb => Some(
302 "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",
303 ),
304 Self::Ladybug => Some(
305 "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
306 ),
307 Self::Kuzu => Some(
308 "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
309 ),
310 Self::Surrealdb => Some(
311 "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",
312 ),
313 }
314 }
315
316 fn promotion_gate(self) -> GraphDbBackendPromotionGate {
317 match self {
318 Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
319 status: "hold_native_adapter_required".to_string(),
320 native_adapter_required: true,
321 required_checks: vec![
322 "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
323 .to_string(),
324 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
325 .to_string(),
326 "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
327 "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
328 .to_string(),
329 ],
330 },
331 Self::Falkordb => GraphDbBackendPromotionGate {
332 status: "hold_native_adapter_required".to_string(),
333 native_adapter_required: true,
334 required_checks: vec![
335 "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
336 .to_string(),
337 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
338 .to_string(),
339 "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
340 .to_string(),
341 "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
342 .to_string(),
343 ],
344 },
345 Self::Ladybug => GraphDbBackendPromotionGate {
346 status: "hold_native_adapter_required".to_string(),
347 native_adapter_required: true,
348 required_checks: vec![
349 "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
350 .to_string(),
351 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
352 .to_string(),
353 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
354 .to_string(),
355 "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
356 .to_string(),
357 ],
358 },
359 Self::Kuzu => GraphDbBackendPromotionGate {
360 status: "hold_native_adapter_required".to_string(),
361 native_adapter_required: true,
362 required_checks: vec![
363 "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
364 .to_string(),
365 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
366 .to_string(),
367 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
368 .to_string(),
369 "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
370 .to_string(),
371 ],
372 },
373 Self::Surrealdb => GraphDbBackendPromotionGate {
374 status: "hold_native_adapter_required".to_string(),
375 native_adapter_required: true,
376 required_checks: vec![
377 "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
378 .to_string(),
379 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
380 .to_string(),
381 "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
382 .to_string(),
383 "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
384 .to_string(),
385 ],
386 },
387 }
388 }
389
390 fn parse(raw: &str) -> Result<Self> {
391 match raw {
392 "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
393 "falkordb" | "falkor" => Ok(Self::Falkordb),
394 "ladybug" => Ok(Self::Ladybug),
395 "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
396 "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
397 _ => {
398 bail!(
399 "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
400 )
401 }
402 }
403 }
404}
405
406pub fn run() -> Result<()> {
407 let cli = Cli::parse();
408 let compact = cli.compact;
409 let pretty = cli.pretty;
410 let terse = cli.terse || cli.ultra_terse;
411 let ultra_terse = cli.ultra_terse;
412 let absolute = cli.absolute;
413 let tabular = cli.tabular;
414 let schema = cli.schema;
415 let envelope = cli.envelope;
416 match cli.command {
417 Some(Commands::Search {
418 query,
419 path,
420 limit,
421 strategy,
422 exact,
423 scope,
424 federated,
425 lang,
426 kind,
427 node_kind,
428 section,
429 parent,
430 child,
431 fence_language,
432 list_depth,
433 heading_level,
434 json,
435 autoindex,
436 no_autoindex,
437 timeout,
438 max_items,
439 max_bytes,
440 budget,
441 no_tagpath,
442 tagpath_strict,
443 }) => cmd_search_with_budget(
444 query,
445 path,
446 limit,
447 if exact {
448 Some("exact".to_string())
449 } else {
450 strategy
451 },
452 scope,
453 federated,
454 json || terse || schema || envelope,
455 autoindex || !no_autoindex,
456 timeout,
457 compact,
458 pretty,
459 terse,
460 ultra_terse,
461 absolute,
462 tabular,
463 schema,
464 envelope,
465 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
466 TagpathSearchOpts {
467 no_tagpath,
468 strict: tagpath_strict,
469 },
470 SearchFacetFilters {
471 languages: lang,
472 kinds: kind,
473 node_kinds: node_kind,
474 sections: section,
475 parents: parent,
476 children: child,
477 fence_languages: fence_language,
478 list_depths: list_depth,
479 heading_levels: heading_level,
480 },
481 ),
482 Some(Commands::SearchWorker {
483 path,
484 cache_dir,
485 query,
486 limit,
487 strategy,
488 output,
489 }) => cmd_search_worker(&path, &cache_dir, &query, limit, &strategy, &output),
490 Some(Commands::DigestRunner {
491 kind,
492 path,
493 runner,
494 shell_command,
495 json,
496 }) => cmd_digest_runner(
497 &kind,
498 &path,
499 runner.as_deref(),
500 &shell_command,
501 OutputFormat {
502 json_output: json || terse || schema || envelope,
503 compact,
504 pretty,
505 terse,
506 ultra_terse,
507 schema,
508 envelope,
509 },
510 ),
511 Some(Commands::Edit { dry_run, file }) => {
512 cmd_edit(dry_run, file, compact, pretty, terse, schema)
513 }
514 Some(Commands::EditIntents {
515 path,
516 scope,
517 file,
518 json,
519 apply,
520 verify,
521 verify_command,
522 max_items,
523 max_bytes,
524 budget,
525 }) => cmd_edit_intents(
526 &path,
527 scope.as_deref(),
528 file,
529 apply,
530 SemanticEditVerifyOptions {
531 enabled: verify,
532 command: verify_command.as_deref(),
533 },
534 OutputFormat {
535 json_output: json || terse || schema || envelope,
536 compact,
537 pretty,
538 terse,
539 ultra_terse,
540 schema,
541 envelope,
542 },
543 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
544 ),
545 Some(Commands::Index {
546 path,
547 rebuild,
548 check,
549 exit_code,
550 prune,
551 quiet,
552 workspace,
553 submodule,
554 json,
555 }) => cmd_index(
556 &path,
557 rebuild,
558 check,
559 exit_code,
560 prune,
561 quiet,
562 workspace,
563 submodule.as_deref(),
564 json || terse || schema || envelope,
565 compact,
566 pretty,
567 terse,
568 absolute,
569 schema,
570 ),
571 Some(Commands::Rewrite { command, run }) => cmd_rewrite(
572 &command,
573 run,
574 OutputFormat {
575 json_output: terse || schema || envelope,
576 compact,
577 pretty,
578 terse,
579 ultra_terse,
580 schema,
581 envelope,
582 },
583 ),
584 Some(Commands::Route { task, id }) => cmd_route(&task, id),
585 Some(Commands::Memory { command }) => {
586 let json = command.json_output();
587 cmd_memory(
588 command,
589 OutputFormat {
590 json_output: json || terse || schema || envelope,
591 compact,
592 pretty,
593 terse,
594 ultra_terse,
595 schema,
596 envelope,
597 },
598 )
599 }
600 Some(Commands::Finding { command }) => match command {
601 cli::FindingCommand::Add {
602 path,
603 kind,
604 title,
605 body,
606 about,
607 confidence,
608 status,
609 relates,
610 scope,
611 json,
612 } => commands::finding::cmd_finding_add(
613 &path,
614 &kind,
615 &title,
616 &body,
617 &about,
618 confidence,
619 &status,
620 relates.as_deref(),
621 scope.as_deref(),
622 json || terse || schema || envelope,
623 pretty,
624 ),
625 cli::FindingCommand::List {
626 path,
627 about,
628 kind,
629 status,
630 include_stale,
631 scope,
632 json,
633 } => commands::finding::cmd_finding_list(
634 &path,
635 about.as_deref(),
636 kind.as_deref(),
637 status.as_deref(),
638 include_stale,
639 scope.as_deref(),
640 json || terse || schema || envelope,
641 pretty,
642 ),
643 cli::FindingCommand::Harvest { path, scope, json } => {
644 commands::finding::cmd_finding_harvest(
645 &path,
646 scope.as_deref(),
647 json || terse || schema || envelope,
648 pretty,
649 )
650 }
651 cli::FindingCommand::Promote { id, path, json } => {
652 commands::finding::cmd_finding_promote(
653 &path,
654 &id,
655 json || terse || schema || envelope,
656 pretty,
657 )
658 }
659 },
660 Some(Commands::Graph {
661 symbol,
662 path,
663 callers,
664 callees,
665 scope,
666 limit,
667 json,
668 no_tagpath,
669 tagpath_strict,
670 }) => cmd_graph(
671 &symbol,
672 &path,
673 callers,
674 callees,
675 scope.as_deref(),
676 limit,
677 json || terse || schema || envelope,
678 compact,
679 pretty,
680 terse,
681 absolute,
682 tabular,
683 schema,
684 TagpathSearchOpts {
685 no_tagpath,
686 strict: tagpath_strict,
687 },
688 ),
689 Some(Commands::Sql {
690 db,
691 query,
692 table,
693 json,
694 }) => cmd_sql(
695 &db,
696 query,
697 table,
698 json || terse || schema || envelope,
699 compact,
700 pretty,
701 terse,
702 schema,
703 ),
704 Some(Commands::Communities {
705 path,
706 scope,
707 min_size,
708 limit,
709 json,
710 no_tagpath,
711 tagpath_strict,
712 }) => cmd_communities(
713 &path,
714 scope.as_deref(),
715 min_size,
716 limit,
717 json || terse || schema || envelope,
718 compact,
719 pretty,
720 terse,
721 tabular,
722 schema,
723 TagpathSearchOpts {
724 no_tagpath,
725 strict: tagpath_strict,
726 },
727 ),
728 Some(Commands::Analyze {
729 path,
730 scope,
731 entry_points,
732 limit,
733 json,
734 }) => cmd_analyze(
735 &path,
736 scope.as_deref(),
737 &entry_points,
738 limit,
739 OutputFormat {
740 json_output: json || terse || schema || envelope,
741 compact,
742 pretty,
743 terse,
744 ultra_terse,
745 schema,
746 envelope,
747 },
748 ),
749 Some(Commands::Path {
750 from,
751 to,
752 path,
753 scope,
754 json,
755 no_tagpath,
756 tagpath_strict,
757 }) => cmd_path(
758 &from,
759 &to,
760 &path,
761 scope.as_deref(),
762 json || terse || schema || envelope,
763 compact,
764 pretty,
765 terse,
766 schema,
767 TagpathSearchOpts {
768 no_tagpath,
769 strict: tagpath_strict,
770 },
771 ),
772 Some(Commands::Explain {
773 symbol,
774 path,
775 scope,
776 limit,
777 json,
778 max_items,
779 max_bytes,
780 budget,
781 no_tagpath,
782 tagpath_strict,
783 }) => cmd_explain_with_budget(
784 &symbol,
785 &path,
786 scope.as_deref(),
787 limit,
788 json || terse || schema || envelope,
789 compact,
790 pretty,
791 terse,
792 ultra_terse,
793 absolute,
794 tabular,
795 schema,
796 envelope,
797 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
798 TagpathSearchOpts {
799 no_tagpath,
800 strict: tagpath_strict,
801 },
802 ),
803 Some(Commands::Traverse {
804 node,
805 to,
806 path,
807 scope,
808 depth,
809 limit,
810 format,
811 convex_snapshot,
812 }) => cmd_traverse(
813 node.as_deref(),
814 to.as_deref(),
815 &path,
816 scope.as_deref(),
817 depth,
818 limit,
819 format,
820 pretty,
821 terse,
822 schema,
823 convex_snapshot.as_deref(),
824 ),
825 Some(Commands::ConvexSync {
826 path,
827 scope,
828 snapshot,
829 chunk_size,
830 remote_snapshot,
831 apply,
832 endpoint,
833 auth_token_env,
834 json,
835 }) => cmd_convex_sync(
836 ConvexSyncOptions {
837 path: &path,
838 scope: scope.as_deref(),
839 snapshot: snapshot.as_deref(),
840 chunk_size,
841 remote_snapshot,
842 apply,
843 endpoint: endpoint.as_deref(),
844 auth_token_env: &auth_token_env,
845 },
846 OutputFormat {
847 json_output: json || terse || schema || envelope,
848 compact,
849 pretty,
850 terse,
851 ultra_terse,
852 schema,
853 envelope,
854 },
855 ),
856 Some(Commands::GraphDb {
857 path,
858 scope,
859 backend,
860 convex_snapshot,
861 json,
862 query,
863 }) => cmd_graph_db(
864 &path,
865 scope.as_deref(),
866 backend,
867 convex_snapshot.as_deref(),
868 query,
869 OutputFormat {
870 json_output: json || terse || schema || envelope,
871 compact,
872 pretty,
873 terse,
874 ultra_terse,
875 schema,
876 envelope,
877 },
878 ),
879 Some(Commands::SourceRead {
880 file,
881 path,
882 style,
883 start,
884 lines,
885 end,
886 scope,
887 json,
888 max_items,
889 max_bytes,
890 budget,
891 }) => cmd_source_read(
892 &file,
893 &path,
894 style,
895 start,
896 lines,
897 end,
898 scope.as_deref(),
899 OutputFormat {
900 json_output: json || terse || schema || envelope,
901 compact,
902 pretty,
903 terse,
904 ultra_terse,
905 schema,
906 envelope,
907 },
908 absolute,
909 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
910 ),
911 Some(Commands::MarkdownAst {
912 file,
913 path,
914 node,
915 json,
916 max_items,
917 max_bytes,
918 budget,
919 }) => cmd_markdown_ast(
920 &file,
921 &path,
922 node.as_deref(),
923 OutputFormat {
924 json_output: json || terse || schema || envelope,
925 compact,
926 pretty,
927 terse,
928 ultra_terse,
929 schema,
930 envelope,
931 },
932 absolute,
933 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
934 ),
935 Some(Commands::SymbolRead {
936 symbol,
937 file,
938 path,
939 scope,
940 json,
941 max_items,
942 max_bytes,
943 budget,
944 }) => cmd_symbol_read(
945 &symbol,
946 file.as_deref(),
947 &path,
948 scope.as_deref(),
949 OutputFormat {
950 json_output: json || terse || schema || envelope,
951 compact,
952 pretty,
953 terse,
954 ultra_terse,
955 schema,
956 envelope,
957 },
958 absolute,
959 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
960 ),
961 Some(Commands::Audit {
962 skills_dir,
963 manifest,
964 usage,
965 cleanup,
966 report,
967 json,
968 }) => cmd_audit(
969 &skills_dir,
970 manifest,
971 usage,
972 cleanup,
973 report,
974 json || terse || schema || envelope,
975 compact,
976 pretty,
977 terse,
978 schema,
979 ),
980 Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
981 &path,
982 scope.as_deref(),
983 json || terse || schema || envelope,
984 pretty,
985 terse,
986 schema,
987 ),
988 Some(Commands::Init {
989 path,
990 codex,
991 opencode,
992 workspace,
993 }) => cmd_init(&path, codex, opencode, workspace),
994 Some(Commands::Lint {
995 file,
996 index,
997 entities_from,
998 json,
999 }) => cmd_lint(
1000 &file,
1001 index,
1002 entities_from,
1003 json || terse || schema || envelope,
1004 compact,
1005 pretty,
1006 terse,
1007 schema,
1008 ),
1009 Some(Commands::Summarize {
1010 symbol,
1011 file,
1012 extract,
1013 diff,
1014 stats,
1015 path,
1016 json,
1017 }) => cmd_summarize(
1018 symbol,
1019 file,
1020 extract,
1021 diff,
1022 stats,
1023 &path,
1024 json || terse || schema || envelope,
1025 compact,
1026 pretty,
1027 terse,
1028 schema,
1029 ),
1030 Some(Commands::Semantic {
1031 query,
1032 path,
1033 scope,
1034 limit,
1035 kind,
1036 json,
1037 }) => cmd_semantic_related(
1038 &query,
1039 &path,
1040 scope.as_deref(),
1041 limit,
1042 kind,
1043 json || terse || schema || envelope,
1044 compact,
1045 pretty,
1046 terse,
1047 schema,
1048 ),
1049 Some(Commands::DiffDigest {
1050 path,
1051 cached,
1052 revision,
1053 max_parsed_files,
1054 json,
1055 }) => cmd_diff_digest(
1056 &path,
1057 cached,
1058 revision.as_deref(),
1059 max_parsed_files,
1060 OutputFormat {
1061 json_output: json || terse || schema || envelope,
1062 compact,
1063 pretty,
1064 terse,
1065 ultra_terse,
1066 schema,
1067 envelope,
1068 },
1069 ),
1070 Some(Commands::Impact {
1071 path,
1072 cached,
1073 revision,
1074 scope,
1075 limit,
1076 json,
1077 }) => cmd_impact(
1078 &path,
1079 cached,
1080 revision.as_deref(),
1081 scope.as_deref(),
1082 limit,
1083 OutputFormat {
1084 json_output: json || terse || schema || envelope,
1085 compact,
1086 pretty,
1087 terse,
1088 ultra_terse,
1089 schema,
1090 envelope,
1091 },
1092 ),
1093 Some(Commands::TestDigest {
1094 path,
1095 input,
1096 runner,
1097 json,
1098 }) => cmd_test_digest(
1099 &path,
1100 input.as_deref(),
1101 runner.as_deref(),
1102 OutputFormat {
1103 json_output: json || terse || schema || envelope,
1104 compact,
1105 pretty,
1106 terse,
1107 ultra_terse,
1108 schema,
1109 envelope,
1110 },
1111 ),
1112 Some(Commands::LogDigest {
1113 path,
1114 input,
1115 fixture,
1116 fail_under,
1117 json,
1118 }) => cmd_log_digest(
1119 &path,
1120 input.as_deref(),
1121 fixture.as_deref(),
1122 fail_under,
1123 OutputFormat {
1124 json_output: json || terse || schema || envelope,
1125 compact,
1126 pretty,
1127 terse,
1128 ultra_terse,
1129 schema,
1130 envelope,
1131 },
1132 ),
1133 Some(Commands::ContextPack {
1134 path,
1135 test_input,
1136 runner,
1137 log_input,
1138 json,
1139 max_items,
1140 max_bytes,
1141 budget,
1142 convex_snapshot,
1143 }) => cmd_context_pack(
1144 &path,
1145 test_input.as_deref(),
1146 runner.as_deref(),
1147 log_input.as_deref(),
1148 OutputFormat {
1149 json_output: json || terse || schema || envelope,
1150 compact,
1151 pretty,
1152 terse,
1153 ultra_terse,
1154 schema,
1155 envelope,
1156 },
1157 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1158 convex_snapshot.as_deref(),
1159 ),
1160 Some(Commands::ConflictMatrix {
1161 targets,
1162 path,
1163 scope,
1164 depth,
1165 limit,
1166 impact_limit,
1167 json,
1168 }) => cmd_conflict_matrix(
1169 &path,
1170 scope.as_deref(),
1171 &targets,
1172 depth,
1173 limit,
1174 impact_limit,
1175 OutputFormat {
1176 json_output: json || terse || schema || envelope,
1177 compact,
1178 pretty,
1179 terse,
1180 ultra_terse,
1181 schema,
1182 envelope,
1183 },
1184 ),
1185 Some(Commands::DispatchTrace {
1186 targets,
1187 path,
1188 scope,
1189 depth,
1190 limit,
1191 impact_limit,
1192 format,
1193 json,
1194 }) => cmd_dispatch_trace(
1195 DispatchTraceOptions {
1196 path: &path,
1197 scope: scope.as_deref(),
1198 raw_targets: &targets,
1199 depth,
1200 limit,
1201 impact_limit,
1202 trace_format: if json {
1203 DispatchTraceFormat::Json
1204 } else {
1205 format
1206 },
1207 },
1208 OutputFormat {
1209 json_output: json || terse || schema || envelope,
1210 compact,
1211 pretty,
1212 terse,
1213 ultra_terse,
1214 schema,
1215 envelope,
1216 },
1217 ),
1218 Some(Commands::DependencyDag {
1219 targets,
1220 path,
1221 scope,
1222 depth,
1223 limit,
1224 json,
1225 }) => cmd_dependency_dag(
1226 &path,
1227 scope.as_deref(),
1228 &targets,
1229 depth,
1230 limit,
1231 OutputFormat {
1232 json_output: json || terse || schema || envelope,
1233 compact,
1234 pretty,
1235 terse,
1236 ultra_terse,
1237 schema,
1238 envelope,
1239 },
1240 ),
1241 Some(Commands::TokenSavings {
1242 fixture,
1243 fail_under,
1244 json,
1245 }) => token_savings::cmd_token_savings(
1246 &fixture,
1247 fail_under,
1248 OutputFormat {
1249 json_output: json || terse || schema || envelope,
1250 compact,
1251 pretty,
1252 terse,
1253 ultra_terse,
1254 schema,
1255 envelope,
1256 },
1257 ),
1258 Some(Commands::MetricDigest {
1259 input,
1260 baseline,
1261 metrics,
1262 lower_is_better,
1263 higher_is_better,
1264 history,
1265 top,
1266 json,
1267 }) => cmd_metric_digest(
1268 MetricDigestOptions {
1269 input_path: input.as_deref(),
1270 baseline_path: baseline.as_deref(),
1271 metrics: &metrics,
1272 lower_is_better: &lower_is_better,
1273 higher_is_better: &higher_is_better,
1274 history,
1275 top,
1276 },
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::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1288 &fixture,
1289 OutputFormat {
1290 json_output: json || terse || schema || envelope,
1291 compact,
1292 pretty,
1293 terse,
1294 ultra_terse,
1295 schema,
1296 envelope,
1297 },
1298 ),
1299 Some(Commands::TokenGate { command }) => {
1300 cmd_token_gate(
1301 command,
1302 OutputFormat {
1303 json_output: true,
1304 compact,
1305 pretty,
1306 terse,
1307 ultra_terse,
1308 schema,
1309 envelope,
1310 },
1311 )?;
1312 Ok(())
1313 }
1314 Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1315 &topic,
1316 OutputFormat {
1317 json_output: json || terse || schema || envelope,
1318 compact,
1319 pretty,
1320 terse,
1321 ultra_terse,
1322 schema,
1323 envelope,
1324 },
1325 ),
1326 Some(Commands::SessionDigest {
1327 path,
1328 input,
1329 source,
1330 json,
1331 }) => cmd_session_digest(
1332 &path,
1333 input.as_deref(),
1334 source.as_deref(),
1335 OutputFormat {
1336 json_output: json || terse || schema || envelope,
1337 compact,
1338 pretty,
1339 terse,
1340 ultra_terse,
1341 schema,
1342 envelope,
1343 },
1344 ),
1345 Some(Commands::SessionCost {
1346 input,
1347 fixture,
1348 fail_under,
1349 source,
1350 json,
1351 }) => cmd_session_cost(
1352 input.as_deref(),
1353 fixture.as_deref(),
1354 fail_under,
1355 source.as_deref(),
1356 OutputFormat {
1357 json_output: json || terse || schema || envelope,
1358 compact,
1359 pretty,
1360 terse,
1361 ultra_terse,
1362 schema,
1363 envelope,
1364 },
1365 ),
1366 Some(Commands::SessionReview {
1367 path,
1368 next_context,
1369 json,
1370 max_items,
1371 max_bytes,
1372 budget,
1373 }) => cmd_session_review_with_budget(
1374 &path,
1375 next_context,
1376 OutputFormat {
1377 json_output: json || terse || schema || envelope,
1378 compact,
1379 pretty,
1380 terse,
1381 ultra_terse,
1382 schema,
1383 envelope,
1384 },
1385 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1386 ),
1387 Some(Commands::Status {
1388 path,
1389 fix,
1390 no_fix,
1391 json,
1392 }) => cmd_status(
1393 &path,
1394 StatusCommandOptions {
1395 fix,
1396 no_fix,
1397 json_output: json || terse || schema || envelope,
1398 compact,
1399 pretty,
1400 terse,
1401 schema,
1402 },
1403 ),
1404 Some(Commands::Locks { path, scope, json }) => cmd_locks(
1405 &path,
1406 scope.as_deref(),
1407 json || terse || schema || envelope,
1408 compact,
1409 pretty,
1410 terse,
1411 schema,
1412 ),
1413 None => {
1414 println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1415 println!("Run `tsift --help` for usage.");
1416 Ok(())
1417 }
1418 }
1419}
1420
1421pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1424 let lower = task.to_lowercase();
1425 for signal in &[
1427 "architect",
1428 "architecture",
1429 "design",
1430 "plan",
1431 "strateg",
1432 "analy",
1433 "review",
1434 "evaluate",
1435 "assess",
1436 ] {
1437 if lower.contains(signal) {
1438 return ("opus", "claude-opus-4-6");
1439 }
1440 }
1441 for signal in &[
1443 "edit",
1444 "write",
1445 "fix",
1446 "change",
1447 "update",
1448 "create",
1449 "add ",
1450 "remove",
1451 "delete",
1452 "modify",
1453 "refactor",
1454 "implement",
1455 "build",
1456 ] {
1457 if lower.contains(signal) {
1458 return ("sonnet", "claude-sonnet-4-6");
1459 }
1460 }
1461 ("haiku", "claude-haiku-4-5-20251001")
1463}
1464
1465#[cfg(test)]
1466fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
1467 to_json_schema(val, pretty, terse, false, false)
1468}
1469
1470pub(crate) fn inject_tagpath_stale_into_json(
1477 value: &mut serde_json::Value,
1478 stale: bool,
1479 reason: Option<&str>,
1480) {
1481 if !stale {
1482 return;
1483 }
1484 if let Some(obj) = value.as_object_mut() {
1485 obj.insert(
1486 "tagpath_index_stale".to_string(),
1487 serde_json::Value::Bool(true),
1488 );
1489 if let Some(reason) = reason {
1490 obj.insert(
1491 "tagpath_stale_reason".to_string(),
1492 serde_json::Value::String(reason.to_string()),
1493 );
1494 }
1495 }
1496}
1497
1498pub(crate) fn to_json_schema<T: serde::Serialize>(
1499 val: &T,
1500 pretty: bool,
1501 terse: bool,
1502 ultra_terse: bool,
1503 schema: bool,
1504) -> anyhow::Result<String> {
1505 if terse || schema {
1506 let value = serde_json::to_value(val)?;
1507 let mut transformed = if terse { terse_transform(value) } else { value };
1508 if ultra_terse {
1509 transformed = ultra_terse_transform(transformed);
1510 transformed = edge_index_transform(transformed);
1511 }
1512 if schema {
1513 transformed = schema_transform(transformed);
1514 }
1515 if terse {
1516 let terse_schema = terse_schema_for(&transformed);
1517 let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
1518 if pretty {
1519 Ok(serde_json::to_string_pretty(&wrapped)?)
1520 } else {
1521 Ok(serde_json::to_string(&wrapped)?)
1522 }
1523 } else if pretty {
1524 Ok(serde_json::to_string_pretty(&transformed)?)
1525 } else {
1526 Ok(serde_json::to_string(&transformed)?)
1527 }
1528 } else if pretty {
1529 Ok(serde_json::to_string_pretty(val)?)
1530 } else {
1531 Ok(serde_json::to_string(val)?)
1532 }
1533}
1534
1535pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
1536 ToolEnvelopeMetric {
1537 label: label.to_string(),
1538 value: value.to_string(),
1539 }
1540}
1541
1542pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
1543 let mut seen = HashSet::new();
1544 let mut deduped = Vec::new();
1545 for value in values {
1546 if seen.insert(value.clone()) {
1547 deduped.push(value);
1548 }
1549 }
1550 deduped
1551}
1552
1553pub(crate) fn print_json_or_envelope<T: Serialize>(
1554 report: &T,
1555 format: &OutputFormat,
1556 tool: &str,
1557 view: &str,
1558 summary: ToolEnvelopeSummary,
1559 truncated: bool,
1560 follow_up: Vec<String>,
1561) -> Result<()> {
1562 if format.envelope {
1563 let schema = format.schema || tool == "source-read";
1564 let envelope = ToolEnvelope {
1565 tool,
1566 view,
1567 summary,
1568 truncated,
1569 follow_up: dedupe_preserve_order(follow_up),
1570 report,
1571 };
1572 println!(
1573 "{}",
1574 to_json_schema(
1575 &envelope,
1576 format.pretty,
1577 format.terse,
1578 format.ultra_terse,
1579 schema
1580 )?
1581 );
1582 } else {
1583 println!(
1584 "{}",
1585 to_json_schema(
1586 report,
1587 format.pretty,
1588 format.terse,
1589 format.ultra_terse,
1590 format.schema
1591 )?
1592 );
1593 }
1594 Ok(())
1595}
1596
1597pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
1598 bytes.div_ceil(4)
1599}
1600
1601fn cmd_token_gate(command: cli::TokenGateCommand, format: OutputFormat) -> Result<()> {
1602 match command {
1603 cli::TokenGateCommand::Sample {
1604 surface,
1605 path,
1606 scope,
1607 target,
1608 depth,
1609 sample_index,
1610 json: _,
1611 } => cmd_token_gate_sample(
1612 &surface,
1613 &path,
1614 scope.as_deref(),
1615 target.as_deref(),
1616 depth,
1617 sample_index,
1618 ),
1619 cli::TokenGateCommand::Evaluate {
1620 history,
1621 allowed_regression_percent,
1622 json: _,
1623 } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
1624 }
1625}
1626
1627fn cmd_token_gate_sample(
1628 surface: &str,
1629 path: &Path,
1630 scope: Option<&str>,
1631 target: Option<&str>,
1632 depth: usize,
1633 sample_index: usize,
1634) -> Result<()> {
1635 if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
1636 bail!(
1637 "unknown surface `{}`; expected one of: {}",
1638 surface,
1639 token_gate::TOKEN_GATE_SURFACES.join(", ")
1640 );
1641 }
1642
1643 let path_str = path.to_string_lossy().to_string();
1644 let tsift_bin = std::env::current_exe()?;
1645
1646 let args: Vec<String> = match surface {
1647 "context_pack" => vec!["context-pack".to_string(), "--json".to_string(), path_str],
1648 "session_review_next_context" => vec![
1649 "session-review".to_string(),
1650 "--json".to_string(),
1651 "--next-context".to_string(),
1652 path_str,
1653 ],
1654 "graph_db_evidence" => {
1655 let tgt = target.unwrap_or("default").to_string();
1656 vec![
1657 "graph-db".to_string(),
1658 "--json".to_string(),
1659 "--path".to_string(),
1660 path_str,
1661 "evidence".to_string(),
1662 tgt,
1663 "--depth".to_string(),
1664 depth.to_string(),
1665 ]
1666 }
1667 "conflict_matrix" => {
1668 let tgt = target.unwrap_or("default").to_string();
1669 let mut a = vec![
1670 "conflict-matrix".to_string(),
1671 "--json".to_string(),
1672 "--path".to_string(),
1673 path_str,
1674 "--depth".to_string(),
1675 depth.to_string(),
1676 ];
1677 if let Some(s) = scope {
1678 a.push("--scope".to_string());
1679 a.push(s.to_string());
1680 }
1681 a.push(tgt);
1682 a
1683 }
1684 "dispatch_trace" => {
1685 let tgt = target.unwrap_or("default").to_string();
1686 vec![
1687 "dispatch-trace".to_string(),
1688 "--json".to_string(),
1689 "--path".to_string(),
1690 path_str,
1691 tgt,
1692 ]
1693 }
1694 _ => bail!("unhandled surface: {}", surface),
1695 };
1696
1697 let start = Instant::now();
1698 let child = Command::new(&tsift_bin)
1699 .args(&args)
1700 .stdout(Stdio::piped())
1701 .stderr(Stdio::piped())
1702 .env("TSIFT_QUIET", "1")
1703 .spawn();
1704 let output = match child {
1705 Ok(c) => c.wait_with_output()?,
1706 Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
1707 };
1708 let runtime_micros = start.elapsed().as_micros() as f64;
1709
1710 let stdout = String::from_utf8_lossy(&output.stdout);
1711 let envelope_bytes = stdout.trim().len() as f64;
1712 let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
1713
1714 let cache_hit_rate_percent = 0.0;
1715 let raw_read_avoidance = 0.0;
1716 let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
1717
1718 let timestamp = iso_timestamp_now();
1719 let id = format!(
1720 "{surface}-baseline-{}-sample-{sample_index}",
1721 ×tamp[..10]
1722 );
1723 let label = format!(
1724 "token-gate baseline {surface} sample {sample_index} for {}",
1725 path.display()
1726 );
1727
1728 let mut metrics = BTreeMap::new();
1729 metrics.insert("prompt_tokens".to_string(), prompt_tokens);
1730 metrics.insert("envelope_bytes".to_string(), envelope_bytes);
1731 metrics.insert("runtime_micros".to_string(), runtime_micros);
1732 metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
1733 metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
1734 metrics.insert("useful_hit_density".to_string(), useful_hit_density);
1735
1736 let sample = token_gate::TokenGateSample {
1737 label,
1738 id,
1739 timestamp: Some(timestamp),
1740 surface: surface.to_string(),
1741 metrics,
1742 };
1743
1744 println!("{}", serde_json::to_string_pretty(&sample)?);
1745 Ok(())
1746}
1747
1748fn cmd_token_gate_evaluate(
1749 history_path: Option<&Path>,
1750 allowed_regression_percent: f64,
1751 format: &OutputFormat,
1752) -> Result<()> {
1753 let history_path = history_path.map(PathBuf::from).unwrap_or_else(|| {
1754 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1755 p.push("../../fixtures/token-gate-history.json");
1756 p
1757 });
1758
1759 let raw = std::fs::read_to_string(&history_path).with_context(|| {
1760 format!(
1761 "failed to read token gate history: {}",
1762 history_path.display()
1763 )
1764 })?;
1765 let samples = token_gate::parse_token_history(&raw)?;
1766 let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
1767
1768 if format.json_output {
1769 println!(
1770 "{}",
1771 to_json_schema(&report, format.pretty, format.terse, false, format.schema)?
1772 );
1773 } else {
1774 println!("Token Gate Report");
1775 println!(" min_samples: {}", report.min_samples);
1776 println!(
1777 " allowed_regression: {:.1}%",
1778 report.allowed_regression_percent
1779 );
1780 println!(" decision: {:?}", report.decision);
1781 for eval in &report.surface_evaluations {
1782 println!(
1783 " {} ({} samples): {:?}",
1784 eval.display_name, eval.sample_count, eval.verdict
1785 );
1786 for me in &eval.metric_evaluations {
1787 println!(" {} ({:?}): {}", me.metric, me.direction, me.diagnostic);
1788 }
1789 }
1790 for d in &report.diagnostics {
1791 println!(" ! {}", d);
1792 }
1793 }
1794 Ok(())
1795}
1796
1797fn iso_timestamp_now() -> String {
1798 let dur = SystemTime::now()
1799 .duration_since(UNIX_EPOCH)
1800 .unwrap_or_default();
1801 let total_secs = dur.as_secs();
1802 let days_since_epoch = total_secs / 86400;
1803 let (year, month, day) = days_to_ymd(days_since_epoch);
1804 let time_of_day = total_secs % 86400;
1805 let hour = (time_of_day / 3600) as u8;
1806 let minute = ((time_of_day % 3600) / 60) as u8;
1807 let second = (time_of_day % 60) as u8;
1808 format!(
1809 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1810 year, month, day, hour, minute, second
1811 )
1812}
1813
1814fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
1815 let mut year = 1970u64;
1816 loop {
1817 let days_in_year = if is_leap(year) { 366 } else { 365 };
1818 if days < days_in_year {
1819 break;
1820 }
1821 days -= days_in_year;
1822 year += 1;
1823 }
1824 let leap = is_leap(year);
1825 let month_days: [u8; 12] = if leap {
1826 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1827 } else {
1828 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1829 };
1830 let mut month: u8 = 1;
1831 for &md in &month_days {
1832 if days < md as u64 {
1833 break;
1834 }
1835 days -= md as u64;
1836 month += 1;
1837 }
1838 let day = days as u8 + 1;
1839 (year, month, day)
1840}
1841
1842fn is_leap(year: u64) -> bool {
1843 year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
1844}
1845
1846fn persist_transcript_artifact(
1847 root: &Path,
1848 prefix: &str,
1849 suffix: &str,
1850 key: &str,
1851 body: &str,
1852 expand: String,
1853) -> Result<TranscriptArtifactRef> {
1854 let handle = stable_handle(prefix, key);
1855 let artifacts_dir = root.join(".tsift/artifacts");
1856 fs::create_dir_all(&artifacts_dir).with_context(|| {
1857 format!(
1858 "creating transcript artifacts dir: {}",
1859 artifacts_dir.display()
1860 )
1861 })?;
1862 let file_name = format!("{handle}.{suffix}");
1863 let artifact_path = artifacts_dir.join(file_name);
1864 fs::write(&artifact_path, body)
1865 .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
1866 let rel_path = relativize_pathbuf(&artifact_path, root);
1867 Ok(TranscriptArtifactRef {
1868 handle,
1869 path: rel_path.display().to_string(),
1870 bytes: body.len(),
1871 lines: body.lines().count(),
1872 expand,
1873 })
1874}
1875
1876fn terse_key(key: &str) -> &str {
1877 match key {
1878 "name" => "n",
1879 "kind" => "k",
1880 "file" => "f",
1881 "line" => "l",
1882 "path" => "p",
1883 "from" => "fr",
1884 "type" => "ty",
1885 "text" => "tx",
1886 "new" => "nw",
1887 "run" => "r",
1888 "use" => "u",
1889 "score" => "sc",
1890 "language" => "la",
1891 "status" => "st",
1892 "state" => "stt",
1893 "error" => "err",
1894 "errors" => "ers",
1895 "hops" => "hp",
1896 "tags" => "tg",
1897 "model" => "ml",
1898 "skill" => "sk",
1899 "count" => "ct",
1900 "total" => "tot",
1901 "column" => "col",
1902 "description" => "dsc",
1903 "end_line" => "el",
1904 "signature" => "sig",
1905 "parent_module" => "pm",
1906 "visibility" => "vis",
1907 "match_type" => "mt",
1908 "caller_file" => "cf",
1909 "caller_name" => "cn",
1910 "caller_line" => "cl",
1911 "callee_name" => "en",
1912 "call_site_line" => "csl",
1913 "members" => "m",
1914 "refs" => "refs",
1915 "role" => "rl",
1916 "peer" => "pr",
1917 "modularity" => "q",
1918 "modularity_contribution" => "mc",
1919 "iterations" => "it",
1920 "node_count" => "nc",
1921 "edge_count" => "ec",
1922 "community_count" => "cc",
1923 "communities" => "cms",
1924 "community" => "cm",
1925 "community_diagnostics" => "cd",
1926 "cache_hit" => "cah",
1927 "tagpath_state" => "tps",
1928 "tagpath_stale_reason" => "tsr",
1929 "annotated_community_count" => "acc",
1930 "annotated_member_count" => "amc",
1931 "ambiguous_member_count" => "ambc",
1932 "ambiguous_members" => "amb",
1933 "candidate_count" => "cand",
1934 "tagpath_candidate_count" => "tcand",
1935 "evidence" => "ev",
1936 "chosen_file" => "chf",
1937 "symbol" => "s",
1938 "symbols" => "sy",
1939 "definitions" => "df",
1940 "callers" => "crs",
1941 "callees" => "ces",
1942 "total_tracked" => "tt",
1943 "modified" => "md",
1944 "deleted" => "dl",
1945 "unchanged" => "uc",
1946 "changes" => "ch",
1947 "prune_stats" => "ps",
1948 "hits" => "h",
1949 "rank" => "rk",
1950 "snippet" => "sn",
1951 "confidence" => "co",
1952 "index" => "ix",
1953 "summaries" => "sms",
1954 "recommendations" => "rec",
1955 "total_files" => "tf",
1956 "stale_files" => "sf",
1957 "last_indexed_secs_ago" => "age",
1958 "cached_files" => "caf",
1959 "total_indexed_files" => "tif",
1960 "coverage_pct" => "cov",
1961 "symbol_name" => "syn",
1962 "file_path" => "fp",
1963 "content_hash" => "hsh",
1964 "summary" => "sum",
1965 "tool" => "tl",
1966 "view" => "vw",
1967 "truncated" => "tr",
1968 "follow_up" => "fu",
1969 "report" => "rp",
1970 "metrics" => "ms",
1971 "label" => "lb",
1972 "value" => "v",
1973 "command" => "cmd",
1974 "exit_code" => "xc",
1975 "success" => "ok",
1976 "artifact" => "art",
1977 "digest" => "dg",
1978 "bytes" => "bt",
1979 "lines" => "lns",
1980 "expand" => "xp",
1981 "entities" => "ent",
1982 "relationships" => "rel",
1983 "concept_labels" => "cls",
1984 "extracted_at" => "at",
1985 "tokens_input" => "ti",
1986 "tokens_output" => "tout",
1987 "total_summaries" => "ts",
1988 "stale_count" => "stc",
1989 "total_tokens_input" => "tti",
1990 "total_tokens_output" => "tto",
1991 "estimated_tokens_saved" => "ets",
1992 "files_processed" => "fps",
1993 "symbols_extracted" => "se",
1994 "skills_dir" => "sd",
1995 "healthy" => "ok",
1996 "broken" => "brk",
1997 "skills" => "sks",
1998 "manifest_diffs" => "mdf",
1999 "similar_pairs" => "sim",
2000 "usage" => "usg",
2001 "cleanup" => "cln",
2002 "has_skill_md" => "hsm",
2003 "is_symlink" => "isl",
2004 "issues" => "iss",
2005 "invocation_count" => "inv",
2006 "reasons" => "rsn",
2007 "token_estimate" => "te",
2008 "skill_a" => "sa",
2009 "skill_b" => "sb",
2010 "desc_a" => "da",
2011 "desc_b" => "db",
2012 "annotations" => "ann",
2013 "entity" => "ety",
2014 "suggestion" => "sug",
2015 "columns" => "cols",
2016 "row_count" => "rc",
2017 "notnull" => "nn",
2018 "default_value" => "dv",
2019 "replace_all" => "ra",
2020 other => other,
2021 }
2022}
2023
2024fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2025 match val {
2026 serde_json::Value::Object(map) => {
2027 let mut new_map = serde_json::Map::new();
2028 for (k, v) in map {
2029 new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2030 }
2031 serde_json::Value::Object(new_map)
2032 }
2033 serde_json::Value::Array(arr) => {
2034 serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2035 }
2036 other => other,
2037 }
2038}
2039
2040fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2041 match val {
2042 serde_json::Value::Object(mut map) => {
2043 let is_graph_node =
2044 map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2045 let is_graph_edge =
2046 map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2047 if is_graph_node || is_graph_edge {
2048 map.remove("properties");
2049 map.remove("provenance");
2050 map.remove("freshness");
2051 }
2052 if is_graph_edge && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2053 *s = abbreviate_edge_kind(s).to_string();
2054 }
2055 let is_coverage = map.contains_key("mode")
2056 && (map.contains_key("total_sector_count")
2057 || map.contains_key("dirty_sector_count"));
2058 if is_coverage {
2059 map.remove("active_rebuild");
2060 map.remove("completed_dirty_sector_count");
2061 map.remove("mounted_sector_count");
2062 map.remove("rebuilding_sector_count");
2063 map.remove("resumed_sector_count");
2064 map.remove("reused_sector_count");
2065 }
2066 if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2067 *s = truncate_for_ultra_terse(s, 80);
2068 }
2069 if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2070 *s = truncate_for_ultra_terse(s, 80);
2071 }
2072 let new_map: serde_json::Map<String, serde_json::Value> = map
2073 .into_iter()
2074 .map(|(k, v)| (k, ultra_terse_transform(v)))
2075 .collect();
2076 serde_json::Value::Object(new_map)
2077 }
2078 serde_json::Value::Array(arr) => {
2079 serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2080 }
2081 other => other,
2082 }
2083}
2084
2085fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2086 match val {
2087 serde_json::Value::Object(mut map) => {
2088 let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2089 nodes.as_array().map(|arr| {
2090 arr.iter()
2091 .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2092 .collect()
2093 })
2094 });
2095 if let Some(ref ids) = node_ids {
2096 let id_map: std::collections::HashMap<&str, usize> = ids
2097 .iter()
2098 .enumerate()
2099 .map(|(i, id)| (id.as_str(), i))
2100 .collect();
2101 if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2102 for edge in edges.iter_mut() {
2103 if let serde_json::Value::Object(edge_map) = edge {
2104 if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id")
2105 {
2106 if let Some(&idx) = id_map.get(fid.as_str()) {
2107 edge_map.insert(
2108 "from".to_string(),
2109 serde_json::Value::Number(idx.into()),
2110 );
2111 } else {
2112 edge_map.insert(
2113 "from_id".to_string(),
2114 serde_json::Value::String(fid),
2115 );
2116 }
2117 }
2118 if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2119 if let Some(&idx) = id_map.get(tid.as_str()) {
2120 edge_map.insert(
2121 "to".to_string(),
2122 serde_json::Value::Number(idx.into()),
2123 );
2124 } else {
2125 edge_map.insert(
2126 "to_id".to_string(),
2127 serde_json::Value::String(tid),
2128 );
2129 }
2130 }
2131 }
2132 }
2133 }
2134 }
2135 let new_map: serde_json::Map<String, serde_json::Value> = map
2136 .into_iter()
2137 .map(|(k, v)| (k, edge_index_transform(v)))
2138 .collect();
2139 serde_json::Value::Object(new_map)
2140 }
2141 serde_json::Value::Array(arr) => {
2142 serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2143 }
2144 other => other,
2145 }
2146}
2147
2148fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2149 if s.len() <= max_len {
2150 s.to_string()
2151 } else {
2152 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2153 format!("{truncated}...")
2154 }
2155}
2156
2157fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2158 let mut keys = HashSet::new();
2159 collect_terse_keys(val, &mut keys);
2160 let mut schema = serde_json::Map::new();
2161 for (long, short) in TERSE_PAIRS {
2162 if keys.contains(*short) {
2163 schema.insert(
2164 short.to_string(),
2165 serde_json::Value::String(long.to_string()),
2166 );
2167 }
2168 }
2169 serde_json::Value::Object(schema)
2170}
2171
2172fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2173 match val {
2174 serde_json::Value::Object(map) => {
2175 for (k, v) in map {
2176 keys.insert(k.clone());
2177 collect_terse_keys(v, keys);
2178 }
2179 }
2180 serde_json::Value::Array(arr) => {
2181 for v in arr {
2182 collect_terse_keys(v, keys);
2183 }
2184 }
2185 _ => {}
2186 }
2187}
2188
2189fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2190 match val {
2191 serde_json::Value::Array(arr) if arr.len() >= 2 => {
2192 if let Some(cols) = homogeneous_keys(&arr) {
2193 let rows: Vec<serde_json::Value> = arr
2194 .into_iter()
2195 .map(|item| {
2196 if let serde_json::Value::Object(map) = item {
2197 let vals: Vec<serde_json::Value> = cols
2198 .iter()
2199 .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2200 .collect();
2201 serde_json::Value::Array(vals)
2202 } else {
2203 item
2204 }
2205 })
2206 .collect();
2207 let col_vals: Vec<serde_json::Value> =
2208 cols.into_iter().map(serde_json::Value::String).collect();
2209 serde_json::json!({"_c": col_vals, "_r": rows})
2210 } else {
2211 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2212 }
2213 }
2214 serde_json::Value::Array(arr) => {
2215 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2216 }
2217 serde_json::Value::Object(map) => {
2218 let new_map: serde_json::Map<String, serde_json::Value> = map
2219 .into_iter()
2220 .map(|(k, v)| (k, schema_transform(v)))
2221 .collect();
2222 serde_json::Value::Object(new_map)
2223 }
2224 other => other,
2225 }
2226}
2227
2228fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2229 let first = arr.first()?.as_object()?;
2230 let keys: Vec<String> = first.keys().cloned().collect();
2231 for item in &arr[1..] {
2232 let obj = item.as_object()?;
2233 if obj.len() != keys.len() {
2234 return None;
2235 }
2236 for k in &keys {
2237 if !obj.contains_key(k) {
2238 return None;
2239 }
2240 }
2241 }
2242 Some(keys)
2243}
2244
2245const TERSE_PAIRS: &[(&str, &str)] = &[
2246 ("name", "n"),
2247 ("kind", "k"),
2248 ("file", "f"),
2249 ("line", "l"),
2250 ("path", "p"),
2251 ("from", "fr"),
2252 ("type", "ty"),
2253 ("text", "tx"),
2254 ("new", "nw"),
2255 ("run", "r"),
2256 ("use", "u"),
2257 ("score", "sc"),
2258 ("language", "la"),
2259 ("status", "st"),
2260 ("state", "stt"),
2261 ("error", "err"),
2262 ("errors", "ers"),
2263 ("hops", "hp"),
2264 ("tags", "tg"),
2265 ("model", "ml"),
2266 ("skill", "sk"),
2267 ("count", "ct"),
2268 ("total", "tot"),
2269 ("column", "col"),
2270 ("description", "dsc"),
2271 ("end_line", "el"),
2272 ("signature", "sig"),
2273 ("parent_module", "pm"),
2274 ("visibility", "vis"),
2275 ("match_type", "mt"),
2276 ("caller_file", "cf"),
2277 ("caller_name", "cn"),
2278 ("caller_line", "cl"),
2279 ("callee_name", "en"),
2280 ("call_site_line", "csl"),
2281 ("members", "m"),
2282 ("refs", "refs"),
2283 ("role", "rl"),
2284 ("peer", "pr"),
2285 ("modularity", "q"),
2286 ("modularity_contribution", "mc"),
2287 ("iterations", "it"),
2288 ("node_count", "nc"),
2289 ("edge_count", "ec"),
2290 ("community_count", "cc"),
2291 ("communities", "cms"),
2292 ("community", "cm"),
2293 ("community_diagnostics", "cd"),
2294 ("cache_hit", "cah"),
2295 ("tagpath_state", "tps"),
2296 ("tagpath_stale_reason", "tsr"),
2297 ("annotated_community_count", "acc"),
2298 ("annotated_member_count", "amc"),
2299 ("ambiguous_member_count", "ambc"),
2300 ("ambiguous_members", "amb"),
2301 ("candidate_count", "cand"),
2302 ("tagpath_candidate_count", "tcand"),
2303 ("evidence", "ev"),
2304 ("chosen_file", "chf"),
2305 ("symbol", "s"),
2306 ("symbols", "sy"),
2307 ("definitions", "df"),
2308 ("callers", "crs"),
2309 ("callees", "ces"),
2310 ("total_tracked", "tt"),
2311 ("modified", "md"),
2312 ("deleted", "dl"),
2313 ("unchanged", "uc"),
2314 ("changes", "ch"),
2315 ("prune_stats", "ps"),
2316 ("hits", "h"),
2317 ("rank", "rk"),
2318 ("snippet", "sn"),
2319 ("confidence", "co"),
2320 ("index", "ix"),
2321 ("summaries", "sms"),
2322 ("recommendations", "rec"),
2323 ("total_files", "tf"),
2324 ("stale_files", "sf"),
2325 ("last_indexed_secs_ago", "age"),
2326 ("cached_files", "caf"),
2327 ("total_indexed_files", "tif"),
2328 ("coverage_pct", "cov"),
2329 ("symbol_name", "syn"),
2330 ("file_path", "fp"),
2331 ("content_hash", "hsh"),
2332 ("summary", "sum"),
2333 ("tool", "tl"),
2334 ("view", "vw"),
2335 ("truncated", "tr"),
2336 ("follow_up", "fu"),
2337 ("report", "rp"),
2338 ("metrics", "ms"),
2339 ("label", "lb"),
2340 ("value", "v"),
2341 ("command", "cmd"),
2342 ("exit_code", "xc"),
2343 ("success", "ok"),
2344 ("artifact", "art"),
2345 ("digest", "dg"),
2346 ("bytes", "bt"),
2347 ("lines", "lns"),
2348 ("expand", "xp"),
2349 ("entities", "ent"),
2350 ("relationships", "rel"),
2351 ("concept_labels", "cls"),
2352 ("extracted_at", "at"),
2353 ("tokens_input", "ti"),
2354 ("tokens_output", "tout"),
2355 ("total_summaries", "ts"),
2356 ("stale_count", "stc"),
2357 ("total_tokens_input", "tti"),
2358 ("total_tokens_output", "tto"),
2359 ("estimated_tokens_saved", "ets"),
2360 ("files_processed", "fps"),
2361 ("symbols_extracted", "se"),
2362 ("skills_dir", "sd"),
2363 ("healthy", "ok"),
2364 ("broken", "brk"),
2365 ("skills", "sks"),
2366 ("manifest_diffs", "mdf"),
2367 ("similar_pairs", "sim"),
2368 ("usage", "usg"),
2369 ("cleanup", "cln"),
2370 ("has_skill_md", "hsm"),
2371 ("is_symlink", "isl"),
2372 ("issues", "iss"),
2373 ("invocation_count", "inv"),
2374 ("reasons", "rsn"),
2375 ("token_estimate", "te"),
2376 ("skill_a", "sa"),
2377 ("skill_b", "sb"),
2378 ("desc_a", "da"),
2379 ("desc_b", "db"),
2380 ("annotations", "ann"),
2381 ("entity", "ety"),
2382 ("suggestion", "sug"),
2383 ("columns", "cols"),
2384 ("row_count", "rc"),
2385 ("notnull", "nn"),
2386 ("default_value", "dv"),
2387 ("replace_all", "ra"),
2388];
2389
2390pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2391 let root_str = root.to_string_lossy();
2392 let prefix = format!("{}/", root_str.trim_end_matches('/'));
2393 path.strip_prefix(&prefix).unwrap_or(path).to_string()
2394}
2395
2396fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2397 let canonical = path
2398 .canonicalize()
2399 .with_context(|| format!("canonicalizing {}", path.display()))?;
2400 let start = if canonical.is_dir() {
2401 canonical.clone()
2402 } else {
2403 canonical
2404 .parent()
2405 .map(Path::to_path_buf)
2406 .unwrap_or_else(|| canonical.clone())
2407 };
2408
2409 for ancestor in start.ancestors() {
2410 if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2411 return Ok(ancestor.to_path_buf());
2412 }
2413 }
2414
2415 Ok(start)
2416}
2417
2418pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2419 path.strip_prefix(root)
2420 .map(|p| p.to_path_buf())
2421 .unwrap_or_else(|_| path.to_path_buf())
2422}
2423
2424pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
2425 for edge in edges {
2426 edge.caller_file = relativize(&edge.caller_file, root);
2427 }
2428}
2429
2430pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
2431 for sym in symbols {
2432 sym.file = relativize(&sym.file, root);
2433 }
2434}
2435
2436pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
2437 for hit in hits {
2438 hit.file = relativize(&hit.file, root);
2439 }
2440}
2441
2442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2445pub enum EdgeSide {
2446 Caller,
2447 Callee,
2448}
2449
2450const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
2451
2452pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
2453 let root_str = root.to_string_lossy();
2454 let prefix = format!("{}/", root_str.trim_end_matches('/'));
2455 relativize_json_inner(val, &prefix);
2456}
2457
2458fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
2459 match val {
2460 serde_json::Value::Array(arr) => {
2461 for v in arr {
2462 relativize_json_inner(v, prefix);
2463 }
2464 }
2465 serde_json::Value::Object(map) => {
2466 for (k, v) in map.iter_mut() {
2467 if JSON_PATH_KEYS.contains(&k.as_str())
2468 && let serde_json::Value::String(s) = v
2469 && let Some(rest) = s.strip_prefix(prefix)
2470 {
2471 *s = rest.to_string();
2472 }
2473 relativize_json_inner(v, prefix);
2474 }
2475 }
2476 _ => {}
2477 }
2478}
2479
2480pub(crate) fn format_score(score: f64, compact: bool) -> String {
2481 if compact {
2482 format!("{score:.2}")
2483 } else {
2484 format!("{score:.4}")
2485 }
2486}
2487
2488pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
2489 let trimmed = input.trim();
2490 let count = trimmed.chars().count();
2491 if count <= max_chars {
2492 return trimmed.to_string();
2493 }
2494 let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
2495 format!("{prefix}...")
2496}
2497
2498pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
2499 snippet
2500 .lines()
2501 .find(|line| !line.trim().is_empty())
2502 .map(|line| truncate_for_compact(line, 100))
2503}
2504
2505pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
2506 let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
2507 if names.len() <= limit {
2508 return names.join(", ");
2509 }
2510 format!(
2511 "{} (+{} more)",
2512 names[..limit].join(", "),
2513 names.len() - limit
2514 )
2515}
2516
2517pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
2518 let mut hasher = blake3::Hasher::new();
2519 hasher.update(prefix.as_bytes());
2520 hasher.update(&[0]);
2521 hasher.update(key.as_bytes());
2522 let hex = hasher.finalize().to_hex();
2523 format!("{prefix}-{}", &hex[..10])
2524}
2525
2526#[derive(Clone, Debug, PartialEq, Eq)]
2527struct CanonicalTagFamily {
2528 canonical: String,
2529 tag_alias: String,
2530}
2531
2532fn canonical_family_from_tagpath_family(
2533 family: tagpath_family::TagFamily,
2534) -> Option<CanonicalTagFamily> {
2535 let tag_alias = if family.dimensions.is_empty() {
2536 family.tags.join("/")
2537 } else {
2538 family
2539 .dimensions
2540 .iter()
2541 .filter(|dimension| !dimension.tags.is_empty())
2542 .map(|dimension| dimension.tags.join("."))
2543 .collect::<Vec<_>>()
2544 .join("/")
2545 };
2546
2547 if tag_alias.is_empty() {
2548 None
2549 } else {
2550 Some(CanonicalTagFamily {
2551 canonical: family.canonical,
2552 tag_alias,
2553 })
2554 }
2555}
2556
2557fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
2558 let trimmed = name.trim();
2559 if trimmed.is_empty() {
2560 return None;
2561 }
2562
2563 canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
2564}
2565
2566fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
2567 let canonical = tags
2568 .split(',')
2569 .map(str::trim)
2570 .filter(|tag| !tag.is_empty())
2571 .collect::<Vec<_>>()
2572 .join("_");
2573 if canonical.is_empty() {
2574 None
2575 } else {
2576 canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
2577 }
2578}
2579
2580pub(crate) fn canonical_tag_family_from_symbol(
2581 name: &str,
2582 tags: Option<&str>,
2583) -> Option<CanonicalTagFamily> {
2584 tags.and_then(canonical_tag_family_from_tags)
2585 .or_else(|| canonical_tag_family_from_name(name))
2586}
2587
2588fn tag_alias_from_name(name: &str) -> Option<String> {
2589 canonical_tag_family_from_name(name).map(|family| family.tag_alias)
2590}
2591
2592fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
2593 canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
2594}
2595
2596pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
2597 let query = tag_alias
2598 .split(['/', '.'])
2599 .map(str::trim)
2600 .filter(|part| !part.is_empty())
2601 .collect::<Vec<_>>()
2602 .join(" ");
2603 if query.is_empty() { None } else { Some(query) }
2604}
2605
2606#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2607struct CompactOntologyRefPreview {
2608 handle: String,
2609 tag: String,
2610 path: String,
2611 #[serde(skip_serializing_if = "Option::is_none")]
2612 title: Option<String>,
2613 #[serde(skip_serializing_if = "Option::is_none")]
2614 domain: Option<String>,
2615}
2616
2617#[derive(Clone, Debug)]
2618struct TagOntologyPreviewContext {
2619 project_root: PathBuf,
2620 tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
2621}
2622
2623#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2624struct CompactSymbolRefPreview {
2625 handle: String,
2626 name: String,
2627 #[serde(skip_serializing_if = "Option::is_none")]
2628 tag_alias: Option<String>,
2629 #[serde(skip_serializing_if = "Vec::is_empty", default)]
2630 ontology_refs: Vec<CompactOntologyRefPreview>,
2631}
2632
2633fn build_compact_symbol_ref(
2634 prefix: &str,
2635 key: &str,
2636 name: &str,
2637 tags: Option<&str>,
2638 max_bytes: usize,
2639) -> CompactSymbolRefPreview {
2640 build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
2641}
2642
2643fn build_compact_symbol_ref_with_ontology(
2644 prefix: &str,
2645 key: &str,
2646 name: &str,
2647 tags: Option<&str>,
2648 max_bytes: usize,
2649 ontology: Option<&TagOntologyPreviewContext>,
2650) -> CompactSymbolRefPreview {
2651 let tag_alias = tag_alias_from_tags(name, tags);
2652 let ontology_refs = tag_alias
2653 .as_deref()
2654 .map(|alias| ontology_refs_for_alias(ontology, alias))
2655 .unwrap_or_default();
2656 CompactSymbolRefPreview {
2657 handle: stable_handle(prefix, key),
2658 name: truncate_for_budget(name, max_bytes),
2659 tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
2660 ontology_refs,
2661 }
2662}
2663
2664fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
2665 let report = tagpath_ontology::load_project(root).ok()?;
2666 if report.tags.is_empty() {
2667 return None;
2668 }
2669 Some(TagOntologyPreviewContext {
2670 project_root: report.project_path,
2671 tags: report
2672 .tags
2673 .into_iter()
2674 .map(|tag| (tag.tag.clone(), tag))
2675 .collect(),
2676 })
2677}
2678
2679fn ontology_refs_for_alias(
2680 ontology: Option<&TagOntologyPreviewContext>,
2681 alias: &str,
2682) -> Vec<CompactOntologyRefPreview> {
2683 let Some(ontology) = ontology else {
2684 return Vec::new();
2685 };
2686 let mut seen = BTreeSet::new();
2687 alias
2688 .split('/')
2689 .flat_map(|part| part.split('.'))
2690 .map(str::trim)
2691 .filter(|tag| !tag.is_empty())
2692 .filter_map(|tag| {
2693 let key = tag.to_ascii_lowercase();
2694 if !seen.insert(key.clone()) {
2695 return None;
2696 }
2697 let ontology_tag = ontology.tags.get(&key)?;
2698 let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
2699 Some(CompactOntologyRefPreview {
2700 handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
2701 tag: ontology_tag.tag.clone(),
2702 path,
2703 title: ontology_tag.title.clone(),
2704 domain: ontology_tag.domain.clone(),
2705 })
2706 })
2707 .collect()
2708}
2709
2710fn relativize_ontology_path(path: &Path, root: &Path) -> String {
2711 path.strip_prefix(root)
2712 .unwrap_or(path)
2713 .to_string_lossy()
2714 .replace('\\', "/")
2715}
2716
2717fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
2718 match tag_alias {
2719 Some(alias) => format!("{handle} {name} tag:{alias}"),
2720 None => format!("{handle} {name}"),
2721 }
2722}
2723
2724fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
2725 match summary.tag_alias.as_deref() {
2726 Some(alias) => format!(
2727 "{} {} tag:{} expand:{}",
2728 summary.handle, summary.symbol, alias, summary.expand
2729 ),
2730 None => format!(
2731 "{} {} expand:{}",
2732 summary.handle, summary.symbol, summary.expand
2733 ),
2734 }
2735}
2736
2737fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
2738 match symbol.tag_alias.as_deref() {
2739 Some(alias) => format!("{}@{}", symbol.handle, alias),
2740 None => format!("{}@{}", symbol.handle, symbol.name),
2741 }
2742}
2743
2744pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
2745 let trimmed = input.trim();
2746 if trimmed.len() <= max_bytes {
2747 return trimmed.to_string();
2748 }
2749 if max_bytes <= 3 {
2750 return ".".repeat(max_bytes);
2751 }
2752
2753 let mut end = 0usize;
2754 for (idx, ch) in trimmed.char_indices() {
2755 let next = idx + ch.len_utf8();
2756 if next > max_bytes.saturating_sub(3) {
2757 break;
2758 }
2759 end = next;
2760 }
2761
2762 if end == 0 {
2763 "...".to_string()
2764 } else {
2765 format!("{}...", &trimmed[..end])
2766 }
2767}
2768
2769struct TokenCappedPreview {
2770 preview: Vec<SourceLinePreview>,
2771 capped_end: usize,
2772 was_capped: bool,
2773}
2774
2775fn build_token_capped_preview(
2776 all_lines: &[&str],
2777 start: usize,
2778 end: usize,
2779 max_bytes: usize,
2780 token_cap: usize,
2781) -> TokenCappedPreview {
2782 let mut preview = Vec::new();
2783 let mut accumulated_tokens = 0usize;
2784 let mut capped_end = end;
2785 let mut was_capped = false;
2786
2787 for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
2788 let truncated = truncate_for_budget(line, max_bytes);
2789 let line_tokens = estimated_tokens_from_bytes(truncated.len());
2790 if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
2791 capped_end = start + idx - 1;
2792 was_capped = true;
2793 break;
2794 }
2795 accumulated_tokens += line_tokens;
2796 preview.push(SourceLinePreview {
2797 line: start + idx,
2798 text: truncated,
2799 });
2800 }
2801
2802 TokenCappedPreview {
2803 preview,
2804 capped_end,
2805 was_capped,
2806 }
2807}
2808
2809pub(crate) fn abbreviate_kind(kind: &str) -> &str {
2810 match kind {
2811 "function" => "fn",
2812 "method" => "meth",
2813 "module" | "mod" => "mod",
2814 "struct" => "struct",
2815 "trait" => "trait",
2816 "impl" => "impl",
2817 "class" => "cls",
2818 "interface" => "iface",
2819 "type_alias" => "type",
2820 "data_class" => "data_cls",
2821 "sealed_class" => "sealed_cls",
2822 "enum_class" => "enum_cls",
2823 "companion_object" => "comp_obj",
2824 "object" => "obj",
2825 "heading" => "h",
2826 "code_block" => "code",
2827 "alias" => "alias",
2828 other => other,
2829 }
2830}
2831
2832pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
2833 match kind {
2834 "calls" => "c",
2835 "defines" => "d",
2836 "contains" => "ct",
2837 "imports" => "i",
2838 "mentions" => "m",
2839 "mentions_concept" => "mc",
2840 "mentions_entity" => "me",
2841 "semantic_relation" => "sr",
2842 "belongs_to" => "bt",
2843 "scopes_context" => "sctx",
2844 "scopes_source" => "ssrc",
2845 "requests_context" => "rctx",
2846 "explains_result" => "er",
2847 "tagged_concept" => "tc",
2848 "tagged_entity" => "te",
2849 "related_concept" => "relc",
2850 "handled_by" => "hb",
2851 "defines_route" => "dr",
2852 "handles_route" => "hr",
2853 "targets" => "tgt",
2854 "has_vector_handle" => "hv",
2855 "parent" => "p",
2856 "child" => "ch",
2857 "uses" => "u",
2858 "projects_source" => "psrc",
2859 "records_memory_source" => "rms",
2860 "records_memory_event" => "rme",
2861 "has_ast_span" => "ha",
2862 "represents_symbol" => "rs",
2863 "contains_embedded_symbol" => "ces",
2864 "embedded_in_fence" => "ef",
2865 "contains_markdown_block" => "cmb",
2866 "contains_embedded_code" => "cec",
2867 "enclosing_module" => "em",
2868 "enclosing_section" => "es",
2869 "previous_sibling" => "psib",
2870 "next_sibling" => "nsib",
2871 "explicit_depends_on" => "edo",
2872 "worker_result_follow_up" => "wrf",
2873 "shared_resource" => "shr",
2874 "community_member" => "cm",
2875 other => other,
2876 }
2877}
2878
2879pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
2880 match mt {
2881 "exact_name" => "exact",
2882 "all_tags" => "all_tags",
2883 "partial_tags" => "partial",
2884 other => other,
2885 }
2886}
2887
2888pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
2889 path.iter()
2890 .map(|n| n.name.as_str())
2891 .collect::<Vec<_>>()
2892 .join(" -> ")
2893}
2894
2895const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
2896
2897struct SearchHitGroup {
2898 path: String,
2899 first_rank: usize,
2900 top_score: f64,
2901 confidence: String,
2902 hits: usize,
2903 samples: Vec<String>,
2904}
2905
2906fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
2907 let snippet = compact_snippet(&hit.snippet)?;
2908 Some(match hit.location.as_deref() {
2909 Some(location) => format!("{location}: {snippet}"),
2910 None => snippet,
2911 })
2912}
2913
2914pub(crate) fn group_search_hits(
2915 hits: &[sift::SearchHit],
2916 root: &Path,
2917 absolute: bool,
2918) -> Vec<SearchHitGroup> {
2919 let mut positions = BTreeMap::new();
2920 let mut groups = Vec::new();
2921 for hit in hits {
2922 let path = if absolute {
2923 hit.path.clone()
2924 } else {
2925 relativize(&hit.path, root)
2926 };
2927 let entry = positions.entry(path.clone()).or_insert_with(|| {
2928 groups.push(SearchHitGroup {
2929 path: path.clone(),
2930 first_rank: hit.rank,
2931 top_score: hit.score,
2932 confidence: format!("{:?}", hit.confidence),
2933 hits: 0,
2934 samples: Vec::new(),
2935 });
2936 groups.len() - 1
2937 });
2938 let group = &mut groups[*entry];
2939 group.hits += 1;
2940 if hit.rank < group.first_rank {
2941 group.first_rank = hit.rank;
2942 }
2943 if hit.score > group.top_score {
2944 group.top_score = hit.score;
2945 }
2946 if let Some(sample) = format_search_sample(hit)
2947 && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
2948 && !group.samples.contains(&sample)
2949 {
2950 group.samples.push(sample);
2951 }
2952 }
2953 groups.sort_by_key(|group| group.first_rank);
2954 groups
2955}
2956
2957pub(crate) fn should_collapse_search_hits(
2958 hits: &[sift::SearchHit],
2959 root: &Path,
2960 absolute: bool,
2961) -> bool {
2962 let groups = group_search_hits(hits, root, absolute);
2963 let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
2964 max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
2965}
2966
2967pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
2968 let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
2969 for edge in edges {
2970 let key = edge.caller_file.as_str();
2971 let name = if use_callers {
2972 edge.caller_name.as_str()
2973 } else {
2974 edge.callee_name.as_str()
2975 };
2976 let names = grouped.entry(key).or_default();
2977 if !names.contains(&name) {
2978 names.push(name);
2979 }
2980 }
2981
2982 grouped
2983 .into_iter()
2984 .map(|(file, names)| format!(" {} ({}): {}", file, names.len(), names.join(", ")))
2985 .collect()
2986}
2987
2988pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
2989 let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
2990 for edge in edges {
2991 *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
2992 }
2993 let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
2994 max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
2995}
2996
2997fn resolve_query_index_target(
2998 root: &Path,
2999 path_hint: &Path,
3000 scope: Option<&str>,
3001) -> Result<SearchIndexTarget> {
3002 let cfg = config::Config::load(root)?;
3003 if let Some(scope_name) = scope {
3004 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3005 return Ok(SearchIndexTarget {
3006 label: format!("submodule `{}` index", scope.id),
3007 db_path: cfg.db_path_for(root, &scope.id),
3008 source_root: scope.source_root.clone(),
3009 scope_name: Some(scope.id.clone()),
3010 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3011 });
3012 }
3013 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3014 return Ok(cargo_package_index_target(root, package));
3015 }
3016 config::Config::resolve_submodule(root, scope_name)?;
3017 }
3018
3019 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3020 return Ok(SearchIndexTarget {
3021 label: format!("submodule `{}` index", scope.id),
3022 db_path: cfg.db_path_for(root, &scope.id),
3023 source_root: scope.source_root.clone(),
3024 scope_name: Some(scope.id.clone()),
3025 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3026 });
3027 }
3028
3029 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3030 return Ok(cargo_package_index_target(root, package));
3031 }
3032
3033 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3034 return Ok(SearchIndexTarget {
3035 label: format!("submodule `{}` index", scope.id),
3036 db_path: cfg.db_path_for(root, &scope.id),
3037 source_root: scope.source_root.clone(),
3038 scope_name: Some(scope.id.clone()),
3039 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3040 });
3041 }
3042
3043 let db_path = root.join(".tsift/index.db");
3044 if db_path.exists() {
3045 return Ok(SearchIndexTarget {
3046 label: "index".to_string(),
3047 db_path,
3048 source_root: root.to_path_buf(),
3049 scope_name: None,
3050 reindex_cmd: format!("tsift index {}", root.display()),
3051 });
3052 }
3053
3054 let scopes = config::Config::submodule_dirs(root)?;
3055 if scopes.is_empty() {
3056 return Ok(SearchIndexTarget {
3057 label: "index".to_string(),
3058 db_path,
3059 source_root: root.to_path_buf(),
3060 scope_name: None,
3061 reindex_cmd: format!("tsift index {}", root.display()),
3062 });
3063 }
3064
3065 let available_scopes = scopes
3066 .iter()
3067 .map(|scope| scope.id.as_str())
3068 .collect::<Vec<_>>()
3069 .join(", ");
3070 let indexed_scopes = scopes
3071 .iter()
3072 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3073 .map(|scope| scope.id.as_str())
3074 .collect::<Vec<_>>();
3075 let indexed_label = if indexed_scopes.is_empty() {
3076 "none".to_string()
3077 } else {
3078 indexed_scopes.join(", ")
3079 };
3080
3081 bail!(
3082 "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: {}.",
3083 root.display(),
3084 db_path.display(),
3085 available_scopes,
3086 indexed_label
3087 );
3088}
3089
3090pub(crate) fn resolve_query_db_path(
3091 root: &Path,
3092 path_hint: &Path,
3093 scope: Option<&str>,
3094) -> Result<PathBuf> {
3095 Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3096}
3097
3098fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3099 let state = inspect_search_index(target)?;
3100 let Some(reason) = index_reason_for_state(state) else {
3101 return Ok(());
3102 };
3103
3104 match apply_search_index_update(root, target) {
3105 Ok(_) => {
3106 index::inspect_scope_invalidate_all();
3107 Ok(())
3108 }
3109 Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3110 eprintln!(
3111 "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3112 Continuing with the current read-only index snapshot; graph results may lag. \
3113 Retry `{}` after the active writer finishes for fresh graph results.",
3114 index_reason_detail(target, reason),
3115 target.reindex_cmd
3116 );
3117 Ok(())
3118 }
3119 Err(err) => Err(err),
3120 }
3121}
3122
3123pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3124 let root = lint::resolve_project_root_or_canonical_path(path)?;
3125 let target = resolve_query_index_target(&root, path, scope)?;
3126 ensure_query_index_current(&root, &target)?;
3127 let db_path = target.db_path;
3128 if !db_path.exists() {
3129 bail!(
3130 "no index found at {}. Run `tsift index` first.",
3131 db_path.display()
3132 );
3133 }
3134 index::IndexDb::open_read_only_resilient(&db_path)
3135}
3136
3137pub(crate) fn query_tagpath_root(
3138 root: &std::path::Path,
3139 path_hint: &std::path::Path,
3140 scope: Option<&str>,
3141) -> Result<PathBuf> {
3142 if let Some(scope_name) = scope {
3143 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3144 return Ok(scope.source_root);
3145 }
3146 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3147 return Ok(package.package_root);
3148 }
3149 config::Config::resolve_submodule(root, scope_name)?;
3150 }
3151 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3152 return Ok(scope.source_root);
3153 }
3154 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3155 return Ok(package.package_root);
3156 }
3157 Ok(root.to_path_buf())
3158}
3159
3160#[derive(Clone, Debug, Serialize, PartialEq)]
3161struct TraversalNode {
3162 handle: String,
3163 kind: String,
3164 label: String,
3165 #[serde(skip_serializing_if = "Option::is_none")]
3166 ref_id: Option<String>,
3167 #[serde(skip_serializing_if = "Option::is_none")]
3168 path: Option<String>,
3169 #[serde(skip_serializing_if = "Option::is_none")]
3170 line: Option<i64>,
3171 #[serde(skip_serializing_if = "Option::is_none")]
3172 detail: Option<String>,
3173 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3174 properties: BTreeMap<String, String>,
3175 expand: String,
3176}
3177
3178#[derive(Clone, Debug, Serialize, PartialEq)]
3179struct TraversalEdge {
3180 from: String,
3181 to: String,
3182 relation: String,
3183 #[serde(skip_serializing_if = "Option::is_none")]
3184 label: Option<String>,
3185 weight: usize,
3186}
3187
3188#[derive(Clone, Debug, Default)]
3189struct TraversalGraphBuild {
3190 nodes: BTreeMap<String, TraversalNode>,
3191 edges: Vec<TraversalEdge>,
3192 edge_keys: BTreeSet<(String, String, String)>,
3193 warnings: Vec<String>,
3194}
3195
3196pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3197const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3198const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3199const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3200const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3201 "context-pack-graph-orchestration-v1";
3202const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3203const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3204const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3205const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3206const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3207const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3208const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3209
3210#[derive(Debug, Serialize, PartialEq)]
3211struct TraversalTotals {
3212 nodes: usize,
3213 edges: usize,
3214}
3215
3216#[derive(Debug, Serialize, PartialEq)]
3217struct TraversalPathReport {
3218 from: TraversalNode,
3219 to: TraversalNode,
3220 hops: usize,
3221 nodes: Vec<TraversalNode>,
3222 edges: Vec<TraversalEdge>,
3223}
3224
3225#[derive(Debug, Serialize, PartialEq)]
3226struct TraversalRecommendation {
3227 handle: String,
3228 kind: String,
3229 label: String,
3230 reason: String,
3231 score: usize,
3232 expand: String,
3233}
3234
3235#[derive(Debug, Serialize, PartialEq)]
3236struct TraversalReport {
3237 root: String,
3238 #[serde(skip_serializing_if = "Option::is_none")]
3239 scope: Option<String>,
3240 mode: String,
3241 totals: TraversalTotals,
3242 #[serde(skip_serializing_if = "Option::is_none")]
3243 query: Option<String>,
3244 #[serde(skip_serializing_if = "Option::is_none")]
3245 target: Option<String>,
3246 nodes: Vec<TraversalNode>,
3247 edges: Vec<TraversalEdge>,
3248 #[serde(skip_serializing_if = "Option::is_none")]
3249 shortest_path: Option<TraversalPathReport>,
3250 recommendations: Vec<TraversalRecommendation>,
3251 exploration: ExplorationPacket,
3252 truncated: bool,
3253 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3254 warnings: Vec<String>,
3255}
3256
3257#[derive(Debug, Serialize, PartialEq)]
3258struct SemanticRelatedReport {
3259 root: String,
3260 #[serde(skip_serializing_if = "Option::is_none")]
3261 scope: Option<String>,
3262 query: String,
3263 embedding_model: String,
3264 count: usize,
3265 items: Vec<SemanticRelatedItem>,
3266 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3267 warnings: Vec<String>,
3268}
3269
3270#[derive(Clone, Debug, Serialize, PartialEq)]
3271struct SemanticRelatedItem {
3272 handle: String,
3273 kind: String,
3274 label: String,
3275 score: f64,
3276 #[serde(skip_serializing_if = "Option::is_none")]
3277 file_path: Option<String>,
3278 #[serde(skip_serializing_if = "Option::is_none")]
3279 source_symbol: Option<String>,
3280 #[serde(skip_serializing_if = "Option::is_none")]
3281 detail: Option<String>,
3282 expand: String,
3283}
3284
3285#[derive(Clone)]
3286struct TraversalSymbolIndexEntry {
3287 handle: String,
3288 node: TraversalNode,
3289 tokens: BTreeSet<String>,
3290}
3291
3292#[derive(Clone)]
3293struct TraversalFileIndexEntry {
3294 handle: String,
3295 node: TraversalNode,
3296 tokens: BTreeSet<String>,
3297}
3298
3299#[derive(Clone)]
3300struct TraversalRouteIndexEntry {
3301 handle: String,
3302 node: TraversalNode,
3303 tokens: BTreeSet<String>,
3304}
3305
3306#[derive(Clone)]
3307struct TraversalAstSpanIndexEntry {
3308 handle: String,
3309 symbol_handle: String,
3310 file_handle: Option<String>,
3311 file: String,
3312 name: String,
3313 kind: String,
3314 language: String,
3315 node_kind: String,
3316 start_byte: usize,
3317 end_byte: usize,
3318 parent_module: Option<String>,
3319 markdown: Option<MarkdownSpanMetadata>,
3320}
3321
3322#[derive(Clone)]
3323struct TraversalMultiplicityIndexEntry {
3324 handle: String,
3325 node: TraversalNode,
3326 tokens: BTreeSet<String>,
3327}
3328
3329struct TraversalCodeLookup<'a> {
3330 symbols: &'a [TraversalSymbolIndexEntry],
3331 files: &'a [TraversalFileIndexEntry],
3332 routes: &'a [TraversalRouteIndexEntry],
3333 multiplicities: &'a [TraversalMultiplicityIndexEntry],
3334 symbol_index: HashMap<String, Vec<usize>>,
3335 file_index: HashMap<String, Vec<usize>>,
3336 route_index: HashMap<String, Vec<usize>>,
3337 multiplicity_index: HashMap<String, Vec<usize>>,
3338 file_path_index: HashMap<String, String>,
3339}
3340
3341#[derive(Clone, Debug, Serialize, PartialEq)]
3342struct ExplorationBudget {
3343 project_size: String,
3344 max_source_windows: usize,
3345 lines_per_window: usize,
3346 relationship_limit: usize,
3347}
3348
3349#[derive(Clone, Debug, Serialize, PartialEq)]
3350struct ExplorationRelation {
3351 from: String,
3352 relation: String,
3353 to: String,
3354 #[serde(skip_serializing_if = "Option::is_none")]
3355 label: Option<String>,
3356}
3357
3358#[derive(Clone, Debug, Serialize, PartialEq)]
3359struct ExplorationSourceWindow {
3360 handle: String,
3361 file: String,
3362 start: usize,
3363 end: usize,
3364 reason: String,
3365 expand: String,
3366}
3367
3368#[derive(Clone, Debug, Serialize, PartialEq)]
3369struct ExplorationWorkerContext {
3370 handle: String,
3371 target: String,
3372 summary: String,
3373 expand: String,
3374}
3375
3376#[derive(Clone, Debug, Serialize, PartialEq)]
3377struct ExplorationPacket {
3378 budget: ExplorationBudget,
3379 relationship_map: Vec<ExplorationRelation>,
3380 source_windows: Vec<ExplorationSourceWindow>,
3381 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3382 worker_context: Vec<ExplorationWorkerContext>,
3383 no_reread_guidance: String,
3384}
3385
3386impl TraversalGraphBuild {
3387 fn add_node(&mut self, node: TraversalNode) {
3388 self.nodes.entry(node.handle.clone()).or_insert(node);
3389 }
3390
3391 fn add_edge(
3392 &mut self,
3393 from: &str,
3394 to: &str,
3395 relation: &str,
3396 label: Option<String>,
3397 weight: usize,
3398 ) {
3399 if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3400 return;
3401 }
3402 let key = (from.to_string(), to.to_string(), relation.to_string());
3403 if self.edge_keys.insert(key) {
3404 self.edges.push(TraversalEdge {
3405 from: from.to_string(),
3406 to: to.to_string(),
3407 relation: relation.to_string(),
3408 label,
3409 weight,
3410 });
3411 }
3412 }
3413}
3414
3415pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3416 match scope {
3417 Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3418 None => root.join(".tsift/graph.db"),
3419 }
3420}
3421
3422fn graph_projection_meta_id(scope: Option<&str>) -> String {
3423 format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3424}
3425
3426pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
3427 let bytes = serde_json::to_vec(value)?;
3428 Ok(blake3::hash(&bytes).to_hex().to_string())
3429}
3430
3431fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
3432 let mut hashable = node.clone();
3433 hashable.freshness = None;
3434 node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3435 Ok(node)
3436}
3437
3438fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
3439 let mut hashable = edge.clone();
3440 hashable.freshness = None;
3441 edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3442 Ok(edge)
3443}
3444
3445const SEMANTIC_EMBEDDING_DIM: usize = 32;
3446const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
3447
3448fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
3449 match kind {
3450 SemanticRelatedKind::Concept => "concept",
3451 SemanticRelatedKind::Entity => "entity",
3452 SemanticRelatedKind::All => "all",
3453 }
3454}
3455
3456fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
3457 format!(
3458 "tsift semantic {} --path {} --kind {} --limit 10",
3459 shell_quote(query),
3460 shell_quote(root.to_string_lossy().as_ref()),
3461 semantic_related_kind_name(kind)
3462 )
3463}
3464
3465fn semantic_embedding(input: &str) -> Vec<f64> {
3466 let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
3467 let mut tokens = traversal_tokens(input);
3468 if tokens.is_empty() {
3469 let trimmed = input.trim().to_ascii_lowercase();
3470 if !trimmed.is_empty() {
3471 tokens.insert(trimmed);
3472 }
3473 }
3474
3475 for token in tokens {
3476 let hash = blake3::hash(token.as_bytes());
3477 let bytes = hash.as_bytes();
3478 let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
3479 let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
3480 vector[idx] += sign;
3481 }
3482
3483 let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
3484 if norm > 0.0 {
3485 for value in &mut vector {
3486 *value /= norm;
3487 }
3488 }
3489 vector
3490}
3491
3492fn semantic_embedding_property(input: &str) -> String {
3493 semantic_embedding(input)
3494 .iter()
3495 .map(|value| format!("{value:.6}"))
3496 .collect::<Vec<_>>()
3497 .join(",")
3498}
3499
3500fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
3501 let parsed = value
3502 .split(',')
3503 .map(str::trim)
3504 .map(str::parse::<f64>)
3505 .collect::<std::result::Result<Vec<_>, _>>()
3506 .ok()?;
3507 (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
3508}
3509
3510fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
3511 if left.len() != right.len() {
3512 return 0.0;
3513 }
3514 left.iter()
3515 .zip(right.iter())
3516 .map(|(left, right)| left * right)
3517 .sum::<f64>()
3518}
3519
3520fn semantic_entity_handle(name: &str, kind: &str) -> String {
3521 stable_handle(
3522 "gent",
3523 &format!(
3524 "entity:{}:{}",
3525 kind.trim().to_ascii_lowercase(),
3526 name.trim().to_ascii_lowercase()
3527 ),
3528 )
3529}
3530
3531fn semantic_concept_handle(label: &str) -> String {
3532 stable_handle(
3533 "gcon",
3534 &format!("concept:{}", label.trim().to_ascii_lowercase()),
3535 )
3536}
3537
3538fn summary_source_handles(
3539 summary: &summarize::Summary,
3540 file_node_by_path: &BTreeMap<String, String>,
3541 symbol_node_by_file_label: &BTreeMap<(String, String), String>,
3542) -> Vec<String> {
3543 let mut handles = Vec::new();
3544 if let Some(handle) = file_node_by_path.get(&summary.file_path) {
3545 handles.push(handle.clone());
3546 }
3547 if let Some(handle) =
3548 symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
3549 && !handles.iter().any(|existing| existing == handle)
3550 {
3551 handles.push(handle.clone());
3552 }
3553 handles
3554}
3555
3556fn semantic_entity_node(
3557 root: &Path,
3558 summary: &summarize::Summary,
3559 name: &str,
3560 kind: &str,
3561 description: &str,
3562 provenance: &GraphProvenance,
3563) -> SubstrateGraphNode {
3564 let handle = semantic_entity_handle(name, kind);
3565 let detail = if description.trim().is_empty() {
3566 format!("{kind} entity from cached summaries")
3567 } else {
3568 format!("{kind}: {description}")
3569 };
3570 SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
3571 .with_property("handle", handle)
3572 .with_property("ref_id", name.to_string())
3573 .with_property("detail", detail)
3574 .with_property("entity_kind", kind.to_string())
3575 .with_property("description", description.to_string())
3576 .with_property("source_file", summary.file_path.clone())
3577 .with_property("source_symbol", summary.symbol_name.clone())
3578 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3579 .with_property(
3580 "embedding",
3581 semantic_embedding_property(&format!("{name} {kind} {description}")),
3582 )
3583 .with_property(
3584 "expand",
3585 semantic_related_command(root, name, SemanticRelatedKind::Entity),
3586 )
3587 .with_provenance(provenance.clone())
3588}
3589
3590fn semantic_concept_node(
3591 root: &Path,
3592 summary: &summarize::Summary,
3593 label: &str,
3594 provenance: &GraphProvenance,
3595) -> SubstrateGraphNode {
3596 let handle = semantic_concept_handle(label);
3597 SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
3598 .with_property("handle", handle)
3599 .with_property("ref_id", label.to_string())
3600 .with_property("detail", "concept label from cached summaries".to_string())
3601 .with_property("source_file", summary.file_path.clone())
3602 .with_property("source_symbol", summary.symbol_name.clone())
3603 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3604 .with_property("embedding", semantic_embedding_property(label))
3605 .with_property(
3606 "expand",
3607 semantic_related_command(root, label, SemanticRelatedKind::Concept),
3608 )
3609 .with_provenance(provenance.clone())
3610}
3611
3612fn insert_semantic_edge(
3613 edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
3614 edge: SubstrateGraphEdge,
3615) {
3616 edge_map
3617 .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
3618 .or_insert(edge);
3619}
3620
3621fn append_summary_semantic_projection_rows(
3622 root: &Path,
3623 graph: &TraversalGraphBuild,
3624 provenance: &GraphProvenance,
3625 nodes: &mut Vec<SubstrateGraphNode>,
3626 edges: &mut Vec<SubstrateGraphEdge>,
3627) -> Result<()> {
3628 let summaries_db = root.join(".tsift/summaries.db");
3629 if !summaries_db.exists() {
3630 return Ok(());
3631 }
3632
3633 let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
3634 let summaries = summary_db.all()?;
3635 if summaries.is_empty() {
3636 return Ok(());
3637 }
3638
3639 let file_node_by_path = graph
3640 .nodes
3641 .values()
3642 .filter(|node| node.kind == "file")
3643 .filter_map(|node| {
3644 node.path
3645 .as_ref()
3646 .map(|path| (path.clone(), node.handle.clone()))
3647 })
3648 .collect::<BTreeMap<_, _>>();
3649 let symbol_node_by_file_label = graph
3650 .nodes
3651 .values()
3652 .filter(|node| node.kind == "symbol")
3653 .filter_map(|node| {
3654 Some((
3655 (node.path.clone()?, node.label.clone()),
3656 node.handle.clone(),
3657 ))
3658 })
3659 .collect::<BTreeMap<_, _>>();
3660
3661 let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
3662 let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
3663
3664 for summary in &summaries {
3665 let source_handles =
3666 summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
3667 let mut entity_ids_by_name = BTreeMap::<String, String>::new();
3668
3669 if let Some(entities) = &summary.entities {
3670 for entity in entities {
3671 let node = semantic_entity_node(
3672 root,
3673 summary,
3674 &entity.name,
3675 &entity.kind,
3676 &entity.description,
3677 provenance,
3678 );
3679 let entity_id = node.id.clone();
3680 entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
3681 semantic_nodes.entry(entity_id.clone()).or_insert(node);
3682
3683 for source_handle in &source_handles {
3684 insert_semantic_edge(
3685 &mut semantic_edges,
3686 SubstrateGraphEdge::new(
3687 source_handle.clone(),
3688 entity_id.clone(),
3689 "mentions_entity",
3690 )
3691 .with_property("label", format!("summary entity: {}", entity.name))
3692 .with_property("source_file", summary.file_path.clone())
3693 .with_provenance(provenance.clone()),
3694 );
3695 }
3696 }
3697 }
3698
3699 let mut concept_ids = Vec::new();
3700 if let Some(labels) = &summary.concept_labels {
3701 for label in labels
3702 .iter()
3703 .map(|label| label.trim())
3704 .filter(|label| !label.is_empty())
3705 {
3706 let node = semantic_concept_node(root, summary, label, provenance);
3707 let concept_id = node.id.clone();
3708 semantic_nodes.entry(concept_id.clone()).or_insert(node);
3709 concept_ids.push(concept_id.clone());
3710
3711 for source_handle in &source_handles {
3712 insert_semantic_edge(
3713 &mut semantic_edges,
3714 SubstrateGraphEdge::new(
3715 source_handle.clone(),
3716 concept_id.clone(),
3717 "mentions_concept",
3718 )
3719 .with_property("label", format!("summary concept: {label}"))
3720 .with_property("source_file", summary.file_path.clone())
3721 .with_provenance(provenance.clone()),
3722 );
3723 }
3724 }
3725 }
3726
3727 for entity_id in entity_ids_by_name.values() {
3728 for concept_id in &concept_ids {
3729 insert_semantic_edge(
3730 &mut semantic_edges,
3731 SubstrateGraphEdge::new(
3732 entity_id.clone(),
3733 concept_id.clone(),
3734 "tagged_concept",
3735 )
3736 .with_property("label", "entity concept label".to_string())
3737 .with_property("source_file", summary.file_path.clone())
3738 .with_provenance(provenance.clone()),
3739 );
3740 }
3741 }
3742
3743 for idx in 0..concept_ids.len() {
3744 for next_idx in (idx + 1)..concept_ids.len() {
3745 insert_semantic_edge(
3746 &mut semantic_edges,
3747 SubstrateGraphEdge::new(
3748 concept_ids[idx].clone(),
3749 concept_ids[next_idx].clone(),
3750 "related_concept",
3751 )
3752 .with_property("label", format!("co-occurs in {}", summary.symbol_name))
3753 .with_property("source_file", summary.file_path.clone())
3754 .with_provenance(provenance.clone()),
3755 );
3756 }
3757 }
3758
3759 if let Some(relationships) = &summary.relationships {
3760 for relationship in relationships {
3761 let from_id = entity_ids_by_name
3762 .get(&relationship.from.to_ascii_lowercase())
3763 .cloned()
3764 .unwrap_or_else(|| {
3765 let node = semantic_entity_node(
3766 root,
3767 summary,
3768 &relationship.from,
3769 "unknown",
3770 "",
3771 provenance,
3772 );
3773 let id = node.id.clone();
3774 semantic_nodes.entry(id.clone()).or_insert(node);
3775 id
3776 });
3777 let to_id = entity_ids_by_name
3778 .get(&relationship.to.to_ascii_lowercase())
3779 .cloned()
3780 .unwrap_or_else(|| {
3781 let node = semantic_entity_node(
3782 root,
3783 summary,
3784 &relationship.to,
3785 "unknown",
3786 "",
3787 provenance,
3788 );
3789 let id = node.id.clone();
3790 semantic_nodes.entry(id.clone()).or_insert(node);
3791 id
3792 });
3793 insert_semantic_edge(
3794 &mut semantic_edges,
3795 SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
3796 .with_property("relationship_kind", relationship.kind.clone())
3797 .with_property("label", relationship.kind.clone())
3798 .with_property("source_file", summary.file_path.clone())
3799 .with_property("source_symbol", summary.symbol_name.clone())
3800 .with_provenance(provenance.clone()),
3801 );
3802 }
3803 }
3804 }
3805
3806 for node in semantic_nodes.into_values() {
3807 nodes.push(node_with_content_freshness(node)?);
3808 }
3809 for edge in semantic_edges.into_values() {
3810 edges.push(edge_with_content_freshness(edge)?);
3811 }
3812
3813 Ok(())
3814}
3815
3816fn projection_content_hash(
3817 nodes: &[SubstrateGraphNode],
3818 edges: &[SubstrateGraphEdge],
3819) -> Result<String> {
3820 #[derive(Serialize)]
3821 struct Payload<'a> {
3822 version: &'static str,
3823 nodes: &'a [SubstrateGraphNode],
3824 edges: &'a [SubstrateGraphEdge],
3825 }
3826
3827 content_hash(&Payload {
3828 version: GRAPH_PROJECTION_VERSION,
3829 nodes,
3830 edges,
3831 })
3832}
3833
3834pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
3835 projection
3836 .nodes
3837 .iter()
3838 .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
3839 .and_then(|node| node.properties.get("content_hash").cloned())
3840}
3841
3842fn traversal_projection_from_graph(
3843 root: &Path,
3844 scope: Option<&str>,
3845 graph: &TraversalGraphBuild,
3846) -> Result<GraphProjection> {
3847 let provenance = GraphProvenance::new(
3848 "tsift.traverse",
3849 format!("{}:{}", root.display(), scope.unwrap_or("root")),
3850 );
3851 let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
3852 for node in graph.nodes.values() {
3853 let mut projected =
3854 SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
3855 .with_property("handle", node.handle.clone())
3856 .with_property("expand", node.expand.clone())
3857 .with_provenance(provenance.clone());
3858 if let Some(ref_id) = &node.ref_id {
3859 projected = projected.with_property("ref_id", ref_id.clone());
3860 }
3861 if let Some(path) = &node.path {
3862 projected = projected.with_property("path", path.clone());
3863 }
3864 if let Some(line) = node.line {
3865 projected = projected.with_property("line", line.to_string());
3866 }
3867 if let Some(detail) = &node.detail {
3868 projected = projected.with_property("detail", detail.clone());
3869 }
3870 for (key, value) in &node.properties {
3871 projected = projected.with_property(key.clone(), value.clone());
3872 }
3873 nodes.push(node_with_content_freshness(projected)?);
3874 }
3875
3876 let mut edges = Vec::with_capacity(graph.edges.len());
3877 for edge in &graph.edges {
3878 let mut projected =
3879 SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
3880 .with_property("weight", edge.weight.to_string())
3881 .with_provenance(provenance.clone());
3882 if let Some(label) = &edge.label {
3883 projected = projected.with_property("label", label.clone());
3884 }
3885 edges.push(edge_with_content_freshness(projected)?);
3886 }
3887
3888 append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3889 append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3890 append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
3891
3892 let projection_hash = projection_content_hash(&nodes, &edges)?;
3893 let meta = SubstrateGraphNode::new(
3894 graph_projection_meta_id(scope),
3895 GRAPH_PROJECTION_META_KIND,
3896 "tsift traversal projection",
3897 )
3898 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
3899 .with_property("content_hash", projection_hash.clone())
3900 .with_property("root", root.to_string_lossy().to_string())
3901 .with_property("scope", scope.unwrap_or("root"))
3902 .with_property("node_count", graph.nodes.len().to_string())
3903 .with_property("edge_count", graph.edges.len().to_string())
3904 .with_provenance(provenance)
3905 .with_freshness(GraphFreshness::content_hash(projection_hash));
3906 nodes.push(meta);
3907
3908 Ok(GraphProjection { nodes, edges })
3909}
3910
3911#[allow(clippy::too_many_arguments)]
3912fn ensure_traversal_source_handle(
3913 root: &Path,
3914 provenance: &GraphProvenance,
3915 file_node_by_path: &BTreeMap<String, String>,
3916 node: &TraversalNode,
3917 budget: &ExplorationBudget,
3918 source_handle_by_node: &mut BTreeMap<String, String>,
3919 seen_windows: &mut BTreeMap<(String, usize, usize), String>,
3920 nodes: &mut Vec<SubstrateGraphNode>,
3921 edges: &mut Vec<SubstrateGraphEdge>,
3922) -> Result<Option<String>> {
3923 if let Some(handle) = source_handle_by_node.get(&node.handle) {
3924 return Ok(Some(handle.clone()));
3925 }
3926 let Some(window) = exploration_source_window_for_node(root, node, budget) else {
3927 return Ok(None);
3928 };
3929 let window_key = (window.file.clone(), window.start, window.end);
3930 let handle = if let Some(handle) = seen_windows.get(&window_key) {
3931 handle.clone()
3932 } else {
3933 let label = format!("{}:{}-{}", window.file, window.start, window.end);
3934 let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
3935 .with_property("handle", window.handle.clone())
3936 .with_property("file", window.file.clone())
3937 .with_property("start", window.start.to_string())
3938 .with_property("end", window.end.to_string())
3939 .with_property("reason", window.reason.clone())
3940 .with_property("expand", window.expand.clone())
3941 .with_provenance(provenance.clone());
3942 nodes.push(node_with_content_freshness(projected)?);
3943
3944 if let Some(file_handle) = file_node_by_path.get(&window.file) {
3945 let edge = SubstrateGraphEdge::new(
3946 window.handle.clone(),
3947 file_handle.clone(),
3948 "expands_source",
3949 )
3950 .with_property("label", window.reason.clone())
3951 .with_provenance(provenance.clone());
3952 edges.push(edge_with_content_freshness(edge)?);
3953 }
3954 if node.kind != "file" {
3955 let edge = SubstrateGraphEdge::new(
3956 window.handle.clone(),
3957 node.handle.clone(),
3958 "anchors_source",
3959 )
3960 .with_property("label", window.reason.clone())
3961 .with_provenance(provenance.clone());
3962 edges.push(edge_with_content_freshness(edge)?);
3963 }
3964 seen_windows.insert(window_key, window.handle.clone());
3965 window.handle
3966 };
3967 source_handle_by_node.insert(node.handle.clone(), handle.clone());
3968 Ok(Some(handle))
3969}
3970
3971fn push_traversal_backlog_target_handles<'a>(
3972 backlog: &TraversalNode,
3973 edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
3974 node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
3975 max_handles: usize,
3976 seen_target_nodes: &mut BTreeSet<String>,
3977 target_node_handles: &mut Vec<String>,
3978) {
3979 for edge in edges_by_from
3980 .get(backlog.handle.as_str())
3981 .into_iter()
3982 .flatten()
3983 .filter(|edge| edge.relation == "mentions")
3984 {
3985 let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
3986 continue;
3987 };
3988 if !matches!(
3989 target_node.kind.as_str(),
3990 "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
3991 ) {
3992 continue;
3993 }
3994 if target_node
3995 .path
3996 .as_deref()
3997 .zip(backlog.path.as_deref())
3998 .is_some_and(|(target_path, backlog_path)| {
3999 target_path == backlog_path && target_path.ends_with(".md")
4000 })
4001 {
4002 continue;
4003 }
4004 if seen_target_nodes.insert(target_node.handle.clone()) {
4005 target_node_handles.push(target_node.handle.clone());
4006 }
4007 if target_node_handles.len() >= max_handles {
4008 break;
4009 }
4010 }
4011}
4012
4013fn append_traversal_context_projection_rows(
4014 root: &Path,
4015 graph: &TraversalGraphBuild,
4016 provenance: &GraphProvenance,
4017 nodes: &mut Vec<SubstrateGraphNode>,
4018 edges: &mut Vec<SubstrateGraphEdge>,
4019) -> Result<()> {
4020 let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4021 let file_node_by_path = graph
4022 .nodes
4023 .values()
4024 .filter(|node| node.kind == "file")
4025 .filter_map(|node| {
4026 node.path
4027 .as_ref()
4028 .map(|path| (path.clone(), node.handle.clone()))
4029 })
4030 .collect::<BTreeMap<_, _>>();
4031
4032 let node_by_handle = graph
4033 .nodes
4034 .values()
4035 .map(|node| (node.handle.as_str(), node))
4036 .collect::<BTreeMap<_, _>>();
4037 let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4038 for edge in &graph.edges {
4039 edges_by_from
4040 .entry(edge.from.as_str())
4041 .or_default()
4042 .push(edge);
4043 }
4044 for rows in edges_by_from.values_mut() {
4045 rows.sort_by(|left, right| {
4046 right
4047 .weight
4048 .cmp(&left.weight)
4049 .then(left.relation.cmp(&right.relation))
4050 .then(left.to.cmp(&right.to))
4051 });
4052 }
4053
4054 let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4055 let mut source_handle_by_node = BTreeMap::<String, String>::new();
4056
4057 let mut code_context_count = 0usize;
4058 let code_context_limit = budget.relationship_limit.min(8);
4059 for node in graph.nodes.values() {
4060 if !matches!(
4061 node.kind.as_str(),
4062 "backlog" | "job_packet" | "worker_result"
4063 ) {
4064 continue;
4065 }
4066 let mut target_node_handles = Vec::new();
4067 let mut fallback_target_handles = Vec::new();
4068 let mut seen_target_nodes = BTreeSet::new();
4069 if node.kind == "backlog" || node.kind == "worker_result" {
4070 push_traversal_backlog_target_handles(
4071 node,
4072 &edges_by_from,
4073 &node_by_handle,
4074 budget.max_source_windows,
4075 &mut seen_target_nodes,
4076 &mut target_node_handles,
4077 );
4078 fallback_target_handles.push(node.handle.clone());
4079 } else {
4080 for edge in edges_by_from
4081 .get(node.handle.as_str())
4082 .into_iter()
4083 .flatten()
4084 .filter(|edge| edge.relation == "targets")
4085 {
4086 let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4087 continue;
4088 };
4089 fallback_target_handles.push(backlog.handle.clone());
4090 push_traversal_backlog_target_handles(
4091 backlog,
4092 &edges_by_from,
4093 &node_by_handle,
4094 budget.max_source_windows,
4095 &mut seen_target_nodes,
4096 &mut target_node_handles,
4097 );
4098 if target_node_handles.len() >= budget.max_source_windows {
4099 break;
4100 }
4101 }
4102 if fallback_target_handles.is_empty() {
4103 continue;
4104 }
4105 }
4106 let code_context = !target_node_handles.is_empty();
4107 if target_node_handles.is_empty() {
4108 target_node_handles = dedupe_preserve_order(fallback_target_handles);
4109 } else if code_context_count >= code_context_limit {
4110 continue;
4111 }
4112
4113 let mut worker_source_handles = Vec::new();
4114 let mut seen_worker_handles = BTreeSet::new();
4115 for target_handle in target_node_handles {
4116 if worker_source_handles.len() >= budget.max_source_windows {
4117 break;
4118 }
4119 let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4120 continue;
4121 };
4122 let Some(handle) = ensure_traversal_source_handle(
4123 root,
4124 provenance,
4125 &file_node_by_path,
4126 target_node,
4127 &budget,
4128 &mut source_handle_by_node,
4129 &mut seen_windows,
4130 nodes,
4131 edges,
4132 )?
4133 else {
4134 continue;
4135 };
4136 if seen_worker_handles.insert(handle.clone()) {
4137 worker_source_handles.push(handle);
4138 }
4139 }
4140 if worker_source_handles.is_empty() {
4141 continue;
4142 }
4143 let target = node
4144 .path
4145 .clone()
4146 .unwrap_or_else(|| root.to_string_lossy().to_string());
4147 let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4148 let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4149 let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4150 .with_property("handle", handle.clone())
4151 .with_property("target", target.clone())
4152 .with_property("summary", summary)
4153 .with_property(
4154 "source_handle_count",
4155 worker_source_handles.len().to_string(),
4156 )
4157 .with_property(
4158 "expand",
4159 format!(
4160 "tsift --envelope context-pack {} --budget normal",
4161 shell_quote(&target)
4162 ),
4163 )
4164 .with_provenance(provenance.clone());
4165 nodes.push(node_with_content_freshness(projected)?);
4166
4167 let request_edge =
4168 SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4169 .with_property("label", "bounded worker context".to_string())
4170 .with_provenance(provenance.clone());
4171 edges.push(edge_with_content_freshness(request_edge)?);
4172
4173 for source_handle in &worker_source_handles {
4174 let scope_edge =
4175 SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4176 .with_property("label", "bounded worker source window".to_string())
4177 .with_provenance(provenance.clone());
4178 edges.push(edge_with_content_freshness(scope_edge)?);
4179 }
4180 if code_context {
4181 code_context_count += 1;
4182 }
4183 }
4184
4185 Ok(())
4186}
4187
4188fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4189 let handle = node
4190 .properties
4191 .get("handle")
4192 .cloned()
4193 .unwrap_or_else(|| node.id.clone());
4194 TraversalNode {
4195 expand: node
4196 .properties
4197 .get("expand")
4198 .cloned()
4199 .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4200 handle,
4201 kind: node.kind,
4202 label: node.label,
4203 ref_id: node.properties.get("ref_id").cloned(),
4204 path: node.properties.get("path").cloned(),
4205 line: node
4206 .properties
4207 .get("line")
4208 .and_then(|value| value.parse::<i64>().ok()),
4209 detail: node.properties.get("detail").cloned(),
4210 properties: node.properties,
4211 }
4212}
4213
4214fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4215 let mut graph = TraversalGraphBuild::default();
4216 for node in store.all_nodes()? {
4217 if node.kind == GRAPH_PROJECTION_META_KIND {
4218 continue;
4219 }
4220 graph.add_node(traversal_node_from_graph_node(root, node));
4221 }
4222 for edge in store.all_edges()? {
4223 graph.add_edge(
4224 &edge.from_id,
4225 &edge.to_id,
4226 &edge.kind,
4227 edge.properties.get("label").cloned(),
4228 edge.properties
4229 .get("weight")
4230 .and_then(|value| value.parse::<usize>().ok())
4231 .unwrap_or(1),
4232 );
4233 }
4234 Ok(graph)
4235}
4236
4237pub(crate) fn convex_rows_from_graph_store(
4238 store: &impl GraphStore,
4239) -> Result<ConvexProjectionRows> {
4240 Ok(GraphProjection {
4241 nodes: store.all_nodes()?,
4242 edges: store.all_edges()?,
4243 }
4244 .to_convex_rows())
4245}
4246
4247#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4248struct ConvexRequiredIndex {
4249 table: String,
4250 name: String,
4251 fields: Vec<String>,
4252}
4253
4254#[derive(Clone, Debug, Serialize, PartialEq)]
4255struct ConvexSyncChunk {
4256 operation: String,
4257 chunk: usize,
4258 count: usize,
4259 keys: Vec<String>,
4260 max_attempts: usize,
4261 retry_policy: String,
4262}
4263
4264#[derive(Clone, Debug, Serialize, PartialEq)]
4265struct ConvexTransportSummary {
4266 endpoint_env: String,
4267 endpoint_configured: bool,
4268 auth_token_env: String,
4269 auth_configured: bool,
4270 remote_snapshot: bool,
4271 applied_chunks: usize,
4272}
4273
4274#[derive(Clone, Debug, Serialize, PartialEq)]
4275struct ConvexTransportReceipt {
4276 operation: String,
4277 chunk: usize,
4278 attempt: usize,
4279 status: String,
4280 message: Option<String>,
4281}
4282
4283#[derive(Serialize)]
4284#[serde(rename_all = "camelCase")]
4285struct ConvexTransportRequest<'a> {
4286 operation: &'a str,
4287 chunk: usize,
4288 projection_version: &'a str,
4289 projection_hash: Option<&'a str>,
4290 #[serde(skip_serializing_if = "Option::is_none")]
4291 projection_meta_id: Option<&'a str>,
4292 node_rows: Vec<ConvexNodeRow>,
4293 edge_rows: Vec<ConvexEdgeRow>,
4294 keys: Vec<String>,
4295 #[serde(skip_serializing_if = "Option::is_none")]
4296 cursor: Option<String>,
4297 #[serde(skip_serializing_if = "Option::is_none")]
4298 limit: Option<usize>,
4299}
4300
4301#[derive(Deserialize)]
4302#[serde(rename_all = "camelCase")]
4303struct ConvexTransportResponse {
4304 status: Option<String>,
4305 message: Option<String>,
4306 rows: Option<ConvexProjectionRows>,
4307 #[serde(default)]
4308 meta: Option<ConvexSnapshotMeta>,
4309 #[serde(default)]
4310 page: Option<ConvexSnapshotPage>,
4311}
4312
4313#[derive(Deserialize, Debug, Clone)]
4314#[serde(rename_all = "camelCase")]
4315struct ConvexSnapshotMeta {
4316 #[serde(default)]
4320 #[allow(dead_code)]
4321 indexes: Vec<ConvexRequiredIndex>,
4322 #[serde(default)]
4323 #[allow(dead_code)]
4324 node_count: Option<usize>,
4325 #[serde(default)]
4326 #[allow(dead_code)]
4327 edge_count: Option<usize>,
4328 #[serde(default)]
4329 projection_hash: Option<String>,
4330 #[serde(default)]
4331 #[allow(dead_code)]
4332 page_size: Option<usize>,
4333}
4334
4335#[derive(Deserialize, Debug, Clone)]
4340#[serde(rename_all = "camelCase")]
4341struct ConvexSnapshotPage {
4342 rows: Vec<serde_json::Value>,
4343 #[serde(default)]
4344 next_cursor: Option<String>,
4345}
4346
4347#[derive(Clone, Debug, Serialize, PartialEq)]
4348struct ConvexProjectionFreshness {
4349 status: String,
4350 fail_closed: bool,
4351 local_hash: Option<String>,
4352 snapshot_hash: Option<String>,
4353 missing_nodes: Vec<String>,
4354 stale_nodes: Vec<String>,
4355 missing_edges: Vec<String>,
4356 stale_edges: Vec<String>,
4357 diagnostics: Vec<String>,
4358}
4359
4360const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4361
4362impl ConvexProjectionFreshness {
4363 fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4364 Self {
4365 status: "current".to_string(),
4366 fail_closed: false,
4367 local_hash,
4368 snapshot_hash,
4369 missing_nodes: Vec::new(),
4370 stale_nodes: Vec::new(),
4371 missing_edges: Vec::new(),
4372 stale_edges: Vec::new(),
4373 diagnostics: Vec::new(),
4374 }
4375 }
4376}
4377
4378#[derive(Clone, Debug, Serialize, PartialEq)]
4379struct ConvexSyncReport {
4380 root: String,
4381 #[serde(skip_serializing_if = "Option::is_none")]
4382 scope: Option<String>,
4383 graph_db: String,
4384 dry_run: bool,
4385 projection_version: String,
4386 projection_hash: Option<String>,
4387 required_indexes: Vec<ConvexRequiredIndex>,
4388 node_upserts: Vec<ConvexNodeRow>,
4389 edge_upserts: Vec<ConvexEdgeRow>,
4390 node_tombstones: Vec<String>,
4391 edge_tombstones: Vec<String>,
4392 chunks: Vec<ConvexSyncChunk>,
4393 freshness: ConvexProjectionFreshness,
4394 transport: Option<ConvexTransportSummary>,
4395 receipts: Vec<ConvexTransportReceipt>,
4396 diagnostics: Vec<String>,
4397 warnings: Vec<String>,
4398}
4399
4400fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4401 vec![
4402 ConvexRequiredIndex {
4403 table: "nodes".to_string(),
4404 name: "by_external_id".to_string(),
4405 fields: vec!["externalId".to_string()],
4406 },
4407 ConvexRequiredIndex {
4408 table: "nodes".to_string(),
4409 name: "by_kind".to_string(),
4410 fields: vec!["kind".to_string()],
4411 },
4412 ConvexRequiredIndex {
4413 table: "edges".to_string(),
4414 name: "by_edge_key".to_string(),
4415 fields: vec!["edgeKey".to_string()],
4416 },
4417 ConvexRequiredIndex {
4418 table: "edges".to_string(),
4419 name: "by_from_kind".to_string(),
4420 fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4421 },
4422 ConvexRequiredIndex {
4423 table: "edges".to_string(),
4424 name: "by_to_kind".to_string(),
4425 fields: vec!["toExternalId".to_string(), "kind".to_string()],
4426 },
4427 ]
4428}
4429
4430pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
4431 let content = fs::read_to_string(path)
4432 .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
4433 serde_json::from_str(&content)
4434 .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
4435}
4436
4437fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
4438 let mut diagnostics = Vec::new();
4439 let mut node_counts = BTreeMap::<&str, usize>::new();
4440 for row in &rows.nodes {
4441 *node_counts.entry(row.external_id.as_str()).or_default() += 1;
4442 }
4443 for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
4444 diagnostics.push(format!(
4445 "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
4446 ));
4447 }
4448
4449 let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
4450 let mut edge_counts = BTreeMap::<&str, usize>::new();
4451 for edge in &rows.edges {
4452 *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
4453 if !node_ids.contains(edge.from_external_id.as_str()) {
4454 diagnostics.push(format!(
4455 "Convex snapshot edge {} references missing from node {}",
4456 edge.edge_key, edge.from_external_id
4457 ));
4458 }
4459 if !node_ids.contains(edge.to_external_id.as_str()) {
4460 diagnostics.push(format!(
4461 "Convex snapshot edge {} references missing to node {}",
4462 edge.edge_key, edge.to_external_id
4463 ));
4464 }
4465 let expected_key =
4466 ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
4467 if edge.edge_key != expected_key {
4468 diagnostics.push(format!(
4469 "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
4470 edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
4471 ));
4472 }
4473 }
4474 for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
4475 diagnostics.push(format!(
4476 "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
4477 ));
4478 }
4479 diagnostics
4480}
4481
4482pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
4483 let diagnostics = convex_projection_row_diagnostics(rows);
4484 if diagnostics.is_empty() {
4485 Ok(())
4486 } else {
4487 bail!("{}", diagnostics.join("; "))
4488 }
4489}
4490
4491pub(crate) struct ConvexHttpTransport {
4492 endpoint: String,
4493 auth_token_env: String,
4494 auth_token: Option<String>,
4495}
4496
4497impl ConvexHttpTransport {
4498 fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
4499 let endpoint = endpoint
4500 .map(str::to_string)
4501 .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
4502 .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
4503 let auth_token = env::var(auth_token_env)
4504 .ok()
4505 .filter(|value| !value.trim().is_empty());
4506 Ok(Self {
4507 endpoint,
4508 auth_token_env: auth_token_env.to_string(),
4509 auth_token,
4510 })
4511 }
4512
4513 fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
4514 ConvexTransportSummary {
4515 endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
4516 endpoint_configured: true,
4517 auth_token_env: self.auth_token_env.clone(),
4518 auth_configured: self.auth_token.is_some(),
4519 remote_snapshot,
4520 applied_chunks,
4521 }
4522 }
4523
4524 fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
4525 let mut builder = ureq::post(&self.endpoint);
4526 if let Some(token) = &self.auth_token {
4527 builder = builder.header("Authorization", &format!("Bearer {token}"));
4528 }
4529 builder
4530 .send_json(request)
4531 .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
4532 .body_mut()
4533 .read_json::<ConvexTransportResponse>()
4534 .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
4535 }
4536
4537 fn fetch_snapshot(
4548 &self,
4549 projection_version: &str,
4550 scope: Option<&str>,
4551 local_hash: Option<&str>,
4552 local_rows: Option<&ConvexProjectionRows>,
4553 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4554 match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
4555 Ok(rows) => Ok(rows),
4556 Err(err) => {
4557 let msg = format!("{err:#}");
4562 let is_unknown_op = msg.contains("unknown operation")
4563 || msg.contains("snapshot_meta")
4564 || msg.contains("404");
4565 if !is_unknown_op {
4566 return Err(err);
4567 }
4568 self.fetch_snapshot_legacy(projection_version)
4569 .map(|rows| (rows, Vec::new()))
4570 }
4571 }
4572 }
4573
4574 fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
4575 let response = self.post(&ConvexTransportRequest {
4576 operation: "snapshot",
4577 chunk: 0,
4578 projection_version,
4579 projection_hash: None,
4580 projection_meta_id: None,
4581 node_rows: Vec::new(),
4582 edge_rows: Vec::new(),
4583 keys: Vec::new(),
4584 cursor: None,
4585 limit: None,
4586 })?;
4587 response
4588 .rows
4589 .context("Convex snapshot response did not include rows")
4590 }
4591
4592 fn fetch_snapshot_paginated(
4593 &self,
4594 projection_version: &str,
4595 scope: Option<&str>,
4596 local_hash: Option<&str>,
4597 local_rows: Option<&ConvexProjectionRows>,
4598 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4599 let projection_meta_id = graph_projection_meta_id(scope);
4600 let meta_response = self.post(&ConvexTransportRequest {
4601 operation: "snapshot_meta",
4602 chunk: 0,
4603 projection_version,
4604 projection_hash: None,
4605 projection_meta_id: Some(&projection_meta_id),
4606 node_rows: Vec::new(),
4607 edge_rows: Vec::new(),
4608 keys: Vec::new(),
4609 cursor: None,
4610 limit: None,
4611 })?;
4612 if matches!(meta_response.status.as_deref(), Some("error")) {
4613 anyhow::bail!(
4614 "Convex snapshot_meta returned error: {}",
4615 meta_response.message.unwrap_or_default()
4616 );
4617 }
4618 let meta = meta_response
4619 .meta
4620 .context("Convex snapshot_meta response did not include meta")?;
4621 if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
4622 (meta.projection_hash.as_deref(), local_hash, local_rows)
4623 && remote_hash == local_hash
4624 {
4625 return Ok((
4626 local_rows.clone(),
4627 vec![
4628 "remote projection hash matched local graph; skipped full row-page snapshot diff"
4629 .to_string(),
4630 ],
4631 ));
4632 }
4633
4634 let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
4635 let mut node_cursor: Option<String> = None;
4636 loop {
4637 let response = self.post(&ConvexTransportRequest {
4638 operation: "snapshot_nodes_page",
4639 chunk: 0,
4640 projection_version,
4641 projection_hash: None,
4642 projection_meta_id: None,
4643 node_rows: Vec::new(),
4644 edge_rows: Vec::new(),
4645 keys: Vec::new(),
4646 cursor: node_cursor.clone(),
4647 limit: None,
4648 })?;
4649 let page = response
4650 .page
4651 .context("Convex snapshot_nodes_page response did not include page")?;
4652 for raw in page.rows {
4653 let row: ConvexNodeRow =
4654 serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
4655 nodes.push(row);
4656 }
4657 match page.next_cursor {
4658 Some(next) => node_cursor = Some(next),
4659 None => break,
4660 }
4661 }
4662
4663 let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
4664 let mut edge_cursor: Option<String> = None;
4665 loop {
4666 let response = self.post(&ConvexTransportRequest {
4667 operation: "snapshot_edges_page",
4668 chunk: 0,
4669 projection_version,
4670 projection_hash: None,
4671 projection_meta_id: None,
4672 node_rows: Vec::new(),
4673 edge_rows: Vec::new(),
4674 keys: Vec::new(),
4675 cursor: edge_cursor.clone(),
4676 limit: None,
4677 })?;
4678 let page = response
4679 .page
4680 .context("Convex snapshot_edges_page response did not include page")?;
4681 for raw in page.rows {
4682 let row: ConvexEdgeRow =
4683 serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
4684 edges.push(row);
4685 }
4686 match page.next_cursor {
4687 Some(next) => edge_cursor = Some(next),
4688 None => break,
4689 }
4690 }
4691
4692 Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
4693 }
4694
4695 fn apply_chunk(
4696 &self,
4697 report: &ConvexSyncReport,
4698 chunk: &ConvexSyncChunk,
4699 ) -> Result<ConvexTransportReceipt> {
4700 let node_rows = if chunk.operation == "upsert_nodes" {
4701 report
4702 .node_upserts
4703 .iter()
4704 .filter(|row| chunk.keys.contains(&row.external_id))
4705 .cloned()
4706 .collect()
4707 } else {
4708 Vec::new()
4709 };
4710 let edge_rows = if chunk.operation == "upsert_edges" {
4711 report
4712 .edge_upserts
4713 .iter()
4714 .filter(|row| chunk.keys.contains(&row.edge_key))
4715 .cloned()
4716 .collect()
4717 } else {
4718 Vec::new()
4719 };
4720 let request = ConvexTransportRequest {
4721 operation: &chunk.operation,
4722 chunk: chunk.chunk,
4723 projection_version: &report.projection_version,
4724 projection_hash: report.projection_hash.as_deref(),
4725 projection_meta_id: None,
4726 node_rows,
4727 edge_rows,
4728 keys: chunk.keys.clone(),
4729 cursor: None,
4730 limit: None,
4731 };
4732 let mut last_error = None;
4733 for attempt in 1..=chunk.max_attempts {
4734 match self.post(&request) {
4735 Ok(response) => {
4736 return Ok(ConvexTransportReceipt {
4737 operation: chunk.operation.clone(),
4738 chunk: chunk.chunk,
4739 attempt,
4740 status: response.status.unwrap_or_else(|| "ok".to_string()),
4741 message: response.message,
4742 });
4743 }
4744 Err(err) => {
4745 last_error = Some(err);
4746 if attempt < chunk.max_attempts {
4747 std::thread::sleep(Duration::from_millis(100 * attempt as u64));
4748 }
4749 }
4750 }
4751 }
4752 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
4753 .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
4754 }
4755}
4756
4757fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
4758 let meta_id = graph_projection_meta_id(scope);
4759 rows.nodes
4760 .iter()
4761 .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
4762 .and_then(|row| row.properties.get("content_hash").cloned())
4763}
4764
4765fn convex_projection_freshness(
4766 local: &ConvexProjectionRows,
4767 snapshot: Option<&ConvexProjectionRows>,
4768 scope: Option<&str>,
4769) -> ConvexProjectionFreshness {
4770 let local_hash = convex_projection_hash(local, scope);
4771 let Some(snapshot) = snapshot else {
4772 return ConvexProjectionFreshness {
4773 status: "unchecked".to_string(),
4774 fail_closed: false,
4775 local_hash,
4776 snapshot_hash: None,
4777 missing_nodes: Vec::new(),
4778 stale_nodes: Vec::new(),
4779 missing_edges: Vec::new(),
4780 stale_edges: Vec::new(),
4781 diagnostics: vec![
4782 "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
4783 ],
4784 };
4785 };
4786
4787 let snapshot_hash = convex_projection_hash(snapshot, scope);
4788 let snapshot_nodes = snapshot
4789 .nodes
4790 .iter()
4791 .map(|row| (row.external_id.as_str(), row))
4792 .collect::<BTreeMap<_, _>>();
4793 let snapshot_edges = snapshot
4794 .edges
4795 .iter()
4796 .map(|row| (row.edge_key.as_str(), row))
4797 .collect::<BTreeMap<_, _>>();
4798
4799 let mut missing_nodes = Vec::new();
4800 let mut stale_nodes = Vec::new();
4801 for row in &local.nodes {
4802 match snapshot_nodes.get(row.external_id.as_str()) {
4803 Some(snapshot_row) if *snapshot_row == row => {}
4804 Some(_) => stale_nodes.push(row.external_id.clone()),
4805 None => missing_nodes.push(row.external_id.clone()),
4806 }
4807 }
4808
4809 let mut missing_edges = Vec::new();
4810 let mut stale_edges = Vec::new();
4811 for row in &local.edges {
4812 match snapshot_edges.get(row.edge_key.as_str()) {
4813 Some(snapshot_row) if *snapshot_row == row => {}
4814 Some(_) => stale_edges.push(row.edge_key.clone()),
4815 None => missing_edges.push(row.edge_key.clone()),
4816 }
4817 }
4818
4819 let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
4820 let rows_current = missing_nodes.is_empty()
4821 && stale_nodes.is_empty()
4822 && missing_edges.is_empty()
4823 && stale_edges.is_empty();
4824 if hash_current && rows_current {
4825 return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
4826 }
4827
4828 let mut diagnostics = Vec::new();
4829 if local_hash != snapshot_hash {
4830 diagnostics.push(format!(
4831 "projection hash mismatch: local={} snapshot={}",
4832 local_hash.as_deref().unwrap_or("missing"),
4833 snapshot_hash.as_deref().unwrap_or("missing")
4834 ));
4835 }
4836 if !missing_nodes.is_empty() || !missing_edges.is_empty() {
4837 diagnostics.push(format!(
4838 "Convex snapshot is missing {} node(s) and {} edge(s)",
4839 missing_nodes.len(),
4840 missing_edges.len()
4841 ));
4842 }
4843 if !stale_nodes.is_empty() || !stale_edges.is_empty() {
4844 diagnostics.push(format!(
4845 "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
4846 stale_nodes.len(),
4847 stale_edges.len()
4848 ));
4849 }
4850
4851 ConvexProjectionFreshness {
4852 status: "stale".to_string(),
4853 fail_closed: true,
4854 local_hash,
4855 snapshot_hash,
4856 missing_nodes,
4857 stale_nodes,
4858 missing_edges,
4859 stale_edges,
4860 diagnostics,
4861 }
4862}
4863
4864pub(crate) fn verify_convex_projection_snapshot(
4865 root: &Path,
4866 scope: Option<&str>,
4867 snapshot_path: &Path,
4868) -> Result<()> {
4869 let graph_db = graph_substrate_db_path(root, scope);
4870 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4871 let local = convex_rows_from_graph_store(&store)?;
4872 let snapshot = load_convex_projection_rows(snapshot_path)?;
4873 validate_convex_projection_rows(&snapshot)?;
4874 let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
4875 if freshness.fail_closed {
4876 bail!(
4877 "Convex graph projection is not current for {}: {}",
4878 root.display(),
4879 freshness.diagnostics.join("; ")
4880 );
4881 }
4882 Ok(())
4883}
4884
4885fn convex_rows_diff(
4886 local: &ConvexProjectionRows,
4887 snapshot: Option<&ConvexProjectionRows>,
4888) -> (
4889 Vec<ConvexNodeRow>,
4890 Vec<ConvexEdgeRow>,
4891 Vec<String>,
4892 Vec<String>,
4893) {
4894 let Some(snapshot) = snapshot else {
4895 return (
4896 local.nodes.clone(),
4897 local.edges.clone(),
4898 Vec::new(),
4899 Vec::new(),
4900 );
4901 };
4902 let local_nodes = local
4903 .nodes
4904 .iter()
4905 .map(|row| (row.external_id.as_str(), row))
4906 .collect::<BTreeMap<_, _>>();
4907 let local_edges = local
4908 .edges
4909 .iter()
4910 .map(|row| (row.edge_key.as_str(), row))
4911 .collect::<BTreeMap<_, _>>();
4912 let snapshot_nodes = snapshot
4913 .nodes
4914 .iter()
4915 .map(|row| (row.external_id.as_str(), row))
4916 .collect::<BTreeMap<_, _>>();
4917 let snapshot_edges = snapshot
4918 .edges
4919 .iter()
4920 .map(|row| (row.edge_key.as_str(), row))
4921 .collect::<BTreeMap<_, _>>();
4922
4923 let node_upserts = local
4924 .nodes
4925 .iter()
4926 .filter(|row| {
4927 snapshot_nodes
4928 .get(row.external_id.as_str())
4929 .is_none_or(|snapshot_row| *snapshot_row != *row)
4930 })
4931 .cloned()
4932 .collect::<Vec<_>>();
4933 let edge_upserts = local
4934 .edges
4935 .iter()
4936 .filter(|row| {
4937 snapshot_edges
4938 .get(row.edge_key.as_str())
4939 .is_none_or(|snapshot_row| *snapshot_row != *row)
4940 })
4941 .cloned()
4942 .collect::<Vec<_>>();
4943 let node_tombstones = snapshot
4944 .nodes
4945 .iter()
4946 .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
4947 .map(|row| row.external_id.clone())
4948 .collect::<Vec<_>>();
4949 let edge_tombstones = snapshot
4950 .edges
4951 .iter()
4952 .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
4953 .map(|row| row.edge_key.clone())
4954 .collect::<Vec<_>>();
4955
4956 (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
4957}
4958
4959fn push_sync_chunks(
4960 chunks: &mut Vec<ConvexSyncChunk>,
4961 operation: &str,
4962 keys: Vec<String>,
4963 size: usize,
4964) {
4965 if keys.is_empty() {
4966 return;
4967 }
4968 for (idx, chunk) in keys.chunks(size).enumerate() {
4969 chunks.push(ConvexSyncChunk {
4970 operation: operation.to_string(),
4971 chunk: idx + 1,
4972 count: chunk.len(),
4973 keys: chunk.to_vec(),
4974 max_attempts: 3,
4975 retry_policy:
4976 "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
4977 .to_string(),
4978 });
4979 }
4980}
4981
4982pub(crate) fn build_convex_sync_report_with_snapshot(
4983 path: &Path,
4984 scope: Option<&str>,
4985 snapshot: Option<ConvexProjectionRows>,
4986 chunk_size: usize,
4987 dry_run: bool,
4988) -> Result<ConvexSyncReport> {
4989 if chunk_size == 0 {
4990 bail!("--chunk-size must be greater than zero");
4991 }
4992 let root = lint::resolve_project_root_or_canonical_path(path)?;
4993 let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
4994 let graph_db = graph_substrate_db_path(&root, scope);
4995 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4996 let local = convex_rows_from_graph_store(&store)?;
4997 let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
4998 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
4999 convex_rows_diff(&local, snapshot.as_ref());
5000
5001 let mut chunks = Vec::new();
5002 push_sync_chunks(
5003 &mut chunks,
5004 "delete_edges",
5005 edge_tombstones.clone(),
5006 chunk_size,
5007 );
5008 push_sync_chunks(
5009 &mut chunks,
5010 "upsert_nodes",
5011 node_upserts
5012 .iter()
5013 .map(|row| row.external_id.clone())
5014 .collect(),
5015 chunk_size,
5016 );
5017 push_sync_chunks(
5018 &mut chunks,
5019 "upsert_edges",
5020 edge_upserts
5021 .iter()
5022 .map(|row| row.edge_key.clone())
5023 .collect(),
5024 chunk_size,
5025 );
5026 push_sync_chunks(
5027 &mut chunks,
5028 "delete_nodes",
5029 node_tombstones.clone(),
5030 chunk_size,
5031 );
5032
5033 let mut diagnostics = vec![
5034 "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5035 .to_string(),
5036 ];
5037 if dry_run {
5038 diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5039 }
5040 if freshness.fail_closed {
5041 diagnostics.push(
5042 "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5043 .to_string(),
5044 );
5045 }
5046
5047 Ok(ConvexSyncReport {
5048 root: root.to_string_lossy().to_string(),
5049 scope: scope.map(str::to_string),
5050 graph_db: graph_db.to_string_lossy().to_string(),
5051 dry_run,
5052 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5053 projection_hash: convex_projection_hash(&local, scope),
5054 required_indexes: convex_required_indexes(),
5055 node_upserts,
5056 edge_upserts,
5057 node_tombstones,
5058 edge_tombstones,
5059 chunks,
5060 freshness,
5061 transport: None,
5062 receipts: Vec::new(),
5063 diagnostics,
5064 warnings: graph.warnings,
5065 })
5066}
5067
5068#[cfg(test)]
5069fn build_convex_sync_report(
5070 path: &Path,
5071 scope: Option<&str>,
5072 snapshot_path: Option<&Path>,
5073 chunk_size: usize,
5074) -> Result<ConvexSyncReport> {
5075 let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5076 build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5077}
5078
5079pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5080 if compact {
5081 println!(
5082 "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5083 report.node_upserts.len(),
5084 report.node_tombstones.len(),
5085 report.edge_upserts.len(),
5086 report.edge_tombstones.len(),
5087 report.chunks.len(),
5088 report.freshness.status
5089 );
5090 return;
5091 }
5092
5093 println!(
5094 "Convex graph sync {}",
5095 if report.dry_run { "dry-run" } else { "apply" }
5096 );
5097 println!("root: {}", report.root);
5098 println!("graph_db: {}", report.graph_db);
5099 println!(
5100 "upserts: {} node(s), {} edge(s)",
5101 report.node_upserts.len(),
5102 report.edge_upserts.len()
5103 );
5104 println!(
5105 "tombstones: {} node(s), {} edge(s)",
5106 report.node_tombstones.len(),
5107 report.edge_tombstones.len()
5108 );
5109 println!("chunks: {}", report.chunks.len());
5110 println!("freshness: {}", report.freshness.status);
5111 if let Some(transport) = &report.transport {
5112 println!(
5113 "transport: endpoint_env={} auth_env={} applied_chunks={}",
5114 transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5115 );
5116 }
5117 for receipt in &report.receipts {
5118 println!(
5119 "receipt: {} chunk {} attempt {} {}",
5120 receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5121 );
5122 }
5123 for diagnostic in report
5124 .diagnostics
5125 .iter()
5126 .chain(report.freshness.diagnostics.iter())
5127 {
5128 println!("- {}", diagnostic);
5129 }
5130}
5131
5132pub(crate) struct ConvexSyncOptions<'a> {
5133 path: &'a Path,
5134 scope: Option<&'a str>,
5135 snapshot: Option<&'a Path>,
5136 chunk_size: usize,
5137 remote_snapshot: bool,
5138 apply: bool,
5139 endpoint: Option<&'a str>,
5140 auth_token_env: &'a str,
5141}
5142
5143#[derive(Serialize)]
5144struct GraphDbSchemaField {
5145 name: &'static str,
5146 value_type: &'static str,
5147 description: &'static str,
5148}
5149
5150#[derive(Serialize)]
5151struct GraphDbSchemaOperation {
5152 command: &'static str,
5153 description: &'static str,
5154}
5155
5156#[derive(Serialize)]
5157struct GraphDbSchemaContract {
5158 name: &'static str,
5159 version: &'static str,
5160 description: &'static str,
5161}
5162
5163#[derive(Serialize)]
5164struct GraphDbSchema {
5165 contract_versions: Vec<GraphDbSchemaContract>,
5166 node_fields: Vec<GraphDbSchemaField>,
5167 edge_fields: Vec<GraphDbSchemaField>,
5168 operations: Vec<GraphDbSchemaOperation>,
5169}
5170
5171#[derive(Clone, Serialize, Deserialize)]
5172struct GraphDbFreshnessReport {
5173 status: String,
5174 fail_closed: bool,
5175 projection_version: Option<String>,
5176 content_hash: Option<String>,
5177 source_watermark: Option<String>,
5178 diagnostics: Vec<String>,
5179}
5180
5181#[derive(Clone, Debug, Serialize)]
5182pub(crate) struct GraphEffectivenessReadiness {
5183 pub(crate) status: String,
5184 pub(crate) fail_closed: bool,
5185 pub(crate) reason: String,
5186 pub(crate) diagnostics: Vec<String>,
5187 pub(crate) next_commands: Vec<String>,
5188}
5189
5190#[derive(Clone, Debug, Serialize, PartialEq)]
5191struct GraphDbPropertyFilter {
5192 key: String,
5193 value: String,
5194}
5195
5196#[derive(Clone, Debug, Default)]
5197struct GraphDbQueryOptions {
5198 cursor: Option<String>,
5199 limit: Option<usize>,
5200 property_filters: Vec<GraphDbPropertyFilter>,
5201}
5202
5203#[derive(Clone, Debug, Serialize, PartialEq)]
5204struct GraphDbPageReport {
5205 #[serde(skip_serializing_if = "Option::is_none")]
5206 cursor: Option<String>,
5207 #[serde(skip_serializing_if = "Option::is_none")]
5208 limit: Option<usize>,
5209 #[serde(skip_serializing_if = "Option::is_none")]
5210 next_cursor: Option<String>,
5211 returned_nodes: usize,
5212 returned_edges: usize,
5213 truncated: bool,
5214 property_filters: Vec<GraphDbPropertyFilter>,
5215 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5216 diagnostics: Vec<String>,
5217}
5218
5219type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5220
5221#[derive(Clone, Debug, Serialize)]
5222struct CommunityTruncationSummary {
5223 total_communities: usize,
5224 fully_kept: usize,
5225 partially_pruned: usize,
5226 fully_pruned: usize,
5227 pruned_community_kinds: Vec<String>,
5228 pruned_community_top_labels: Vec<String>,
5229}
5230
5231#[derive(Clone, Debug, Serialize)]
5232struct GraphDbRankedNeighborhoodComparison {
5233 traversal_nodes: usize,
5234 traversal_edges: usize,
5235 pruned_count: usize,
5236 total_discovered: usize,
5237 latency_micros: u128,
5238 overlap_with_unranked_pct: f64,
5239 useful_hit_density_ranked: f64,
5240 useful_hit_density_unranked: f64,
5241 duplicate_name_count_ranked: usize,
5242 duplicate_name_count_unranked: usize,
5243 handle_coverage_ranked_pct: f64,
5244 handle_coverage_unranked_pct: f64,
5245 #[serde(skip_serializing_if = "Option::is_none")]
5246 community_truncation_summary: Option<CommunityTruncationSummary>,
5247 diagnostics: Vec<String>,
5248}
5249
5250#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5251struct GraphDbDroppedByBudget {
5252 item: String,
5253 kind: String,
5254 dropped: usize,
5255 reason: String,
5256}
5257
5258#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5259struct GraphDbOutputBudgetReport {
5260 max_tokens: usize,
5261 estimated_tokens: usize,
5262 selected_nodes: usize,
5263 selected_edges: usize,
5264 candidate_nodes: usize,
5265 candidate_edges: usize,
5266 dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5267 diagnostics: Vec<String>,
5268}
5269
5270#[derive(Clone, Debug, Serialize, PartialEq)]
5271struct GraphDbKnowledgeRetrieval {
5272 mode: String,
5273 query: String,
5274 seed_kind: String,
5275 seed_limit: usize,
5276 seed_count: usize,
5277 depth: usize,
5278 limit: usize,
5279 node_count: usize,
5280 edge_count: usize,
5281 truncated: bool,
5282 traversal: String,
5283 freshness_boundary: String,
5284 privacy_boundary: String,
5285 diagnostics: Vec<String>,
5286}
5287
5288struct GraphDbSemanticSeededSubgraph {
5289 nodes: Vec<SubstrateGraphNode>,
5290 edges: Vec<SubstrateGraphEdge>,
5291 truncated: bool,
5292 diagnostics: Vec<String>,
5293}
5294
5295type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5296
5297#[derive(Serialize)]
5298struct GraphDbReport {
5299 root: String,
5300 #[serde(skip_serializing_if = "Option::is_none")]
5301 scope: Option<String>,
5302 backend: String,
5303 query: String,
5304 freshness: GraphDbFreshnessReport,
5305 #[serde(skip_serializing_if = "Option::is_none")]
5306 readiness: Option<GraphEffectivenessReadiness>,
5307 #[serde(skip_serializing_if = "Option::is_none")]
5308 schema: Option<GraphDbSchema>,
5309 #[serde(skip_serializing_if = "Option::is_none")]
5310 node: Option<SubstrateTerseGraphNode>,
5311 #[serde(skip_serializing_if = "Option::is_none")]
5312 edge: Option<SubstrateTerseGraphEdge>,
5313 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5314 nodes: Vec<SubstrateTerseGraphNode>,
5315 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5316 edges: Vec<SubstrateTerseGraphEdge>,
5317 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5318 ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5319 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5320 semantic_related: Vec<SemanticRelatedItem>,
5321 #[serde(skip_serializing_if = "Option::is_none")]
5322 neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5323 #[serde(skip_serializing_if = "Option::is_none")]
5324 ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5325 #[serde(skip_serializing_if = "Option::is_none")]
5326 knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5327 #[serde(skip_serializing_if = "Option::is_none")]
5328 output_budget: Option<GraphDbOutputBudgetReport>,
5329 #[serde(skip_serializing_if = "Option::is_none")]
5330 path: Option<substrate::GraphPath>,
5331 #[serde(skip_serializing_if = "Option::is_none")]
5332 page: Option<GraphDbPageReport>,
5333 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5334 warnings: Vec<String>,
5335}
5336
5337struct ExperimentalReadOnlyGraphStore {
5338 backend: GraphDbExperimentalBackend,
5339 nodes: BTreeMap<String, SubstrateGraphNode>,
5340 edges: BTreeMap<String, SubstrateGraphEdge>,
5341 node_ids_by_kind: BTreeMap<String, Vec<String>>,
5342 outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5343}
5344
5345impl ExperimentalReadOnlyGraphStore {
5346 fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5347 validate_convex_projection_rows(rows)?;
5348 let nodes = rows
5349 .nodes
5350 .iter()
5351 .map(|row| {
5352 let node = SubstrateGraphNode {
5353 id: row.external_id.clone(),
5354 kind: row.kind.clone(),
5355 label: row.label.clone(),
5356 properties: row.properties.clone(),
5357 provenance: row.provenance.clone(),
5358 freshness: row.freshness.clone(),
5359 };
5360 (node.id.clone(), node)
5361 })
5362 .collect::<BTreeMap<_, _>>();
5363 let edges = rows
5364 .edges
5365 .iter()
5366 .map(|row| {
5367 let edge = SubstrateGraphEdge {
5368 id: row.edge_key.clone(),
5369 from_id: row.from_external_id.clone(),
5370 to_id: row.to_external_id.clone(),
5371 kind: row.kind.clone(),
5372 properties: row.properties.clone(),
5373 provenance: row.provenance.clone(),
5374 freshness: row.freshness.clone(),
5375 };
5376 (graph_db_edge_key(&edge), edge)
5377 })
5378 .collect::<BTreeMap<_, _>>();
5379 let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5380 for node in nodes.values() {
5381 node_ids_by_kind
5382 .entry(node.kind.clone())
5383 .or_default()
5384 .push(node.id.clone());
5385 }
5386 for ids in node_ids_by_kind.values_mut() {
5387 ids.sort();
5388 }
5389 let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5390 for edge in edges.values() {
5391 outgoing_edge_keys_by_from
5392 .entry(edge.from_id.clone())
5393 .or_default()
5394 .push(graph_db_edge_key(edge));
5395 }
5396 for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5397 edge_keys.sort_by(|left_key, right_key| {
5398 let left = &edges[left_key];
5399 let right = &edges[right_key];
5400 left.to_id
5401 .cmp(&right.to_id)
5402 .then(left.kind.cmp(&right.kind))
5403 .then(left_key.cmp(right_key))
5404 });
5405 }
5406 Ok(Self {
5407 backend,
5408 nodes,
5409 edges,
5410 node_ids_by_kind,
5411 outgoing_edge_keys_by_from,
5412 })
5413 }
5414}
5415
5416impl GraphStore for ExperimentalReadOnlyGraphStore {
5417 fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5418 bail!("{} backend-eval adapter is read-only", self.backend.name())
5419 }
5420
5421 fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5422 bail!("{} backend-eval adapter is read-only", self.backend.name())
5423 }
5424
5425 fn delete_node(&self, _id: &str) -> Result<usize> {
5426 bail!("{} backend-eval adapter is read-only", self.backend.name())
5427 }
5428
5429 fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
5430 bail!("{} backend-eval adapter is read-only", self.backend.name())
5431 }
5432
5433 fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
5434 Ok(self.nodes.get(id).cloned())
5435 }
5436
5437 fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
5438 Ok(self.nodes.values().cloned().collect())
5439 }
5440
5441 fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
5442 let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
5443 edges.sort_by(|left, right| {
5444 left.from_id
5445 .cmp(&right.from_id)
5446 .then(left.kind.cmp(&right.kind))
5447 .then(left.to_id.cmp(&right.to_id))
5448 });
5449 Ok(edges)
5450 }
5451
5452 fn graph_counts(&self) -> Result<(usize, usize)> {
5453 Ok((self.nodes.len(), self.edges.len()))
5454 }
5455
5456 fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
5457 let mut edges = self
5458 .edges
5459 .values()
5460 .filter(|edge| edge.from_id != edge.to_id)
5461 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5462 .cloned()
5463 .collect::<Vec<_>>();
5464 edges.sort_by(|left, right| {
5465 left.from_id
5466 .cmp(&right.from_id)
5467 .then(left.kind.cmp(&right.kind))
5468 .then(left.to_id.cmp(&right.to_id))
5469 });
5470 Ok(edges.into_iter().next())
5471 }
5472
5473 fn sample_edge_with_property(
5474 &self,
5475 ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
5476 Ok(self
5477 .edges
5478 .values()
5479 .filter(|edge| edge.from_id != edge.to_id)
5480 .filter_map(|edge| {
5481 edge.properties.iter().next().map(|(key, value)| {
5482 (
5483 edge,
5484 GraphPropertyFilter {
5485 key: key.clone(),
5486 value: value.clone(),
5487 },
5488 )
5489 })
5490 })
5491 .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
5492 left_filter
5493 .key
5494 .cmp(&right_filter.key)
5495 .then(left_filter.value.cmp(&right_filter.value))
5496 .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
5497 })
5498 .map(|(edge, filter)| (edge.clone(), filter)))
5499 }
5500
5501 fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
5502 Ok(self
5503 .node_ids_by_kind
5504 .get(kind)
5505 .into_iter()
5506 .flatten()
5507 .filter_map(|id| self.nodes.get(id).cloned())
5508 .collect())
5509 }
5510
5511 fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
5512 Ok(self
5513 .outgoing_edge_keys_by_from
5514 .get(from_id)
5515 .into_iter()
5516 .flatten()
5517 .filter_map(|key| self.edges.get(key))
5518 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5519 .cloned()
5520 .collect())
5521 }
5522
5523 fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
5524 Ok(self
5525 .edges
5526 .values()
5527 .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
5528 .cloned()
5529 .collect())
5530 }
5531
5532 fn shortest_path(
5533 &self,
5534 from_id: &str,
5535 to_id: &str,
5536 kind: Option<&str>,
5537 ) -> Result<Option<substrate::GraphPath>> {
5538 if from_id == to_id {
5539 return Ok(Some(substrate::GraphPath {
5540 nodes: vec![from_id.to_string()],
5541 hops: 0,
5542 }));
5543 }
5544
5545 let mut queue = VecDeque::new();
5546 let mut parent = BTreeMap::<String, String>::new();
5547 parent.insert(from_id.to_string(), String::new());
5548 queue.push_back(from_id.to_string());
5549
5550 while let Some(current) = queue.pop_front() {
5551 for edge in self.outgoing_edges(¤t, kind)? {
5552 if parent.contains_key(&edge.to_id) {
5553 continue;
5554 }
5555 parent.insert(edge.to_id.clone(), current.clone());
5556 if edge.to_id == to_id {
5557 let mut nodes = vec![to_id.to_string()];
5558 let mut cursor = to_id;
5559 while let Some(previous) = parent.get(cursor) {
5560 if previous.is_empty() {
5561 break;
5562 }
5563 nodes.push(previous.clone());
5564 cursor = previous;
5565 }
5566 nodes.reverse();
5567 return Ok(Some(substrate::GraphPath {
5568 hops: nodes.len().saturating_sub(1),
5569 nodes,
5570 }));
5571 }
5572 queue.push_back(edge.to_id);
5573 }
5574 }
5575
5576 Ok(None)
5577 }
5578
5579 fn reachable_nodes_by_kinds(
5580 &self,
5581 from_id: &str,
5582 kinds: &[&str],
5583 depth: usize,
5584 limit: usize,
5585 ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
5586 let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
5587 let mut rows = requested
5588 .iter()
5589 .map(|kind| {
5590 (
5591 (*kind).to_string(),
5592 BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
5593 )
5594 })
5595 .collect::<BTreeMap<_, _>>();
5596 if requested.is_empty() {
5597 return Ok(BTreeMap::new());
5598 }
5599
5600 let mut seen = BTreeSet::from([from_id.to_string()]);
5601 let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
5602 while let Some((current, path)) = queue.pop_front() {
5603 let current_depth = path.len().saturating_sub(1);
5604 if current_depth >= depth {
5605 continue;
5606 }
5607 for edge in self.outgoing_edges(¤t, None)? {
5608 if !seen.insert(edge.to_id.clone()) {
5609 continue;
5610 }
5611 let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
5612 continue;
5613 };
5614 let mut next_path = path.clone();
5615 next_path.push(edge.to_id.clone());
5616 let graph_path = substrate::GraphPath {
5617 hops: next_path.len().saturating_sub(1),
5618 nodes: next_path.clone(),
5619 };
5620 if requested.contains(node.kind.as_str()) {
5621 rows.entry(node.kind.clone())
5622 .or_default()
5623 .entry(node.id.clone())
5624 .or_insert((node.clone(), graph_path));
5625 }
5626 queue.push_back((edge.to_id, next_path));
5627 }
5628 }
5629
5630 Ok(rows
5631 .into_iter()
5632 .map(|(kind, values)| {
5633 let mut values = values.into_values().collect::<Vec<_>>();
5634 values.sort_by(|(left_node, left_path), (right_node, right_path)| {
5635 left_path
5636 .hops
5637 .cmp(&right_path.hops)
5638 .then(left_node.label.cmp(&right_node.label))
5639 .then(left_node.id.cmp(&right_node.id))
5640 });
5641 if limit > 0 && values.len() > limit {
5642 values.truncate(limit);
5643 }
5644 (kind, values)
5645 })
5646 .collect())
5647 }
5648}
5649
5650pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
5651pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
5652pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
5653const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
5654pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
5655const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
5656const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
5657const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
5658const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
5659
5660#[derive(Clone, Serialize, Deserialize)]
5661pub(crate) struct GraphDbBackendEvalPhaseTiming {
5662 name: String,
5663 duration_micros: u128,
5664 detail: String,
5665}
5666
5667#[derive(Serialize, Deserialize)]
5668struct GraphDbBackendEvalFullProjectionCache {
5669 version: String,
5670 key: String,
5671 source_watermark: String,
5672 projection: GraphProjection,
5673 warnings: Vec<String>,
5674}
5675
5676#[derive(Clone, Default)]
5677struct GraphDbBackendEvalFullProjectionCacheStats {
5678 hit: bool,
5679 disk_bytes: u64,
5680 json_bytes: u64,
5681 pruned_files: usize,
5682 pruned_bytes: u64,
5683}
5684
5685#[derive(Serialize)]
5686struct GraphDbBackendEvalRawSourceWatermarkRow {
5687 path: String,
5688 bytes: u64,
5689 content_hash: String,
5690}
5691
5692#[derive(Clone)]
5693struct GraphDbBackendEvalFullProjectionSourceWatermark {
5694 value: String,
5695 detail: String,
5696}
5697
5698#[derive(Serialize)]
5699pub(crate) struct GraphDbBackendEvalConfig {
5700 high_degree_nodes: usize,
5701 high_degree_fanout: usize,
5702 deep_chain_nodes: usize,
5703 deep_chain_fanout: usize,
5704 depth: usize,
5705 limit: usize,
5706 impact_limit: usize,
5707 path_max_hops: usize,
5708 path_direct_hop_budget: usize,
5709 path_deep_chain_hop_budget: usize,
5710 path_extended_hop_budgets: Vec<usize>,
5711 path_hop_policy: String,
5712 path_probe_strategy: String,
5713 path_query_plan_checks: Vec<String>,
5714 full_projection_enabled: bool,
5715 full_projection_profile: String,
5716 normalization_row_unit: usize,
5717}
5718
5719#[derive(Clone)]
5720struct GraphDbBackendEvalSignature {
5721 operation: String,
5722 value: serde_json::Value,
5723}
5724
5725#[derive(Serialize)]
5726struct GraphDbBackendEvalOperation {
5727 name: String,
5728 supported: bool,
5729 status: String,
5730 duration_micros: u128,
5731 #[serde(skip_serializing_if = "Option::is_none")]
5732 rows: Option<usize>,
5733 #[serde(skip_serializing_if = "Option::is_none")]
5734 error: Option<String>,
5735}
5736
5737#[derive(Serialize)]
5738struct GraphDbBackendEvalParity {
5739 matches_sqlite: bool,
5740 diagnostics: Vec<String>,
5741}
5742
5743#[derive(Serialize)]
5744struct GraphDbBackendEvalBackendReport {
5745 backend: String,
5746 adapter: String,
5747 read_only: bool,
5748 projection_load: String,
5749 operations: Vec<GraphDbBackendEvalOperation>,
5750 total_micros: u128,
5751 parity: GraphDbBackendEvalParity,
5752 lock_behavior: String,
5753 install_portability: String,
5754}
5755
5756#[derive(Serialize)]
5757struct GraphDbBackendEvalDataset {
5758 name: String,
5759 target_count: usize,
5760 nodes: usize,
5761 edges: usize,
5762 backends: Vec<GraphDbBackendEvalBackendReport>,
5763}
5764
5765#[derive(Serialize)]
5766struct GraphDbBackendPromotionDecision {
5767 backend: String,
5768 decision: String,
5769 reasons: Vec<String>,
5770 gate: GraphDbBackendPromotionGate,
5771}
5772
5773#[derive(Serialize)]
5774struct GraphDbBackendEvalPerformanceGate {
5775 baseline_fixture: String,
5776 ci_profile: String,
5777 opt_in_real_profile: String,
5778 full_projection_cache_hit_gate: String,
5779 allowed_regression_percent: f64,
5780 minimum_sample_runs: usize,
5781 normalized_metric_unit: String,
5782 required_metrics: Vec<String>,
5783 digest_command: String,
5784 repeated_sample_command: String,
5785 hop_cap_promotion: GraphDbHopCapPromotionGate,
5786 backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
5787}
5788
5789#[derive(Serialize)]
5790struct GraphDbHopCapPromotionGate {
5791 status: String,
5792 current_default_hops: usize,
5793 candidate_hop_tiers: Vec<usize>,
5794 required_backend: String,
5795 required_workloads: Vec<String>,
5796 required_metrics: Vec<String>,
5797 allowed_regression_percent: f64,
5798 minimum_sample_runs: usize,
5799 decision_rule: String,
5800}
5801
5802#[derive(Serialize)]
5803struct GraphDbBackendAdapterSpikeGate {
5804 status: String,
5805 candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
5806 required_workloads: Vec<String>,
5807 required_checks: Vec<String>,
5808 decision_rule: String,
5809 evidence_plan: String,
5810}
5811
5812#[derive(Serialize)]
5813struct GraphDbBackendAdapterSpikeCandidate {
5814 backend: String,
5815 adapter_label: String,
5816 projection_load: String,
5817 lock_behavior: String,
5818 install_portability: String,
5819}
5820
5821#[derive(Serialize)]
5822pub(crate) struct GraphDbBackendEvalReport {
5823 root: String,
5824 #[serde(skip_serializing_if = "Option::is_none")]
5825 scope: Option<String>,
5826 label: String,
5827 baseline_backend: String,
5828 candidates: Vec<String>,
5829 targets: Vec<String>,
5830 config: GraphDbBackendEvalConfig,
5831 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5832 datasets: Vec<GraphDbBackendEvalDataset>,
5833 promotion: Vec<GraphDbBackendPromotionDecision>,
5834 performance_gate: GraphDbBackendEvalPerformanceGate,
5835 metrics: BTreeMap<String, f64>,
5836 metric_digest_command: String,
5837 warnings: Vec<String>,
5838}
5839
5840#[derive(Clone, Debug, Serialize)]
5841struct GraphDbDoctorCheck {
5842 name: String,
5843 status: String,
5844 fail_closed: bool,
5845 diagnostics: Vec<String>,
5846 repair_commands: Vec<String>,
5847}
5848
5849#[derive(Serialize)]
5850pub(crate) struct GraphDbDoctorReport {
5851 root: String,
5852 #[serde(skip_serializing_if = "Option::is_none")]
5853 scope: Option<String>,
5854 backend: String,
5855 graph_db: String,
5856 #[serde(skip_serializing_if = "Option::is_none")]
5857 convex_snapshot: Option<String>,
5858 status: String,
5859 fail_closed: bool,
5860 checks: Vec<GraphDbDoctorCheck>,
5861 repair_commands: Vec<String>,
5862 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5863 required_indexes: Vec<ConvexRequiredIndex>,
5864}
5865
5866#[derive(Serialize)]
5867struct GraphDbDriftSummary {
5868 node_upserts: usize,
5869 edge_upserts: usize,
5870 node_tombstones: usize,
5871 edge_tombstones: usize,
5872 stale_nodes: usize,
5873 stale_edges: usize,
5874 stale_projection_metadata: usize,
5875 duplicate_failures: usize,
5876 orphan_failures: usize,
5877 missing_required_indexes: usize,
5878}
5879
5880#[derive(Serialize)]
5881struct GraphDbDriftReport {
5882 root: String,
5883 #[serde(skip_serializing_if = "Option::is_none")]
5884 scope: Option<String>,
5885 graph_db: String,
5886 convex_snapshot: String,
5887 status: String,
5888 graph_reads_allowed: bool,
5889 projection_version: String,
5890 local_hash: Option<String>,
5891 snapshot_hash: Option<String>,
5892 summary: GraphDbDriftSummary,
5893 node_upserts: Vec<String>,
5894 edge_upserts: Vec<String>,
5895 node_tombstones: Vec<String>,
5896 edge_tombstones: Vec<String>,
5897 stale_nodes: Vec<String>,
5898 stale_edges: Vec<String>,
5899 diagnostics: Vec<String>,
5900 next_commands: Vec<String>,
5901 required_indexes: Vec<ConvexRequiredIndex>,
5902 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5903 warnings: Vec<String>,
5904}
5905
5906#[derive(Clone, Serialize)]
5907struct GraphDbTombstoneCounts {
5908 nodes: usize,
5909 edges: usize,
5910 total: usize,
5911}
5912
5913#[derive(Clone, Serialize)]
5914struct GraphDbOperatorCounts {
5915 nodes: usize,
5916 edges: usize,
5917 tombstones: GraphDbTombstoneCounts,
5918 #[serde(skip_serializing_if = "Option::is_none")]
5919 file_size_bytes: Option<u64>,
5920 #[serde(skip_serializing_if = "Option::is_none")]
5921 freelist_bytes: Option<u64>,
5922}
5923
5924#[derive(Clone, Serialize)]
5925struct GraphDbCompactionPolicy {
5926 status: String,
5927 tombstone_scan_rows: usize,
5928 live_rows: usize,
5929 file_size_bytes: Option<u64>,
5930 freelist_bytes: Option<u64>,
5931 safe_to_prune_tombstones: bool,
5932 requires_convex_reconciliation: bool,
5933 recommendations: Vec<String>,
5934 proof: Vec<String>,
5935}
5936
5937#[derive(Serialize)]
5938pub(crate) struct GraphDbRefreshSummary {
5939 scope: String,
5940 projection_version: String,
5941 mode: String,
5942 #[serde(skip_serializing_if = "Option::is_none")]
5943 source_watermark: Option<String>,
5944 tombstoned_nodes: usize,
5945 tombstoned_edges: usize,
5946 upserted_nodes: usize,
5947 upserted_edges: usize,
5948 unchanged_nodes: usize,
5949 unchanged_edges: usize,
5950 upserted_properties: usize,
5951 unchanged_properties: usize,
5952 deleted_properties: usize,
5953 deleted_nodes: usize,
5954 deleted_edges: usize,
5955 pruned_tombstones: usize,
5956 #[serde(skip_serializing_if = "Option::is_none")]
5957 file_size_bytes_before: Option<u64>,
5958 #[serde(skip_serializing_if = "Option::is_none")]
5959 file_size_bytes_after: Option<u64>,
5960 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5961 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5962}
5963
5964#[derive(Serialize)]
5965struct GraphDbOperatorReport {
5966 root: String,
5967 #[serde(skip_serializing_if = "Option::is_none")]
5968 scope: Option<String>,
5969 graph_db: String,
5970 operation: String,
5971 status: String,
5972 materialized: bool,
5973 freshness: GraphDbFreshnessReport,
5974 readiness: GraphEffectivenessReadiness,
5975 counts: GraphDbOperatorCounts,
5976 #[serde(skip_serializing_if = "Option::is_none")]
5977 refresh: Option<GraphDbRefreshSummary>,
5978 compaction: GraphDbCompactionPolicy,
5979 #[serde(skip_serializing_if = "Option::is_none")]
5980 recovery: Option<index::ReadOnlyRecovery>,
5981 next_commands: Vec<String>,
5982 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5983 warnings: Vec<String>,
5984}
5985
5986#[derive(Serialize)]
5987pub(crate) struct GraphDbCompactionReport {
5988 root: String,
5989 #[serde(skip_serializing_if = "Option::is_none")]
5990 scope: Option<String>,
5991 graph_db: String,
5992 applied: bool,
5993 pruned_tombstones: usize,
5994 counts_before: GraphDbOperatorCounts,
5995 counts_after: GraphDbOperatorCounts,
5996 compaction_before: GraphDbCompactionPolicy,
5997 compaction_after: GraphDbCompactionPolicy,
5998 reclaimed_bytes: i64,
5999 next_commands: Vec<String>,
6000 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6001 warnings: Vec<String>,
6002}
6003
6004#[derive(Clone, Serialize, Deserialize)]
6005struct GraphDbEvidencePath {
6006 to: String,
6007 kind: String,
6008 label: String,
6009 #[serde(skip_serializing_if = "Option::is_none")]
6010 path: Option<substrate::GraphPath>,
6011 #[serde(skip_serializing_if = "Option::is_none")]
6012 expand: Option<String>,
6013}
6014
6015#[derive(Clone, Serialize, Deserialize)]
6016struct GraphDbFixtureCoverage {
6017 test: String,
6018 fixture: String,
6019 assertions: Vec<String>,
6020}
6021
6022#[derive(Clone, Serialize, Deserialize)]
6023struct GraphDbEvidenceReport {
6024 root: String,
6025 #[serde(skip_serializing_if = "Option::is_none")]
6026 scope: Option<String>,
6027 backend: String,
6028 contract_version: String,
6029 target: String,
6030 packet_id: String,
6031 #[serde(skip_serializing_if = "Option::is_none")]
6032 projection_hash: Option<String>,
6033 freshness: GraphDbFreshnessReport,
6034 target_node: SubstrateTerseGraphNode,
6035 worker_context: Vec<SubstrateTerseGraphNode>,
6036 source_handles: Vec<SubstrateTerseGraphNode>,
6037 worker_results: Vec<SubstrateTerseGraphNode>,
6038 semantic_related: Vec<SubstrateTerseGraphNode>,
6039 shortest_paths: Vec<GraphDbEvidencePath>,
6040 #[serde(skip_serializing_if = "Option::is_none")]
6041 output_budget: Option<GraphDbOutputBudgetReport>,
6042 #[serde(default)]
6043 truncated: bool,
6044 #[serde(skip_serializing_if = "Option::is_none")]
6045 next_cursor: Option<String>,
6046 next_commands: Vec<String>,
6047 replay_commands: Vec<String>,
6048 repair_commands: Vec<String>,
6049 fixture_coverage: GraphDbFixtureCoverage,
6050 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6051 warnings: Vec<String>,
6052}
6053
6054pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6055 root: &'a Path,
6056 scope: Option<&'a str>,
6057 backend: &'a str,
6058 target: &'a str,
6059 preferred_path: Option<&'a str>,
6060 depth: usize,
6061 limit: usize,
6062 cursor: Option<&'a str>,
6063 store: &'a S,
6064 freshness: GraphDbFreshnessReport,
6065 warnings: Vec<String>,
6066}
6067
6068impl GraphDbDoctorReport {
6069 fn new(
6070 root: &Path,
6071 scope: Option<&str>,
6072 backend: &str,
6073 graph_db: &Path,
6074 convex_snapshot: Option<&Path>,
6075 ) -> Self {
6076 Self {
6077 root: root.to_string_lossy().to_string(),
6078 scope: scope.map(str::to_string),
6079 backend: backend.to_string(),
6080 graph_db: graph_db.to_string_lossy().to_string(),
6081 convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6082 status: "ok".to_string(),
6083 fail_closed: false,
6084 checks: Vec::new(),
6085 repair_commands: Vec::new(),
6086 required_indexes: Vec::new(),
6087 }
6088 }
6089
6090 fn push_check(&mut self, check: GraphDbDoctorCheck) {
6091 self.checks.push(check);
6092 }
6093
6094 fn finalize(&mut self) {
6095 self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6096 self.status = if self.fail_closed {
6097 "fail_closed"
6098 } else {
6099 "ok"
6100 }
6101 .to_string();
6102 let mut commands = BTreeSet::new();
6103 for check in &self.checks {
6104 commands.extend(check.repair_commands.iter().cloned());
6105 }
6106 self.repair_commands = commands.into_iter().collect();
6107 }
6108
6109 fn summary(&self) -> String {
6110 self.checks
6111 .iter()
6112 .filter(|check| check.fail_closed)
6113 .flat_map(|check| check.diagnostics.iter())
6114 .take(3)
6115 .cloned()
6116 .collect::<Vec<_>>()
6117 .join("; ")
6118 }
6119}
6120
6121fn graph_db_doctor_check(
6122 name: impl Into<String>,
6123 diagnostics: Vec<String>,
6124 repair_commands: Vec<String>,
6125) -> GraphDbDoctorCheck {
6126 let fail_closed = !diagnostics.is_empty();
6127 GraphDbDoctorCheck {
6128 name: name.into(),
6129 status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6130 fail_closed,
6131 diagnostics,
6132 repair_commands: if fail_closed {
6133 repair_commands
6134 } else {
6135 Vec::new()
6136 },
6137 }
6138}
6139
6140pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6141 scope
6142 .map(|scope| format!(" --scope {}", shell_quote(scope)))
6143 .unwrap_or_default()
6144}
6145
6146fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6147 format!(
6148 "tsift graph-db --path {}{} refresh --json",
6149 shell_quote(root.to_string_lossy().as_ref()),
6150 graph_db_scope_arg(scope)
6151 )
6152}
6153
6154fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6155 graph_db_refresh_command(root, scope)
6156}
6157
6158fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6159 let backup = format!("{}.bak", graph_db.to_string_lossy());
6160 format!(
6161 "mv {} {} && {}",
6162 shell_quote(graph_db.to_string_lossy().as_ref()),
6163 shell_quote(&backup),
6164 graph_db_rebuild_command(root, scope)
6165 )
6166}
6167
6168fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6169 format!(
6170 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6171 shell_quote(root.to_string_lossy().as_ref()),
6172 graph_db_scope_arg(scope)
6173 )
6174}
6175
6176fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6177 substrate::open_graph_read_only_connection_resilient(graph_db)
6178}
6179
6180fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6181 conn.query_row(
6182 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6183 [table],
6184 |row| row.get::<_, bool>(0),
6185 )
6186 .map_err(Into::into)
6187}
6188
6189fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6190 let sql = match table {
6191 "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6192 "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6193 "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6194 other => bail!("unsupported graph count table {other}"),
6195 };
6196 conn.query_row(sql, [], |row| row.get::<_, usize>(0))
6197 .map_err(Into::into)
6198}
6199
6200fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6201 if !sqlite_table_exists(conn, "graph_tombstones")? {
6202 return Ok(GraphDbTombstoneCounts {
6203 nodes: 0,
6204 edges: 0,
6205 total: 0,
6206 });
6207 }
6208 let mut stmt =
6209 conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6210 let mut rows = stmt.query([])?;
6211 let mut nodes = 0usize;
6212 let mut edges = 0usize;
6213 while let Some(row) = rows.next()? {
6214 let row_kind: String = row.get(0)?;
6215 let count: usize = row.get(1)?;
6216 match row_kind.as_str() {
6217 "node" => nodes = count,
6218 "edge" => edges = count,
6219 _ => {}
6220 }
6221 }
6222 Ok(GraphDbTombstoneCounts {
6223 nodes,
6224 edges,
6225 total: nodes + edges,
6226 })
6227}
6228
6229fn sqlite_graph_counts_from_cache(
6230 conn: &Connection,
6231 scope: &str,
6232) -> Result<Option<GraphDbOperatorCounts>> {
6233 if !sqlite_table_exists(conn, "graph_operator_stats")? {
6234 return Ok(None);
6235 }
6236 let row = conn
6237 .query_row(
6238 r#"
6239 SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6240 FROM graph_operator_stats
6241 WHERE scope = ?1
6242 "#,
6243 [scope],
6244 |row| {
6245 Ok((
6246 row.get::<_, usize>(0)?,
6247 row.get::<_, usize>(1)?,
6248 row.get::<_, usize>(2)?,
6249 row.get::<_, usize>(3)?,
6250 row.get::<_, Option<i64>>(4)?,
6251 row.get::<_, Option<i64>>(5)?,
6252 ))
6253 },
6254 )
6255 .optional()?;
6256 Ok(row.map(
6257 |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6258 GraphDbOperatorCounts {
6259 nodes,
6260 edges,
6261 tombstones: GraphDbTombstoneCounts {
6262 nodes: tombstone_nodes,
6263 edges: tombstone_edges,
6264 total: tombstone_nodes + tombstone_edges,
6265 },
6266 file_size_bytes: file_size_bytes
6267 .and_then(|value| u64::try_from(value).ok())
6268 .or_else(|| sqlite_database_size_bytes(conn).ok()),
6269 freelist_bytes: freelist_bytes
6270 .and_then(|value| u64::try_from(value).ok())
6271 .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6272 }
6273 },
6274 ))
6275}
6276
6277fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6278 if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6279 return Ok(counts);
6280 }
6281 let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6282 sqlite_known_table_count(conn, "graph_nodes")?
6283 } else {
6284 0
6285 };
6286 let edges = if sqlite_table_exists(conn, "graph_edges")? {
6287 sqlite_known_table_count(conn, "graph_edges")?
6288 } else {
6289 0
6290 };
6291 Ok(GraphDbOperatorCounts {
6292 nodes,
6293 edges,
6294 tombstones: sqlite_tombstone_counts(conn)?,
6295 file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6296 freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6297 })
6298}
6299
6300fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6301 if !sqlite_table_exists(conn, "graph_nodes")? {
6302 return Ok(0);
6303 }
6304 let count: i64 = conn.query_row(
6305 "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6306 [],
6307 |row| row.get(0),
6308 )?;
6309 Ok(count as usize)
6310}
6311
6312pub(crate) fn graph_db_compaction_policy(
6313 root: &Path,
6314 scope: Option<&str>,
6315 counts: &GraphDbOperatorCounts,
6316 prune_confirmed: bool,
6317) -> GraphDbCompactionPolicy {
6318 let live_rows = counts.nodes + counts.edges;
6319 let tombstone_scan_rows = counts.tombstones.total;
6320 let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6321 let freelist_heavy = counts
6322 .file_size_bytes
6323 .zip(counts.freelist_bytes)
6324 .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6325 let status = if tombstone_heavy || freelist_heavy {
6326 "recommended"
6327 } else {
6328 "not_needed"
6329 }
6330 .to_string();
6331 let mut recommendations = vec![
6332 convex_refresh_command(root, scope),
6333 graph_db_refresh_command(root, scope),
6334 format!(
6335 "tsift graph-db --path {}{} compact --apply --json",
6336 shell_quote(root.to_string_lossy().as_ref()),
6337 graph_db_scope_arg(scope)
6338 ),
6339 ];
6340 if prune_confirmed {
6341 recommendations.push(format!(
6342 "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6343 shell_quote(root.to_string_lossy().as_ref()),
6344 graph_db_scope_arg(scope)
6345 ));
6346 }
6347 let proof = vec![
6348 format!("{live_rows} live graph row(s)"),
6349 format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6350 format!(
6351 "graph.db file_size={} byte(s), freelist={} byte(s)",
6352 counts.file_size_bytes.unwrap_or(0),
6353 counts.freelist_bytes.unwrap_or(0)
6354 ),
6355 ];
6356 GraphDbCompactionPolicy {
6357 status,
6358 tombstone_scan_rows,
6359 live_rows,
6360 file_size_bytes: counts.file_size_bytes,
6361 freelist_bytes: counts.freelist_bytes,
6362 safe_to_prune_tombstones: prune_confirmed,
6363 requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6364 recommendations,
6365 proof,
6366 }
6367}
6368
6369fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6370 let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
6371 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6372 Ok(page_count.saturating_mul(page_size))
6373}
6374
6375fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6376 let freelist_count: u64 = conn.query_row("PRAGMA freelist_count", [], |row| row.get(0))?;
6377 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6378 Ok(freelist_count.saturating_mul(page_size))
6379}
6380
6381fn sqlite_graph_tombstone_retention_diagnostics(
6382 conn: &Connection,
6383 scope: &str,
6384) -> Result<Vec<String>> {
6385 if !sqlite_table_exists(conn, "graph_tombstones")? {
6386 return Ok(Vec::new());
6387 }
6388 let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6389 let counts = match cached.clone() {
6390 Some(counts) => counts,
6391 None => sqlite_graph_counts(conn, scope)?,
6392 };
6393 let live_rows = counts.nodes + counts.edges;
6394 let file_size = counts.file_size_bytes.unwrap_or(0);
6395 let freelist = counts.freelist_bytes.unwrap_or(0);
6396 let stale_live_tombstones = if cached.is_some() {
6397 0
6398 } else {
6399 let mut live_keys = BTreeSet::new();
6400 if sqlite_table_exists(conn, "graph_nodes")? {
6401 let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6402 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6403 live_keys.insert(format!("node:{}", row?));
6404 }
6405 }
6406 if sqlite_table_exists(conn, "graph_edges")? {
6407 let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6408 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6409 live_keys.insert(format!("edge:{}", row?));
6410 }
6411 }
6412 let mut stale_live_tombstones = 0usize;
6413 let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6414 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6415 if live_keys.contains(&row?) {
6416 stale_live_tombstones += 1;
6417 }
6418 }
6419 stale_live_tombstones
6420 };
6421
6422 let mut diagnostics = Vec::new();
6423 if stale_live_tombstones > 0 {
6424 diagnostics.push(format!(
6425 "{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"
6426 ));
6427 }
6428 if counts.tombstones.total > live_rows.max(1) {
6429 let source = if cached.is_some() {
6430 "cached refresh stats"
6431 } else {
6432 "live row scan"
6433 };
6434 diagnostics.push(format!(
6435 "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.",
6436 counts.tombstones.total,
6437 live_rows,
6438 source,
6439 file_size,
6440 freelist,
6441 counts.tombstones.total
6442 ));
6443 }
6444 Ok(diagnostics)
6445}
6446
6447fn sqlite_graph_freshness_from_conn(
6448 conn: &Connection,
6449 scope: &str,
6450) -> Result<GraphDbFreshnessReport> {
6451 if !sqlite_table_exists(conn, "graph_projection_versions")? {
6452 return Ok(GraphDbFreshnessReport {
6453 status: "missing".to_string(),
6454 fail_closed: true,
6455 projection_version: None,
6456 content_hash: None,
6457 source_watermark: None,
6458 diagnostics: vec![
6459 "graph projection metadata table is missing; refresh graph.db before trusting reads"
6460 .to_string(),
6461 ],
6462 });
6463 }
6464 let version = conn
6465 .query_row(
6466 r#"
6467 SELECT projection_version, content_hash, source_watermark
6468 FROM graph_projection_versions
6469 WHERE scope = ?1
6470 "#,
6471 [scope],
6472 |row| {
6473 Ok((
6474 row.get::<_, String>(0)?,
6475 row.get::<_, Option<String>>(1)?,
6476 row.get::<_, Option<String>>(2)?,
6477 ))
6478 },
6479 )
6480 .optional()?;
6481 let Some((projection_version, content_hash, source_watermark)) = version else {
6482 return Ok(GraphDbFreshnessReport {
6483 status: "missing".to_string(),
6484 fail_closed: true,
6485 projection_version: None,
6486 content_hash: None,
6487 source_watermark: None,
6488 diagnostics: vec![
6489 "graph projection metadata is missing; refresh graph.db before trusting reads"
6490 .to_string(),
6491 ],
6492 });
6493 };
6494
6495 let mut diagnostics = Vec::new();
6496 if projection_version != GRAPH_PROJECTION_VERSION {
6497 diagnostics.push(format!(
6498 "projection version mismatch: expected {} got {}",
6499 GRAPH_PROJECTION_VERSION, projection_version
6500 ));
6501 }
6502 if content_hash.is_none() {
6503 diagnostics.push("projection content hash is missing".to_string());
6504 }
6505 let fail_closed = !diagnostics.is_empty();
6506 Ok(GraphDbFreshnessReport {
6507 status: if fail_closed { "stale" } else { "current" }.to_string(),
6508 fail_closed,
6509 projection_version: Some(projection_version),
6510 content_hash,
6511 source_watermark,
6512 diagnostics,
6513 })
6514}
6515
6516fn graph_db_operator_next_commands(
6517 root: &Path,
6518 scope: Option<&str>,
6519 include_refresh: bool,
6520) -> Vec<String> {
6521 let mut commands = Vec::new();
6522 if include_refresh {
6523 commands.push(graph_db_refresh_command(root, scope));
6524 }
6525 commands.push(format!(
6526 "tsift graph-db --path {}{} doctor --json",
6527 shell_quote(root.to_string_lossy().as_ref()),
6528 graph_db_scope_arg(scope)
6529 ));
6530 commands.push(format!(
6531 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
6532 shell_quote(root.to_string_lossy().as_ref()),
6533 graph_db_scope_arg(scope)
6534 ));
6535 commands.push(format!(
6536 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6537 shell_quote(root.to_string_lossy().as_ref()),
6538 graph_db_scope_arg(scope)
6539 ));
6540 commands
6541}
6542
6543pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
6544 match recovery {
6545 index::ReadOnlyRecovery::SnapshotFallback => {
6546 "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
6547 }
6548 index::ReadOnlyRecovery::SnapshotFallbackWal => {
6549 "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
6550 }
6551 }
6552}
6553
6554fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
6555 let mut stmt = conn.prepare(sql)?;
6556 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6557 let mut values = BTreeSet::new();
6558 for row in rows {
6559 values.insert(row?);
6560 }
6561 Ok(values)
6562}
6563
6564fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
6565 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
6566 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
6567 let mut columns = BTreeSet::new();
6568 for row in rows {
6569 columns.insert(row?);
6570 }
6571 Ok(columns)
6572}
6573
6574fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6575 let mut diagnostics = Vec::new();
6576 let user_version: i64 =
6577 conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
6578 if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
6579 diagnostics.push(format!(
6580 "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6581 ));
6582 } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
6583 diagnostics.push(format!(
6584 "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6585 ));
6586 }
6587
6588 let tables = sqlite_string_set(
6589 conn,
6590 "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
6591 )?;
6592 let required_tables = [
6593 (
6594 "graph_nodes",
6595 vec![
6596 "id",
6597 "kind",
6598 "label",
6599 "properties_json",
6600 "provenance_json",
6601 "freshness_json",
6602 "row_hash",
6603 "source_watermark",
6604 ],
6605 ),
6606 (
6607 "graph_edges",
6608 vec![
6609 "edge_key",
6610 "from_id",
6611 "to_id",
6612 "kind",
6613 "properties_json",
6614 "provenance_json",
6615 "freshness_json",
6616 "row_hash",
6617 "source_watermark",
6618 ],
6619 ),
6620 (
6621 "graph_projection_versions",
6622 vec![
6623 "scope",
6624 "projection_version",
6625 "content_hash",
6626 "source_watermark",
6627 "observed_at_unix",
6628 ],
6629 ),
6630 (
6631 "graph_tombstones",
6632 vec!["row_key", "row_kind", "deleted_at_unix"],
6633 ),
6634 ("graph_node_properties", vec!["node_id", "key", "value"]),
6635 ("graph_edge_properties", vec!["edge_key", "key", "value"]),
6636 ];
6637 for (table, required_columns) in required_tables {
6638 if !tables.contains(table) {
6639 diagnostics.push(format!("graph.db schema drift: missing table {table}"));
6640 continue;
6641 }
6642 let columns = sqlite_column_names(conn, table)?;
6643 for column in required_columns {
6644 if !columns.contains(column) {
6645 diagnostics.push(format!(
6646 "graph.db schema drift: missing column {table}.{column}"
6647 ));
6648 }
6649 }
6650 }
6651
6652 let indexes = sqlite_string_set(
6653 conn,
6654 "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
6655 )?;
6656 for index in [
6657 "idx_graph_nodes_kind",
6658 "idx_graph_edges_from_kind",
6659 "idx_graph_edges_to_kind",
6660 "idx_graph_edges_edge_key",
6661 "idx_graph_node_properties_key_value_node",
6662 "idx_graph_edge_properties_key_value_edge",
6663 ] {
6664 if !indexes.contains(index) {
6665 diagnostics.push(format!("graph.db schema drift: missing index {index}"));
6666 }
6667 }
6668
6669 if tables.contains("graph_edges") {
6670 let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
6671 let rows = stmt.query_map([], |row| {
6672 Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
6673 })?;
6674 let mut fks = BTreeSet::new();
6675 for row in rows {
6676 fks.insert(row?);
6677 }
6678 for expected in [
6679 ("from_id".to_string(), "id".to_string()),
6680 ("to_id".to_string(), "id".to_string()),
6681 ] {
6682 if !fks.contains(&expected) {
6683 diagnostics.push(format!(
6684 "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
6685 expected.0, expected.1
6686 ));
6687 }
6688 }
6689 }
6690
6691 Ok(diagnostics)
6692}
6693
6694fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
6695 let mut stmt = conn.prepare(sql)?;
6696 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6697 let mut diagnostics = Vec::new();
6698 for row in rows {
6699 diagnostics.push(row?);
6700 }
6701 Ok(diagnostics)
6702}
6703
6704fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6705 let mut diagnostics = sqlite_query_diagnostics(
6706 conn,
6707 r#"
6708 SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
6709 FROM graph_nodes
6710 GROUP BY id
6711 HAVING COUNT(*) > 1
6712 ORDER BY id
6713 "#,
6714 )?;
6715 diagnostics.extend(sqlite_query_diagnostics(
6716 conn,
6717 r#"
6718 SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
6719 FROM graph_edges
6720 GROUP BY from_id, to_id, kind
6721 HAVING COUNT(*) > 1
6722 ORDER BY from_id, kind, to_id
6723 "#,
6724 )?);
6725 diagnostics.extend(sqlite_query_diagnostics(
6726 conn,
6727 r#"
6728 SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
6729 FROM graph_edges
6730 GROUP BY edge_key
6731 HAVING COUNT(*) > 1
6732 ORDER BY edge_key
6733 "#,
6734 )?);
6735 Ok(diagnostics)
6736}
6737
6738fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6739 sqlite_query_diagnostics(
6740 conn,
6741 r#"
6742 SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6743 FROM graph_edges e
6744 LEFT JOIN graph_nodes n ON n.id = e.from_id
6745 WHERE n.id IS NULL
6746 UNION ALL
6747 SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6748 FROM graph_edges e
6749 LEFT JOIN graph_nodes n ON n.id = e.to_id
6750 WHERE n.id IS NULL
6751 ORDER BY 1
6752 "#,
6753 )
6754}
6755
6756fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6757 let mut diagnostics = Vec::new();
6758 let mut node_stmt = conn.prepare(
6759 "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6760 )?;
6761 let node_rows = node_stmt.query_map([], |row| {
6762 Ok((
6763 row.get::<_, String>(0)?,
6764 row.get::<_, String>(1)?,
6765 row.get::<_, String>(2)?,
6766 row.get::<_, Option<String>>(3)?,
6767 ))
6768 })?;
6769 for row in node_rows {
6770 let (id, properties_json, provenance_json, freshness_json) = row?;
6771 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6772 diagnostics.push(format!(
6773 "graph_nodes {id} properties_json is invalid: {err}"
6774 ));
6775 }
6776 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6777 diagnostics.push(format!(
6778 "graph_nodes {id} provenance_json is invalid: {err}"
6779 ));
6780 }
6781 if let Some(freshness_json) = freshness_json
6782 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6783 {
6784 diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
6785 }
6786 }
6787
6788 let mut edge_stmt = conn.prepare(
6789 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6790 )?;
6791 let edge_rows = edge_stmt.query_map([], |row| {
6792 Ok((
6793 row.get::<_, String>(0)?,
6794 row.get::<_, String>(1)?,
6795 row.get::<_, String>(2)?,
6796 row.get::<_, String>(3)?,
6797 row.get::<_, String>(4)?,
6798 row.get::<_, String>(5)?,
6799 row.get::<_, Option<String>>(6)?,
6800 ))
6801 })?;
6802 for row in edge_rows {
6803 let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
6804 row?;
6805 let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
6806 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6807 diagnostics.push(format!(
6808 "graph_edges {edge} properties_json is invalid: {err}"
6809 ));
6810 }
6811 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6812 diagnostics.push(format!(
6813 "graph_edges {edge} provenance_json is invalid: {err}"
6814 ));
6815 }
6816 if let Some(freshness_json) = freshness_json
6817 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6818 {
6819 diagnostics.push(format!(
6820 "graph_edges {edge} freshness_json is invalid: {err}"
6821 ));
6822 }
6823 }
6824 Ok(diagnostics)
6825}
6826
6827fn sqlite_graph_projection_metadata_diagnostics(
6828 conn: &Connection,
6829 scope: Option<&str>,
6830) -> Result<Vec<String>> {
6831 let mut diagnostics = Vec::new();
6832 let scope_key = scope.unwrap_or("root");
6833 let version = conn
6834 .query_row(
6835 r#"
6836 SELECT projection_version, content_hash, source_watermark
6837 FROM graph_projection_versions
6838 WHERE scope = ?1
6839 "#,
6840 [scope_key],
6841 |row| {
6842 Ok((
6843 row.get::<_, String>(0)?,
6844 row.get::<_, Option<String>>(1)?,
6845 row.get::<_, Option<String>>(2)?,
6846 ))
6847 },
6848 )
6849 .optional()?;
6850 let Some((projection_version, content_hash, _source_watermark)) = version else {
6851 diagnostics.push(format!(
6852 "graph projection metadata is missing for scope {scope_key}"
6853 ));
6854 return Ok(diagnostics);
6855 };
6856 if projection_version != GRAPH_PROJECTION_VERSION {
6857 diagnostics.push(format!(
6858 "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
6859 ));
6860 }
6861 if content_hash.is_none() {
6862 diagnostics.push("projection content hash is missing".to_string());
6863 }
6864
6865 let meta_id = graph_projection_meta_id(scope);
6866 let meta_properties = conn
6867 .query_row(
6868 "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
6869 (&meta_id, GRAPH_PROJECTION_META_KIND),
6870 |row| row.get::<_, String>(0),
6871 )
6872 .optional()?;
6873 let Some(meta_properties) = meta_properties else {
6874 diagnostics.push(format!("projection_meta node {meta_id} is missing"));
6875 return Ok(diagnostics);
6876 };
6877 let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
6878 .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
6879 if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
6880 diagnostics.push(format!(
6881 "projection_meta node {meta_id} has stale projection_version"
6882 ));
6883 }
6884 if properties.get("content_hash") != content_hash.as_ref() {
6885 diagnostics.push(format!(
6886 "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
6887 ));
6888 }
6889 Ok(diagnostics)
6890}
6891
6892pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
6893 let mut node_stmt = conn.prepare(
6894 "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6895 )?;
6896 let node_rows = node_stmt.query_map([], |row| {
6897 let properties_json: String = row.get(3)?;
6898 let provenance_json: String = row.get(4)?;
6899 let freshness_json: Option<String> = row.get(5)?;
6900 Ok((
6901 row.get::<_, String>(0)?,
6902 row.get::<_, String>(1)?,
6903 row.get::<_, String>(2)?,
6904 properties_json,
6905 provenance_json,
6906 freshness_json,
6907 ))
6908 })?;
6909 let mut nodes = Vec::new();
6910 for row in node_rows {
6911 let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
6912 nodes.push(ConvexNodeRow {
6913 external_id,
6914 kind,
6915 label,
6916 properties: serde_json::from_str(&properties_json)?,
6917 provenance: serde_json::from_str(&provenance_json)?,
6918 freshness: freshness_json
6919 .map(|value| serde_json::from_str(&value))
6920 .transpose()?,
6921 });
6922 }
6923
6924 let mut edge_stmt = conn.prepare(
6925 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6926 )?;
6927 let edge_rows = edge_stmt.query_map([], |row| {
6928 let properties_json: String = row.get(4)?;
6929 let provenance_json: String = row.get(5)?;
6930 let freshness_json: Option<String> = row.get(6)?;
6931 Ok((
6932 row.get::<_, String>(0)?,
6933 row.get::<_, String>(1)?,
6934 row.get::<_, String>(2)?,
6935 row.get::<_, String>(3)?,
6936 properties_json,
6937 provenance_json,
6938 freshness_json,
6939 ))
6940 })?;
6941 let mut edges = Vec::new();
6942 for row in edge_rows {
6943 let (
6944 edge_key,
6945 from_external_id,
6946 to_external_id,
6947 kind,
6948 properties_json,
6949 provenance_json,
6950 freshness_json,
6951 ) = row?;
6952 edges.push(ConvexEdgeRow {
6953 edge_key,
6954 from_external_id,
6955 to_external_id,
6956 kind,
6957 properties: serde_json::from_str(&properties_json)?,
6958 provenance: serde_json::from_str(&provenance_json)?,
6959 freshness: freshness_json
6960 .map(|value| serde_json::from_str(&value))
6961 .transpose()?,
6962 });
6963 }
6964 Ok(ConvexProjectionRows { nodes, edges })
6965}
6966
6967fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
6968 format!("{}.{}({})", index.table, index.name, index.fields.join(","))
6969}
6970
6971fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
6972 value
6973 .get("indexes")
6974 .or_else(|| value.get("requiredIndexes"))
6975 .or_else(|| {
6976 value
6977 .get("metadata")
6978 .and_then(|metadata| metadata.get("indexes"))
6979 })
6980}
6981
6982fn convex_snapshot_declared_indexes(
6983 value: &serde_json::Value,
6984) -> Result<Option<Vec<ConvexRequiredIndex>>> {
6985 convex_snapshot_index_value(value)
6986 .map(|indexes| {
6987 serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
6988 .context("parsing Convex snapshot index metadata")
6989 })
6990 .transpose()
6991}
6992
6993fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
6994 let required = convex_required_indexes();
6995 let Some(declared) = convex_snapshot_declared_indexes(value)? else {
6996 return Ok(vec![format!(
6997 "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
6998 required
6999 .iter()
7000 .map(convex_required_index_label)
7001 .collect::<Vec<_>>()
7002 .join(", ")
7003 )]);
7004 };
7005 let declared = declared.into_iter().collect::<BTreeSet<_>>();
7006 let missing = required
7007 .iter()
7008 .filter(|index| !declared.contains(*index))
7009 .map(convex_required_index_label)
7010 .collect::<Vec<_>>();
7011 if missing.is_empty() {
7012 Ok(Vec::new())
7013 } else {
7014 Ok(vec![format!(
7015 "Convex snapshot is missing required index metadata: {}",
7016 missing.join(", ")
7017 )])
7018 }
7019}
7020
7021pub(crate) fn load_convex_projection_snapshot_value(
7022 snapshot_path: &Path,
7023) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7024 let content = fs::read_to_string(snapshot_path).with_context(|| {
7025 format!(
7026 "reading Convex projection snapshot {}",
7027 snapshot_path.display()
7028 )
7029 })?;
7030 let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7031 format!(
7032 "parsing Convex projection snapshot {}",
7033 snapshot_path.display()
7034 )
7035 })?;
7036 let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7037 .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7038 Ok((rows, value))
7039}
7040
7041pub(crate) fn append_sqlite_graph_doctor_checks(
7042 report: &mut GraphDbDoctorReport,
7043 root: &Path,
7044 scope: Option<&str>,
7045 graph_db: &Path,
7046) -> Option<substrate::SqliteReadOnlyConnection> {
7047 let rebuild = graph_db_rebuild_command(root, scope);
7048 let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7049 if !graph_db.exists() {
7050 report.push_check(graph_db_doctor_check(
7051 "sqlite_graph_db_exists",
7052 vec![format!("graph.db is missing at {}", graph_db.display())],
7053 vec![rebuild],
7054 ));
7055 return None;
7056 }
7057 report.push_check(graph_db_doctor_check(
7058 "sqlite_graph_db_exists",
7059 Vec::new(),
7060 vec![rebuild.clone()],
7061 ));
7062
7063 let conn = match open_sqlite_graph_db_readonly(graph_db) {
7064 Ok(conn) => conn,
7065 Err(err) => {
7066 report.push_check(graph_db_doctor_check(
7067 "sqlite_graph_db_open",
7068 vec![err.to_string()],
7069 vec![backup_rebuild],
7070 ));
7071 return None;
7072 }
7073 };
7074 report.push_check(graph_db_doctor_check(
7075 "sqlite_graph_db_open",
7076 Vec::new(),
7077 vec![rebuild.clone()],
7078 ));
7079 if let Some(recovery) = conn.recovery() {
7080 report.push_check(GraphDbDoctorCheck {
7081 name: "sqlite_graph_db_read_recovery".to_string(),
7082 status: "recovered".to_string(),
7083 fail_closed: false,
7084 diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7085 repair_commands: Vec::new(),
7086 });
7087 }
7088
7089 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7090 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7091 report.push_check(graph_db_doctor_check(
7092 "sqlite_schema",
7093 schema_diagnostics,
7094 vec![backup_rebuild.clone()],
7095 ));
7096
7097 let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7098 .unwrap_or_else(|err| {
7099 vec![format!(
7100 "graph projection metadata inspection failed: {err}"
7101 )]
7102 });
7103 report.push_check(graph_db_doctor_check(
7104 "sqlite_projection_metadata",
7105 metadata_diagnostics,
7106 vec![rebuild.clone()],
7107 ));
7108
7109 let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7110 .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7111 report.push_check(graph_db_doctor_check(
7112 "sqlite_duplicate_ids",
7113 duplicate_diagnostics,
7114 vec![backup_rebuild.clone()],
7115 ));
7116
7117 let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7118 .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7119 report.push_check(graph_db_doctor_check(
7120 "sqlite_orphan_edges",
7121 orphan_diagnostics,
7122 vec![rebuild.clone()],
7123 ));
7124
7125 let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7126 .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7127 report.push_check(graph_db_doctor_check(
7128 "sqlite_row_json",
7129 json_diagnostics,
7130 vec![backup_rebuild],
7131 ));
7132
7133 let tombstone_diagnostics =
7134 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7135 .unwrap_or_else(|err| {
7136 vec![format!(
7137 "graph tombstone retention inspection failed: {err}"
7138 )]
7139 });
7140 report.push_check(GraphDbDoctorCheck {
7141 name: "sqlite_tombstone_retention".to_string(),
7142 status: if tombstone_diagnostics.is_empty() {
7143 "ok".to_string()
7144 } else {
7145 "warning".to_string()
7146 },
7147 fail_closed: false,
7148 diagnostics: tombstone_diagnostics,
7149 repair_commands: Vec::new(),
7150 });
7151 let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7152 Ok(counts) => {
7153 let policy = graph_db_compaction_policy(root, scope, &counts, false);
7154 GraphDbDoctorCheck {
7155 name: "sqlite_compaction_policy".to_string(),
7156 status: policy.status.clone(),
7157 fail_closed: false,
7158 diagnostics: policy.proof,
7159 repair_commands: if policy.status == "recommended" {
7160 policy.recommendations
7161 } else {
7162 Vec::new()
7163 },
7164 }
7165 }
7166 Err(err) => GraphDbDoctorCheck {
7167 name: "sqlite_compaction_policy".to_string(),
7168 status: "warning".to_string(),
7169 fail_closed: false,
7170 diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7171 repair_commands: Vec::new(),
7172 },
7173 };
7174 report.push_check(compaction_check);
7175
7176 Some(conn)
7177}
7178
7179pub(crate) fn append_convex_snapshot_doctor_checks(
7180 report: &mut GraphDbDoctorReport,
7181 root: &Path,
7182 scope: Option<&str>,
7183 local_rows: Option<&ConvexProjectionRows>,
7184 snapshot_path: Option<&Path>,
7185) {
7186 let repair = convex_refresh_command(root, scope);
7187 let Some(snapshot_path) = snapshot_path else {
7188 report.push_check(graph_db_doctor_check(
7189 "convex_snapshot_present",
7190 vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7191 vec![format!(
7192 "tsift convex-sync {}{} --json > convex-rows.json",
7193 shell_quote(root.to_string_lossy().as_ref()),
7194 graph_db_scope_arg(scope)
7195 )],
7196 ));
7197 return;
7198 };
7199 report.push_check(graph_db_doctor_check(
7200 "convex_snapshot_present",
7201 Vec::new(),
7202 vec![repair.clone()],
7203 ));
7204
7205 let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7206 Ok(snapshot) => snapshot,
7207 Err(err) => {
7208 report.push_check(graph_db_doctor_check(
7209 "convex_snapshot_parse",
7210 vec![err.to_string()],
7211 vec![repair],
7212 ));
7213 return;
7214 }
7215 };
7216 report.push_check(graph_db_doctor_check(
7217 "convex_snapshot_parse",
7218 Vec::new(),
7219 vec![repair.clone()],
7220 ));
7221
7222 let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7223 report.push_check(graph_db_doctor_check(
7224 "convex_snapshot_rows",
7225 row_diagnostics,
7226 vec![repair.clone()],
7227 ));
7228
7229 let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7230 .unwrap_or_else(|err| vec![err.to_string()]);
7231 report.required_indexes = convex_required_indexes();
7232 report.push_check(graph_db_doctor_check(
7233 "convex_required_indexes",
7234 index_diagnostics,
7235 vec![
7236 "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7237 .to_string(),
7238 ],
7239 ));
7240
7241 if let Some(local_rows) = local_rows {
7242 let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7243 report.push_check(graph_db_doctor_check(
7244 "convex_projection_freshness",
7245 freshness.diagnostics,
7246 vec![repair],
7247 ));
7248 } else {
7249 report.push_check(graph_db_doctor_check(
7250 "convex_projection_freshness",
7251 vec![
7252 "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7253 .to_string(),
7254 ],
7255 vec![graph_db_rebuild_command(root, scope)],
7256 ));
7257 }
7258}
7259
7260fn graph_db_convex_snapshot_doctor_command(
7261 root: &Path,
7262 scope: Option<&str>,
7263 snapshot_path: &Path,
7264) -> String {
7265 format!(
7266 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7267 shell_quote(root.to_string_lossy().as_ref()),
7268 graph_db_scope_arg(scope),
7269 shell_quote(snapshot_path.to_string_lossy().as_ref())
7270 )
7271}
7272
7273fn graph_db_convex_snapshot_read_command(
7274 root: &Path,
7275 scope: Option<&str>,
7276 snapshot_path: &Path,
7277) -> String {
7278 format!(
7279 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7280 shell_quote(root.to_string_lossy().as_ref()),
7281 graph_db_scope_arg(scope),
7282 shell_quote(snapshot_path.to_string_lossy().as_ref())
7283 )
7284}
7285
7286fn convex_sync_snapshot_diff_command(
7287 root: &Path,
7288 scope: Option<&str>,
7289 snapshot_path: &Path,
7290) -> String {
7291 format!(
7292 "tsift convex-sync {}{} --snapshot {} --json",
7293 shell_quote(root.to_string_lossy().as_ref()),
7294 graph_db_scope_arg(scope),
7295 shell_quote(snapshot_path.to_string_lossy().as_ref())
7296 )
7297}
7298
7299pub(crate) struct GraphDbDriftInput<'a> {
7300 root: &'a Path,
7301 scope: Option<&'a str>,
7302 graph_db: &'a Path,
7303 snapshot_path: &'a Path,
7304 local: &'a ConvexProjectionRows,
7305 snapshot: &'a ConvexProjectionRows,
7306 snapshot_value: &'a serde_json::Value,
7307 warnings: Vec<String>,
7308}
7309
7310pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7311 let GraphDbDriftInput {
7312 root,
7313 scope,
7314 graph_db,
7315 snapshot_path,
7316 local,
7317 snapshot,
7318 snapshot_value,
7319 warnings,
7320 } = input;
7321 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7322 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7323 convex_rows_diff(local, Some(snapshot));
7324 let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7325 let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7326 .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7327 let local_hash = freshness.local_hash.clone();
7328 let snapshot_hash = freshness.snapshot_hash.clone();
7329 let stale_nodes = freshness.stale_nodes.clone();
7330 let stale_edges = freshness.stale_edges.clone();
7331
7332 let duplicate_failures = row_diagnostics
7333 .iter()
7334 .filter(|diagnostic| diagnostic.contains("duplicate"))
7335 .count();
7336 let orphan_failures = row_diagnostics
7337 .iter()
7338 .filter(|diagnostic| diagnostic.contains("references missing"))
7339 .count();
7340 let missing_required_indexes = index_diagnostics.len();
7341 let stale_projection_metadata =
7342 usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7343 let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7344 let has_drift = freshness.fail_closed
7345 || !node_upserts.is_empty()
7346 || !edge_upserts.is_empty()
7347 || !node_tombstones.is_empty()
7348 || !edge_tombstones.is_empty();
7349 let status = if hard_failures > 0 {
7350 "fail_closed"
7351 } else if has_drift {
7352 "drift"
7353 } else {
7354 "current"
7355 }
7356 .to_string();
7357
7358 let mut diagnostics = Vec::new();
7359 diagnostics.extend(row_diagnostics);
7360 diagnostics.extend(index_diagnostics);
7361 diagnostics.extend(freshness.diagnostics.clone());
7362 if has_drift {
7363 diagnostics.push(format!(
7364 "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7365 node_upserts.len(),
7366 edge_upserts.len(),
7367 node_tombstones.len(),
7368 edge_tombstones.len()
7369 ));
7370 }
7371
7372 let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7373 root,
7374 scope,
7375 snapshot_path,
7376 )];
7377 if status == "current" {
7378 next_commands.push(graph_db_convex_snapshot_read_command(
7379 root,
7380 scope,
7381 snapshot_path,
7382 ));
7383 } else {
7384 next_commands.push(convex_sync_snapshot_diff_command(
7385 root,
7386 scope,
7387 snapshot_path,
7388 ));
7389 next_commands.push(convex_refresh_command(root, scope));
7390 }
7391
7392 GraphDbDriftReport {
7393 root: root.to_string_lossy().to_string(),
7394 scope: scope.map(str::to_string),
7395 graph_db: graph_db.to_string_lossy().to_string(),
7396 convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7397 status: status.clone(),
7398 graph_reads_allowed: status == "current",
7399 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7400 local_hash,
7401 snapshot_hash,
7402 summary: GraphDbDriftSummary {
7403 node_upserts: node_upserts.len(),
7404 edge_upserts: edge_upserts.len(),
7405 node_tombstones: node_tombstones.len(),
7406 edge_tombstones: edge_tombstones.len(),
7407 stale_nodes: stale_nodes.len(),
7408 stale_edges: stale_edges.len(),
7409 stale_projection_metadata,
7410 duplicate_failures,
7411 orphan_failures,
7412 missing_required_indexes,
7413 },
7414 node_upserts: node_upserts
7415 .into_iter()
7416 .map(|row| row.external_id)
7417 .collect(),
7418 edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
7419 node_tombstones,
7420 edge_tombstones,
7421 stale_nodes,
7422 stale_edges,
7423 diagnostics,
7424 next_commands,
7425 required_indexes: convex_required_indexes(),
7426 warnings,
7427 }
7428}
7429
7430pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
7431 println!(
7432 "graph-db drift status: {} reads_allowed: {}",
7433 report.status, report.graph_reads_allowed
7434 );
7435 println!("graph_db: {}", report.graph_db);
7436 println!("convex_snapshot: {}", report.convex_snapshot);
7437 println!(
7438 "upserts: {} node(s), {} edge(s)",
7439 report.summary.node_upserts, report.summary.edge_upserts
7440 );
7441 println!(
7442 "tombstones: {} node(s), {} edge(s)",
7443 report.summary.node_tombstones, report.summary.edge_tombstones
7444 );
7445 for diagnostic in &report.diagnostics {
7446 println!("diagnostic: {diagnostic}");
7447 }
7448 for command in &report.next_commands {
7449 println!("next: {command}");
7450 }
7451}
7452
7453pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
7454 println!(
7455 "graph-db doctor backend: {} status: {}",
7456 report.backend, report.status
7457 );
7458 println!("graph_db: {}", report.graph_db);
7459 if let Some(snapshot) = &report.convex_snapshot {
7460 println!("convex_snapshot: {snapshot}");
7461 }
7462 for check in &report.checks {
7463 println!("check: {} {}", check.name, check.status);
7464 for diagnostic in &check.diagnostics {
7465 println!(" diagnostic: {diagnostic}");
7466 }
7467 }
7468 for command in &report.repair_commands {
7469 println!("repair: {command}");
7470 }
7471}
7472
7473pub(crate) fn graph_db_operator_report_from_disk(
7474 root: &Path,
7475 scope: Option<&str>,
7476 graph_db: &Path,
7477 operation: &str,
7478 refresh: Option<GraphDbRefreshSummary>,
7479 warnings: Vec<String>,
7480) -> Result<GraphDbOperatorReport> {
7481 if !graph_db.exists() {
7482 let next_commands = graph_db_operator_next_commands(root, scope, true);
7483 let counts = GraphDbOperatorCounts {
7484 nodes: 0,
7485 edges: 0,
7486 tombstones: GraphDbTombstoneCounts {
7487 nodes: 0,
7488 edges: 0,
7489 total: 0,
7490 },
7491 file_size_bytes: None,
7492 freelist_bytes: None,
7493 };
7494 return Ok(GraphDbOperatorReport {
7495 root: root.to_string_lossy().to_string(),
7496 scope: scope.map(str::to_string),
7497 graph_db: graph_db.to_string_lossy().to_string(),
7498 operation: operation.to_string(),
7499 status: "missing".to_string(),
7500 materialized: false,
7501 freshness: GraphDbFreshnessReport {
7502 status: "missing".to_string(),
7503 fail_closed: true,
7504 projection_version: None,
7505 content_hash: None,
7506 source_watermark: None,
7507 diagnostics: vec![
7508 "graph.db is missing; run graph-db refresh before trusting graph reads"
7509 .to_string(),
7510 ],
7511 },
7512 readiness: graph_effectiveness_blocked(
7513 "graph_db_missing",
7514 vec![
7515 "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
7516 ],
7517 next_commands.clone(),
7518 ),
7519 counts: counts.clone(),
7520 refresh,
7521 compaction: graph_db_compaction_policy(root, scope, &counts, false),
7522 recovery: None,
7523 next_commands,
7524 warnings,
7525 });
7526 }
7527
7528 let conn = open_sqlite_graph_db_readonly(graph_db)?;
7529 let recovery = conn.recovery();
7530 let mut warnings = warnings;
7531 if let Some(recovery) = recovery {
7532 warnings.push(graph_db_read_recovery_diagnostic(recovery));
7533 }
7534 let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
7535 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7536 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7537 if !schema_diagnostics.is_empty() {
7538 freshness.diagnostics.extend(schema_diagnostics);
7539 freshness.fail_closed = true;
7540 freshness.status = "stale".to_string();
7541 }
7542 let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
7543 let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
7544 warnings.extend(
7545 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7546 .unwrap_or_else(|err| {
7547 vec![format!(
7548 "graph tombstone retention inspection failed: {err}"
7549 )]
7550 }),
7551 );
7552 let status = if freshness.fail_closed {
7553 "stale"
7554 } else {
7555 "current"
7556 }
7557 .to_string();
7558
7559 Ok(GraphDbOperatorReport {
7560 root: root.to_string_lossy().to_string(),
7561 scope: scope.map(str::to_string),
7562 graph_db: graph_db.to_string_lossy().to_string(),
7563 operation: operation.to_string(),
7564 status,
7565 materialized: true,
7566 freshness,
7567 readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
7568 compaction: graph_db_compaction_policy(root, scope, &counts, false),
7569 counts,
7570 refresh,
7571 recovery,
7572 next_commands: graph_db_operator_next_commands(root, scope, false),
7573 warnings,
7574 })
7575}
7576
7577fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
7578 println!(
7579 "graph-db {} status: {} materialized: {}",
7580 report.operation, report.status, report.materialized
7581 );
7582 println!("graph_db: {}", report.graph_db);
7583 println!(
7584 "projection: version={} hash={} watermark={}",
7585 report
7586 .freshness
7587 .projection_version
7588 .as_deref()
7589 .unwrap_or("<missing>"),
7590 report
7591 .freshness
7592 .content_hash
7593 .as_deref()
7594 .unwrap_or("<missing>"),
7595 report
7596 .freshness
7597 .source_watermark
7598 .as_deref()
7599 .unwrap_or("<missing>")
7600 );
7601 println!(
7602 "rows: {} node(s), {} edge(s), {} tombstone(s)",
7603 report.counts.nodes, report.counts.edges, report.counts.tombstones.total
7604 );
7605 println!(
7606 "readiness: {} reason: {} fail_closed: {}",
7607 report.readiness.status, report.readiness.reason, report.readiness.fail_closed
7608 );
7609 if let Some(file_size) = report.counts.file_size_bytes {
7610 println!(
7611 "storage: {} byte(s), {} free byte(s)",
7612 file_size,
7613 report.counts.freelist_bytes.unwrap_or(0)
7614 );
7615 }
7616 if let Some(refresh) = &report.refresh {
7617 println!(
7618 "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
7619 refresh.tombstoned_nodes, refresh.tombstoned_edges
7620 );
7621 println!(
7622 "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)",
7623 refresh.upserted_nodes,
7624 refresh.upserted_edges,
7625 refresh.upserted_properties,
7626 refresh.unchanged_nodes,
7627 refresh.unchanged_edges,
7628 refresh.unchanged_properties,
7629 refresh.deleted_properties,
7630 refresh.pruned_tombstones
7631 );
7632 }
7633 println!(
7634 "compaction: {} tombstone_scan_rows={} live_rows={}",
7635 report.compaction.status,
7636 report.compaction.tombstone_scan_rows,
7637 report.compaction.live_rows
7638 );
7639 for proof in &report.compaction.proof {
7640 println!("compaction proof: {proof}");
7641 }
7642 if let Some(recovery) = report.recovery {
7643 println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
7644 }
7645 for diagnostic in &report.freshness.diagnostics {
7646 println!("diagnostic: {diagnostic}");
7647 }
7648 for diagnostic in &report.readiness.diagnostics {
7649 println!("readiness diagnostic: {diagnostic}");
7650 }
7651 for warning in &report.warnings {
7652 println!("warning: {warning}");
7653 }
7654 for command in &report.readiness.next_commands {
7655 println!("readiness next: {command}");
7656 }
7657 for command in &report.next_commands {
7658 println!("next: {command}");
7659 }
7660}
7661
7662pub(crate) fn print_graph_db_operator_report(
7663 report: &GraphDbOperatorReport,
7664 format: OutputFormat,
7665) -> Result<()> {
7666 if format.json_output {
7667 print_json_or_envelope(
7668 report,
7669 &format,
7670 "graph-db",
7671 &report.operation,
7672 ToolEnvelopeSummary {
7673 text: format!(
7674 "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
7675 report.operation,
7676 report.status,
7677 report.counts.nodes,
7678 report.counts.edges,
7679 report.counts.tombstones.total
7680 ),
7681 metrics: vec![
7682 envelope_metric("operation", &report.operation),
7683 envelope_metric("status", &report.status),
7684 envelope_metric("nodes", report.counts.nodes),
7685 envelope_metric("edges", report.counts.edges),
7686 envelope_metric("tombstones", report.counts.tombstones.total),
7687 envelope_metric("compaction", &report.compaction.status),
7688 envelope_metric("readiness", &report.readiness.status),
7689 ],
7690 },
7691 false,
7692 report.next_commands.clone(),
7693 )
7694 } else {
7695 print_graph_db_operator_human(report);
7696 Ok(())
7697 }
7698}
7699
7700fn status_run_command_without_notes(run: &str) -> &str {
7701 run.split_once(" (")
7702 .map(|(command, _)| command)
7703 .unwrap_or(run)
7704}
7705
7706fn status_summarize_extract_command(run: &str) -> &str {
7707 let run = status_run_command_without_notes(run);
7708 run.split(" && ")
7709 .find(|command| command.contains("summarize --extract"))
7710 .unwrap_or(run)
7711}
7712
7713fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
7714 report
7715 .recommendations
7716 .run
7717 .as_deref()
7718 .filter(|command| command.contains("summarize --extract"))
7719 .map(status_summarize_extract_command)
7720 .unwrap_or("tsift summarize --extract .")
7721 .to_string()
7722}
7723
7724fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
7725 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7726 readiness.diagnostics.push(format!(
7727 "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
7728 ));
7729 readiness
7730}
7731
7732fn graph_db_semantic_readiness(
7733 root: &Path,
7734 scope: Option<&str>,
7735 semantic_row_count: Option<usize>,
7736) -> GraphEffectivenessReadiness {
7737 if let Some(row_count) = semantic_row_count
7738 && row_count > 0
7739 {
7740 return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
7741 }
7742
7743 let report = match status::check_status(root) {
7744 Ok(report) => report,
7745 Err(err) => {
7746 return graph_effectiveness_blocked(
7747 "status_check_unavailable",
7748 vec![format!(
7749 "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
7750 )],
7751 vec![graph_db_refresh_command(root, scope)],
7752 );
7753 }
7754 };
7755
7756 match &report.summaries {
7757 status::SummaryStatus::Available {
7758 cached_files,
7759 total_indexed_files,
7760 coverage_pct,
7761 ..
7762 } => {
7763 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7764 readiness.diagnostics.push(format!(
7765 "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
7766 ));
7767 readiness
7768 }
7769 status::SummaryStatus::None { .. } => {
7770 let summarize = graph_db_status_summarize_command(&report);
7771 let index_command = report
7772 .recommendations
7773 .run
7774 .as_deref()
7775 .filter(|cmd| cmd.contains("index"))
7776 .map(str::to_string);
7777 let mut repair = Vec::new();
7778 if let Some(cmd) = index_command {
7779 repair.push(cmd);
7780 }
7781 repair.push(summarize.clone());
7782 repair.push(graph_db_refresh_command(root, scope));
7783 graph_effectiveness_blocked(
7784 "summary_cache_empty",
7785 vec![format!(
7786 "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7787 summarize,
7788 root.display(),
7789 graph_db_refresh_command(root, scope)
7790 )],
7791 repair,
7792 )
7793 }
7794 status::SummaryStatus::Unavailable => {
7795 let mut repair: Vec<String> = report.recommendations.run.clone().into_iter().collect();
7796 let summarize = "tsift summarize --extract .".to_string();
7797 repair.push(summarize);
7798 repair.push(graph_db_refresh_command(root, scope));
7799 graph_effectiveness_blocked(
7800 "summary_cache_unavailable",
7801 vec![
7802 "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(),
7803 ],
7804 repair,
7805 )
7806 }
7807 }
7808}
7809
7810pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
7811 let report = match status::check_status(root) {
7812 Ok(report) => report,
7813 Err(err) => {
7814 return vec![format!(
7815 "status check unavailable after graph-db refresh: {err:#}"
7816 )];
7817 }
7818 };
7819
7820 let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7821 Some(graph_db_status_summarize_command(&report))
7822 } else {
7823 None
7824 };
7825 let mut warnings = report.reminders;
7826 if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7827 let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
7828 warnings.push(format!(
7829 "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7830 run,
7831 root.display(),
7832 graph_db_refresh_command(root, scope)
7833 ));
7834 }
7835 dedupe_preserve_order(warnings)
7836}
7837
7838pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
7839 println!(
7840 "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
7841 report.applied, report.pruned_tombstones, report.reclaimed_bytes
7842 );
7843 println!("graph_db: {}", report.graph_db);
7844 println!(
7845 "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7846 report.counts_before.nodes,
7847 report.counts_before.edges,
7848 report.counts_before.tombstones.total,
7849 report.counts_before.file_size_bytes.unwrap_or(0),
7850 report.counts_before.freelist_bytes.unwrap_or(0)
7851 );
7852 println!(
7853 "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7854 report.counts_after.nodes,
7855 report.counts_after.edges,
7856 report.counts_after.tombstones.total,
7857 report.counts_after.file_size_bytes.unwrap_or(0),
7858 report.counts_after.freelist_bytes.unwrap_or(0)
7859 );
7860 for proof in &report.compaction_after.proof {
7861 println!("proof: {proof}");
7862 }
7863 for warning in &report.warnings {
7864 println!("warning: {warning}");
7865 }
7866 for command in &report.next_commands {
7867 println!("next: {command}");
7868 }
7869}
7870
7871fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
7872 raw.iter()
7873 .map(|value| {
7874 let (key, filter_value) = value
7875 .split_once('=')
7876 .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
7877 let key = key.trim();
7878 let filter_value = filter_value.trim();
7879 if key.is_empty() || filter_value.is_empty() {
7880 bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
7881 }
7882 Ok(GraphDbPropertyFilter {
7883 key: key.to_string(),
7884 value: filter_value.to_string(),
7885 })
7886 })
7887 .collect()
7888}
7889
7890fn graph_db_query_options(
7891 cursor: Option<String>,
7892 limit: Option<usize>,
7893 property_filters: &[String],
7894) -> Result<GraphDbQueryOptions> {
7895 Ok(GraphDbQueryOptions {
7896 cursor,
7897 limit: limit.filter(|limit| *limit > 0),
7898 property_filters: parse_graph_db_property_filters(property_filters)?,
7899 })
7900}
7901
7902fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
7903 GraphQueryOptions {
7904 cursor: options.cursor.clone(),
7905 limit: options.limit,
7906 property_filters: options
7907 .property_filters
7908 .iter()
7909 .map(|filter| GraphPropertyFilter {
7910 key: filter.key.clone(),
7911 value: filter.value.clone(),
7912 })
7913 .collect(),
7914 }
7915}
7916
7917fn graph_db_page_report_from_store(
7918 page: GraphQueryPage,
7919 property_filters: Vec<GraphDbPropertyFilter>,
7920) -> GraphDbPageReport {
7921 GraphDbPageReport {
7922 cursor: page.cursor,
7923 limit: page.limit,
7924 next_cursor: page.next_cursor,
7925 returned_nodes: page.returned_nodes,
7926 returned_edges: page.returned_edges,
7927 truncated: page.truncated,
7928 property_filters,
7929 diagnostics: page.diagnostics,
7930 }
7931}
7932
7933fn graph_db_neighborhood_ranking_gate(
7934 ranked_neighbor_cap: usize,
7935) -> GraphDbNeighborhoodRankingGate {
7936 GraphDbNeighborhoodRankingGate {
7937 status: "held_default_order_unchanged".to_string(),
7938 ranked_output_default: false,
7939 default_order: "stable_node_id".to_string(),
7940 default_change_gate: "community_search_quality_metrics".to_string(),
7941 required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
7942 .iter()
7943 .map(|workload| (*workload).to_string())
7944 .collect(),
7945 required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
7946 .iter()
7947 .map(|metric| (*metric).to_string())
7948 .collect(),
7949 max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
7950 min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
7951 min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
7952 min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
7953 diagnostics: vec![
7954 "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
7955 format!(
7956 "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
7957 ),
7958 "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
7959 ],
7960 }
7961}
7962
7963fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
7964 match limit {
7965 Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
7966 Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
7967 }
7968}
7969
7970fn graph_db_ranked_neighbors(
7971 center_id: &str,
7972 nodes: &[SubstrateGraphNode],
7973 edges: &[SubstrateGraphEdge],
7974 cap: usize,
7975) -> Vec<GraphDbRankedNeighbor> {
7976 resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
7977}
7978
7979fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
7980 center_id: &str,
7981 depth: usize,
7982 edge_kind: Option<&str>,
7983 limit: Option<usize>,
7984 unranked_nodes: &[SubstrateGraphNode],
7985 unranked_edges: &[SubstrateGraphEdge],
7986 store: &S,
7987) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
7988 use std::time::Instant;
7989 let max_nodes = match limit {
7990 Some(0) | None => 200,
7991 Some(n) => n.clamp(10, 500),
7992 };
7993 let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
7994 .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
7995 if let Some(kind) = edge_kind {
7996 options = options.with_edge_kind(kind);
7997 }
7998 let start = Instant::now();
7999 let result = store.ranked_neighborhood(center_id, &options)?;
8000 let latency = start.elapsed().as_micros();
8001 let Some(ranked) = result else {
8002 return Ok(None);
8003 };
8004 let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
8005 let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
8006 let overlap_count = ranked_ids.intersection(&unranked_ids).count();
8007 let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
8008 0.0
8009 } else {
8010 (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8011 };
8012 let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8013 let mut name_count = BTreeMap::<&str, usize>::new();
8014 for n in nodes {
8015 *name_count.entry(&n.label).or_default() += 1;
8016 }
8017 name_count.values().filter(|&&c| c > 1).count()
8018 };
8019 let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8020 if nodes.is_empty() {
8021 return 100.0;
8022 }
8023 let with_handle = nodes
8024 .iter()
8025 .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8026 .count();
8027 (with_handle as f64 / nodes.len() as f64) * 100.0
8028 };
8029 let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8030 if nodes.is_empty() {
8031 return 0.0;
8032 }
8033 let semantic_kinds = [
8034 "semantic_concept",
8035 "semantic_entity",
8036 "symbol",
8037 "file",
8038 "source_handle",
8039 ];
8040 let useful = nodes
8041 .iter()
8042 .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8043 .count();
8044 let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8045 let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8046 (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8047 / nodes.len() as f64
8048 };
8049 let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8050 let edge_pairs: Vec<(String, String)> = ranked
8051 .edges
8052 .iter()
8053 .map(|e| (e.from_id.clone(), e.to_id.clone()))
8054 .collect();
8055 let cr = tsift_graph::detect_communities(&edge_pairs);
8056 let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8057 let mut fully_kept = 0usize;
8058 let mut partially_pruned = 0usize;
8059 let mut fully_pruned = 0usize;
8060 let mut pruned_kinds = BTreeSet::new();
8061 let mut pruned_labels = Vec::new();
8062 for comm in &cr.communities {
8063 let kept_in_comm: Vec<&str> = comm
8064 .members
8065 .iter()
8066 .filter(|m| kept_labels.contains(m.name.as_str()))
8067 .map(|m| m.name.as_str())
8068 .collect();
8069 if kept_in_comm.len() == comm.members.len() {
8070 fully_kept += 1;
8071 } else if kept_in_comm.is_empty() {
8072 fully_pruned += 1;
8073 for m in &comm.members {
8074 if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8075 pruned_kinds.insert(n.kind.clone());
8076 }
8077 pruned_labels.push(m.name.clone());
8078 }
8079 } else {
8080 partially_pruned += 1;
8081 }
8082 }
8083 pruned_labels.truncate(5);
8084 Some(CommunityTruncationSummary {
8085 total_communities: cr.communities.len(),
8086 fully_kept,
8087 partially_pruned,
8088 fully_pruned,
8089 pruned_community_kinds: pruned_kinds.into_iter().collect(),
8090 pruned_community_top_labels: pruned_labels,
8091 })
8092 } else {
8093 None
8094 };
8095 Ok(Some(GraphDbRankedNeighborhoodComparison {
8096 traversal_nodes: ranked.nodes.len(),
8097 traversal_edges: ranked.edges.len(),
8098 pruned_count: ranked.pruned_count,
8099 total_discovered: ranked.total_discovered,
8100 latency_micros: latency,
8101 overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8102 useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8103 / 1000.0,
8104 useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8105 .round()
8106 / 1000.0,
8107 duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8108 duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8109 handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8110 handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8111 / 100.0,
8112 community_truncation_summary,
8113 diagnostics: vec![
8114 format!(
8115 "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8116 ranked.nodes.len(),
8117 ranked.edges.len(),
8118 ranked.pruned_count,
8119 ranked.total_discovered,
8120 latency
8121 ),
8122 format!(
8123 "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8124 overlap_pct,
8125 overlap_count,
8126 unranked_ids.len(),
8127 ranked_ids.len()
8128 ),
8129 "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8130 ],
8131 }))
8132}
8133
8134struct GraphDbBudgetedSubgraph {
8135 nodes: Vec<SubstrateGraphNode>,
8136 edges: Vec<SubstrateGraphEdge>,
8137 report: GraphDbOutputBudgetReport,
8138 truncated: bool,
8139 next_cursor: Option<String>,
8140}
8141
8142const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8143const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8144const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8145
8146fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8147 match limit {
8148 Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8149 Some(limit) => limit
8150 .saturating_mul(320)
8151 .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8152 }
8153}
8154
8155fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8156 if matches!(limit, Some(0) | None) {
8157 return match kind {
8158 "source_handle" => 10,
8159 "worker_context" | "worker_result" => 8,
8160 "semantic_concept" | "semantic_entity" => 10,
8161 "file" | "symbol" | "route" => 12,
8162 _ => 8,
8163 };
8164 }
8165 let base = limit.unwrap_or(0).max(1);
8166 match kind {
8167 "source_handle" => base.saturating_add(4),
8168 "worker_context" | "worker_result" => base.saturating_add(2),
8169 "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8170 "file" | "symbol" | "route" => base.saturating_add(4),
8171 _ => base.saturating_add(1),
8172 }
8173}
8174
8175fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8176 if matches!(limit, Some(0) | None) {
8177 return match kind {
8178 "mentions" | "mentions_concept" | "mentions_entity" => 24,
8179 "semantic_relation" | "calls" | "defines" => 20,
8180 _ => 16,
8181 };
8182 }
8183 let base = limit.unwrap_or(0).max(1);
8184 match kind {
8185 "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8186 "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8187 _ => base.saturating_add(2),
8188 }
8189}
8190
8191fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8192 serde_json::to_vec(value)
8193 .map(|bytes| bytes.len().div_ceil(4).max(1))
8194 .unwrap_or(1)
8195}
8196
8197fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8198 let mut parts = vec![node.kind.clone(), node.label.clone()];
8199 for key in [
8200 "detail",
8201 "description",
8202 "source_ref",
8203 "path",
8204 "source_file",
8205 "source_symbol",
8206 "text_preview",
8207 ] {
8208 if let Some(value) = node.properties.get(key) {
8209 parts.push(value.clone());
8210 }
8211 }
8212 parts.join(" ")
8213}
8214
8215fn graph_db_semantic_scores_for_query(
8216 query: Option<&str>,
8217 nodes: &[SubstrateGraphNode],
8218) -> BTreeMap<String, f64> {
8219 let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8220 return BTreeMap::new();
8221 };
8222 let query_embedding = semantic_embedding(query);
8223 nodes
8224 .iter()
8225 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8226 .filter_map(|node| {
8227 let embedding = node
8228 .properties
8229 .get("embedding")
8230 .and_then(|value| parse_semantic_embedding_property(value))?;
8231 Some((
8232 node.id.clone(),
8233 semantic_cosine(&query_embedding, &embedding),
8234 ))
8235 })
8236 .collect()
8237}
8238
8239fn graph_db_depth_by_id(
8240 origin_ids: &[String],
8241 edges: &[SubstrateGraphEdge],
8242) -> BTreeMap<String, usize> {
8243 let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8244 for edge in edges {
8245 adjacency
8246 .entry(edge.from_id.clone())
8247 .or_default()
8248 .push(edge.to_id.clone());
8249 adjacency
8250 .entry(edge.to_id.clone())
8251 .or_default()
8252 .push(edge.from_id.clone());
8253 }
8254
8255 let mut depth_by_id = BTreeMap::<String, usize>::new();
8256 let mut queue = VecDeque::<String>::new();
8257 for origin in origin_ids {
8258 if depth_by_id.insert(origin.clone(), 0).is_none() {
8259 queue.push_back(origin.clone());
8260 }
8261 }
8262 while let Some(current) = queue.pop_front() {
8263 let depth = depth_by_id.get(¤t).copied().unwrap_or(0);
8264 for next in adjacency.get(¤t).into_iter().flatten() {
8265 if depth_by_id.contains_key(next) {
8266 continue;
8267 }
8268 depth_by_id.insert(next.clone(), depth.saturating_add(1));
8269 queue.push_back(next.clone());
8270 }
8271 }
8272 depth_by_id
8273}
8274
8275fn graph_db_source_covered_ids(
8276 nodes: &[SubstrateGraphNode],
8277 edges: &[SubstrateGraphEdge],
8278) -> BTreeSet<String> {
8279 let source_ids = nodes
8280 .iter()
8281 .filter(|node| node.kind == "source_handle")
8282 .map(|node| node.id.as_str())
8283 .collect::<BTreeSet<_>>();
8284 let mut covered = source_ids
8285 .iter()
8286 .map(|id| (*id).to_string())
8287 .collect::<BTreeSet<_>>();
8288 for edge in edges {
8289 if source_ids.contains(edge.from_id.as_str()) {
8290 covered.insert(edge.to_id.clone());
8291 }
8292 if source_ids.contains(edge.to_id.as_str()) {
8293 covered.insert(edge.from_id.clone());
8294 }
8295 }
8296 covered
8297}
8298
8299fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8300 for key in [
8301 "observed_at_unix",
8302 "completed_at_unix",
8303 "created_at_unix",
8304 "started_at_unix",
8305 ] {
8306 if let Some(value) = node.properties.get(key)
8307 && let Ok(epoch) = value.parse::<i64>()
8308 {
8309 return epoch.div_euclid(86_400).clamp(0, 40_000);
8310 }
8311 }
8312 0
8313}
8314
8315fn graph_db_node_kind_score(kind: &str) -> i64 {
8316 match kind {
8317 "source_handle" => 180,
8318 "worker_context" => 170,
8319 "worker_result" => 160,
8320 "semantic_concept" | "semantic_entity" => 150,
8321 "backlog" | "job_packet" => 130,
8322 "symbol" => 120,
8323 "file" => 110,
8324 "route" => 105,
8325 "session" => 90,
8326 _ => 40,
8327 }
8328}
8329
8330fn graph_db_edge_kind_score(kind: &str) -> i64 {
8331 match kind {
8332 "mentions_concept" | "mentions_entity" => 180,
8333 "semantic_relation" => 170,
8334 "mentions" => 165,
8335 "requests_context" | "scopes_context" | "scopes_source" => 155,
8336 "explains_result" => 150,
8337 "calls" => 145,
8338 "defines" | "handled_by" | "defines_route" => 130,
8339 "contains" | "targets" => 120,
8340 "records_memory_source" | "has_vector_handle" => 115,
8341 _ => 40,
8342 }
8343}
8344
8345fn graph_db_node_usefulness_score(
8346 node: &SubstrateGraphNode,
8347 depth_by_id: &BTreeMap<String, usize>,
8348 semantic_scores: &BTreeMap<String, f64>,
8349 source_covered_ids: &BTreeSet<String>,
8350 origin_ids: &[String],
8351) -> i64 {
8352 if origin_ids.iter().any(|origin| origin == &node.id) {
8353 return 1_000_000;
8354 }
8355 let semantic = semantic_scores
8356 .get(&node.id)
8357 .map(|score| (score.max(0.0) * 1_000.0) as i64)
8358 .unwrap_or(0);
8359 let depth_penalty = depth_by_id
8360 .get(&node.id)
8361 .map(|depth| (*depth as i64).saturating_mul(55))
8362 .unwrap_or(180);
8363 let source_coverage = if source_covered_ids.contains(&node.id)
8364 || node.properties.contains_key("source_ref")
8365 || node.properties.contains_key("path")
8366 {
8367 120
8368 } else {
8369 0
8370 };
8371 graph_db_node_kind_score(&node.kind)
8372 + semantic
8373 + source_coverage
8374 + graph_db_recency_score(node).min(80)
8375 - depth_penalty
8376}
8377
8378fn graph_db_edge_usefulness_score(
8379 edge: &SubstrateGraphEdge,
8380 node_score_by_id: &BTreeMap<String, i64>,
8381 depth_by_id: &BTreeMap<String, usize>,
8382) -> i64 {
8383 let endpoint_score = node_score_by_id
8384 .get(&edge.from_id)
8385 .copied()
8386 .unwrap_or_default()
8387 .max(
8388 node_score_by_id
8389 .get(&edge.to_id)
8390 .copied()
8391 .unwrap_or_default(),
8392 );
8393 let depth_penalty = depth_by_id
8394 .get(&edge.from_id)
8395 .into_iter()
8396 .chain(depth_by_id.get(&edge.to_id))
8397 .min()
8398 .map(|depth| (*depth as i64).saturating_mul(35))
8399 .unwrap_or(140);
8400 graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8401}
8402
8403fn graph_db_push_drop(
8404 drops: &mut BTreeMap<(String, String, String), usize>,
8405 item: &str,
8406 kind: &str,
8407 reason: &str,
8408) {
8409 *drops
8410 .entry((item.to_string(), kind.to_string(), reason.to_string()))
8411 .or_default() += 1;
8412}
8413
8414fn graph_db_budget_drop_report(
8415 drops: BTreeMap<(String, String, String), usize>,
8416) -> Vec<GraphDbDroppedByBudget> {
8417 drops
8418 .into_iter()
8419 .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
8420 item,
8421 kind,
8422 reason,
8423 dropped,
8424 })
8425 .collect()
8426}
8427
8428fn graph_db_apply_output_budget(
8429 origin_ids: &[String],
8430 semantic_scores: &BTreeMap<String, f64>,
8431 nodes: Vec<SubstrateGraphNode>,
8432 edges: Vec<SubstrateGraphEdge>,
8433 limit: Option<usize>,
8434) -> GraphDbBudgetedSubgraph {
8435 graph_db_apply_output_budget_with_depths_and_cursor(
8436 origin_ids,
8437 semantic_scores,
8438 nodes,
8439 edges,
8440 limit,
8441 None,
8442 None,
8443 )
8444}
8445
8446fn graph_db_apply_output_budget_with_depths_and_cursor(
8447 origin_ids: &[String],
8448 semantic_scores: &BTreeMap<String, f64>,
8449 nodes: Vec<SubstrateGraphNode>,
8450 edges: Vec<SubstrateGraphEdge>,
8451 limit: Option<usize>,
8452 depth_overrides: Option<&BTreeMap<String, usize>>,
8453 cursor: Option<&str>,
8454) -> GraphDbBudgetedSubgraph {
8455 let max_tokens = graph_db_output_token_cap(limit);
8456 let candidate_nodes = nodes.len();
8457 let candidate_edges = edges.len();
8458 let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
8459 if let Some(depth_overrides) = depth_overrides {
8460 for (id, depth) in depth_overrides {
8461 depth_by_id
8462 .entry(id.clone())
8463 .and_modify(|current| *current = (*current).min(*depth))
8464 .or_insert(*depth);
8465 }
8466 }
8467 let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
8468 let node_score_by_id = nodes
8469 .iter()
8470 .map(|node| {
8471 (
8472 node.id.clone(),
8473 graph_db_node_usefulness_score(
8474 node,
8475 &depth_by_id,
8476 semantic_scores,
8477 &source_covered_ids,
8478 origin_ids,
8479 ),
8480 )
8481 })
8482 .collect::<BTreeMap<_, _>>();
8483
8484 let mut node_candidates = nodes.iter().collect::<Vec<_>>();
8485 node_candidates.sort_by(|left, right| {
8486 node_score_by_id
8487 .get(&right.id)
8488 .cmp(&node_score_by_id.get(&left.id))
8489 .then_with(|| left.kind.cmp(&right.kind))
8490 .then_with(|| left.label.cmp(&right.label))
8491 .then_with(|| left.id.cmp(&right.id))
8492 });
8493
8494 let cursor_skip = if let Some(cursor) = cursor {
8495 node_candidates
8496 .iter()
8497 .position(|node| node.id == cursor)
8498 .map(|pos| pos.saturating_add(1))
8499 .unwrap_or(0)
8500 } else {
8501 0
8502 };
8503 if cursor_skip > 0 {
8504 node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
8505 }
8506
8507 let mut selected_node_ids = BTreeSet::new();
8508 let mut selected_node_counts = BTreeMap::<String, usize>::new();
8509 let mut estimated_tokens = 0usize;
8510 let mut drops = BTreeMap::<(String, String, String), usize>::new();
8511 for node in &node_candidates {
8512 let kind_count = selected_node_counts
8513 .get(&node.kind)
8514 .copied()
8515 .unwrap_or_default();
8516 if !origin_ids.iter().any(|origin| origin == &node.id)
8517 && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
8518 {
8519 graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
8520 continue;
8521 }
8522 let tokens = graph_db_estimated_tokens(node);
8523 if !origin_ids.iter().any(|origin| origin == &node.id)
8524 && estimated_tokens.saturating_add(tokens) > max_tokens
8525 {
8526 graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
8527 continue;
8528 }
8529 selected_node_ids.insert(node.id.clone());
8530 *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
8531 estimated_tokens = estimated_tokens.saturating_add(tokens);
8532 }
8533
8534 let has_remaining_candidates = node_candidates
8535 .iter()
8536 .any(|node| !selected_node_ids.contains(&node.id));
8537
8538 let mut selected_nodes = nodes
8539 .into_iter()
8540 .filter(|node| selected_node_ids.contains(&node.id))
8541 .collect::<Vec<_>>();
8542
8543 let mut edge_candidates = edges
8544 .iter()
8545 .filter(|edge| {
8546 selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
8547 })
8548 .collect::<Vec<_>>();
8549 let edge_score_by_key = edge_candidates
8550 .iter()
8551 .map(|edge| {
8552 (
8553 graph_db_edge_key(edge),
8554 graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
8555 )
8556 })
8557 .collect::<BTreeMap<_, _>>();
8558 edge_candidates.sort_by(|left, right| {
8559 edge_score_by_key
8560 .get(&graph_db_edge_key(right))
8561 .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
8562 .then_with(|| left.kind.cmp(&right.kind))
8563 .then_with(|| left.from_id.cmp(&right.from_id))
8564 .then_with(|| left.to_id.cmp(&right.to_id))
8565 });
8566
8567 let endpoint_dropped_edges = edges
8568 .iter()
8569 .filter(|edge| {
8570 !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
8571 })
8572 .count();
8573 if endpoint_dropped_edges > 0 {
8574 drops.insert(
8575 (
8576 "edge".to_string(),
8577 "*".to_string(),
8578 "endpoint_node_dropped".to_string(),
8579 ),
8580 endpoint_dropped_edges,
8581 );
8582 }
8583
8584 let mut selected_edge_ids = BTreeSet::new();
8585 let mut selected_edge_counts = BTreeMap::<String, usize>::new();
8586 for edge in edge_candidates {
8587 let kind_count = selected_edge_counts
8588 .get(&edge.kind)
8589 .copied()
8590 .unwrap_or_default();
8591 if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
8592 graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
8593 continue;
8594 }
8595 let tokens = graph_db_estimated_tokens(edge);
8596 if estimated_tokens.saturating_add(tokens) > max_tokens {
8597 graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
8598 continue;
8599 }
8600 selected_edge_ids.insert(graph_db_edge_key(edge));
8601 *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
8602 estimated_tokens = estimated_tokens.saturating_add(tokens);
8603 }
8604
8605 let selected_edges = edges
8606 .into_iter()
8607 .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
8608 .collect::<Vec<_>>();
8609 let dropped_by_budget = graph_db_budget_drop_report(drops);
8610 let truncated = has_remaining_candidates;
8611 let next_cursor = if truncated {
8612 selected_nodes.last().map(|node| node.id.clone())
8613 } else {
8614 None
8615 };
8616 let mut diagnostics = vec![
8617 "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
8618 .to_string(),
8619 format!(
8620 "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
8621 selected_nodes.len(),
8622 candidate_nodes,
8623 selected_edges.len(),
8624 candidate_edges,
8625 max_tokens
8626 ),
8627 ];
8628 if cursor.is_some() {
8629 diagnostics.push(format!(
8630 "cursor skipped {} previously returned candidate(s)",
8631 cursor_skip
8632 ));
8633 }
8634 if next_cursor.is_some() {
8635 diagnostics.push(
8636 "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
8637 );
8638 }
8639 selected_nodes.shrink_to_fit();
8640
8641 GraphDbBudgetedSubgraph {
8642 nodes: selected_nodes,
8643 edges: selected_edges,
8644 report: GraphDbOutputBudgetReport {
8645 max_tokens,
8646 estimated_tokens,
8647 selected_nodes: selected_node_ids.len(),
8648 selected_edges: selected_edge_ids.len(),
8649 candidate_nodes,
8650 candidate_edges,
8651 dropped_by_budget,
8652 diagnostics,
8653 },
8654 truncated,
8655 next_cursor,
8656 }
8657}
8658
8659fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
8660 if edge.id.is_empty() {
8661 substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
8662 } else {
8663 edge.id.clone()
8664 }
8665}
8666
8667fn graph_db_schema() -> GraphDbSchema {
8668 GraphDbSchema {
8669 contract_versions: vec![
8670 GraphDbSchemaContract {
8671 name: "graph_db_evidence",
8672 version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
8673 description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
8674 },
8675 GraphDbSchemaContract {
8676 name: "worker_prompt_packet",
8677 version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
8678 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",
8679 },
8680 GraphDbSchemaContract {
8681 name: "conflict_matrix",
8682 version: CONFLICT_MATRIX_CONTRACT_VERSION,
8683 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",
8684 },
8685 GraphDbSchemaContract {
8686 name: "context_pack_graph_orchestration",
8687 version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
8688 description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
8689 },
8690 GraphDbSchemaContract {
8691 name: "session_review_follow_up",
8692 version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
8693 description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
8694 },
8695 GraphDbSchemaContract {
8696 name: "dispatch_trace",
8697 version: DISPATCH_TRACE_CONTRACT_VERSION,
8698 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",
8699 },
8700 GraphDbSchemaContract {
8701 name: "dependency_dag",
8702 version: DEPENDENCY_DAG_CONTRACT_VERSION,
8703 description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
8704 },
8705 ],
8706 node_fields: vec![
8707 GraphDbSchemaField {
8708 name: "id",
8709 value_type: "string",
8710 description: "Stable provider-neutral node id",
8711 },
8712 GraphDbSchemaField {
8713 name: "kind",
8714 value_type: "string",
8715 description: "Application-defined node family such as file, symbol, or backlog",
8716 },
8717 GraphDbSchemaField {
8718 name: "label",
8719 value_type: "string",
8720 description: "Human-readable label",
8721 },
8722 GraphDbSchemaField {
8723 name: "properties",
8724 value_type: "object<string,string>",
8725 description: "Adapter-specific string properties",
8726 },
8727 GraphDbSchemaField {
8728 name: "provenance",
8729 value_type: "array",
8730 description: "Source system and source reference metadata",
8731 },
8732 GraphDbSchemaField {
8733 name: "freshness",
8734 value_type: "object|null",
8735 description: "Optional content hash and observed timestamp",
8736 },
8737 ],
8738 edge_fields: vec![
8739 GraphDbSchemaField {
8740 name: "id",
8741 value_type: "string",
8742 description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
8743 },
8744 GraphDbSchemaField {
8745 name: "from_id",
8746 value_type: "string",
8747 description: "Source node id",
8748 },
8749 GraphDbSchemaField {
8750 name: "to_id",
8751 value_type: "string",
8752 description: "Target node id",
8753 },
8754 GraphDbSchemaField {
8755 name: "kind",
8756 value_type: "string",
8757 description: "Application-defined edge relation",
8758 },
8759 GraphDbSchemaField {
8760 name: "properties",
8761 value_type: "object<string,string>",
8762 description: "Adapter-specific string properties",
8763 },
8764 GraphDbSchemaField {
8765 name: "provenance",
8766 value_type: "array",
8767 description: "Source system and source reference metadata",
8768 },
8769 GraphDbSchemaField {
8770 name: "freshness",
8771 value_type: "object|null",
8772 description: "Optional content hash and observed timestamp",
8773 },
8774 ],
8775 operations: vec![
8776 GraphDbSchemaOperation {
8777 command: "refresh",
8778 description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
8779 },
8780 GraphDbSchemaOperation {
8781 command: "status",
8782 description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
8783 },
8784 GraphDbSchemaOperation {
8785 command: "doctor",
8786 description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
8787 },
8788 GraphDbSchemaOperation {
8789 command: "drift",
8790 description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
8791 },
8792 GraphDbSchemaOperation {
8793 command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
8794 description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
8795 },
8796 GraphDbSchemaOperation {
8797 command: "snapshot-export <output.db.gz> [--force]",
8798 description: "Export the current SQLite graph.db as a gzip-compressed shareable artifact only after freshness, doctor, WAL, and sidecar checks pass",
8799 },
8800 GraphDbSchemaOperation {
8801 command: "snapshot-import <artifact.db.gz> [--replace]",
8802 description: "Stage and validate a compressed SQLite graph.db artifact through doctor and freshness checks before replacing the local graph.db",
8803 },
8804 GraphDbSchemaOperation {
8805 command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
8806 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",
8807 },
8808 GraphDbSchemaOperation {
8809 command: "evidence <target> [--depth N] [--limit N]",
8810 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",
8811 },
8812 GraphDbSchemaOperation {
8813 command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
8814 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",
8815 },
8816 GraphDbSchemaOperation {
8817 command: "dispatch-trace [target...] --path <session> [--format json|html]",
8818 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",
8819 },
8820 GraphDbSchemaOperation {
8821 command: "dependency-dag [target...] --path <session>",
8822 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",
8823 },
8824 GraphDbSchemaOperation {
8825 command: "schema",
8826 description: "Return record and operation schemas",
8827 },
8828 GraphDbSchemaOperation {
8829 command: "node <id>",
8830 description: "Return one node by stable id",
8831 },
8832 GraphDbSchemaOperation {
8833 command: "edge <id>",
8834 description: "Return one edge by stable edge id",
8835 },
8836 GraphDbSchemaOperation {
8837 command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8838 description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
8839 },
8840 GraphDbSchemaOperation {
8841 command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8842 description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
8843 },
8844 GraphDbSchemaOperation {
8845 command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
8846 description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
8847 },
8848 GraphDbSchemaOperation {
8849 command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
8850 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",
8851 },
8852 GraphDbSchemaOperation {
8853 command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
8854 description: "Return the shortest directed path by node id, optionally bounded by hop count",
8855 },
8856 ],
8857 }
8858}
8859
8860pub(crate) fn sqlite_graph_freshness(
8861 store: &SqliteGraphStore,
8862 scope: &str,
8863) -> Result<GraphDbFreshnessReport> {
8864 let version = store.projection_version(scope)?;
8865 let Some(version) = version else {
8866 return Ok(GraphDbFreshnessReport {
8867 status: "missing".to_string(),
8868 fail_closed: true,
8869 projection_version: None,
8870 content_hash: None,
8871 source_watermark: None,
8872 diagnostics: vec![
8873 "graph projection metadata is missing; rebuild the graph before trusting reads"
8874 .to_string(),
8875 ],
8876 });
8877 };
8878 let mut diagnostics = Vec::new();
8879 let fail_closed =
8880 version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
8881 if version.projection_version != GRAPH_PROJECTION_VERSION {
8882 diagnostics.push(format!(
8883 "projection version mismatch: expected {} got {}",
8884 GRAPH_PROJECTION_VERSION, version.projection_version
8885 ));
8886 }
8887 if version.content_hash.is_none() {
8888 diagnostics.push("projection content hash is missing".to_string());
8889 }
8890 Ok(GraphDbFreshnessReport {
8891 status: if fail_closed { "stale" } else { "current" }.to_string(),
8892 fail_closed,
8893 projection_version: Some(version.projection_version),
8894 content_hash: version.content_hash,
8895 source_watermark: version.source_watermark,
8896 diagnostics,
8897 })
8898}
8899
8900pub(crate) fn convex_graph_freshness(
8901 local: &ConvexProjectionRows,
8902 snapshot: &ConvexProjectionRows,
8903 scope: Option<&str>,
8904) -> GraphDbFreshnessReport {
8905 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
8906 GraphDbFreshnessReport {
8907 status: freshness.status,
8908 fail_closed: freshness.fail_closed,
8909 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
8910 content_hash: freshness.snapshot_hash,
8911 source_watermark: None,
8912 diagnostics: freshness.diagnostics,
8913 }
8914}
8915
8916pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
8917 let (nodes, edges) = store.graph_counts()?;
8918 let files = store.file_count()?;
8919 Ok(GraphDbFreshnessReport {
8920 status: "current".to_string(),
8921 fail_closed: false,
8922 projection_version: Some("tokensave-readonly".to_string()),
8923 content_hash: None,
8924 source_watermark: Some(store.db_path().to_string_lossy().to_string()),
8925 diagnostics: vec![format!(
8926 "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
8927 nodes, edges, files
8928 )],
8929 })
8930}
8931
8932pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
8933 match TokensaveDb::discover(root) {
8934 Ok(Some(store)) => {
8935 report.push_check(GraphDbDoctorCheck {
8936 name: "tokensave_db_open".to_string(),
8937 status: "ok".to_string(),
8938 fail_closed: false,
8939 diagnostics: vec![format!(
8940 "opened tokensave database at {}",
8941 store.db_path().display()
8942 )],
8943 repair_commands: Vec::new(),
8944 });
8945 match (store.node_count(), store.edge_count(), store.file_count()) {
8946 (Ok(nodes), Ok(edges), Ok(files)) => {
8947 report.push_check(GraphDbDoctorCheck {
8948 name: "tokensave_counts".to_string(),
8949 status: "ok".to_string(),
8950 fail_closed: false,
8951 diagnostics: vec![format!(
8952 "tokensave contains {} node(s), {} edge(s), {} file(s)",
8953 nodes, edges, files
8954 )],
8955 repair_commands: Vec::new(),
8956 });
8957 }
8958 (nodes, edges, files) => {
8959 report.push_check(graph_db_doctor_check(
8960 "tokensave_counts",
8961 vec![format!(
8962 "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
8963 nodes.err(),
8964 edges.err(),
8965 files.err()
8966 )],
8967 Vec::new(),
8968 ));
8969 }
8970 }
8971 }
8972 Ok(None) => report.push_check(graph_db_doctor_check(
8973 "tokensave_db_exists",
8974 vec![format!(
8975 "tokensave database is missing at {}",
8976 root.join(".tokensave").join("tokensave.db").display()
8977 )],
8978 Vec::new(),
8979 )),
8980 Err(err) => report.push_check(graph_db_doctor_check(
8981 "tokensave_db_open",
8982 vec![err.to_string()],
8983 Vec::new(),
8984 )),
8985 }
8986}
8987
8988const GRAPH_DB_EVIDENCE_TARGET_KINDS: &[&str] = &[
8989 "backlog",
8990 "job_packet",
8991 "worker_result",
8992 "worker_context",
8993 "source_handle",
8994];
8995
8996pub(crate) fn graph_db_evidence_preferred_path(root: &Path, path_hint: &Path) -> Option<String> {
8997 hinted_markdown_file(root, path_hint).map(|path| {
8998 relativize_pathbuf(&path, root)
8999 .to_string_lossy()
9000 .replace('\\', "/")
9001 })
9002}
9003
9004fn graph_db_ambiguous_target_message(
9005 target: &str,
9006 kind: &str,
9007 candidates: &[SubstrateGraphNode],
9008) -> String {
9009 let mut by_path = BTreeMap::<String, String>::new();
9010 for candidate in candidates.iter().filter(|node| node.kind == kind) {
9011 let path = candidate
9012 .properties
9013 .get("path")
9014 .cloned()
9015 .unwrap_or_else(|| "<no path>".to_string());
9016 by_path.entry(path).or_insert_with(|| candidate.id.clone());
9017 }
9018 let examples = by_path
9019 .iter()
9020 .take(5)
9021 .map(|(path, node_id)| format!("{node_id} path={path}"))
9022 .collect::<Vec<_>>()
9023 .join(", ");
9024 format!(
9025 "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",
9026 by_path.len()
9027 )
9028}
9029
9030pub(crate) fn graph_db_resolve_evidence_target_with_path(
9031 store: &impl GraphStore,
9032 target: &str,
9033 preferred_path: Option<&str>,
9034) -> Result<Option<SubstrateGraphNode>> {
9035 if let Some(node) = store.node(target)? {
9036 return Ok(Some(node));
9037 }
9038 let candidates =
9039 store.evidence_target_candidates(target, GRAPH_DB_EVIDENCE_TARGET_KINDS, preferred_path)?;
9040 if candidates.is_empty() {
9041 return Ok(None);
9042 }
9043 if preferred_path.is_none() {
9044 let first_kind = candidates[0].kind.as_str();
9045 let distinct_paths = candidates
9046 .iter()
9047 .filter(|node| node.kind == first_kind)
9048 .map(|node| {
9049 node.properties
9050 .get("path")
9051 .map(String::as_str)
9052 .unwrap_or("")
9053 })
9054 .collect::<BTreeSet<_>>();
9055 if distinct_paths.len() > 1 {
9056 bail!(
9057 "{}",
9058 graph_db_ambiguous_target_message(target, first_kind, &candidates)
9059 );
9060 }
9061 }
9062 Ok(candidates.into_iter().next())
9063}
9064
9065pub(crate) fn graph_db_resolve_evidence_target(
9066 store: &impl GraphStore,
9067 target: &str,
9068) -> Result<Option<SubstrateGraphNode>> {
9069 graph_db_resolve_evidence_target_with_path(store, target, None)
9070}
9071
9072fn graph_db_reachable_nodes_by_kind(
9073 store: &impl GraphStore,
9074 from_id: &str,
9075 kind: &str,
9076 depth: usize,
9077 limit: usize,
9078) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
9079 store.reachable_nodes_by_kind(from_id, kind, depth, limit)
9080}
9081
9082fn graph_db_evidence_completed_queue_drift_warnings(
9083 store: &impl GraphStore,
9084 target: &SubstrateGraphNode,
9085 worker_results: &[SubstrateGraphNode],
9086) -> Result<Vec<String>> {
9087 let ref_id = target.properties.get("ref_id").map(String::as_str);
9088 let has_completed_result = worker_results.iter().any(|node| {
9089 node.properties.get("status").map(String::as_str) == Some("completed")
9090 && node.properties.get("ref_id").map(String::as_str) == ref_id
9091 });
9092 if !has_completed_result {
9093 return Ok(Vec::new());
9094 }
9095 let active_jobs = store
9096 .nodes_by_kind("job_packet")?
9097 .into_iter()
9098 .filter(|node| {
9099 node.properties.get("ref_id").map(String::as_str) == ref_id
9100 && node.label.starts_with("do #")
9101 })
9102 .collect::<Vec<_>>();
9103 if active_jobs.is_empty() {
9104 return Ok(Vec::new());
9105 }
9106 let repair = match (target.properties.get("path"), ref_id) {
9107 (Some(path), Some(id)) => format!(
9108 "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9109 shell_quote(path),
9110 shell_quote(id),
9111 shell_quote(id)
9112 ),
9113 _ => {
9114 "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9115 }
9116 };
9117 Ok(vec![format!(
9118 "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",
9119 target.label,
9120 active_jobs.len()
9121 )])
9122}
9123
9124fn graph_db_evidence_next_commands(
9125 root: &Path,
9126 scope: Option<&str>,
9127 target: &SubstrateGraphNode,
9128 worker_context: &[SubstrateGraphNode],
9129 source_handles: &[SubstrateGraphNode],
9130 worker_results: &[SubstrateGraphNode],
9131 semantic_related: &[SubstrateGraphNode],
9132) -> Vec<String> {
9133 let mut commands = BTreeSet::new();
9134 if let Some(expand) = target.properties.get("expand") {
9135 commands.insert(expand.clone());
9136 }
9137 for worker in worker_context {
9138 if let Some(expand) = worker.properties.get("expand") {
9139 commands.insert(expand.clone());
9140 }
9141 }
9142 for source in source_handles {
9143 if let Some(expand) = source.properties.get("expand") {
9144 commands.insert(expand.clone());
9145 }
9146 }
9147 for result in worker_results {
9148 if let Some(expand) = result.properties.get("expand") {
9149 commands.insert(expand.clone());
9150 }
9151 }
9152 for semantic in semantic_related {
9153 if let Some(expand) = semantic.properties.get("expand") {
9154 commands.insert(expand.clone());
9155 }
9156 }
9157 commands.insert(format!(
9158 "tsift graph-db --path {}{} status --json",
9159 shell_quote(root.to_string_lossy().as_ref()),
9160 graph_db_scope_arg(scope)
9161 ));
9162 commands.insert(format!(
9163 "tsift graph-db --path {}{} doctor --json",
9164 shell_quote(root.to_string_lossy().as_ref()),
9165 graph_db_scope_arg(scope)
9166 ));
9167 commands.into_iter().collect()
9168}
9169
9170fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9171 vec![
9172 format!(
9173 "tsift graph-db --path {}{} refresh --json",
9174 shell_quote(root.to_string_lossy().as_ref()),
9175 graph_db_scope_arg(scope)
9176 ),
9177 format!(
9178 "tsift graph-db --path {}{} doctor --json",
9179 shell_quote(root.to_string_lossy().as_ref()),
9180 graph_db_scope_arg(scope)
9181 ),
9182 ]
9183}
9184
9185fn graph_db_evidence_replay_commands(
9186 root: &Path,
9187 scope: Option<&str>,
9188 target: &str,
9189 depth: usize,
9190 limit: usize,
9191) -> Vec<String> {
9192 vec![
9193 format!(
9194 "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9195 shell_quote(root.to_string_lossy().as_ref()),
9196 graph_db_scope_arg(scope),
9197 shell_quote(target),
9198 depth,
9199 limit
9200 ),
9201 format!(
9202 "tsift conflict-matrix --path {} {} --json",
9203 shell_quote(root.to_string_lossy().as_ref()),
9204 shell_quote(target)
9205 ),
9206 ]
9207}
9208
9209fn graph_db_evidence_packet_id(
9210 target: &str,
9211 target_node: &SubstrateGraphNode,
9212 freshness: &GraphDbFreshnessReport,
9213) -> String {
9214 stable_handle(
9215 "gevd",
9216 &format!(
9217 "{}:{}:{}:{}",
9218 GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9219 target,
9220 target_node.id,
9221 freshness.content_hash.as_deref().unwrap_or("no-hash")
9222 ),
9223 )
9224}
9225
9226pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9227 input: GraphDbEvidenceInput<'_, S>,
9228) -> Result<GraphDbEvidenceReport> {
9229 let GraphDbEvidenceInput {
9230 root,
9231 scope,
9232 backend,
9233 target,
9234 preferred_path,
9235 depth,
9236 limit,
9237 cursor,
9238 store,
9239 freshness,
9240 mut warnings,
9241 } = input;
9242 let repair_commands = graph_db_repair_commands(root, scope);
9243 if freshness.fail_closed {
9244 bail!(
9245 "graph database evidence failed closed for {} backend: {}; repair: {}",
9246 backend,
9247 freshness.diagnostics.join("; "),
9248 repair_commands.join("; ")
9249 );
9250 }
9251 let semantic_readiness =
9252 graph_db_semantic_readiness(root, scope, graph_store_semantic_node_count(store).ok());
9253 if semantic_readiness.fail_closed {
9254 warnings.push(format!(
9255 "graph evidence semantic readiness blocked: {} — {}",
9256 semantic_readiness.reason,
9257 semantic_readiness.diagnostics.join("; ")
9258 ));
9259 warnings.push(format!(
9260 "repair: {}",
9261 semantic_readiness.next_commands.join("; then ")
9262 ));
9263 }
9264 let target_node = graph_db_resolve_evidence_target_with_path(store, target, preferred_path)?
9265 .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9266 let max_rows = if limit == 0 { usize::MAX } else { limit };
9267 let mut reachable = store.reachable_nodes_by_kinds(
9268 &target_node.id,
9269 &[
9270 "worker_context",
9271 "source_handle",
9272 "worker_result",
9273 "semantic_concept",
9274 "semantic_entity",
9275 ],
9276 depth,
9277 max_rows,
9278 )?;
9279 let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9280 let source_paths = reachable.remove("source_handle").unwrap_or_default();
9281 let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9282 let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9283 semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9284 semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9285 left_path
9286 .hops
9287 .cmp(&right_path.hops)
9288 .then(left_node.kind.cmp(&right_node.kind))
9289 .then(left_node.label.cmp(&right_node.label))
9290 .then(left_node.id.cmp(&right_node.id))
9291 });
9292 if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9293 semantic_paths.truncate(max_rows);
9294 }
9295
9296 let evidence_nodes = worker_paths
9297 .iter()
9298 .chain(source_paths.iter())
9299 .chain(worker_result_paths.iter())
9300 .chain(semantic_paths.iter())
9301 .map(|(node, _)| node.clone())
9302 .collect::<Vec<_>>();
9303 let evidence_depth_by_id = worker_paths
9304 .iter()
9305 .chain(source_paths.iter())
9306 .chain(worker_result_paths.iter())
9307 .chain(semantic_paths.iter())
9308 .map(|(node, path)| (node.id.clone(), path.hops))
9309 .collect::<BTreeMap<_, _>>();
9310 let target_query = graph_db_node_search_text(&target_node);
9311 let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9312 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9313 std::slice::from_ref(&target_node.id),
9314 &semantic_scores,
9315 evidence_nodes,
9316 Vec::new(),
9317 Some(limit),
9318 Some(&evidence_depth_by_id),
9319 cursor,
9320 );
9321 let output_budget = budgeted.report;
9322 let truncated = budgeted.truncated;
9323 let next_cursor = budgeted.next_cursor;
9324 let retained_evidence_ids = budgeted
9325 .nodes
9326 .iter()
9327 .map(|node| node.id.as_str())
9328 .collect::<BTreeSet<_>>();
9329 let worker_context = worker_paths
9330 .iter()
9331 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9332 .map(|(node, _)| node.clone())
9333 .collect::<Vec<_>>();
9334 let source_handles = source_paths
9335 .iter()
9336 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9337 .map(|(node, _)| node.clone())
9338 .collect::<Vec<_>>();
9339 let worker_results = worker_result_paths
9340 .iter()
9341 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9342 .map(|(node, _)| node.clone())
9343 .collect::<Vec<_>>();
9344 let semantic_related = semantic_paths
9345 .iter()
9346 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9347 .map(|(node, _)| node.clone())
9348 .collect::<Vec<_>>();
9349 warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9350 store,
9351 &target_node,
9352 &worker_results,
9353 )?);
9354 if worker_context.is_empty()
9355 && source_handles.is_empty()
9356 && worker_results.is_empty()
9357 && semantic_related.is_empty()
9358 {
9359 warnings.push(format!(
9360 "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",
9361 target, target_node.kind
9362 ));
9363 }
9364 let shortest_paths = worker_paths
9365 .iter()
9366 .chain(source_paths.iter())
9367 .chain(worker_result_paths.iter())
9368 .chain(semantic_paths.iter())
9369 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9370 .map(|(node, path)| GraphDbEvidencePath {
9371 to: node.id.clone(),
9372 kind: node.kind.clone(),
9373 label: node.label.clone(),
9374 path: Some(path.clone()),
9375 expand: node.properties.get("expand").cloned(),
9376 })
9377 .collect::<Vec<_>>();
9378 let next_commands = graph_db_evidence_next_commands(
9379 root,
9380 scope,
9381 &target_node,
9382 &worker_context,
9383 &source_handles,
9384 &worker_results,
9385 &semantic_related,
9386 );
9387 let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9388 let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9389 let projection_hash = freshness.content_hash.clone();
9390
9391 Ok(GraphDbEvidenceReport {
9392 root: root.to_string_lossy().to_string(),
9393 scope: scope.map(str::to_string),
9394 backend: backend.to_string(),
9395 contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9396 target: target.to_string(),
9397 packet_id,
9398 projection_hash,
9399 freshness,
9400 target_node: target_node.into(),
9401 worker_context: worker_context.into_iter().map(Into::into).collect(),
9402 source_handles: source_handles.into_iter().map(Into::into).collect(),
9403 worker_results: worker_results.into_iter().map(Into::into).collect(),
9404 semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9405 shortest_paths,
9406 output_budget: Some(output_budget),
9407 truncated,
9408 next_cursor,
9409 next_commands,
9410 replay_commands,
9411 repair_commands,
9412 fixture_coverage: GraphDbFixtureCoverage {
9413 test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9414 .to_string(),
9415 fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
9416 assertions: vec![
9417 "backlog id and job packet handle resolve to graph nodes".to_string(),
9418 "worker_context rows are reachable from queued work".to_string(),
9419 "source_handle rows are reachable through bounded shortest paths".to_string(),
9420 "worker_result rows are reachable from completed or blocked work".to_string(),
9421 ],
9422 },
9423 warnings,
9424 })
9425}
9426
9427fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
9428 println!(
9429 "graph-db evidence backend: {} target: {} [{}] packet:{}",
9430 report.backend, report.target_node.id, report.target_node.kind, report.packet_id
9431 );
9432 let page_info = if report.truncated {
9433 let cursor = report.next_cursor.as_deref().unwrap_or("?");
9434 format!(" (truncated, next_cursor: {cursor})")
9435 } else {
9436 String::new()
9437 };
9438 println!(
9439 "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
9440 report.worker_context.len(),
9441 report.source_handles.len(),
9442 report.worker_results.len(),
9443 report.semantic_related.len(),
9444 report.shortest_paths.len()
9445 );
9446 for path in &report.shortest_paths {
9447 if let Some(graph_path) = &path.path {
9448 println!(
9449 "path: {} hop(s) {}",
9450 graph_path.hops,
9451 graph_path.nodes.join(" -> ")
9452 );
9453 }
9454 }
9455 for command in &report.next_commands {
9456 println!("next: {command}");
9457 }
9458 for warning in &report.warnings {
9459 println!("warning: {warning}");
9460 }
9461}
9462
9463pub(crate) fn print_graph_db_evidence_report(
9464 report: &GraphDbEvidenceReport,
9465 format: OutputFormat,
9466) -> Result<()> {
9467 if format.json_output {
9468 let page_info = if report.truncated {
9469 let cursor = report.next_cursor.as_deref().unwrap_or("?");
9470 format!(" (truncated, next_cursor: {cursor})")
9471 } else {
9472 String::new()
9473 };
9474 print_json_or_envelope(
9475 report,
9476 &format,
9477 "graph-db",
9478 "evidence",
9479 ToolEnvelopeSummary {
9480 text: format!(
9481 "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}",
9482 report.target,
9483 report.worker_context.len(),
9484 report.source_handles.len(),
9485 report.worker_results.len(),
9486 report.semantic_related.len(),
9487 report.shortest_paths.len()
9488 ),
9489 metrics: vec![
9490 envelope_metric("backend", &report.backend),
9491 envelope_metric("worker_context", report.worker_context.len()),
9492 envelope_metric("source_handles", report.source_handles.len()),
9493 envelope_metric("worker_results", report.worker_results.len()),
9494 envelope_metric("semantic_related", report.semantic_related.len()),
9495 envelope_metric("paths", report.shortest_paths.len()),
9496 ],
9497 },
9498 report.truncated,
9499 report.next_commands.clone(),
9500 )
9501 } else {
9502 print_graph_db_evidence_human(report);
9503 Ok(())
9504 }
9505}
9506
9507pub(crate) fn graph_db_report_from_store(
9508 root: &Path,
9509 scope: Option<&str>,
9510 backend: &str,
9511 query: GraphDbQuery,
9512 store: &impl GraphStore,
9513 freshness: GraphDbFreshnessReport,
9514 warnings: Vec<String>,
9515) -> Result<GraphDbReport> {
9516 if freshness.fail_closed {
9517 bail!(
9518 "graph database read failed closed for {} backend: {}",
9519 backend,
9520 freshness.diagnostics.join("; ")
9521 );
9522 }
9523 let mut report = GraphDbReport {
9524 root: root.to_string_lossy().to_string(),
9525 scope: scope.map(str::to_string),
9526 backend: backend.to_string(),
9527 query: format!("{query:?}"),
9528 freshness,
9529 readiness: None,
9530 schema: None,
9531 node: None,
9532 edge: None,
9533 nodes: Vec::new(),
9534 edges: Vec::new(),
9535 ranked_neighbors: Vec::new(),
9536 semantic_related: Vec::new(),
9537 neighborhood_ranking_gate: None,
9538 ranked_neighborhood_comparison: None,
9539 knowledge_retrieval: None,
9540 output_budget: None,
9541 path: None,
9542 page: None,
9543 warnings,
9544 };
9545
9546 match query {
9547 GraphDbQuery::Refresh => {
9548 bail!("graph-db refresh must be handled by the refresh command path");
9549 }
9550 GraphDbQuery::Status => {
9551 bail!("graph-db status must be handled by the status command path");
9552 }
9553 GraphDbQuery::Doctor => {
9554 bail!("graph-db doctor must be handled by the doctor command path");
9555 }
9556 GraphDbQuery::Drift => {
9557 bail!("graph-db drift must be handled by the drift command path");
9558 }
9559 GraphDbQuery::Compact { .. } => {
9560 bail!("graph-db compact must be handled by the compact command path");
9561 }
9562 GraphDbQuery::SnapshotExport { .. } => {
9563 bail!("graph-db snapshot-export must be handled by the snapshot command path");
9564 }
9565 GraphDbQuery::SnapshotImport { .. } => {
9566 bail!("graph-db snapshot-import must be handled by the snapshot command path");
9567 }
9568 GraphDbQuery::BackendEval { .. } => {
9569 bail!("graph-db backend-eval must be handled by the benchmark command path");
9570 }
9571 GraphDbQuery::Evidence { .. } => {
9572 bail!("graph-db evidence must be handled by the evidence command path");
9573 }
9574 GraphDbQuery::Related {
9575 query,
9576 kind,
9577 depth,
9578 seed_limit,
9579 limit,
9580 } => {
9581 let semantic =
9582 semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
9583 let SemanticRelatedReport {
9584 items,
9585 warnings: semantic_warnings,
9586 ..
9587 } = semantic;
9588 let readiness = graph_db_semantic_readiness(
9589 root,
9590 scope,
9591 (!items.is_empty()).then_some(items.len()),
9592 );
9593 report.warnings.extend(semantic_warnings);
9594 let seed_ids = items
9595 .iter()
9596 .map(|item| item.handle.clone())
9597 .collect::<Vec<_>>();
9598 let semantic_scores = items
9599 .iter()
9600 .map(|item| (item.handle.clone(), item.score))
9601 .collect::<BTreeMap<_, _>>();
9602 let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
9603 let seed_count = seed_ids.len();
9604 let mut diagnostics = subgraph.diagnostics;
9605 let budgeted = graph_db_apply_output_budget(
9606 &seed_ids,
9607 &semantic_scores,
9608 subgraph.nodes,
9609 subgraph.edges,
9610 Some(limit),
9611 );
9612 let budget_report = budgeted.report;
9613 let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
9614 diagnostics.extend(budget_report.diagnostics.clone());
9615 diagnostics.extend(readiness.diagnostics.clone());
9616
9617 report.readiness = Some(readiness);
9618 report.semantic_related = items;
9619 if let Some(seed_id) = seed_ids.first() {
9620 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
9621 report.ranked_neighbors = graph_db_ranked_neighbors(
9622 seed_id,
9623 &budgeted.nodes,
9624 &budgeted.edges,
9625 ranked_neighbor_cap,
9626 );
9627 report.neighborhood_ranking_gate =
9628 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9629 }
9630 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9631 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9632 report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
9633 mode: "semantic_seeded_neighborhood".to_string(),
9634 query,
9635 seed_kind: semantic_related_kind_name(kind).to_string(),
9636 seed_limit,
9637 seed_count,
9638 depth,
9639 limit,
9640 node_count: report.nodes.len(),
9641 edge_count: report.edges.len(),
9642 truncated: subgraph.truncated || dropped_by_budget,
9643 traversal: "incident_plus_outgoing_edges".to_string(),
9644 freshness_boundary:
9645 "semantic rows must come from refreshed summary or tsift-memory graph records"
9646 .to_string(),
9647 privacy_boundary:
9648 "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
9649 .to_string(),
9650 diagnostics,
9651 });
9652 report.output_budget = Some(budget_report);
9653 }
9654 GraphDbQuery::Schema => {
9655 report.schema = Some(graph_db_schema());
9656 }
9657 GraphDbQuery::Node { id } => {
9658 report.node = store.node(&id)?.map(Into::into);
9659 }
9660 GraphDbQuery::Edge { id } => {
9661 report.edge = store.edge(&id)?.map(Into::into);
9662 }
9663 GraphDbQuery::Edges {
9664 edge_kind,
9665 cursor,
9666 limit,
9667 property_filters,
9668 } => {
9669 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9670 let paged = store.paged_edges(
9671 edge_kind.as_deref(),
9672 graph_db_query_options_for_store(&options),
9673 )?;
9674 report.edges = paged.edges.into_iter().map(Into::into).collect();
9675 report.page = Some(graph_db_page_report_from_store(
9676 paged.page,
9677 options.property_filters,
9678 ));
9679 }
9680 GraphDbQuery::Incident {
9681 id,
9682 edge_kind,
9683 cursor,
9684 limit,
9685 property_filters,
9686 } => {
9687 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9688 let paged = store.paged_incident_edges(
9689 &id,
9690 edge_kind.as_deref(),
9691 graph_db_query_options_for_store(&options),
9692 )?;
9693 report.edges = paged.edges.into_iter().map(Into::into).collect();
9694 report.page = Some(graph_db_page_report_from_store(
9695 paged.page,
9696 options.property_filters,
9697 ));
9698 }
9699 GraphDbQuery::Kind {
9700 kind,
9701 cursor,
9702 limit,
9703 property_filters,
9704 } => {
9705 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9706 let paged =
9707 store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
9708 report.nodes = paged.nodes.into_iter().map(Into::into).collect();
9709 report.edges = paged.edges.into_iter().map(Into::into).collect();
9710 report.page = Some(graph_db_page_report_from_store(
9711 paged.page,
9712 options.property_filters,
9713 ));
9714 }
9715 GraphDbQuery::Neighborhood {
9716 id,
9717 depth,
9718 edge_kind,
9719 cursor,
9720 limit,
9721 property_filters,
9722 } => {
9723 let options = graph_db_query_options(cursor, limit, &property_filters)?;
9724 if let Some(paged) = store.paged_neighborhood(
9725 &id,
9726 depth,
9727 edge_kind.as_deref(),
9728 graph_db_query_options_for_store(&options),
9729 )? {
9730 let budgeted = graph_db_apply_output_budget(
9731 std::slice::from_ref(&id),
9732 &BTreeMap::new(),
9733 paged.nodes,
9734 paged.edges,
9735 options.limit,
9736 );
9737 let budget_report = budgeted.report;
9738 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
9739 let ranked_neighbors = graph_db_ranked_neighbors(
9740 &id,
9741 &budgeted.nodes,
9742 &budgeted.edges,
9743 ranked_neighbor_cap,
9744 );
9745 let comparison = graph_db_ranked_neighborhood_comparison(
9746 &id,
9747 depth,
9748 edge_kind.as_deref(),
9749 options.limit,
9750 &budgeted.nodes,
9751 &budgeted.edges,
9752 store,
9753 )?;
9754 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9755 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9756 report.ranked_neighbors = ranked_neighbors;
9757 report.neighborhood_ranking_gate =
9758 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9759 let mut page =
9760 graph_db_page_report_from_store(paged.page, options.property_filters);
9761 page.returned_nodes = report.nodes.len();
9762 page.returned_edges = report.edges.len();
9763 page.truncated |= !budget_report.dropped_by_budget.is_empty();
9764 page.diagnostics.extend(budget_report.diagnostics.clone());
9765 report.page = Some(page);
9766 report.output_budget = Some(budget_report);
9767 if let Some(comparison) = comparison {
9768 report.ranked_neighborhood_comparison = Some(comparison);
9769 }
9770 }
9771 }
9772 GraphDbQuery::Path {
9773 from,
9774 to,
9775 edge_kind,
9776 max_hops,
9777 } => {
9778 report.path =
9779 store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
9780 if let Some(max_hops) = max_hops
9781 && report.path.is_none()
9782 {
9783 report.warnings.push(format!(
9784 "no directed path found within --max-hops {}",
9785 max_hops
9786 ));
9787 }
9788 }
9789 GraphDbQuery::Map { .. } => {
9790 bail!("graph-db map must be handled by the map command path");
9791 }
9792 }
9793 Ok(report)
9794}
9795
9796pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
9797 if compact {
9798 println!(
9799 "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
9800 report.backend,
9801 report.query,
9802 report.nodes.len() + usize::from(report.node.is_some()),
9803 report.edges.len() + usize::from(report.edge.is_some()),
9804 report.freshness.status
9805 );
9806 return;
9807 }
9808 println!("graph-db backend: {}", report.backend);
9809 println!("freshness: {}", report.freshness.status);
9810 if let Some(readiness) = &report.readiness {
9811 println!(
9812 "readiness: {} reason: {} fail_closed: {}",
9813 readiness.status, readiness.reason, readiness.fail_closed
9814 );
9815 for diagnostic in &readiness.diagnostics {
9816 println!("readiness diagnostic: {diagnostic}");
9817 }
9818 for command in &readiness.next_commands {
9819 println!("readiness next: {command}");
9820 }
9821 }
9822 if let Some(schema) = &report.schema {
9823 println!(
9824 "schema: {} node fields, {} edge fields, {} operations",
9825 schema.node_fields.len(),
9826 schema.edge_fields.len(),
9827 schema.operations.len()
9828 );
9829 }
9830 if let Some(node) = &report.node {
9831 println!("node: {} [{}] {}", node.id, node.kind, node.label);
9832 }
9833 if let Some(edge) = &report.edge {
9834 let edge_full: SubstrateGraphEdge = edge.into();
9835 println!(
9836 "edge: {} {} -{}-> {}",
9837 graph_db_edge_key(&edge_full),
9838 edge.from_id,
9839 edge.kind,
9840 edge.to_id
9841 );
9842 }
9843 if let Some(knowledge) = &report.knowledge_retrieval {
9844 println!(
9845 "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
9846 knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
9847 );
9848 }
9849 for item in &report.semantic_related {
9850 println!(
9851 "semantic_seed: {:.3} [{}] {} ({})",
9852 item.score, item.kind, item.label, item.handle
9853 );
9854 }
9855 for node in &report.nodes {
9856 println!("node: {} [{}] {}", node.id, node.kind, node.label);
9857 }
9858 for edge in &report.edges {
9859 let edge_full: SubstrateGraphEdge = edge.into();
9860 println!(
9861 "edge: {} {} -{}-> {}",
9862 graph_db_edge_key(&edge_full),
9863 edge.from_id,
9864 edge.kind,
9865 edge.to_id
9866 );
9867 }
9868 for neighbor in &report.ranked_neighbors {
9869 println!(
9870 "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
9871 neighbor.rank,
9872 neighbor.score,
9873 neighbor
9874 .depth
9875 .map(|depth| depth.to_string())
9876 .unwrap_or_else(|| "unknown".to_string()),
9877 neighbor.node_id,
9878 neighbor.kind,
9879 neighbor.label
9880 );
9881 }
9882 if let Some(gate) = &report.neighborhood_ranking_gate {
9883 println!(
9884 "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
9885 gate.status, gate.default_order, gate.ranked_output_default
9886 );
9887 }
9888 if let Some(path) = &report.path {
9889 println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
9890 }
9891 if let Some(page) = &report.page {
9892 if let Some(next_cursor) = &page.next_cursor {
9893 println!("next_cursor: {next_cursor}");
9894 }
9895 for diagnostic in &page.diagnostics {
9896 println!("page: {diagnostic}");
9897 }
9898 }
9899 for warning in &report.warnings {
9900 println!("warning: {warning}");
9901 }
9902}
9903
9904pub(crate) fn graph_db_backend_eval_phase_timing(
9905 name: &str,
9906 duration_micros: u128,
9907 detail: &str,
9908) -> GraphDbBackendEvalPhaseTiming {
9909 GraphDbBackendEvalPhaseTiming {
9910 name: name.to_string(),
9911 duration_micros,
9912 detail: detail.to_string(),
9913 }
9914}
9915
9916pub(crate) fn graph_db_backend_eval_timed_phase<T>(
9917 phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
9918 name: &str,
9919 detail: &str,
9920 run: impl FnOnce() -> Result<T>,
9921) -> Result<T> {
9922 let started = Instant::now();
9923 let result = run();
9924 phases.push(graph_db_backend_eval_phase_timing(
9925 name,
9926 started.elapsed().as_micros(),
9927 detail,
9928 ));
9929 result
9930}
9931
9932pub(crate) fn graph_db_backend_eval_refresh_total_micros(
9933 phases: &[GraphDbBackendEvalPhaseTiming],
9934) -> u128 {
9935 phases
9936 .iter()
9937 .filter(|phase| phase.name != "conflict_matrix_preparation")
9938 .map(|phase| phase.duration_micros)
9939 .sum()
9940}
9941
9942pub(crate) fn graph_db_backend_eval_cached_refresh(
9943 root: &Path,
9944 scope: Option<&str>,
9945 source_watermark: Option<&str>,
9946) -> Result<
9947 Option<(
9948 TraversalGraphBuild,
9949 SqliteProjectionRefresh,
9950 Vec<GraphDbBackendEvalPhaseTiming>,
9951 )>,
9952> {
9953 let Some(source_watermark) = source_watermark else {
9954 return Ok(None);
9955 };
9956 let graph_db = graph_substrate_db_path(root, scope);
9957 if !graph_db.exists() {
9958 return Ok(None);
9959 }
9960
9961 let started = Instant::now();
9962 let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
9963 Ok(store) => store,
9964 Err(_) => return Ok(None),
9965 };
9966 if store.has_user_triggers().unwrap_or(true) {
9967 return Ok(None);
9968 }
9969 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
9970 if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
9971 return Ok(None);
9972 }
9973
9974 let phases = vec![
9975 graph_db_backend_eval_phase_timing(
9976 "source_graph_build",
9977 started.elapsed().as_micros(),
9978 "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
9979 ),
9980 graph_db_backend_eval_phase_timing(
9981 "projection_rows",
9982 0,
9983 "reused cached provider-neutral projection rows from graph.db",
9984 ),
9985 graph_db_backend_eval_phase_timing(
9986 "sqlite_open",
9987 0,
9988 "reused existing graph.db projection without opening a write transaction",
9989 ),
9990 ];
9991 let refresh = SqliteProjectionRefresh {
9992 scope: scope.unwrap_or("root").to_string(),
9993 projection_version: freshness
9994 .projection_version
9995 .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
9996 source_watermark: Some(source_watermark.to_string()),
9997 tombstoned_nodes: Vec::new(),
9998 tombstoned_edges: Vec::new(),
9999 upserted_nodes: 0,
10000 upserted_edges: 0,
10001 unchanged_nodes: 0,
10002 unchanged_edges: 0,
10003 upserted_properties: 0,
10004 unchanged_properties: 0,
10005 deleted_properties: 0,
10006 deleted_nodes: 0,
10007 deleted_edges: 0,
10008 pruned_tombstones: 0,
10009 file_size_bytes_before: None,
10010 file_size_bytes_after: None,
10011 phase_timings: Vec::new(),
10012 };
10013 Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
10014}
10015
10016pub(crate) fn graph_db_backend_eval_reused_cached_projection(
10017 phases: &[GraphDbBackendEvalPhaseTiming],
10018) -> bool {
10019 phases.iter().any(|phase| {
10020 phase.name == "source_graph_build"
10021 && phase.detail.contains("reused current graph.db projection")
10022 })
10023}
10024
10025pub(crate) fn graph_db_backend_eval_update_source_watermark(
10026 root: &Path,
10027 path_hint: &Path,
10028 scope: Option<&str>,
10029) -> Result<()> {
10030 let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
10031 return Ok(());
10032 };
10033 let graph_db = graph_substrate_db_path(root, scope);
10034 let mut store = SqliteGraphStore::open(&graph_db)?;
10035 store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
10036 Ok(())
10037}
10038
10039pub(crate) fn graph_db_backend_eval_refresh_with_profile(
10040 root: &Path,
10041 path_hint: &Path,
10042 scope: Option<&str>,
10043) -> Result<(
10044 TraversalGraphBuild,
10045 SqliteProjectionRefresh,
10046 Vec<GraphDbBackendEvalPhaseTiming>,
10047)> {
10048 let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
10049 if let Some(cached) =
10050 graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
10051 {
10052 return Ok(cached);
10053 }
10054
10055 let mut phases = Vec::new();
10056 let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
10057 "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"
10058 } else {
10059 "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
10060 };
10061 let source_graph = graph_db_backend_eval_timed_phase(
10062 &mut phases,
10063 "source_graph_build",
10064 source_graph_detail,
10065 || build_traversal_graph_source_with_options(root, path_hint, scope, false),
10066 )?;
10067 let projection = graph_db_backend_eval_timed_phase(
10068 &mut phases,
10069 "projection_rows",
10070 "provider-neutral GraphStore node/edge row construction before SQLite persistence",
10071 || traversal_projection_from_graph(root, scope, &source_graph),
10072 )?;
10073 let graph_db = graph_substrate_db_path(root, scope);
10074 let mut store = graph_db_backend_eval_timed_phase(
10075 &mut phases,
10076 "sqlite_open",
10077 "open the local SQLite graph.db with WAL and busy-timeout settings",
10078 || SqliteGraphStore::open(&graph_db),
10079 )?;
10080 let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
10081 .ok()
10082 .flatten();
10083 let refresh = store.replace_projection_with_version(
10084 scope.unwrap_or("root"),
10085 &projection,
10086 Some(GRAPH_PROJECTION_VERSION),
10087 refreshed_source_watermark
10088 .or(source_watermark)
10089 .or_else(|| graph_projection_content_hash(&projection)),
10090 )?;
10091 phases.extend(
10092 refresh
10093 .phase_timings
10094 .iter()
10095 .map(|phase| GraphDbBackendEvalPhaseTiming {
10096 name: phase.name.clone(),
10097 duration_micros: phase.duration_micros,
10098 detail: phase.detail.clone(),
10099 }),
10100 );
10101 Ok((source_graph, refresh, phases))
10102}
10103
10104fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10105 root.join(".tsift/backend-eval-cache")
10106}
10107
10108fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10109 graph_db_backend_eval_disk_cache_dir(root)
10110 .join(kind)
10111 .join(format!("{key}.json.gz"))
10112}
10113
10114fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10115 graph_db_backend_eval_disk_cache_dir(root)
10116 .join(kind)
10117 .join(format!("{key}.json"))
10118}
10119
10120#[derive(Default, Clone)]
10121struct GraphDbBackendEvalDiskCacheReadProfile {
10122 file_read_micros: u128,
10123 gzip_decode_micros: u128,
10124 serde_decode_micros: u128,
10125 legacy: bool,
10126}
10127
10128fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10129 root: &Path,
10130 kind: &str,
10131 key: &str,
10132) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10133 let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10134 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10135 let read_started = Instant::now();
10136 let read_result = fs::read(&path);
10137 profile.file_read_micros = read_started.elapsed().as_micros();
10138 if let Ok(bytes) = read_result {
10139 let decode_started = Instant::now();
10140 let mut decoder = GzDecoder::new(bytes.as_slice());
10141 let mut decoded = Vec::new();
10142 let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10143 profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10144 if decode_ok {
10145 let serde_started = Instant::now();
10146 let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10147 profile.serde_decode_micros = serde_started.elapsed().as_micros();
10148 if let Some(value) = parsed {
10149 return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10150 }
10151 }
10152 }
10153
10154 let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10155 let legacy_started = Instant::now();
10156 let bytes = fs::read(legacy_path).ok()?;
10157 profile.file_read_micros = profile
10158 .file_read_micros
10159 .saturating_add(legacy_started.elapsed().as_micros());
10160 let serde_started = Instant::now();
10161 let value = serde_json::from_slice(&bytes).ok()?;
10162 profile.serde_decode_micros = profile
10163 .serde_decode_micros
10164 .saturating_add(serde_started.elapsed().as_micros());
10165 profile.legacy = true;
10166 Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10167}
10168
10169#[derive(Default, Clone)]
10170struct GraphDbBackendEvalDiskCacheWriteProfile {
10171 serde_encode_micros: u128,
10172 gzip_encode_micros: u128,
10173 file_write_micros: u128,
10174}
10175
10176fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10177 root: &Path,
10178 kind: &str,
10179 key: &str,
10180 value: &T,
10181) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10182 let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10183 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10184 let parent = path.parent()?;
10185 if fs::create_dir_all(parent).is_err() {
10186 return None;
10187 }
10188 let serde_started = Instant::now();
10189 let bytes = serde_json::to_vec(value).ok()?;
10190 profile.serde_encode_micros = serde_started.elapsed().as_micros();
10191 let gzip_started = Instant::now();
10192 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10193 if encoder.write_all(&bytes).is_err() {
10194 return None;
10195 }
10196 let encoded = encoder.finish().ok()?;
10197 profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10198 let write_started = Instant::now();
10199 if fs::write(&path, &encoded).is_err() {
10200 return None;
10201 }
10202 profile.file_write_micros = write_started.elapsed().as_micros();
10203 Some((encoded.len() as u64, bytes.len() as u64, profile))
10204}
10205
10206fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10207 let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10208 let Ok(entries) = fs::read_dir(dir) else {
10209 return (0, 0);
10210 };
10211 let keep_name = format!("{keep_key}.json.gz");
10212 let mut pruned_files = 0usize;
10213 let mut pruned_bytes = 0u64;
10214 for entry in entries.flatten() {
10215 let path = entry.path();
10216 if !path.is_file() {
10217 continue;
10218 }
10219 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10220 continue;
10221 };
10222 if name == keep_name {
10223 continue;
10224 }
10225 let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10226 if !is_backend_eval_cache {
10227 continue;
10228 }
10229 let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10230 if fs::remove_file(&path).is_ok() {
10231 pruned_files += 1;
10232 pruned_bytes += bytes;
10233 }
10234 }
10235 (pruned_files, pruned_bytes)
10236}
10237
10238fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10239 root: &Path,
10240 source_root: &Path,
10241) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10242 let mut rows = Vec::new();
10243 let mut entries = walk::walk_files(source_root)?;
10244 entries.sort_by(|left, right| left.path.cmp(&right.path));
10245 for entry in entries {
10246 if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10247 continue;
10248 }
10249 if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10250 continue;
10251 }
10252 let bytes = fs::read(&entry.path)
10253 .with_context(|| format!("reading source input {}", entry.path.display()))?;
10254 rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10255 path: traversal_watermark_path(root, &entry.path),
10256 bytes: bytes.len() as u64,
10257 content_hash: content_hash(&bytes)?,
10258 });
10259 }
10260 Ok(rows)
10261}
10262
10263fn graph_db_backend_eval_full_projection_source_watermark(
10264 root: &Path,
10265 scope: Option<&str>,
10266) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10267 let path_hint = root;
10268 let mut detail_parts = Vec::new();
10269 let mut parts = vec![
10270 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10271 format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10272 "watermark_kind:stable_full_projection_inputs".to_string(),
10273 format!("scope:{}", scope.unwrap_or("root")),
10274 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10275 ];
10276
10277 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10278 match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10279 Some(db_path) => {
10280 let db = index::IndexDb::open_read_only_resilient(db_path)?;
10281 parts.push("index_mode:indexed".to_string());
10282 detail_parts.push("mode=indexed".to_string());
10283 parts.push(format!(
10284 "index_source_root:{}",
10285 traversal_watermark_path(root, &gate.source_root)
10286 ));
10287
10288 let symbols = db
10289 .all_symbols()?
10290 .into_iter()
10291 .filter(|symbol| {
10292 !traversal_path_is_generated_artifact(
10293 root,
10294 &gate.source_root,
10295 Path::new(&symbol.file),
10296 ) && !traversal_path_is_session_markdown(
10297 root,
10298 &gate.source_root,
10299 Path::new(&symbol.file),
10300 )
10301 })
10302 .collect::<Vec<_>>();
10303 let symbols_hash = content_hash(&symbols)?;
10304 detail_parts.push(format!("symbols={symbols_hash}"));
10305 parts.push(format!("index_symbols:{symbols_hash}"));
10306
10307 let edges = db
10308 .all_stored_edges()?
10309 .into_iter()
10310 .filter(|edge| {
10311 !traversal_path_is_generated_artifact(
10312 root,
10313 &gate.source_root,
10314 Path::new(&edge.caller_file),
10315 ) && !traversal_path_is_session_markdown(
10316 root,
10317 &gate.source_root,
10318 Path::new(&edge.caller_file),
10319 )
10320 })
10321 .collect::<Vec<_>>();
10322 let edges_hash = content_hash(&edges)?;
10323 detail_parts.push(format!("call_edges={edges_hash}"));
10324 parts.push(format!("index_call_edges:{edges_hash}"));
10325
10326 let routes = db
10327 .all_routes()?
10328 .into_iter()
10329 .filter(|route| {
10330 !traversal_path_is_generated_artifact(
10331 root,
10332 &gate.source_root,
10333 Path::new(&route.file),
10334 ) && !traversal_path_is_session_markdown(
10335 root,
10336 &gate.source_root,
10337 Path::new(&route.file),
10338 )
10339 })
10340 .collect::<Vec<_>>();
10341 let routes_hash = content_hash(&routes)?;
10342 detail_parts.push(format!("routes={routes_hash}"));
10343 parts.push(format!("index_routes:{routes_hash}"));
10344 }
10345 None => {
10346 parts.push("index_mode:raw_fallback".to_string());
10347 detail_parts.push("mode=raw_fallback".to_string());
10348 parts.push(format!(
10349 "raw_source_root:{}",
10350 traversal_watermark_path(root, &gate.source_root)
10351 ));
10352 let raw_rows =
10353 graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10354 let raw_hash = content_hash(&raw_rows)?;
10355 detail_parts.push(format!("raw_source_files={raw_hash}"));
10356 parts.push(format!("raw_source_files:{raw_hash}"));
10357 }
10358 }
10359
10360 parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10361 detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10362 let summaries_start = parts.len();
10363 push_traversal_summaries_watermark_part(root, &mut parts)?;
10364 let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10365 detail_parts.push(format!("summaries={summaries_hash}"));
10366 let value = content_hash(&parts)?;
10367 detail_parts.push(format!("watermark={value}"));
10368 Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10369 value,
10370 detail: detail_parts.join(" "),
10371 })
10372}
10373
10374fn graph_db_backend_eval_full_projection_cache_key(
10375 root: &Path,
10376 scope: Option<&str>,
10377) -> Result<(String, String, String)> {
10378 let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10379 let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10380 root,
10381 scope,
10382 &source_watermark.value,
10383 )?;
10384 Ok((source_watermark.value, key, source_watermark.detail))
10385}
10386
10387fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10388 root: &Path,
10389 scope: Option<&str>,
10390 source_watermark: &str,
10391) -> Result<String> {
10392 content_hash(&serde_json::json!({
10393 "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10394 "root": root.display().to_string(),
10395 "scope": scope.unwrap_or("root"),
10396 "source_watermark": source_watermark,
10397 }))
10398}
10399
10400pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10401 root: &Path,
10402 scope: Option<&str>,
10403) -> Result<(
10404 GraphProjection,
10405 Vec<String>,
10406 Vec<GraphDbBackendEvalPhaseTiming>,
10407 GraphDbBackendEvalFullProjectionCacheStats,
10408)> {
10409 let (source_watermark, key, source_watermark_detail) =
10410 graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10411 let lookup_started = Instant::now();
10412 if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10413 graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10414 root,
10415 "full_projection",
10416 &key,
10417 )
10418 && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
10419 && cached.key == key
10420 && cached.source_watermark == source_watermark
10421 {
10422 let lookup_overhead_micros = lookup_started
10423 .elapsed()
10424 .as_micros()
10425 .saturating_sub(read_profile.file_read_micros)
10426 .saturating_sub(read_profile.gzip_decode_micros)
10427 .saturating_sub(read_profile.serde_decode_micros);
10428 let prune_started = Instant::now();
10429 let (pruned_files, pruned_bytes) =
10430 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
10431 let prune_micros = prune_started.elapsed().as_micros();
10432 let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
10433 hit: true,
10434 disk_bytes,
10435 json_bytes,
10436 pruned_files,
10437 pruned_bytes,
10438 };
10439 let read_detail_suffix = if read_profile.legacy {
10440 " (legacy uncompressed cache path)"
10441 } else {
10442 ""
10443 };
10444 return Ok((
10445 cached.projection,
10446 cached.warnings,
10447 vec![
10448 graph_db_backend_eval_phase_timing(
10449 "full_projection.cache_lookup",
10450 lookup_overhead_micros,
10451 &format!(
10452 "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
10453 ),
10454 ),
10455 graph_db_backend_eval_phase_timing(
10456 "full_projection.cache.file_read",
10457 read_profile.file_read_micros,
10458 &format!(
10459 "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
10460 ),
10461 ),
10462 graph_db_backend_eval_phase_timing(
10463 "full_projection.cache.gzip_decode",
10464 read_profile.gzip_decode_micros,
10465 "gunzip the compressed projection cache bytes",
10466 ),
10467 graph_db_backend_eval_phase_timing(
10468 "full_projection.cache.serde_decode",
10469 read_profile.serde_decode_micros,
10470 "serde_json deserialize the decoded projection cache payload",
10471 ),
10472 graph_db_backend_eval_phase_timing(
10473 "full_projection.cache.prune",
10474 prune_micros,
10475 "prune sibling cache files older than the current key",
10476 ),
10477 graph_db_backend_eval_phase_timing(
10478 "full_projection.source_graph_build",
10479 0,
10480 "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10481 ),
10482 graph_db_backend_eval_phase_timing(
10483 "full_projection.projection_rows",
10484 0,
10485 "reused cached provider-neutral full-project projection rows",
10486 ),
10487 ],
10488 cache_stats,
10489 ));
10490 }
10491
10492 let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
10493 let mut phases = vec![graph_db_backend_eval_phase_timing(
10494 "full_projection.cache_lookup",
10495 lookup_started.elapsed().as_micros(),
10496 &format!(
10497 "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
10498 ),
10499 )];
10500 let full_source = graph_db_backend_eval_timed_phase(
10501 &mut phases,
10502 "full_projection.source_graph_build",
10503 "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
10504 || build_traversal_graph_source_with_options(root, root, scope, false),
10505 )?;
10506 let projection = graph_db_backend_eval_timed_phase(
10507 &mut phases,
10508 "full_projection.projection_rows",
10509 "provider-neutral row construction for the opt-in full-project projection dataset",
10510 || traversal_projection_from_graph(root, scope, &full_source),
10511 )?;
10512 let warnings = full_source.warnings;
10513 let refreshed_source_watermark =
10514 graph_db_backend_eval_full_projection_source_watermark(root, scope)
10515 .map(|watermark| watermark.value)
10516 .unwrap_or_else(|_| source_watermark.clone());
10517 let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10518 root,
10519 scope,
10520 &refreshed_source_watermark,
10521 )?;
10522 let cache = GraphDbBackendEvalFullProjectionCache {
10523 version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
10524 key: write_key.clone(),
10525 source_watermark: refreshed_source_watermark,
10526 projection: projection.clone(),
10527 warnings: warnings.clone(),
10528 };
10529 if let Some((disk_bytes, json_bytes, write_profile)) =
10530 graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
10531 {
10532 cache_stats.disk_bytes = disk_bytes;
10533 cache_stats.json_bytes = json_bytes;
10534 phases.push(graph_db_backend_eval_phase_timing(
10535 "full_projection.cache.serde_encode",
10536 write_profile.serde_encode_micros,
10537 "serde_json serialize the projection cache payload before compression",
10538 ));
10539 phases.push(graph_db_backend_eval_phase_timing(
10540 "full_projection.cache.gzip_encode",
10541 write_profile.gzip_encode_micros,
10542 "gzip-compress the serialized projection cache payload",
10543 ));
10544 phases.push(graph_db_backend_eval_phase_timing(
10545 "full_projection.cache.file_write",
10546 write_profile.file_write_micros,
10547 "write the compressed projection cache bytes to .tsift/backend-eval-cache",
10548 ));
10549 }
10550 let prune_started = Instant::now();
10551 let (pruned_files, pruned_bytes) =
10552 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
10553 phases.push(graph_db_backend_eval_phase_timing(
10554 "full_projection.cache.prune",
10555 prune_started.elapsed().as_micros(),
10556 "prune sibling cache files older than the current key",
10557 ));
10558 cache_stats.pruned_files = pruned_files;
10559 cache_stats.pruned_bytes = pruned_bytes;
10560 Ok((projection, warnings, phases, cache_stats))
10561}
10562
10563fn graph_db_backend_eval_timed(
10564 name: &str,
10565 run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
10566) -> (
10567 GraphDbBackendEvalOperation,
10568 Option<GraphDbBackendEvalSignature>,
10569) {
10570 let started = Instant::now();
10571 match run() {
10572 Ok((rows, value)) => (
10573 GraphDbBackendEvalOperation {
10574 name: name.to_string(),
10575 supported: true,
10576 status: "ok".to_string(),
10577 duration_micros: started.elapsed().as_micros(),
10578 rows,
10579 error: None,
10580 },
10581 Some(GraphDbBackendEvalSignature {
10582 operation: name.to_string(),
10583 value,
10584 }),
10585 ),
10586 Err(err) => (
10587 GraphDbBackendEvalOperation {
10588 name: name.to_string(),
10589 supported: false,
10590 status: "error".to_string(),
10591 duration_micros: started.elapsed().as_micros(),
10592 rows: None,
10593 error: Some(format!("{err:#}")),
10594 },
10595 None,
10596 ),
10597 }
10598}
10599
10600fn graph_db_backend_eval_parity(
10601 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10602 candidate_signatures: &[GraphDbBackendEvalSignature],
10603) -> GraphDbBackendEvalParity {
10604 let Some(sqlite_signatures) = sqlite_signatures else {
10605 return GraphDbBackendEvalParity {
10606 matches_sqlite: true,
10607 diagnostics: Vec::new(),
10608 };
10609 };
10610 let sqlite = sqlite_signatures
10611 .iter()
10612 .map(|signature| (signature.operation.as_str(), &signature.value))
10613 .collect::<BTreeMap<_, _>>();
10614 let candidate = candidate_signatures
10615 .iter()
10616 .map(|signature| (signature.operation.as_str(), &signature.value))
10617 .collect::<BTreeMap<_, _>>();
10618 let mut diagnostics = Vec::new();
10619 for (operation, sqlite_value) in sqlite {
10620 match candidate.get(operation) {
10621 Some(candidate_value) if *candidate_value == sqlite_value => {}
10622 Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
10623 None => diagnostics.push(format!(
10624 "{operation} did not complete for candidate backend"
10625 )),
10626 }
10627 }
10628 GraphDbBackendEvalParity {
10629 matches_sqlite: diagnostics.is_empty(),
10630 diagnostics,
10631 }
10632}
10633
10634pub(crate) fn graph_db_backend_eval_targets(
10635 store: &impl GraphStore,
10636 requested: &[String],
10637) -> Result<Vec<String>> {
10638 let requested = requested
10639 .iter()
10640 .filter_map(|target| normalize_conflict_target(target))
10641 .collect::<Vec<_>>();
10642 if !requested.is_empty() {
10643 return Ok(requested);
10644 }
10645
10646 for kind in ["backlog", "job_packet"] {
10647 let nodes = store.nodes_by_kind(kind)?;
10648 if let Some(node) = nodes.first() {
10649 if let Some(ref_id) = node.properties.get("ref_id") {
10650 return Ok(vec![ref_id.clone()]);
10651 }
10652 return Ok(vec![node.id.clone()]);
10653 }
10654 }
10655 Ok(Vec::new())
10656}
10657
10658fn graph_db_backend_eval_path_targets(
10659 store: &impl GraphStore,
10660 max_hops: usize,
10661) -> Result<Option<(String, String, usize)>> {
10662 let synthetic_from = "gsym-synthetic-0000";
10663 let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
10664 if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
10665 let outgoing = store.outgoing_edges(synthetic_from, None)?;
10666 if outgoing.len() > 1
10667 && let Some(edge) = outgoing.first()
10668 {
10669 return Ok(Some((
10670 edge.from_id.clone(),
10671 edge.to_id.clone(),
10672 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10673 )));
10674 }
10675 return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
10676 }
10677
10678 Ok(store.sample_edge(None)?.map(|edge| {
10679 (
10680 edge.from_id,
10681 edge.to_id,
10682 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10683 )
10684 }))
10685}
10686
10687fn graph_db_backend_eval_path_operation<S: GraphStore>(
10688 store: &S,
10689 configured_max_hops: usize,
10690) -> (
10691 GraphDbBackendEvalOperation,
10692 Option<GraphDbBackendEvalSignature>,
10693) {
10694 let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10695 "path_max_hops".to_string()
10696 } else {
10697 format!("path_max_hops_{configured_max_hops}")
10698 };
10699 graph_db_backend_eval_timed(&operation_name, || {
10700 let (from, to, effective_max_hops) =
10701 graph_db_backend_eval_path_targets(store, configured_max_hops)?
10702 .context("backend-eval path probe requires at least one traversable edge")?;
10703 let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
10704 let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10705 Some(format!(
10706 "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
10707 GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
10708 ))
10709 } else if path.is_none() && effective_max_hops == configured_max_hops {
10710 Some(format!(
10711 "path probe truncated at {configured_max_hops} hops before a route was found"
10712 ))
10713 } else {
10714 None
10715 };
10716 Ok((
10717 path.as_ref().map(|path| path.nodes.len()),
10718 serde_json::json!({
10719 "from": from,
10720 "to": to,
10721 "configured_max_hops": configured_max_hops,
10722 "effective_max_hops": effective_max_hops,
10723 "hops": path.as_ref().map(|path| path.hops),
10724 "nodes": path.as_ref().map(|path| &path.nodes),
10725 "found": path.is_some(),
10726 "warning": warning,
10727 }),
10728 ))
10729 })
10730}
10731
10732fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
10733 store: &S,
10734 depth: usize,
10735 limit: usize,
10736) -> (
10737 GraphDbBackendEvalOperation,
10738 Option<GraphDbBackendEvalSignature>,
10739) {
10740 graph_db_backend_eval_timed("neighborhood", || {
10741 let edge = match store.sample_edge(Some("calls"))? {
10742 Some(edge) => edge,
10743 None => store.sample_edge(None)?.context(
10744 "backend-eval neighborhood probe requires at least one traversable edge",
10745 )?,
10746 };
10747 let page = store
10748 .paged_neighborhood(
10749 &edge.from_id,
10750 depth,
10751 Some(&edge.kind),
10752 GraphQueryOptions {
10753 limit: Some(limit.max(1)),
10754 ..GraphQueryOptions::default()
10755 },
10756 )?
10757 .with_context(|| {
10758 format!(
10759 "backend-eval neighborhood target not found: {}",
10760 edge.from_id
10761 )
10762 })?;
10763 Ok((
10764 Some(page.nodes.len() + page.edges.len()),
10765 serde_json::json!({
10766 "center": edge.from_id,
10767 "kind": edge.kind,
10768 "depth": depth,
10769 "limit": limit.max(1),
10770 "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10771 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10772 "truncated": page.page.truncated,
10773 }),
10774 ))
10775 })
10776}
10777
10778fn graph_db_backend_eval_related_operation<S: GraphStore>(
10779 root: &Path,
10780 scope: Option<&str>,
10781 store: &S,
10782 depth: usize,
10783 limit: usize,
10784) -> (
10785 GraphDbBackendEvalOperation,
10786 Option<GraphDbBackendEvalSignature>,
10787) {
10788 graph_db_backend_eval_timed("related", || {
10789 let query = "backend evaluation";
10790 let semantic = semantic_related_report_from_store(
10791 root,
10792 scope,
10793 query,
10794 3,
10795 SemanticRelatedKind::All,
10796 store,
10797 )?;
10798 let seed_ids = semantic
10799 .items
10800 .iter()
10801 .map(|item| item.handle.clone())
10802 .collect::<Vec<_>>();
10803 let subgraph =
10804 graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
10805 Ok((
10806 Some(subgraph.nodes.len() + subgraph.edges.len()),
10807 serde_json::json!({
10808 "query": query,
10809 "seed_ids": seed_ids,
10810 "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10811 "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10812 "truncated": subgraph.truncated,
10813 "warnings": semantic.warnings,
10814 "diagnostics": subgraph.diagnostics,
10815 }),
10816 ))
10817 })
10818}
10819
10820fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
10821 serde_json::json!({
10822 "target": report.target,
10823 "target_node_id": report.target_node.id,
10824 "target_kind": report.target_node.kind,
10825 "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
10826 "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
10827 "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
10828 "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
10829 "path_count": report.shortest_paths.len(),
10830 })
10831}
10832
10833fn graph_db_backend_eval_target_resolution_signature(
10834 resolved: &[(String, SubstrateGraphNode)],
10835) -> serde_json::Value {
10836 serde_json::json!({
10837 "targets": resolved.iter().map(|(target, node)| {
10838 serde_json::json!({
10839 "target": target,
10840 "target_node_id": node.id,
10841 "target_kind": node.kind,
10842 "target_label": node.label,
10843 })
10844 }).collect::<Vec<_>>(),
10845 })
10846}
10847
10848fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
10849 serde_json::json!({
10850 "targets": report.targets,
10851 "can_parallel": report.can_parallel,
10852 "fail_closed": report.fail_closed,
10853 "cross_target_parallel_safe": report.cross_target_parallel_safe,
10854 "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
10855 "candidates": report.candidates.iter().map(|candidate| {
10856 serde_json::json!({
10857 "target": candidate.target,
10858 "risk": conflict_risk_label(candidate.risk),
10859 "owned_files": candidate.owned_files,
10860 "owned_symbols": candidate.owned_symbols,
10861 "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
10862 "previously_completed": candidate.previously_completed,
10863 "parallel_safe": candidate.parallel_safe,
10864 })
10865 }).collect::<Vec<_>>(),
10866 "conflicts": report.conflicts.iter().map(|pair| {
10867 serde_json::json!({
10868 "left": pair.left,
10869 "right": pair.right,
10870 "risk": conflict_risk_label(pair.risk),
10871 })
10872 }).collect::<Vec<_>>(),
10873 })
10874}
10875
10876fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
10877 serde_json::json!({
10878 "targets": report.targets,
10879 "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10880 "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
10881 "evidence_packet_ids": report.evidence_packet_ids,
10882 "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
10883 "truncated": report.truncated,
10884 })
10885}
10886
10887fn graph_db_backend_eval_edge_scan_probe(
10888 store: &impl GraphStore,
10889) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
10890 if let Some((edge, filter)) = store.sample_edge_with_property()? {
10891 return Ok((edge, vec![filter]));
10892 }
10893 let edge = store
10894 .sample_edge(None)?
10895 .context("backend-eval edge scan requires at least one edge")?;
10896 Ok((edge, Vec::new()))
10897}
10898
10899#[allow(clippy::too_many_arguments)]
10900fn graph_db_backend_eval_report_for_store<S: GraphStore>(
10901 backend: &str,
10902 adapter: &str,
10903 read_only: bool,
10904 root: &Path,
10905 path: &Path,
10906 scope: Option<&str>,
10907 targets: &[String],
10908 depth: usize,
10909 limit: usize,
10910 impact_limit: usize,
10911 store: &S,
10912 freshness: GraphDbFreshnessReport,
10913 refresh_operation: GraphDbBackendEvalOperation,
10914 refresh_signature: Option<GraphDbBackendEvalSignature>,
10915 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10916 extra_warnings: Vec<String>,
10917 prepared: &ConflictMatrixPreparedInputs,
10918 projection_load: &str,
10919 lock_behavior: &str,
10920 install_portability: &str,
10921) -> (
10922 GraphDbBackendEvalBackendReport,
10923 Vec<GraphDbBackendEvalSignature>,
10924) {
10925 let mut operations = vec![refresh_operation];
10926 let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
10927
10928 let (operation, signature) = graph_db_backend_eval_timed("status", || {
10929 let (nodes, edges) = store.graph_counts()?;
10930 Ok((
10931 Some(nodes + edges),
10932 serde_json::json!({
10933 "freshness": freshness.status,
10934 "nodes": nodes,
10935 "edges": edges,
10936 }),
10937 ))
10938 });
10939 operations.push(operation);
10940 signatures.extend(signature);
10941
10942 let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
10943 let edge = store
10944 .sample_edge(None)?
10945 .context("backend-eval edge lookup requires at least one edge")?;
10946 let edge_id = graph_db_edge_key(&edge);
10947 let found = store
10948 .edge(&edge_id)?
10949 .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
10950 Ok((
10951 Some(1),
10952 serde_json::json!({
10953 "edge_id": edge_id,
10954 "from_id": found.from_id,
10955 "to_id": found.to_id,
10956 "kind": found.kind,
10957 }),
10958 ))
10959 });
10960 operations.push(operation);
10961 signatures.extend(signature);
10962
10963 let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
10964 let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
10965 let page = store.paged_edges(
10966 Some(&edge.kind),
10967 GraphQueryOptions {
10968 limit: Some(limit.max(1)),
10969 property_filters: filters.clone(),
10970 ..GraphQueryOptions::default()
10971 },
10972 )?;
10973 Ok((
10974 Some(page.edges.len()),
10975 serde_json::json!({
10976 "kind": edge.kind,
10977 "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
10978 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10979 "truncated": page.page.truncated,
10980 }),
10981 ))
10982 });
10983 operations.push(operation);
10984 signatures.extend(signature);
10985
10986 let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
10987 let edge = store
10988 .sample_edge(None)?
10989 .context("backend-eval incident edge scan requires at least one edge")?;
10990 let page = store.paged_incident_edges(
10991 &edge.from_id,
10992 Some(&edge.kind),
10993 GraphQueryOptions {
10994 limit: Some(limit.max(1)),
10995 ..GraphQueryOptions::default()
10996 },
10997 )?;
10998 Ok((
10999 Some(page.edges.len()),
11000 serde_json::json!({
11001 "node_id": edge.from_id,
11002 "kind": edge.kind,
11003 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11004 "truncated": page.page.truncated,
11005 }),
11006 ))
11007 });
11008 operations.push(operation);
11009 signatures.extend(signature);
11010
11011 let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
11012 operations.push(operation);
11013 signatures.extend(signature);
11014
11015 let (operation, signature) =
11016 graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
11017 operations.push(operation);
11018 signatures.extend(signature);
11019
11020 for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
11021 .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
11022 {
11023 let (operation, signature) =
11024 graph_db_backend_eval_path_operation(store, configured_max_hops);
11025 operations.push(operation);
11026 signatures.extend(signature);
11027 }
11028
11029 let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
11030 let resolved = targets
11031 .iter()
11032 .map(|target| {
11033 let node = graph_db_resolve_evidence_target(store, target)?
11034 .with_context(|| format!("backend-eval target not found: {target}"))?;
11035 Ok((target.clone(), node))
11036 })
11037 .collect::<Result<Vec<_>>>()?;
11038 let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
11039 Ok((Some(resolved.len()), signature))
11040 });
11041 operations.push(operation);
11042 signatures.extend(signature);
11043
11044 let mut evidence_for_report = None;
11045 let mut graph_snapshot_for_trace = None;
11046 let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
11047 let resolved_targets =
11048 resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
11049 let evidence = collect_conflict_matrix_evidence_packets(
11050 root,
11051 scope,
11052 backend,
11053 &resolved_targets,
11054 depth,
11055 limit,
11056 store,
11057 freshness.clone(),
11058 )?;
11059 let report = &evidence
11060 .first()
11061 .context("backend-eval evidence requires at least one target")?
11062 .report;
11063 let rows = evidence
11064 .iter()
11065 .map(|entry| {
11066 entry.report.worker_context.len()
11067 + entry.report.source_handles.len()
11068 + entry.report.worker_results.len()
11069 + entry.report.semantic_related.len()
11070 })
11071 .sum();
11072 let signature = graph_db_backend_eval_evidence_signature(report);
11073 evidence_for_report = Some((resolved_targets, evidence));
11074 Ok((Some(rows), signature))
11075 });
11076 operations.push(operation);
11077 signatures.extend(signature);
11078
11079 let mut conflict_for_trace = None;
11080 let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
11081 let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
11082 let graph =
11083 conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
11084 let shared_preparation =
11085 conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
11086 ConflictMatrixGraphPreparedInputs {
11087 targets,
11088 graph,
11089 evidence,
11090 shared_preparation,
11091 }
11092 } else {
11093 prepare_conflict_matrix_graph_orchestration(
11094 root,
11095 scope,
11096 backend,
11097 targets,
11098 prepared,
11099 depth,
11100 limit,
11101 store,
11102 freshness.clone(),
11103 )?
11104 };
11105 let report = build_conflict_matrix_report_from_prepared_graph(
11106 root,
11107 path,
11108 scope,
11109 depth,
11110 limit,
11111 impact_limit,
11112 freshness.clone(),
11113 extra_warnings.clone(),
11114 prepared,
11115 &graph_prepared,
11116 )?;
11117 let signature = graph_db_backend_eval_conflict_signature(&report);
11118 let rows = report.candidates.len() + report.conflicts.len();
11119 conflict_for_trace = Some(report);
11120 graph_snapshot_for_trace = Some(graph_prepared.graph);
11121 Ok((Some(rows), signature))
11122 });
11123 operations.push(operation);
11124 signatures.extend(signature);
11125
11126 let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11127 let conflict = conflict_for_trace
11128 .take()
11129 .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11130 let graph = graph_snapshot_for_trace
11131 .take()
11132 .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11133 let report = build_dispatch_trace_report_from_conflict_snapshot(
11134 root,
11135 scope,
11136 conflict,
11137 graph.nodes,
11138 graph.edges,
11139 depth,
11140 limit,
11141 Vec::new(),
11142 )?;
11143 Ok((
11144 Some(report.nodes.len() + report.edges.len()),
11145 graph_db_backend_eval_dispatch_signature(&report),
11146 ))
11147 });
11148 operations.push(operation);
11149 signatures.extend(signature);
11150
11151 let total_micros = operations
11152 .iter()
11153 .map(|operation| operation.duration_micros)
11154 .sum();
11155 let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11156 (
11157 GraphDbBackendEvalBackendReport {
11158 backend: backend.to_string(),
11159 adapter: adapter.to_string(),
11160 read_only,
11161 projection_load: projection_load.to_string(),
11162 operations,
11163 total_micros,
11164 parity,
11165 lock_behavior: lock_behavior.to_string(),
11166 install_portability: install_portability.to_string(),
11167 },
11168 signatures,
11169 )
11170}
11171
11172pub(crate) fn graph_db_backend_eval_refresh_operation(
11173 duration_micros: u128,
11174 rows: usize,
11175 value: serde_json::Value,
11176) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11177 (
11178 GraphDbBackendEvalOperation {
11179 name: "refresh".to_string(),
11180 supported: true,
11181 status: "ok".to_string(),
11182 duration_micros,
11183 rows: Some(rows),
11184 error: None,
11185 },
11186 GraphDbBackendEvalSignature {
11187 operation: "refresh".to_string(),
11188 value,
11189 },
11190 )
11191}
11192
11193pub(crate) fn graph_db_backend_eval_synthetic_projection(
11194 nodes: usize,
11195 fanout: usize,
11196) -> GraphProjection {
11197 let nodes = nodes.max(12);
11198 let symbol_count = nodes.saturating_sub(9).max(1);
11199 let source = GraphProvenance::new("backend-eval", "synthetic");
11200 let mut projection_nodes = vec![
11201 SubstrateGraphNode::new(
11202 "projection:tsift-traversal:synthetic",
11203 GRAPH_PROJECTION_META_KIND,
11204 "synthetic projection",
11205 )
11206 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11207 .with_property(
11208 "content_hash",
11209 format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11210 )
11211 .with_provenance(source.clone()),
11212 SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11213 .with_property("ref_id", "synthetic-session"),
11214 SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11215 .with_property("ref_id", "synthetic")
11216 .with_property("path", "tasks/software/synthetic.md")
11217 .with_property("line", "1")
11218 .with_property(
11219 "expand",
11220 "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11221 ),
11222 SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11223 .with_property("ref_id", "synthetic"),
11224 SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11225 .with_property("target", "synthetic")
11226 .with_property("summary", "Synthetic worker owns synthetic.rs")
11227 .with_property(
11228 "expand",
11229 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11230 ),
11231 SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11232 .with_property("file", "synthetic.rs")
11233 .with_property("start", "1")
11234 .with_property("end", "80")
11235 .with_property(
11236 "expand",
11237 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11238 ),
11239 SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11240 .with_property("path", "synthetic.rs"),
11241 SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11242 .with_property("handle", "gsem-synthetic")
11243 .with_property("label", "backend evaluation")
11244 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11245 .with_property(
11246 "embedding",
11247 semantic_embedding_property("backend evaluation"),
11248 ),
11249 SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11250 .with_property("ref_id", "synthetic")
11251 .with_property("status", "completed")
11252 .with_property("touched_files", "synthetic.rs")
11253 .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11254 ];
11255 for idx in 0..symbol_count {
11256 projection_nodes.push(
11257 SubstrateGraphNode::new(
11258 format!("gsym-synthetic-{idx:04}"),
11259 "symbol",
11260 format!("synthetic_symbol_{idx:04}"),
11261 )
11262 .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11263 .with_property("path", "synthetic.rs")
11264 .with_property("line", (idx + 1).to_string()),
11265 );
11266 }
11267
11268 let mut projection_edges = vec![
11269 SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11270 SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11271 SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11272 SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11273 SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11274 SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11275 SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11276 SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11277 SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11278 ];
11279 for idx in 0..symbol_count {
11280 let from = format!("gsym-synthetic-{idx:04}");
11281 for offset in 1..=fanout.max(1).min(symbol_count) {
11282 let to_idx = (idx + offset) % symbol_count;
11283 if to_idx != idx {
11284 projection_edges.push(SubstrateGraphEdge::new(
11285 from.clone(),
11286 format!("gsym-synthetic-{to_idx:04}"),
11287 "calls",
11288 ));
11289 }
11290 }
11291 }
11292
11293 GraphProjection {
11294 nodes: projection_nodes,
11295 edges: projection_edges
11296 .into_iter()
11297 .map(|edge| {
11298 edge.with_property("dataset", "synthetic")
11299 .with_provenance(source.clone())
11300 })
11301 .collect(),
11302 }
11303}
11304
11305pub(crate) fn graph_db_backend_eval_promotion(
11306 datasets: &[GraphDbBackendEvalDataset],
11307 candidates: &[GraphDbExperimentalBackend],
11308) -> Vec<GraphDbBackendPromotionDecision> {
11309 let mut decisions = Vec::new();
11310 for candidate in candidates {
11311 let mut reasons = Vec::new();
11312 let mut faster_everywhere = true;
11313 let mut parity_everywhere = true;
11314 for dataset in datasets {
11315 let Some(sqlite_report) = dataset
11316 .backends
11317 .iter()
11318 .find(|backend| backend.backend == "sqlite")
11319 else {
11320 parity_everywhere = false;
11321 faster_everywhere = false;
11322 reasons.push(format!(
11323 "{} dataset is missing SQLite baseline",
11324 dataset.name
11325 ));
11326 continue;
11327 };
11328 let sqlite_total = sqlite_report.total_micros;
11329 let Some(candidate_report) = dataset
11330 .backends
11331 .iter()
11332 .find(|backend| backend.backend == candidate.name())
11333 else {
11334 parity_everywhere = false;
11335 reasons.push(format!("{} dataset did not run", dataset.name));
11336 continue;
11337 };
11338 if !candidate_report.parity.matches_sqlite {
11339 parity_everywhere = false;
11340 reasons.push(format!("{} parity differed from SQLite", dataset.name));
11341 }
11342 if candidate_report.total_micros >= sqlite_total {
11343 faster_everywhere = false;
11344 reasons.push(format!(
11345 "{} total {}us did not beat SQLite {}us",
11346 dataset.name, candidate_report.total_micros, sqlite_total
11347 ));
11348 }
11349 let sqlite_operations = sqlite_report
11350 .operations
11351 .iter()
11352 .map(|operation| (operation.name.as_str(), operation.duration_micros))
11353 .collect::<BTreeMap<_, _>>();
11354 for operation in &candidate_report.operations {
11355 if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11356 && operation.duration_micros >= *sqlite_duration
11357 {
11358 faster_everywhere = false;
11359 reasons.push(format!(
11360 "{} {} operation {}us did not beat SQLite {}us",
11361 dataset.name, operation.name, operation.duration_micros, sqlite_duration
11362 ));
11363 }
11364 }
11365 if candidate_report
11366 .operations
11367 .iter()
11368 .any(|operation| operation.status != "ok")
11369 {
11370 parity_everywhere = false;
11371 reasons.push(format!("{} has failed benchmark operations", dataset.name));
11372 }
11373 }
11374 let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11375 reasons.push(reason.to_string());
11376 reasons.push(
11377 "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11378 .to_string(),
11379 );
11380 "hold"
11381 } else if parity_everywhere && faster_everywhere {
11382 reasons.push(
11383 "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11384 .to_string(),
11385 );
11386 "eligible"
11387 } else {
11388 reasons.push(
11389 "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11390 .to_string(),
11391 );
11392 "hold"
11393 };
11394 decisions.push(GraphDbBackendPromotionDecision {
11395 backend: candidate.name().to_string(),
11396 decision: decision.to_string(),
11397 reasons: dedupe_preserve_order(reasons),
11398 gate: candidate.promotion_gate(),
11399 });
11400 }
11401 decisions
11402}
11403
11404pub(crate) fn graph_db_backend_eval_metrics(
11405 datasets: &[GraphDbBackendEvalDataset],
11406) -> BTreeMap<String, f64> {
11407 let mut metrics = BTreeMap::new();
11408 for dataset in datasets {
11409 let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11410 metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11411 metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11412 metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11413 for backend in &dataset.backends {
11414 let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
11415 metrics.insert(
11416 format!("{prefix}.total_duration_micros"),
11417 backend.total_micros as f64,
11418 );
11419 append_graph_db_backend_eval_normalized_duration_metric(
11420 &mut metrics,
11421 &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
11422 backend.total_micros,
11423 graph_rows,
11424 );
11425 for operation in &backend.operations {
11426 metrics.insert(
11427 format!("{prefix}.{}.duration_micros", operation.name),
11428 operation.duration_micros as f64,
11429 );
11430 append_graph_db_backend_eval_normalized_duration_metric(
11431 &mut metrics,
11432 &format!(
11433 "{prefix}.{}.duration_micros_per_1k_graph_rows",
11434 operation.name
11435 ),
11436 operation.duration_micros,
11437 graph_rows,
11438 );
11439 if let Some(rows) = operation.rows {
11440 metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
11441 }
11442 }
11443 }
11444 }
11445 metrics
11446}
11447
11448pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
11449 dataset.nodes + dataset.edges
11450}
11451
11452pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
11453 metrics: &mut BTreeMap<String, f64>,
11454 key: &str,
11455 duration_micros: u128,
11456 graph_rows: usize,
11457) {
11458 if graph_rows == 0 {
11459 return;
11460 }
11461 metrics.insert(
11462 key.to_string(),
11463 duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
11464 );
11465}
11466
11467pub(crate) fn append_graph_db_backend_eval_phase_metrics(
11468 metrics: &mut BTreeMap<String, f64>,
11469 dataset: &str,
11470 graph_rows: usize,
11471 phases: &[GraphDbBackendEvalPhaseTiming],
11472) {
11473 for phase in phases {
11474 metrics.insert(
11475 format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
11476 phase.duration_micros as f64,
11477 );
11478 append_graph_db_backend_eval_normalized_duration_metric(
11479 metrics,
11480 &format!(
11481 "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
11482 phase.name
11483 ),
11484 phase.duration_micros,
11485 graph_rows,
11486 );
11487 }
11488}
11489
11490fn graph_db_backend_eval_base_command(
11491 root: &Path,
11492 scope: Option<&str>,
11493 full_projection: bool,
11494) -> String {
11495 let full_projection_arg = if full_projection {
11496 " --full-projection"
11497 } else {
11498 ""
11499 };
11500 format!(
11501 "tsift graph-db --path {}{} --json backend-eval{}",
11502 shell_quote(root.to_string_lossy().as_ref()),
11503 graph_db_scope_arg(scope),
11504 full_projection_arg
11505 )
11506}
11507
11508pub(crate) fn graph_db_backend_eval_metric_digest_command(
11509 root: &Path,
11510 scope: Option<&str>,
11511 full_projection: bool,
11512) -> String {
11513 format!(
11514 "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11515 graph_db_backend_eval_base_command(root, scope, full_projection)
11516 )
11517}
11518
11519fn graph_db_backend_eval_repeated_sample_command(
11520 root: &Path,
11521 scope: Option<&str>,
11522 full_projection: bool,
11523) -> String {
11524 format!(
11525 "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11526 graph_db_backend_eval_base_command(root, scope, full_projection)
11527 )
11528}
11529
11530fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
11531 let mut required_metrics = Vec::new();
11532 for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
11533 required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
11534 required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
11535 for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
11536 required_metrics.push(format!(
11537 "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
11538 ));
11539 required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
11540 }
11541 }
11542 GraphDbHopCapPromotionGate {
11543 status: "hold_64_default_until_gate_passes".to_string(),
11544 current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
11545 candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
11546 required_backend: perf_gate::BASELINE_BACKEND.to_string(),
11547 required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
11548 .iter()
11549 .map(|workload| (*workload).to_string())
11550 .collect(),
11551 required_metrics,
11552 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11553 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11554 decision_rule:
11555 "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"
11556 .to_string(),
11557 }
11558}
11559
11560fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
11561 let candidate_backends = [
11562 GraphDbExperimentalBackend::Falkordb,
11563 GraphDbExperimentalBackend::Kuzu,
11564 GraphDbExperimentalBackend::Surrealdb,
11565 ]
11566 .into_iter()
11567 .map(|backend| GraphDbBackendAdapterSpikeCandidate {
11568 backend: backend.name().to_string(),
11569 adapter_label: backend.adapter_label().to_string(),
11570 projection_load: backend.projection_load().to_string(),
11571 lock_behavior: backend.lock_behavior().to_string(),
11572 install_portability: backend.install_portability().to_string(),
11573 })
11574 .collect();
11575
11576 GraphDbBackendAdapterSpikeGate {
11577 status: "hold_real_optional_adapter_required".to_string(),
11578 candidate_backends,
11579 required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
11580 .iter()
11581 .map(|workload| (*workload).to_string())
11582 .collect(),
11583 required_checks: vec![
11584 "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
11585 "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
11586 "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
11587 "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
11588 "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
11589 .to_string(),
11590 "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
11591 "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
11592 ],
11593 decision_rule:
11594 "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"
11595 .to_string(),
11596 evidence_plan: "plans/gback-evidence.md".to_string(),
11597 }
11598}
11599
11600pub(crate) fn graph_db_backend_eval_performance_gate(
11601 root: &Path,
11602 scope: Option<&str>,
11603 full_projection: bool,
11604) -> GraphDbBackendEvalPerformanceGate {
11605 let mut required_metrics = vec![
11606 "real.sqlite.refresh.duration_micros".to_string(),
11607 "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
11608 "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
11609 "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
11610 "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11611 "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11612 "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
11613 "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
11614 "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11615 "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
11616 "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
11617 "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
11618 "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
11619 "real.sqlite.conflict_matrix.duration_micros".to_string(),
11620 "real.sqlite.dispatch_trace.duration_micros".to_string(),
11621 "real.sqlite.path_max_hops.duration_micros".to_string(),
11622 "real.sqlite.path_max_hops_128.duration_micros".to_string(),
11623 "real.sqlite.path_max_hops_256.duration_micros".to_string(),
11624 "real.sqlite.path_max_hops_512.duration_micros".to_string(),
11625 "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
11626 "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
11627 "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
11628 "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
11629 "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11630 "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11631 "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
11632 .to_string(),
11633 "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11634 .to_string(),
11635 "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11636 "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11637 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
11638 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
11639 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
11640 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
11641 "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11642 .to_string(),
11643 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
11644 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
11645 .to_string(),
11646 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
11647 .to_string(),
11648 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
11649 .to_string(),
11650 ];
11651 if full_projection {
11652 required_metrics.extend([
11653 "full_projection.cache.hit".to_string(),
11654 "full_projection.cache.disk_bytes".to_string(),
11655 "full_projection.cache.compression_ratio".to_string(),
11656 "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
11657 "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11658 "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
11659 .to_string(),
11660 "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
11661 .to_string(),
11662 "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
11663 "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
11664 "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
11665 "full_projection.sqlite.neighborhood.duration_micros".to_string(),
11666 "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
11667 "full_projection.sqlite.evidence.duration_micros".to_string(),
11668 "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
11669 "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
11670 "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
11671 "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
11672 "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
11673 "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
11674 ]);
11675 }
11676 GraphDbBackendEvalPerformanceGate {
11677 baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
11678 ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
11679 .to_string(),
11680 opt_in_real_profile:
11681 "pass --full-projection to add the full-project dataset when checking for large projection regressions"
11682 .to_string(),
11683 full_projection_cache_hit_gate: if full_projection {
11684 "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"
11685 .to_string()
11686 } else {
11687 "not evaluated until --full-projection is enabled".to_string()
11688 },
11689 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11690 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11691 normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
11692 required_metrics,
11693 digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
11694 repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
11695 root,
11696 scope,
11697 full_projection,
11698 ),
11699 hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
11700 backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
11701 }
11702}
11703
11704#[cfg(feature = "backend-surrealdb")]
11705fn graph_db_backend_eval_path_segment(value: &str) -> String {
11706 value
11707 .chars()
11708 .map(|ch| {
11709 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
11710 ch
11711 } else {
11712 '_'
11713 }
11714 })
11715 .collect()
11716}
11717
11718#[cfg(feature = "backend-surrealdb")]
11719fn graph_db_backend_eval_surrealdb_store_path(
11720 root: &Path,
11721 scope: Option<&str>,
11722 dataset: &str,
11723) -> PathBuf {
11724 root.join(".tsift/backend-eval-cache/surrealdb")
11725 .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
11726 .join(graph_db_backend_eval_path_segment(dataset))
11727 .join("surrealkv")
11728}
11729
11730pub(crate) struct GraphDbBackendEvalOptions<'a> {
11731 path: &'a Path,
11732 scope: Option<&'a str>,
11733 candidates: &'a [String],
11734 targets: &'a [String],
11735 full_projection: bool,
11736}
11737
11738#[allow(clippy::too_many_arguments)]
11739pub(crate) fn graph_db_backend_eval_dataset(
11740 name: &str,
11741 root: &Path,
11742 path: &Path,
11743 scope: Option<&str>,
11744 targets: &[String],
11745 depth: usize,
11746 limit: usize,
11747 impact_limit: usize,
11748 candidates: &[GraphDbExperimentalBackend],
11749 sqlite_store: &SqliteGraphStore,
11750 sqlite_freshness: GraphDbFreshnessReport,
11751 sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
11752 sqlite_rows: ConvexProjectionRows,
11753 extra_warnings: Vec<String>,
11754 prepared: &ConflictMatrixPreparedInputs,
11755) -> Result<GraphDbBackendEvalDataset> {
11756 let (nodes, edges) = sqlite_store.graph_counts()?;
11757 let (sqlite_operation, sqlite_signature) = sqlite_refresh;
11758 let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
11759 "sqlite",
11760 "SQLite GraphStore correctness baseline",
11761 false,
11762 root,
11763 path,
11764 scope,
11765 targets,
11766 depth,
11767 limit,
11768 impact_limit,
11769 sqlite_store,
11770 sqlite_freshness,
11771 sqlite_operation,
11772 Some(sqlite_signature),
11773 None,
11774 extra_warnings.clone(),
11775 prepared,
11776 "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
11777 "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
11778 "bundled rusqlite baseline; no external service or runtime required",
11779 );
11780
11781 let mut backends = vec![sqlite_report];
11782 for candidate in candidates {
11783 #[cfg(feature = "backend-surrealdb")]
11784 if *candidate == GraphDbExperimentalBackend::Surrealdb {
11785 let started = Instant::now();
11786 let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
11787 let (store, warm_start) =
11788 SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
11789 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11790 let rows = candidate_nodes + candidate_edges;
11791 let mut refresh_meta = serde_json::json!({
11792 "nodes": candidate_nodes,
11793 "edges": candidate_edges,
11794 });
11795 if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
11796 refresh_meta["warm_start"] = serde_json::json!("cache_hit");
11797 }
11798 let refresh = graph_db_backend_eval_refresh_operation(
11799 started.elapsed().as_micros(),
11800 rows,
11801 refresh_meta,
11802 );
11803 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11804 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11805 candidate.name(),
11806 "SurrealDB SurrealKV optional adapter spike",
11807 false,
11808 root,
11809 path,
11810 scope,
11811 targets,
11812 depth,
11813 limit,
11814 impact_limit,
11815 &store,
11816 freshness,
11817 refresh.0,
11818 Some(refresh.1),
11819 Some(&sqlite_signatures),
11820 extra_warnings.clone(),
11821 prepared,
11822 "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",
11823 "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
11824 "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
11825 );
11826 backends.push(candidate_report);
11827 continue;
11828 }
11829 let started = Instant::now();
11830 let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
11831 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11832 let rows = candidate_nodes + candidate_edges;
11833 let refresh = graph_db_backend_eval_refresh_operation(
11834 started.elapsed().as_micros(),
11835 rows,
11836 serde_json::json!({
11837 "nodes": candidate_nodes,
11838 "edges": candidate_edges,
11839 }),
11840 );
11841 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11842 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11843 candidate.name(),
11844 candidate.adapter_label(),
11845 true,
11846 root,
11847 path,
11848 scope,
11849 targets,
11850 depth,
11851 limit,
11852 impact_limit,
11853 &store,
11854 freshness,
11855 refresh.0,
11856 Some(refresh.1),
11857 Some(&sqlite_signatures),
11858 extra_warnings.clone(),
11859 prepared,
11860 candidate.projection_load(),
11861 candidate.lock_behavior(),
11862 candidate.install_portability(),
11863 );
11864 backends.push(candidate_report);
11865 }
11866
11867 Ok(GraphDbBackendEvalDataset {
11868 name: name.to_string(),
11869 target_count: targets.len(),
11870 nodes,
11871 edges,
11872 backends,
11873 })
11874}
11875
11876pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
11877 println!(
11878 "graph-db backend-eval baseline:{} candidates:{}",
11879 report.baseline_backend,
11880 report.candidates.join(", ")
11881 );
11882 for phase in &report.phase_timings {
11883 println!(
11884 "phase:{} {}us {}",
11885 phase.name, phase.duration_micros, phase.detail
11886 );
11887 }
11888 for dataset in &report.datasets {
11889 println!(
11890 "dataset:{} targets:{} rows:{}",
11891 dataset.name,
11892 dataset.target_count,
11893 dataset.nodes + dataset.edges
11894 );
11895 for backend in &dataset.backends {
11896 println!(
11897 " backend:{} total:{}us parity:{}",
11898 backend.backend, backend.total_micros, backend.parity.matches_sqlite
11899 );
11900 println!(" projection-load: {}", backend.projection_load);
11901 println!(" lock-behavior: {}", backend.lock_behavior);
11902 println!(" install-portability: {}", backend.install_portability);
11903 for operation in &backend.operations {
11904 println!(
11905 " {} {} {}us",
11906 operation.name, operation.status, operation.duration_micros
11907 );
11908 }
11909 for diagnostic in &backend.parity.diagnostics {
11910 println!(" parity: {diagnostic}");
11911 }
11912 }
11913 }
11914 for decision in &report.promotion {
11915 println!("promotion {}: {}", decision.backend, decision.decision);
11916 println!(" gate: {}", decision.gate.status);
11917 for reason in &decision.reasons {
11918 println!(" reason: {reason}");
11919 }
11920 for check in &decision.gate.required_checks {
11921 println!(" check: {check}");
11922 }
11923 }
11924 println!("metric-digest: {}", report.metric_digest_command);
11925 println!(
11926 "repeat-samples: {}",
11927 report.performance_gate.repeated_sample_command
11928 );
11929}
11930
11931fn traversal_expand_command(root: &Path, handle: &str) -> String {
11932 format!(
11933 "tsift traverse {} --path {} --depth 1 --limit 50",
11934 shell_quote(handle),
11935 shell_quote(root.to_string_lossy().as_ref())
11936 )
11937}
11938
11939fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
11940 let display = relativize(file, root);
11941 let handle = stable_handle("gfil", &format!("file:{display}"));
11942 TraversalNode {
11943 handle: handle.clone(),
11944 kind: "file".to_string(),
11945 label: display.clone(),
11946 ref_id: Some(display.clone()),
11947 path: Some(display),
11948 line: None,
11949 detail: None,
11950 properties: BTreeMap::new(),
11951 expand: traversal_expand_command(root, &handle),
11952 }
11953}
11954
11955fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
11956 let mut node = traversal_file_node(root, file);
11957 if let Some(path) = node.path.clone() {
11958 node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
11959 node.expand = source_read_command(root, &path, 1, 80);
11960 }
11961 node
11962}
11963
11964fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
11965 let file = relativize(&symbol.file, root);
11966 let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
11967 let handle = stable_handle("gsym", &key);
11968 TraversalNode {
11969 handle: handle.clone(),
11970 kind: "symbol".to_string(),
11971 label: symbol.name.clone(),
11972 ref_id: Some(symbol.name.clone()),
11973 path: Some(file),
11974 line: Some(symbol.line),
11975 detail: Some(format!("{} {}", symbol.language, symbol.kind)),
11976 properties: BTreeMap::new(),
11977 expand: traversal_expand_command(root, &handle),
11978 }
11979}
11980
11981fn traversal_ast_span_expand_command(
11982 root: &Path,
11983 file: &str,
11984 symbol: &index::StoredSymbol,
11985 span: &AstSpanPreview,
11986) -> String {
11987 if symbol.language == "markdown" {
11988 markdown_ast_command(root, file, Some(&span.handle))
11989 } else {
11990 let line_count = span
11991 .end_line
11992 .saturating_sub(span.start_line)
11993 .saturating_add(1)
11994 .max(1);
11995 source_read_command(root, file, span.start_line, line_count)
11996 }
11997}
11998
11999fn traversal_ast_span_node(
12000 root: &Path,
12001 symbol: &index::StoredSymbol,
12002 source: &[u8],
12003 symbols: &[index::StoredSymbol],
12004) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
12005 let span = stored_symbol_ast_span(symbol, source, symbols, usize::MAX)?;
12006 let file = relativize(&symbol.file, root);
12007 let mut properties = BTreeMap::new();
12008 properties.insert("layer".to_string(), "ast_navigation".to_string());
12009 properties.insert("language".to_string(), symbol.language.clone());
12010 properties.insert("symbol_kind".to_string(), symbol.kind.clone());
12011 properties.insert("node_kind".to_string(), span.node_kind.clone());
12012 properties.insert("start_byte".to_string(), span.start_byte.to_string());
12013 properties.insert("end_byte".to_string(), span.end_byte.to_string());
12014 properties.insert("end_line".to_string(), span.end_line.to_string());
12015 if let Some(body_start_byte) = span.body_start_byte {
12016 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12017 }
12018 if let Some(body_end_byte) = span.body_end_byte {
12019 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12020 }
12021 if let Some(body_start_line) = span.body_start_line {
12022 properties.insert("body_start_line".to_string(), body_start_line.to_string());
12023 }
12024 if let Some(body_end_line) = span.body_end_line {
12025 properties.insert("body_end_line".to_string(), body_end_line.to_string());
12026 }
12027 if let Some(parent_handle) = &span.parent_handle {
12028 properties.insert("parent_handle".to_string(), parent_handle.clone());
12029 }
12030 if !span.child_handles.is_empty() {
12031 properties.insert("child_handles".to_string(), span.child_handles.join(","));
12032 }
12033 if let Some(parent_module) = &symbol.parent_module {
12034 properties.insert("parent_module".to_string(), parent_module.clone());
12035 }
12036 if let Some(markdown) = &span.markdown {
12037 properties.insert(
12038 "markdown_block_kind".to_string(),
12039 markdown_ast_block_kind(&symbol.kind),
12040 );
12041 if let Some(heading_level) = markdown.heading_level {
12042 properties.insert("heading_level".to_string(), heading_level.to_string());
12043 }
12044 if !markdown.section_path.is_empty() {
12045 properties.insert(
12046 "section_path".to_string(),
12047 markdown.section_path.join(" > "),
12048 );
12049 }
12050 if let Some(section_handle) = &markdown.section_handle {
12051 properties.insert("section_handle".to_string(), section_handle.clone());
12052 }
12053 if let Some(list_depth) = markdown.list_depth {
12054 properties.insert("list_depth".to_string(), list_depth.to_string());
12055 }
12056 if let Some(fence_language) = &markdown.fence_language {
12057 properties.insert("fence_language".to_string(), fence_language.clone());
12058 }
12059 }
12060
12061 let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
12062 let node = TraversalNode {
12063 handle: span.handle.clone(),
12064 kind: "ast_span".to_string(),
12065 label: symbol.name.clone(),
12066 ref_id: Some(symbol.name.clone()),
12067 path: Some(file.clone()),
12068 line: Some(line),
12069 detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
12070 properties,
12071 expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
12072 };
12073 let entry = TraversalAstSpanIndexEntry {
12074 handle: span.handle,
12075 symbol_handle: String::new(),
12076 file_handle: None,
12077 file,
12078 name: symbol.name.clone(),
12079 kind: symbol.kind.clone(),
12080 language: symbol.language.clone(),
12081 node_kind: span.node_kind,
12082 start_byte: span.start_byte,
12083 end_byte: span.end_byte,
12084 parent_module: symbol.parent_module.clone(),
12085 markdown: span.markdown,
12086 };
12087 Some((node, entry))
12088}
12089
12090fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
12091 let handle = stable_handle("gsym", &format!("symbol:{name}"));
12092 TraversalNode {
12093 handle: handle.clone(),
12094 kind: "symbol".to_string(),
12095 label: name.to_string(),
12096 ref_id: Some(name.to_string()),
12097 path: None,
12098 line: None,
12099 detail: Some("unresolved call target".to_string()),
12100 properties: BTreeMap::new(),
12101 expand: traversal_expand_command(root, &handle),
12102 }
12103}
12104
12105fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12106 let file = relativize(&route.file, root);
12107 let method = route.method.as_deref().unwrap_or("any");
12108 let key = format!(
12109 "route:{file}:{}:{}:{}",
12110 route.line, method, route.route_path
12111 );
12112 let handle = stable_handle("grte", &key);
12113 TraversalNode {
12114 handle: handle.clone(),
12115 kind: "route".to_string(),
12116 label: format!("{} {}", method.to_uppercase(), route.route_path),
12117 ref_id: Some(route.route_path.clone()),
12118 path: Some(file),
12119 line: Some(route.line),
12120 detail: Some(format!(
12121 "{} route handled by {}",
12122 route.framework, route.handler_name
12123 )),
12124 properties: BTreeMap::new(),
12125 expand: traversal_expand_command(root, &handle),
12126 }
12127}
12128
12129fn traversal_cargo_workspace_node(
12130 root: &Path,
12131 workspace: &multiplicity::CargoWorkspaceInfo,
12132) -> TraversalNode {
12133 let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12134 .to_string_lossy()
12135 .replace('\\', "/");
12136 let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12137 .to_string_lossy()
12138 .replace('\\', "/");
12139 let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12140 let mut properties = BTreeMap::new();
12141 properties.insert("layer".to_string(), "cargo_workspace".to_string());
12142 properties.insert("workspace_root".to_string(), workspace_root.clone());
12143 properties.insert("members".to_string(), workspace.members.join(","));
12144 properties.insert(
12145 "default_members".to_string(),
12146 workspace.default_members.join(","),
12147 );
12148 TraversalNode {
12149 handle: handle.clone(),
12150 kind: "cargo_workspace".to_string(),
12151 label: if workspace_root.is_empty() {
12152 "root cargo workspace".to_string()
12153 } else {
12154 workspace_root
12155 },
12156 ref_id: Some(workspace.id.clone()),
12157 path: Some(manifest),
12158 line: None,
12159 detail: Some("Cargo workspace manifest".to_string()),
12160 properties,
12161 expand: traversal_expand_command(root, &handle),
12162 }
12163}
12164
12165fn traversal_cargo_package_node(
12166 root: &Path,
12167 package: &multiplicity::CargoPackageInfo,
12168) -> TraversalNode {
12169 let manifest = relativize_pathbuf(&package.manifest_path, root)
12170 .to_string_lossy()
12171 .replace('\\', "/");
12172 let package_root = relativize_pathbuf(&package.package_root, root)
12173 .to_string_lossy()
12174 .replace('\\', "/");
12175 let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12176 .to_string_lossy()
12177 .replace('\\', "/");
12178 let handle = stable_handle(
12179 "gcpk",
12180 &format!("cargo-package:{manifest}:{}", package.name),
12181 );
12182 let mut properties = BTreeMap::new();
12183 properties.insert("layer".to_string(), "cargo_package".to_string());
12184 properties.insert("package_name".to_string(), package.name.clone());
12185 properties.insert(
12186 "normalized_name".to_string(),
12187 package.normalized_name.clone(),
12188 );
12189 properties.insert("package_root".to_string(), package_root.clone());
12190 properties.insert("workspace_root".to_string(), workspace_root);
12191 properties.insert("features".to_string(), package.features.join(","));
12192 properties.insert("targets".to_string(), package.targets.join(","));
12193 properties.insert(
12194 "dependencies".to_string(),
12195 package
12196 .dependencies
12197 .iter()
12198 .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12199 .collect::<Vec<_>>()
12200 .join(","),
12201 );
12202 TraversalNode {
12203 handle: handle.clone(),
12204 kind: "cargo_package".to_string(),
12205 label: package.name.clone(),
12206 ref_id: Some(package.scope_id.clone()),
12207 path: Some(manifest),
12208 line: None,
12209 detail: Some(format!(
12210 "Cargo package in {}",
12211 if package_root.is_empty() {
12212 "."
12213 } else {
12214 package_root.as_str()
12215 }
12216 )),
12217 properties,
12218 expand: traversal_expand_command(root, &handle),
12219 }
12220}
12221
12222fn traversal_session_node(
12223 root: &Path,
12224 markdown_path: &Path,
12225 session_id: Option<&str>,
12226) -> TraversalNode {
12227 let display = relativize_pathbuf(markdown_path, root)
12228 .to_string_lossy()
12229 .replace('\\', "/");
12230 let handle = stable_handle("gses", &format!("session:{display}"));
12231 TraversalNode {
12232 handle: handle.clone(),
12233 kind: "session".to_string(),
12234 label: session_id.unwrap_or(&display).to_string(),
12235 ref_id: session_id.map(str::to_string),
12236 path: Some(display),
12237 line: None,
12238 detail: Some("agent-doc session artifact".to_string()),
12239 properties: BTreeMap::new(),
12240 expand: traversal_expand_command(root, &handle),
12241 }
12242}
12243
12244fn traversal_backlog_node(
12245 root: &Path,
12246 markdown_path: &Path,
12247 id: &str,
12248 text: &str,
12249 line: i64,
12250) -> TraversalNode {
12251 let display = relativize_pathbuf(markdown_path, root)
12252 .to_string_lossy()
12253 .replace('\\', "/");
12254 let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12255 TraversalNode {
12256 handle: handle.clone(),
12257 kind: "backlog".to_string(),
12258 label: format!("#{id}"),
12259 ref_id: Some(id.to_string()),
12260 path: Some(display),
12261 line: Some(line),
12262 detail: Some(text.to_string()),
12263 properties: BTreeMap::new(),
12264 expand: traversal_expand_command(root, &handle),
12265 }
12266}
12267
12268fn traversal_job_packet_node(
12269 root: &Path,
12270 markdown_path: &Path,
12271 label: &str,
12272 ref_id: Option<&str>,
12273 detail: &str,
12274 line: i64,
12275) -> TraversalNode {
12276 let display = relativize_pathbuf(markdown_path, root)
12277 .to_string_lossy()
12278 .replace('\\', "/");
12279 let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12280 TraversalNode {
12281 handle: handle.clone(),
12282 kind: "job_packet".to_string(),
12283 label: label.to_string(),
12284 ref_id: ref_id.map(str::to_string),
12285 path: Some(display),
12286 line: Some(line),
12287 detail: Some(detail.to_string()),
12288 properties: BTreeMap::new(),
12289 expand: traversal_expand_command(root, &handle),
12290 }
12291}
12292
12293#[derive(Clone, Debug)]
12294struct ParsedWorkerResult {
12295 id: String,
12296 status: String,
12297 touched_files: Vec<String>,
12298 tests: Vec<String>,
12299 follow_up_ids: Vec<String>,
12300}
12301
12302fn traversal_worker_result_node(
12303 root: &Path,
12304 markdown_path: &Path,
12305 parsed: &ParsedWorkerResult,
12306 line_text: &str,
12307 line: i64,
12308) -> TraversalNode {
12309 let display = relativize_pathbuf(markdown_path, root)
12310 .to_string_lossy()
12311 .replace('\\', "/");
12312 let handle = stable_handle(
12313 "wres",
12314 &format!(
12315 "worker-result:{display}:{}:{}:{}",
12316 parsed.id, parsed.status, line
12317 ),
12318 );
12319 let mut properties = BTreeMap::new();
12320 properties.insert("status".to_string(), parsed.status.clone());
12321 if !parsed.touched_files.is_empty() {
12322 properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12323 }
12324 if !parsed.tests.is_empty() {
12325 properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12326 }
12327 if !parsed.follow_up_ids.is_empty() {
12328 properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12329 }
12330 TraversalNode {
12331 handle: handle.clone(),
12332 kind: "worker_result".to_string(),
12333 label: format!("{} #{}", parsed.status, parsed.id),
12334 ref_id: Some(parsed.id.clone()),
12335 path: Some(display),
12336 line: Some(line),
12337 detail: Some(line_text.trim().to_string()),
12338 properties,
12339 expand: traversal_expand_command(root, &handle),
12340 }
12341}
12342
12343fn traversal_tokens(input: &str) -> BTreeSet<String> {
12344 input
12345 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12346 .flat_map(|part| part.split(['_', '-']))
12347 .map(str::trim)
12348 .filter(|part| part.len() >= 3)
12349 .map(|part| part.to_ascii_lowercase())
12350 .collect()
12351}
12352
12353fn traversal_ast_span_contains(
12354 parent: &TraversalAstSpanIndexEntry,
12355 child: &TraversalAstSpanIndexEntry,
12356) -> bool {
12357 parent.handle != child.handle
12358 && parent.file == child.file
12359 && parent.start_byte <= child.start_byte
12360 && parent.end_byte >= child.end_byte
12361}
12362
12363fn traversal_ast_parent_handle<'a>(
12364 entry: &TraversalAstSpanIndexEntry,
12365 entries: &'a [TraversalAstSpanIndexEntry],
12366) -> Option<&'a str> {
12367 entries
12368 .iter()
12369 .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12370 .min_by_key(|candidate| {
12371 (
12372 candidate.end_byte.saturating_sub(candidate.start_byte),
12373 candidate.start_byte,
12374 candidate.end_byte,
12375 candidate.kind.as_str(),
12376 candidate.name.as_str(),
12377 candidate.node_kind.as_str(),
12378 )
12379 })
12380 .map(|candidate| candidate.handle.as_str())
12381}
12382
12383fn traversal_ast_enclosing_module_handle<'a>(
12384 entry: &TraversalAstSpanIndexEntry,
12385 entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12386 parent_by_handle: &BTreeMap<String, String>,
12387) -> Option<&'a str> {
12388 let mut current = parent_by_handle.get(&entry.handle);
12389 while let Some(handle) = current {
12390 let Some(parent) = entries_by_handle.get(handle) else {
12391 break;
12392 };
12393 if matches!(parent.kind.as_str(), "module" | "mod")
12394 || entry
12395 .parent_module
12396 .as_deref()
12397 .is_some_and(|module| module == parent.name)
12398 {
12399 return Some(parent.handle.as_str());
12400 }
12401 current = parent_by_handle.get(&parent.handle);
12402 }
12403 None
12404}
12405
12406fn link_ast_navigation_edges(
12407 graph: &mut TraversalGraphBuild,
12408 entries: &[TraversalAstSpanIndexEntry],
12409) {
12410 let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12411 let entries_by_handle = entries
12412 .iter()
12413 .map(|entry| (entry.handle.clone(), entry.clone()))
12414 .collect::<BTreeMap<_, _>>();
12415 let mut parent_by_handle = BTreeMap::<String, String>::new();
12416 let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
12417
12418 for entry in entries {
12419 entries_by_file
12420 .entry(entry.file.clone())
12421 .or_default()
12422 .push(entry.clone());
12423 }
12424
12425 for file_entries in entries_by_file.values() {
12426 for entry in file_entries {
12427 let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
12428 if let Some(parent) = &parent {
12429 parent_by_handle.insert(entry.handle.clone(), parent.clone());
12430 }
12431 let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
12432 children_by_parent
12433 .entry(sibling_key)
12434 .or_default()
12435 .push(entry.clone());
12436 }
12437 }
12438
12439 for entry in entries {
12440 let parent = parent_by_handle.get(&entry.handle);
12441 if let Some(parent) = parent {
12442 graph.add_edge(
12443 parent,
12444 &entry.handle,
12445 "contains",
12446 Some("AST parent contains child span".to_string()),
12447 1,
12448 );
12449 graph.add_edge(
12450 parent,
12451 &entry.handle,
12452 "child",
12453 Some("AST child span".to_string()),
12454 1,
12455 );
12456 graph.add_edge(
12457 &entry.handle,
12458 parent,
12459 "parent",
12460 Some("AST parent span".to_string()),
12461 1,
12462 );
12463 } else if let Some(file_handle) = &entry.file_handle {
12464 graph.add_edge(
12465 file_handle,
12466 &entry.handle,
12467 "contains",
12468 Some("file contains top-level AST span".to_string()),
12469 1,
12470 );
12471 }
12472
12473 if let Some(module_handle) =
12474 traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
12475 {
12476 graph.add_edge(
12477 &entry.handle,
12478 module_handle,
12479 "enclosing_module",
12480 Some("nearest enclosing module AST span".to_string()),
12481 1,
12482 );
12483 }
12484
12485 if entry.language == "markdown"
12486 && let Some(markdown) = &entry.markdown
12487 && let Some(section_handle) = &markdown.section_handle
12488 && section_handle != &entry.handle
12489 {
12490 graph.add_edge(
12491 section_handle,
12492 &entry.handle,
12493 "contains_markdown_block",
12494 Some("Markdown section contains block".to_string()),
12495 1,
12496 );
12497 graph.add_edge(
12498 &entry.handle,
12499 section_handle,
12500 "enclosing_section",
12501 Some("Markdown enclosing section".to_string()),
12502 1,
12503 );
12504 }
12505 }
12506
12507 for siblings in children_by_parent.values_mut() {
12508 siblings.sort_by(|left, right| {
12509 left.start_byte
12510 .cmp(&right.start_byte)
12511 .then(left.end_byte.cmp(&right.end_byte))
12512 .then(left.kind.cmp(&right.kind))
12513 .then(left.name.cmp(&right.name))
12514 .then(left.node_kind.cmp(&right.node_kind))
12515 .then(left.handle.cmp(&right.handle))
12516 });
12517 for pair in siblings.windows(2) {
12518 let previous = &pair[0];
12519 let next = &pair[1];
12520 graph.add_edge(
12521 &previous.handle,
12522 &next.handle,
12523 "next_sibling",
12524 Some("next AST sibling span".to_string()),
12525 1,
12526 );
12527 graph.add_edge(
12528 &next.handle,
12529 &previous.handle,
12530 "previous_sibling",
12531 Some("previous AST sibling span".to_string()),
12532 1,
12533 );
12534 }
12535 }
12536}
12537
12538fn traversal_markdown_embedded_symbol_node(
12539 root: &Path,
12540 entry: &TraversalAstSpanIndexEntry,
12541 markdown: &MarkdownSpanMetadata,
12542 embedded: &MarkdownEmbeddedSymbol,
12543) -> TraversalNode {
12544 let mut properties = BTreeMap::new();
12545 properties.insert("layer".to_string(), "embedded_code".to_string());
12546 properties.insert("embedded".to_string(), "true".to_string());
12547 properties.insert("language".to_string(), embedded.language.clone());
12548 properties.insert("symbol_kind".to_string(), embedded.kind.clone());
12549 properties.insert("node_kind".to_string(), embedded.node_kind.clone());
12550 properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
12551 properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
12552 properties.insert("end_line".to_string(), embedded.end_line.to_string());
12553 properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
12554 properties.insert(
12555 "markdown_block_kind".to_string(),
12556 markdown_ast_block_kind(&entry.kind),
12557 );
12558 if let Some(body_start_byte) = embedded.body_start_byte {
12559 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12560 }
12561 if let Some(body_end_byte) = embedded.body_end_byte {
12562 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12563 }
12564 if let Some(body_start_line) = embedded.body_start_line {
12565 properties.insert("body_start_line".to_string(), body_start_line.to_string());
12566 }
12567 if let Some(body_end_line) = embedded.body_end_line {
12568 properties.insert("body_end_line".to_string(), body_end_line.to_string());
12569 }
12570 if let Some(fence_language) = &markdown.fence_language {
12571 properties.insert("fence_language".to_string(), fence_language.clone());
12572 }
12573 if !markdown.section_path.is_empty() {
12574 properties.insert(
12575 "section_path".to_string(),
12576 markdown.section_path.join(" > "),
12577 );
12578 }
12579 if let Some(section_handle) = &markdown.section_handle {
12580 properties.insert("section_handle".to_string(), section_handle.clone());
12581 }
12582 let line_count = embedded
12583 .end_line
12584 .saturating_sub(embedded.start_line)
12585 .saturating_add(1)
12586 .max(1);
12587 TraversalNode {
12588 handle: embedded.handle.clone(),
12589 kind: "ast_span".to_string(),
12590 label: embedded.name.clone(),
12591 ref_id: Some(embedded.name.clone()),
12592 path: Some(entry.file.clone()),
12593 line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
12594 detail: Some(format!(
12595 "{} {} embedded in Markdown fence",
12596 embedded.language, embedded.kind
12597 )),
12598 properties,
12599 expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
12600 }
12601}
12602
12603fn link_markdown_embedded_code_edges(
12604 graph: &mut TraversalGraphBuild,
12605 root: &Path,
12606 entries: &[TraversalAstSpanIndexEntry],
12607) {
12608 for entry in entries {
12609 let Some(markdown) = &entry.markdown else {
12610 continue;
12611 };
12612 for embedded in &markdown.embedded_symbols {
12613 let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
12614 graph.add_node(node);
12615 graph.add_edge(
12616 &entry.handle,
12617 &embedded.handle,
12618 "contains",
12619 Some("Markdown fence contains embedded AST symbol".to_string()),
12620 1,
12621 );
12622 graph.add_edge(
12623 &entry.handle,
12624 &embedded.handle,
12625 "child",
12626 Some("embedded code symbol".to_string()),
12627 1,
12628 );
12629 graph.add_edge(
12630 &entry.handle,
12631 &embedded.handle,
12632 "contains_embedded_symbol",
12633 Some("Markdown fence contains embedded code symbol".to_string()),
12634 1,
12635 );
12636 graph.add_edge(
12637 &embedded.handle,
12638 &entry.handle,
12639 "parent",
12640 Some("Markdown fence parent span".to_string()),
12641 1,
12642 );
12643 graph.add_edge(
12644 &embedded.handle,
12645 &entry.handle,
12646 "embedded_in_fence",
12647 Some("embedded code symbol belongs to Markdown fence".to_string()),
12648 1,
12649 );
12650 if let Some(section_handle) = &markdown.section_handle
12651 && section_handle != &entry.handle
12652 {
12653 graph.add_edge(
12654 section_handle,
12655 &embedded.handle,
12656 "contains_embedded_code",
12657 Some("Markdown section contains embedded code symbol".to_string()),
12658 1,
12659 );
12660 graph.add_edge(
12661 &embedded.handle,
12662 section_handle,
12663 "enclosing_section",
12664 Some("Markdown enclosing section".to_string()),
12665 1,
12666 );
12667 }
12668 }
12669 }
12670}
12671
12672fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
12673 let mut tokens = traversal_tokens(&node.label);
12674 if let Some(ref_id) = &node.ref_id {
12675 tokens.extend(traversal_tokens(ref_id));
12676 }
12677 if let Some(path) = &node.path {
12678 tokens.extend(traversal_tokens(path));
12679 }
12680 if let Some(detail) = &node.detail {
12681 tokens.extend(traversal_tokens(detail));
12682 }
12683 tokens
12684}
12685
12686fn markdown_code_spans(input: &str) -> Vec<String> {
12687 input
12688 .split('`')
12689 .enumerate()
12690 .filter(|(idx, _)| idx % 2 == 1)
12691 .map(|(_, part)| part.trim().to_string())
12692 .filter(|part| !part.is_empty())
12693 .collect()
12694}
12695
12696fn push_traversal_token_index(
12697 index: &mut HashMap<String, Vec<usize>>,
12698 tokens: &BTreeSet<String>,
12699 entry_index: usize,
12700) {
12701 for token in tokens {
12702 index.entry(token.clone()).or_default().push(entry_index);
12703 }
12704}
12705
12706impl<'a> TraversalCodeLookup<'a> {
12707 fn new(
12708 symbols: &'a [TraversalSymbolIndexEntry],
12709 files: &'a [TraversalFileIndexEntry],
12710 routes: &'a [TraversalRouteIndexEntry],
12711 multiplicities: &'a [TraversalMultiplicityIndexEntry],
12712 ) -> Self {
12713 let mut symbol_index = HashMap::new();
12714 for (idx, entry) in symbols.iter().enumerate() {
12715 push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
12716 }
12717 let mut file_index = HashMap::new();
12718 let mut file_path_index = HashMap::new();
12719 for (idx, entry) in files.iter().enumerate() {
12720 push_traversal_token_index(&mut file_index, &entry.tokens, idx);
12721 if let Some(path) = entry.node.path.as_ref() {
12722 file_path_index.insert(path.clone(), path.clone());
12723 }
12724 }
12725 let mut route_index = HashMap::new();
12726 for (idx, entry) in routes.iter().enumerate() {
12727 push_traversal_token_index(&mut route_index, &entry.tokens, idx);
12728 }
12729 let mut multiplicity_index = HashMap::new();
12730 for (idx, entry) in multiplicities.iter().enumerate() {
12731 push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
12732 }
12733 Self {
12734 symbols,
12735 files,
12736 routes,
12737 multiplicities,
12738 symbol_index,
12739 file_index,
12740 route_index,
12741 multiplicity_index,
12742 file_path_index,
12743 }
12744 }
12745
12746 fn touched_files_for_line(&self, line: &str) -> Vec<String> {
12747 let mut touched_files = BTreeSet::new();
12748 for candidate in markdown_code_spans(line)
12749 .into_iter()
12750 .chain(line.split_whitespace().map(str::to_string))
12751 {
12752 for path in traversal_path_candidates(&candidate) {
12753 if let Some(file) = self.file_path_index.get(&path) {
12754 touched_files.insert(file.clone());
12755 }
12756 }
12757 }
12758 touched_files.into_iter().collect()
12759 }
12760}
12761
12762fn traversal_path_candidates(candidate: &str) -> Vec<String> {
12763 let trimmed = candidate.trim_matches(|ch: char| {
12764 matches!(
12765 ch,
12766 '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
12767 )
12768 });
12769 if trimmed.is_empty() {
12770 return Vec::new();
12771 }
12772 let mut candidates = vec![trimmed.to_string()];
12773 if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
12774 && !path.is_empty()
12775 && line_suffix.chars().all(|ch| ch.is_ascii_digit())
12776 {
12777 candidates.push(path.to_string());
12778 }
12779 candidates
12780}
12781
12782fn parse_worker_result_line(
12783 line: &str,
12784 lookup: &TraversalCodeLookup<'_>,
12785) -> Vec<ParsedWorkerResult> {
12786 if line.trim_start().starts_with("- [") {
12787 return Vec::new();
12788 }
12789 let lower = line.to_ascii_lowercase();
12790 let status =
12791 if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
12792 {
12793 "completed"
12794 } else if lower.contains("blocked") || lower.contains("externally blocked") {
12795 "blocked"
12796 } else {
12797 return Vec::new();
12798 };
12799 let result_prefix_end = ["follow-up", "follow up", "next:"]
12800 .iter()
12801 .filter_map(|marker| lower.find(marker))
12802 .min()
12803 .unwrap_or(line.len());
12804 let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
12805 if ids.is_empty() {
12806 return Vec::new();
12807 }
12808 let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
12809 let all_ids = extract_conflict_target_refs(line);
12810
12811 let touched_files = lookup.touched_files_for_line(line);
12812 let tests = markdown_code_spans(line)
12813 .into_iter()
12814 .filter(|span| span.to_ascii_lowercase().contains("test"))
12815 .collect::<Vec<_>>();
12816
12817 ids.iter()
12818 .map(|id| ParsedWorkerResult {
12819 id: id.clone(),
12820 status: status.to_string(),
12821 touched_files: touched_files.clone(),
12822 tests: tests.clone(),
12823 follow_up_ids: all_ids
12824 .iter()
12825 .filter(|other| *other != id && !result_ids.contains(*other))
12826 .cloned()
12827 .collect(),
12828 })
12829 .collect()
12830}
12831
12832fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
12833 let hinted_path = if path_hint.is_absolute() {
12834 path_hint.to_path_buf()
12835 } else {
12836 root.join(path_hint)
12837 };
12838 if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
12839 return Some(hinted_path);
12840 }
12841 None
12842}
12843
12844fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
12845 let candidate = if path.is_absolute() {
12846 path.to_path_buf()
12847 } else {
12848 source_root.join(path)
12849 };
12850 if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
12851 return false;
12852 }
12853 if !matches!(
12854 candidate.extension().and_then(|ext| ext.to_str()),
12855 Some("md" | "mdx")
12856 ) {
12857 return false;
12858 }
12859 fs::read_to_string(&candidate)
12860 .map(|content| session_markdown::markdown_content_looks_like_agent_doc_session(&content))
12861 .unwrap_or(false)
12862}
12863
12864fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
12865 if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
12866 return Ok(vec![hinted_path]);
12867 }
12868 let mut files = Vec::new();
12869 let walker = ignore::WalkBuilder::new(root)
12870 .hidden(true)
12871 .git_ignore(true)
12872 .git_global(true)
12873 .git_exclude(true)
12874 .build();
12875 for result in walker {
12876 let entry =
12877 result.with_context(|| format!("walking markdown files under {}", root.display()))?;
12878 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
12879 continue;
12880 }
12881 if traversal_path_is_generated_artifact(root, root, entry.path()) {
12882 continue;
12883 }
12884 if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
12885 files.push(entry.path().to_path_buf());
12886 }
12887 }
12888 files.sort();
12889 Ok(files)
12890}
12891
12892fn traversal_watermark_path(root: &Path, path: &Path) -> String {
12893 path.strip_prefix(root)
12894 .unwrap_or(path)
12895 .to_string_lossy()
12896 .replace('\\', "/")
12897}
12898
12899fn push_traversal_metadata_watermark_part(
12900 root: &Path,
12901 path: &Path,
12902 label: &str,
12903 parts: &mut Vec<String>,
12904) {
12905 let display = traversal_watermark_path(root, path);
12906 match fs::metadata(path) {
12907 Ok(metadata) => {
12908 let (secs, nanos) = metadata
12909 .modified()
12910 .ok()
12911 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
12912 .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
12913 .unwrap_or((0, 0));
12914 parts.push(format!(
12915 "{label}:{display}:len={}:mtime={secs}.{nanos}",
12916 metadata.len()
12917 ));
12918 }
12919 Err(_) => parts.push(format!("{label}:{display}:missing")),
12920 }
12921}
12922
12923#[derive(Serialize)]
12924struct TraversalSummaryWatermarkRow<'a> {
12925 symbol_name: &'a str,
12926 file_path: &'a str,
12927 entities: &'a Option<Vec<summarize::Entity>>,
12928 relationships: &'a Option<Vec<summarize::Relationship>>,
12929 concept_labels: &'a Option<Vec<String>>,
12930}
12931
12932fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
12933 let summaries_db = root.join(".tsift/summaries.db");
12934 if !summaries_db.exists() {
12935 parts.push("summaries_db:absent".to_string());
12936 return Ok(());
12937 }
12938
12939 match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
12940 .and_then(|summary_db| summary_db.all())
12941 {
12942 Ok(summaries) => {
12943 let rows = summaries
12944 .iter()
12945 .map(|summary| TraversalSummaryWatermarkRow {
12946 symbol_name: &summary.symbol_name,
12947 file_path: &summary.file_path,
12948 entities: &summary.entities,
12949 relationships: &summary.relationships,
12950 concept_labels: &summary.concept_labels,
12951 })
12952 .collect::<Vec<_>>();
12953 parts.push(format!(
12954 "summaries_db:rows={}:semantic_hash={}",
12955 rows.len(),
12956 content_hash(&rows)?
12957 ));
12958 }
12959 Err(_) => {
12960 push_traversal_metadata_watermark_part(
12961 root,
12962 &summaries_db,
12963 "summaries_db_unreadable",
12964 parts,
12965 );
12966 }
12967 }
12968 Ok(())
12969}
12970
12971#[cfg(test)]
12972fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
12973 resolution::relative_path_is_generated_artifact(relative)
12974}
12975
12976fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
12977 resolution::path_is_generated_artifact(root, source_root, path)
12978}
12979
12980fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
12981 resolution::index_snapshot_part_is_generated(root, source_root, part)
12982}
12983
12984pub(crate) fn traversal_source_watermark(
12985 root: &Path,
12986 path_hint: &Path,
12987 scope: Option<&str>,
12988 session_only: bool,
12989) -> Result<Option<String>> {
12990 let mut parts = vec![
12991 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
12992 format!("scope:{}", scope.unwrap_or("root")),
12993 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
12994 format!("session_only:{session_only}"),
12995 ];
12996
12997 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
12998 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
12999 Ok(targets) => targets,
13000 Err(_) => return Ok(None),
13001 };
13002 let Some(target) = targets.into_iter().next() else {
13003 return Ok(None);
13004 };
13005 let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
13006 Ok(db) => db,
13007 Err(_) => return Ok(None),
13008 };
13009 parts.push(format!("index_label:{}", target.label));
13010 parts.push(format!(
13011 "index_scope:{}",
13012 target.scope_name.as_deref().unwrap_or("root")
13013 ));
13014 parts.push(format!(
13015 "index_source_root:{}",
13016 traversal_watermark_path(root, &target.source_root)
13017 ));
13018 let mut snapshot_rows = 0usize;
13019 for part in db.source_snapshot_parts()? {
13020 if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
13021 continue;
13022 }
13023 snapshot_rows += 1;
13024 parts.push(format!("index_snapshot:{part}"));
13025 }
13026 parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
13027 }
13028
13029 let markdown_files = markdown_files_for_traversal(root, path_hint)?;
13030 parts.push(format!("markdown_count:{}", markdown_files.len()));
13031 for markdown_path in markdown_files {
13032 push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
13033 }
13034
13035 push_traversal_summaries_watermark_part(root, &mut parts)?;
13036
13037 Ok(Some(content_hash(&parts)?))
13038}
13039
13040fn ranked_symbol_matches<'a>(
13041 query_tokens: &BTreeSet<String>,
13042 entries: &'a [TraversalSymbolIndexEntry],
13043 index: &HashMap<String, Vec<usize>>,
13044) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13045 let mut scores = BTreeMap::<usize, usize>::new();
13046 for token in query_tokens {
13047 if let Some(indices) = index.get(token) {
13048 for idx in indices {
13049 *scores.entry(*idx).or_default() += 1;
13050 }
13051 }
13052 }
13053 let mut matches = scores
13054 .into_iter()
13055 .map(|(idx, score)| (score, &entries[idx]))
13056 .collect::<Vec<_>>();
13057 matches.sort_by(|(left_score, left), (right_score, right)| {
13058 right_score
13059 .cmp(left_score)
13060 .then_with(|| left.node.label.cmp(&right.node.label))
13061 .then_with(|| left.handle.cmp(&right.handle))
13062 });
13063 matches
13064}
13065
13066fn ranked_file_matches<'a>(
13067 query_tokens: &BTreeSet<String>,
13068 entries: &'a [TraversalFileIndexEntry],
13069 index: &HashMap<String, Vec<usize>>,
13070) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13071 let mut scores = BTreeMap::<usize, usize>::new();
13072 for token in query_tokens {
13073 if let Some(indices) = index.get(token) {
13074 for idx in indices {
13075 *scores.entry(*idx).or_default() += 1;
13076 }
13077 }
13078 }
13079 let mut matches = scores
13080 .into_iter()
13081 .map(|(idx, score)| (score, &entries[idx]))
13082 .collect::<Vec<_>>();
13083 matches.sort_by(|(left_score, left), (right_score, right)| {
13084 right_score
13085 .cmp(left_score)
13086 .then_with(|| left.node.label.cmp(&right.node.label))
13087 .then_with(|| left.handle.cmp(&right.handle))
13088 });
13089 matches
13090}
13091
13092fn ranked_route_matches<'a>(
13093 query_tokens: &BTreeSet<String>,
13094 entries: &'a [TraversalRouteIndexEntry],
13095 index: &HashMap<String, Vec<usize>>,
13096) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13097 let mut scores = BTreeMap::<usize, usize>::new();
13098 for token in query_tokens {
13099 if let Some(indices) = index.get(token) {
13100 for idx in indices {
13101 *scores.entry(*idx).or_default() += 1;
13102 }
13103 }
13104 }
13105 let mut matches = scores
13106 .into_iter()
13107 .map(|(idx, score)| (score, &entries[idx]))
13108 .collect::<Vec<_>>();
13109 matches.sort_by(|(left_score, left), (right_score, right)| {
13110 right_score
13111 .cmp(left_score)
13112 .then_with(|| left.node.label.cmp(&right.node.label))
13113 .then_with(|| left.handle.cmp(&right.handle))
13114 });
13115 matches
13116}
13117
13118fn ranked_multiplicity_matches<'a>(
13119 query_tokens: &BTreeSet<String>,
13120 entries: &'a [TraversalMultiplicityIndexEntry],
13121 index: &HashMap<String, Vec<usize>>,
13122) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13123 let mut scores = BTreeMap::<usize, usize>::new();
13124 for token in query_tokens {
13125 if let Some(indices) = index.get(token) {
13126 for idx in indices {
13127 *scores.entry(*idx).or_default() += 1;
13128 }
13129 }
13130 }
13131 let mut matches = scores
13132 .into_iter()
13133 .map(|(idx, score)| (score, &entries[idx]))
13134 .collect::<Vec<_>>();
13135 matches.sort_by(|(left_score, left), (right_score, right)| {
13136 right_score
13137 .cmp(left_score)
13138 .then_with(|| left.node.kind.cmp(&right.node.kind))
13139 .then_with(|| left.node.label.cmp(&right.node.label))
13140 .then_with(|| left.handle.cmp(&right.handle))
13141 });
13142 matches
13143}
13144
13145fn link_backlog_to_code_nodes(
13146 graph: &mut TraversalGraphBuild,
13147 backlog: &TraversalNode,
13148 text: &str,
13149 lookup: &TraversalCodeLookup<'_>,
13150 limit: usize,
13151) {
13152 let mut query_tokens = traversal_tokens(text);
13153 if let Some(ref_id) = &backlog.ref_id {
13154 query_tokens.extend(traversal_tokens(ref_id));
13155 }
13156 if query_tokens.is_empty() {
13157 return;
13158 }
13159
13160 for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13161 .into_iter()
13162 .take(limit)
13163 {
13164 graph.add_edge(
13165 &backlog.handle,
13166 &entry.handle,
13167 "mentions",
13168 Some("backlog text matches symbol tokens".to_string()),
13169 score,
13170 );
13171 }
13172
13173 for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13174 .into_iter()
13175 .take(limit.min(5))
13176 {
13177 graph.add_edge(
13178 &backlog.handle,
13179 &entry.handle,
13180 "mentions",
13181 Some("backlog text matches file tokens".to_string()),
13182 score,
13183 );
13184 }
13185
13186 for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13187 .into_iter()
13188 .take(limit.min(5))
13189 {
13190 graph.add_edge(
13191 &backlog.handle,
13192 &entry.handle,
13193 "mentions",
13194 Some("backlog text matches route tokens".to_string()),
13195 score,
13196 );
13197 }
13198
13199 for (score, entry) in ranked_multiplicity_matches(
13200 &query_tokens,
13201 lookup.multiplicities,
13202 &lookup.multiplicity_index,
13203 )
13204 .into_iter()
13205 .take(limit.min(5))
13206 {
13207 graph.add_edge(
13208 &backlog.handle,
13209 &entry.handle,
13210 "mentions",
13211 Some("backlog text matches multiplicity tokens".to_string()),
13212 score,
13213 );
13214 }
13215}
13216
13217fn load_agent_doc_traversal_nodes(
13218 root: &Path,
13219 path_hint: &Path,
13220 graph: &mut TraversalGraphBuild,
13221 lookup: &TraversalCodeLookup<'_>,
13222) -> Result<()> {
13223 for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13224 let content = match fs::read_to_string(&markdown_path) {
13225 Ok(content) => content,
13226 Err(err) => {
13227 graph.warnings.push(format!(
13228 "session artifact unavailable: {}: {err}",
13229 markdown_path.display()
13230 ));
13231 continue;
13232 }
13233 };
13234 let Some(document) = AgentDocSessionDocument::parse_if_session(&content) else {
13235 continue;
13236 };
13237
13238 let session = traversal_session_node(root, &markdown_path, document.session_id.as_deref());
13239 graph.add_node(session.clone());
13240 let lines = content.lines().collect::<Vec<_>>();
13241 let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13242 for item in &document.backlog_items {
13243 let backlog = traversal_backlog_node(
13244 root,
13245 &markdown_path,
13246 &item.id,
13247 &item.text,
13248 item.line as i64,
13249 );
13250 graph.add_node(backlog.clone());
13251 backlog_by_id.insert(item.id.clone(), backlog.clone());
13252 graph.add_edge(
13253 &session.handle,
13254 &backlog.handle,
13255 "contains",
13256 Some("session backlog item".to_string()),
13257 1,
13258 );
13259 link_backlog_to_code_nodes(graph, &backlog, &item.text, lookup, 8);
13260 }
13261
13262 let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13263 for item in &document.queue_items {
13264 match item {
13265 AgentDocQueueItem::Dispatch { value, line }
13266 | AgentDocQueueItem::Preset { value, line } => {
13267 let dispatch_ref = value.strip_prefix('#').unwrap_or(value.as_str());
13268 let node = traversal_job_packet_node(
13269 root,
13270 &markdown_path,
13271 &format!("dispatch {value}"),
13272 Some(dispatch_ref),
13273 "agent-doc dispatch preset",
13274 *line as i64,
13275 );
13276 graph.add_node(node.clone());
13277 graph.add_edge(
13278 &session.handle,
13279 &node.handle,
13280 "contains",
13281 Some("session queued dispatch".to_string()),
13282 1,
13283 );
13284 }
13285 AgentDocQueueItem::Do { id, line } => {
13286 let detail = backlog_by_id
13287 .get(id)
13288 .and_then(|node| node.detail.clone())
13289 .unwrap_or_else(|| "queued backlog item".to_string());
13290 let node = traversal_job_packet_node(
13291 root,
13292 &markdown_path,
13293 &format!("do #{id}"),
13294 Some(id),
13295 &detail,
13296 *line as i64,
13297 );
13298 graph.add_node(node.clone());
13299 graph.add_edge(
13300 &session.handle,
13301 &node.handle,
13302 "contains",
13303 Some("session queued job packet".to_string()),
13304 1,
13305 );
13306 if let Some(backlog) = backlog_by_id.get(id) {
13307 graph.add_edge(
13308 &node.handle,
13309 &backlog.handle,
13310 "targets",
13311 Some("queued backlog item".to_string()),
13312 1,
13313 );
13314 }
13315 job_by_id.insert(id.clone(), node);
13316 }
13317 }
13318 }
13319
13320 let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13321 for (idx, line) in lines.iter().enumerate() {
13322 for parsed in parse_worker_result_line(line, lookup) {
13323 let line_no = idx as i64 + 1;
13324 if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13325 continue;
13326 }
13327 let result =
13328 traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13329 graph.add_node(result.clone());
13330 graph.add_edge(
13331 &session.handle,
13332 &result.handle,
13333 "contains",
13334 Some("session worker result".to_string()),
13335 1,
13336 );
13337 if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13338 graph.add_edge(
13339 &backlog.handle,
13340 &result.handle,
13341 "has_result",
13342 Some(format!("worker result {}", parsed.status)),
13343 1,
13344 );
13345 }
13346 if let Some(job) = job_by_id.get(&parsed.id) {
13347 graph.add_edge(
13348 &job.handle,
13349 &result.handle,
13350 "has_result",
13351 Some(format!("queued worker result {}", parsed.status)),
13352 1,
13353 );
13354 }
13355 let mut result_text = line.to_string();
13356 if !parsed.touched_files.is_empty() {
13357 result_text.push(' ');
13358 result_text.push_str(&parsed.touched_files.join(" "));
13359 }
13360 link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13361 }
13362 }
13363 }
13364 Ok(())
13365}
13366
13367#[derive(Debug, Clone)]
13368struct AgentDocIndexGate {
13369 db_path: Option<PathBuf>,
13370 source_root: PathBuf,
13371 diagnostics: Vec<String>,
13372}
13373
13374#[derive(Clone, Hash, PartialEq, Eq)]
13375struct AgentDocIndexGateCacheKey {
13376 root: PathBuf,
13377 path_hint: PathBuf,
13378 scope: Option<String>,
13379 packet_label: String,
13380}
13381
13382fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13383 std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13384> {
13385 static CACHE: std::sync::OnceLock<
13386 std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13387 > = std::sync::OnceLock::new();
13388 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13389}
13390
13391fn prepare_agent_doc_index_gate_cached(
13392 root: &Path,
13393 path_hint: &Path,
13394 scope: Option<&str>,
13395 packet_label: &str,
13396) -> (AgentDocIndexGate, String) {
13397 let key = AgentDocIndexGateCacheKey {
13398 root: root.to_path_buf(),
13399 path_hint: path_hint.to_path_buf(),
13400 scope: scope.map(str::to_string),
13401 packet_label: packet_label.to_string(),
13402 };
13403 if let Ok(cache) = agent_doc_index_gate_cache().lock()
13404 && let Some(cached) = cache.get(&key)
13405 {
13406 return (
13407 cached.clone(),
13408 "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13409 );
13410 }
13411 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13412 if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13413 cache.insert(key, gate.clone());
13414 }
13415 (
13416 gate,
13417 "fresh inspection/refresh — cache miss on this preparation key".to_string(),
13418 )
13419}
13420
13421fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
13422 match state {
13423 SearchIndexState::Fresh => None,
13424 SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
13425 SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
13426 }
13427}
13428
13429fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
13430 rebuild_search_target_detail(&RebuildSearchTarget {
13431 label: target.label.clone(),
13432 reason,
13433 reindex_cmd: target.reindex_cmd.clone(),
13434 })
13435}
13436
13437fn index_refresh_diagnostic(
13438 target: &SearchIndexTarget,
13439 reason: RebuildSearchReason,
13440 summary: &index::IndexSummary,
13441 packet_label: &str,
13442) -> String {
13443 let changed = summary.new + summary.modified + summary.deleted;
13444 format!(
13445 "index refreshed: {}; updated {} changed file{} before {}",
13446 index_reason_detail(target, reason),
13447 changed,
13448 if changed == 1 { "" } else { "s" },
13449 packet_label
13450 )
13451}
13452
13453fn index_refresh_fallback_diagnostic(
13454 target: &SearchIndexTarget,
13455 reason: RebuildSearchReason,
13456 err: &anyhow::Error,
13457 packet_label: &str,
13458) -> String {
13459 format!(
13460 "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
13461 index_reason_detail(target, reason),
13462 packet_label
13463 )
13464}
13465
13466fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
13467 if let Some(scope_name) = scope
13468 && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
13469 {
13470 return scope.source_root;
13471 }
13472 if let Some(scope_name) = scope
13473 && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
13474 {
13475 return package.package_root;
13476 }
13477 if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
13478 return scope.source_root;
13479 }
13480 if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
13481 return package.package_root;
13482 }
13483 if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
13484 return scope.source_root;
13485 }
13486 root.to_path_buf()
13487}
13488
13489fn prepare_agent_doc_index_gate(
13490 root: &Path,
13491 path_hint: &Path,
13492 scope: Option<&str>,
13493 packet_label: &str,
13494) -> AgentDocIndexGate {
13495 let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
13496 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13497 Ok(targets) => targets,
13498 Err(err) => {
13499 return AgentDocIndexGate {
13500 db_path: None,
13501 source_root: fallback_source_root,
13502 diagnostics: vec![format!(
13503 "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13504 )],
13505 };
13506 }
13507 };
13508 let Some(target) = targets.into_iter().next() else {
13509 return AgentDocIndexGate {
13510 db_path: None,
13511 source_root: fallback_source_root,
13512 diagnostics: vec![format!(
13513 "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
13514 )],
13515 };
13516 };
13517
13518 let state = match inspect_search_index(&target) {
13519 Ok(state) => state,
13520 Err(err) => {
13521 return AgentDocIndexGate {
13522 db_path: None,
13523 source_root: target.source_root,
13524 diagnostics: vec![format!(
13525 "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13526 )],
13527 };
13528 }
13529 };
13530
13531 let Some(reason) = index_reason_for_state(state) else {
13532 return AgentDocIndexGate {
13533 db_path: Some(target.db_path),
13534 source_root: target.source_root,
13535 diagnostics: Vec::new(),
13536 };
13537 };
13538
13539 match apply_search_index_update(root, &target) {
13540 Ok(summary) => {
13541 index::inspect_scope_invalidate_all();
13547 let diagnostics = vec![index_refresh_diagnostic(
13548 &target,
13549 reason,
13550 &summary,
13551 packet_label,
13552 )];
13553 AgentDocIndexGate {
13554 db_path: Some(target.db_path),
13555 source_root: target.source_root,
13556 diagnostics,
13557 }
13558 }
13559 Err(err) => {
13560 let diagnostics = vec![index_refresh_fallback_diagnostic(
13561 &target,
13562 reason,
13563 &err,
13564 packet_label,
13565 )];
13566 AgentDocIndexGate {
13567 db_path: None,
13568 source_root: target.source_root,
13569 diagnostics,
13570 }
13571 }
13572 }
13573}
13574
13575fn add_raw_source_file_nodes(
13576 root: &Path,
13577 source_root: &Path,
13578 graph: &mut TraversalGraphBuild,
13579 file_entries: &mut Vec<TraversalFileIndexEntry>,
13580) -> Result<()> {
13581 let mut entries = walk::walk_files(source_root)?;
13582 entries.sort_by(|left, right| left.path.cmp(&right.path));
13583 for entry in entries {
13584 let file = entry.path.to_string_lossy();
13585 let node = traversal_raw_source_file_node(root, file.as_ref());
13586 let entry = TraversalFileIndexEntry {
13587 handle: node.handle.clone(),
13588 tokens: traversal_node_tokens(&node),
13589 node: node.clone(),
13590 };
13591 graph.add_node(node);
13592 file_entries.push(entry);
13593 }
13594 Ok(())
13595}
13596
13597fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
13598 if scope_root.is_empty() {
13599 return true;
13600 }
13601 path == scope_root || path.starts_with(&format!("{scope_root}/"))
13602}
13603
13604fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
13605 let path = Path::new(file);
13606 if path.is_absolute() {
13607 return path.to_path_buf();
13608 }
13609 let source_candidate = source_root.join(path);
13610 if source_candidate.exists() {
13611 source_candidate
13612 } else {
13613 root.join(path)
13614 }
13615}
13616
13617fn cargo_import_alias_from_line(line: &str) -> Option<String> {
13618 let trimmed = line.trim();
13619 let rest = trimmed
13620 .strip_prefix("pub use ")
13621 .or_else(|| trimmed.strip_prefix("use "))
13622 .or_else(|| trimmed.strip_prefix("extern crate "))?;
13623 let alias = rest
13624 .split([':', ';', ' ', '\t'])
13625 .next()
13626 .unwrap_or_default()
13627 .trim();
13628 (!alias.is_empty()).then(|| alias.to_string())
13629}
13630
13631fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
13632 let mut aliases = BTreeSet::new();
13633 for entry in walk::walk_files(&package.package_root)? {
13634 if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
13635 continue;
13636 }
13637 let content = fs::read_to_string(&entry.path)
13638 .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
13639 aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
13640 }
13641 Ok(aliases)
13642}
13643
13644fn load_multiplicity_traversal_nodes(
13645 root: &Path,
13646 source_root: &Path,
13647 graph: &mut TraversalGraphBuild,
13648 file_handle_by_path: &HashMap<String, String>,
13649 multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
13650) -> Result<()> {
13651 let inventory = multiplicity::discover_cargo_inventory(source_root)?;
13652 let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
13653 for workspace in &inventory.workspaces {
13654 let node = traversal_cargo_workspace_node(root, workspace);
13655 workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
13656 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13657 handle: node.handle.clone(),
13658 tokens: traversal_node_tokens(&node),
13659 node: node.clone(),
13660 });
13661 graph.add_node(node);
13662 }
13663
13664 let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
13665 let mut package_nodes = Vec::new();
13666 for package in &inventory.packages {
13667 let node = traversal_cargo_package_node(root, package);
13668 package_handle_by_name
13669 .entry(package.name.clone())
13670 .or_default()
13671 .push(node.handle.clone());
13672 package_handle_by_name
13673 .entry(package.normalized_name.clone())
13674 .or_default()
13675 .push(node.handle.clone());
13676 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13677 handle: node.handle.clone(),
13678 tokens: traversal_node_tokens(&node),
13679 node: node.clone(),
13680 });
13681 graph.add_node(node.clone());
13682 package_nodes.push((package, node));
13683 }
13684
13685 for (package, node) in &package_nodes {
13686 if let Some(workspace_handle) =
13687 workspace_handle_by_root.get(&package.relative_workspace_root)
13688 {
13689 graph.add_edge(
13690 workspace_handle,
13691 &node.handle,
13692 "contains_package",
13693 Some("Cargo workspace member package".to_string()),
13694 1,
13695 );
13696 }
13697 let package_root = relativize_pathbuf(&package.package_root, root)
13698 .to_string_lossy()
13699 .replace('\\', "/");
13700 for (file, handle) in file_handle_by_path {
13701 if relative_path_inside_scope(file, &package_root) {
13702 graph.add_edge(
13703 &node.handle,
13704 handle,
13705 "owns_file",
13706 Some("Cargo package owns source file".to_string()),
13707 1,
13708 );
13709 }
13710 }
13711 for dependency in &package.dependencies {
13712 if let Some(handles) = package_handle_by_name.get(&dependency.name)
13713 && handles.len() == 1
13714 {
13715 graph.add_edge(
13716 &node.handle,
13717 &handles[0],
13718 "declares_dependency",
13719 Some(format!("{} Cargo dependency", dependency.kind)),
13720 1,
13721 );
13722 }
13723 }
13724 for alias in cargo_import_aliases(package)? {
13725 if let Some(handles) = package_handle_by_name.get(&alias)
13726 && handles.len() == 1
13727 && handles[0] != node.handle
13728 {
13729 graph.add_edge(
13730 &node.handle,
13731 &handles[0],
13732 "uses_crate",
13733 Some("Rust use/extern crate reference".to_string()),
13734 1,
13735 );
13736 graph.add_edge(
13737 &node.handle,
13738 &handles[0],
13739 "imports",
13740 Some("Rust use/extern crate import".to_string()),
13741 1,
13742 );
13743 }
13744 }
13745 }
13746
13747 Ok(())
13748}
13749
13750fn build_traversal_graph_source_with_options(
13751 root: &Path,
13752 path_hint: &Path,
13753 scope: Option<&str>,
13754 session_only: bool,
13755) -> Result<TraversalGraphBuild> {
13756 let mut graph = TraversalGraphBuild::default();
13757 let mut symbol_entries = Vec::new();
13758 let mut file_entries = Vec::new();
13759 let mut route_entries = Vec::new();
13760 let mut multiplicity_entries = Vec::new();
13761 let mut file_handle_by_path = HashMap::<String, String>::new();
13762 let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
13763 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13764 let (gate, _cache_detail) =
13765 prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
13766 graph.warnings.extend(gate.diagnostics);
13767 let gate_source_root = gate.source_root.clone();
13768
13769 match gate.db_path {
13770 Some(db_path) if db_path.exists() => {
13771 let db = index::IndexDb::open_read_only_resilient(&db_path)?;
13772 let file_paths = db.file_paths()?;
13773 for file in file_paths {
13774 if traversal_path_is_generated_artifact(
13775 root,
13776 &gate_source_root,
13777 Path::new(&file),
13778 ) {
13779 continue;
13780 }
13781 let node = traversal_file_node(root, &file);
13782 let entry = TraversalFileIndexEntry {
13783 handle: node.handle.clone(),
13784 tokens: traversal_node_tokens(&node),
13785 node: node.clone(),
13786 };
13787 if let Some(path) = entry.node.path.as_ref() {
13788 file_handle_by_path.insert(path.clone(), entry.handle.clone());
13789 }
13790 graph.add_node(node);
13791 file_entries.push(entry);
13792 }
13793
13794 let symbols = db.all_symbols()?;
13795 let mut symbol_by_file_name_line = HashMap::new();
13796 let mut span_by_file_name_line = HashMap::new();
13797 let mut first_symbol_by_name = BTreeMap::<String, String>::new();
13798 let mut first_span_by_name = BTreeMap::<String, String>::new();
13799 let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
13800 let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
13801 for symbol in symbols.iter().filter(|symbol| {
13802 !traversal_path_is_generated_artifact(
13803 root,
13804 &gate_source_root,
13805 Path::new(&symbol.file),
13806 )
13807 }) {
13808 let node = traversal_symbol_node(root, symbol);
13809 let file = relativize(&symbol.file, root);
13810 symbol_by_file_name_line.insert(
13811 format!("{file}:{}:{}", symbol.line, symbol.name),
13812 node.handle.clone(),
13813 );
13814 first_symbol_by_name
13815 .entry(symbol.name.clone())
13816 .or_insert_with(|| node.handle.clone());
13817 let entry = TraversalSymbolIndexEntry {
13818 handle: node.handle.clone(),
13819 tokens: traversal_node_tokens(&node),
13820 node: node.clone(),
13821 };
13822 graph.add_node(node.clone());
13823 if let Some(file_handle) = file_handle_by_path.get(&file) {
13824 graph.add_edge(
13825 file_handle,
13826 &node.handle,
13827 "defines",
13828 Some("file defines symbol".to_string()),
13829 1,
13830 );
13831 }
13832 if !source_by_file.contains_key(&symbol.file) {
13833 let source_path =
13834 traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
13835 source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
13836 }
13837 if let Some(Some(source)) = source_by_file.get(&symbol.file)
13838 && let Some((ast_node, mut ast_entry)) =
13839 traversal_ast_span_node(root, symbol, source, &symbols)
13840 {
13841 ast_entry.symbol_handle = node.handle.clone();
13842 ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
13843 span_by_file_name_line.insert(
13844 format!("{file}:{}:{}", symbol.line, symbol.name),
13845 ast_node.handle.clone(),
13846 );
13847 first_span_by_name
13848 .entry(symbol.name.clone())
13849 .or_insert_with(|| ast_node.handle.clone());
13850 graph.add_node(ast_node.clone());
13851 graph.add_edge(
13852 &node.handle,
13853 &ast_node.handle,
13854 "has_ast_span",
13855 Some("symbol projects to indexed AST span".to_string()),
13856 1,
13857 );
13858 graph.add_edge(
13859 &ast_node.handle,
13860 &node.handle,
13861 "represents_symbol",
13862 Some("AST span represents indexed symbol".to_string()),
13863 1,
13864 );
13865 ast_entries.push(ast_entry);
13866 }
13867 symbol_entries.push(entry);
13868 }
13869 link_ast_navigation_edges(&mut graph, &ast_entries);
13870 link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
13871
13872 if !bounded_session_projection {
13873 for edge in db.all_stored_edges()? {
13874 if traversal_path_is_generated_artifact(
13875 root,
13876 &gate_source_root,
13877 Path::new(&edge.caller_file),
13878 ) {
13879 continue;
13880 }
13881 let caller_file = relativize(&edge.caller_file, root);
13882 let caller_key =
13883 format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
13884 let Some(caller_handle) =
13885 symbol_by_file_name_line.get(&caller_key).cloned()
13886 else {
13887 continue;
13888 };
13889 let callee_handle = if let Some(handle) =
13890 first_symbol_by_name.get(&edge.callee_name)
13891 {
13892 handle.clone()
13893 } else {
13894 let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
13895 let handle = node.handle.clone();
13896 graph.add_node(node);
13897 handle
13898 };
13899 graph.add_edge(
13900 &caller_handle,
13901 &callee_handle,
13902 "calls",
13903 Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
13904 1,
13905 );
13906 if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
13907 && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
13908 {
13909 graph.add_edge(
13910 caller_span,
13911 callee_span,
13912 "calls",
13913 Some(format!(
13914 "AST call site {}:{}",
13915 caller_file, edge.call_site_line
13916 )),
13917 1,
13918 );
13919 }
13920 }
13921 }
13922
13923 for route in db.all_routes()? {
13924 if traversal_path_is_generated_artifact(
13925 root,
13926 &gate_source_root,
13927 Path::new(&route.file),
13928 ) {
13929 continue;
13930 }
13931 let node = traversal_route_node(root, &route);
13932 let entry = TraversalRouteIndexEntry {
13933 handle: node.handle.clone(),
13934 tokens: traversal_node_tokens(&node),
13935 node: node.clone(),
13936 };
13937 graph.add_node(node.clone());
13938 if let Some(path) = node.path.as_ref()
13939 && let Some(file_handle) = file_handle_by_path.get(path)
13940 {
13941 graph.add_edge(
13942 file_handle,
13943 &node.handle,
13944 "defines_route",
13945 Some("file declares route".to_string()),
13946 1,
13947 );
13948 }
13949 let handler_handle =
13950 if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
13951 handle.clone()
13952 } else {
13953 let node = traversal_unresolved_symbol_node(root, &route.handler_name);
13954 let handle = node.handle.clone();
13955 graph.add_node(node);
13956 handle
13957 };
13958 graph.add_edge(
13959 &entry.handle,
13960 &handler_handle,
13961 "handled_by",
13962 Some("route handler reference".to_string()),
13963 1,
13964 );
13965 if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
13966 graph.add_edge(
13967 &entry.handle,
13968 handler_span,
13969 "handled_by",
13970 Some("route handler AST span".to_string()),
13971 1,
13972 );
13973 graph.add_edge(
13974 handler_span,
13975 &entry.handle,
13976 "handles_route",
13977 Some("AST span handles route".to_string()),
13978 1,
13979 );
13980 }
13981 route_entries.push(entry);
13982 }
13983 }
13984 _ => {
13985 add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
13986 .with_context(|| {
13987 format!(
13988 "loading raw source fallback nodes from {}",
13989 gate_source_root.display()
13990 )
13991 })?;
13992 for entry in &file_entries {
13993 if let Some(path) = entry.node.path.as_ref() {
13994 file_handle_by_path.insert(path.clone(), entry.handle.clone());
13995 }
13996 }
13997 }
13998 }
13999 load_multiplicity_traversal_nodes(
14000 root,
14001 &gate_source_root,
14002 &mut graph,
14003 &file_handle_by_path,
14004 &mut multiplicity_entries,
14005 )?;
14006 }
14007
14008 let code_lookup = TraversalCodeLookup::new(
14009 &symbol_entries,
14010 &file_entries,
14011 &route_entries,
14012 &multiplicity_entries,
14013 );
14014 load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14015 Ok(graph)
14016}
14017
14018#[cfg(test)]
14019fn build_traversal_graph_source(
14020 root: &Path,
14021 path_hint: &Path,
14022 scope: Option<&str>,
14023) -> Result<TraversalGraphBuild> {
14024 build_traversal_graph_source_with_options(root, path_hint, scope, false)
14025}
14026
14027pub(crate) fn write_traversal_graph_store_with_options(
14028 root: &Path,
14029 path_hint: &Path,
14030 scope: Option<&str>,
14031 session_only: bool,
14032) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14033 let source_graph =
14034 build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14035 let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14036 let graph_db = graph_substrate_db_path(root, scope);
14037 let mut store = SqliteGraphStore::open(&graph_db)?;
14038 let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14039 .ok()
14040 .flatten()
14041 .or_else(|| graph_projection_content_hash(&projection));
14042 let refresh = store.replace_projection_with_version(
14043 scope.unwrap_or("root"),
14044 &projection,
14045 Some(GRAPH_PROJECTION_VERSION),
14046 source_watermark,
14047 )?;
14048 Ok((source_graph, refresh))
14049}
14050
14051pub(crate) fn write_traversal_graph_store(
14052 root: &Path,
14053 path_hint: &Path,
14054 scope: Option<&str>,
14055) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14056 write_traversal_graph_store_with_options(root, path_hint, scope, false)
14057}
14058
14059fn refresh_traversal_graph_store_with_options(
14060 root: &Path,
14061 path_hint: &Path,
14062 scope: Option<&str>,
14063 session_only: bool,
14064) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14065 let (source_graph, refresh) =
14066 write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14067 let graph_db = graph_substrate_db_path(root, scope);
14068 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14069 let mut graph = traversal_graph_from_store(root, &store)?;
14070 graph.warnings = source_graph.warnings;
14071 Ok((graph, refresh))
14072}
14073
14074fn refresh_traversal_graph_store(
14075 root: &Path,
14076 path_hint: &Path,
14077 scope: Option<&str>,
14078) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14079 refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14080}
14081
14082pub(crate) fn build_traversal_graph(
14083 root: &Path,
14084 path_hint: &Path,
14085 scope: Option<&str>,
14086) -> Result<TraversalGraphBuild> {
14087 let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14088 Ok(graph)
14089}
14090
14091fn traversal_query_kind_priority(kind: &str) -> usize {
14092 match kind {
14093 "backlog" => 0,
14094 "job_packet" => 1,
14095 "worker_result" => 2,
14096 "symbol" => 3,
14097 "ast_span" => 4,
14098 "file" => 5,
14099 "route" => 6,
14100 "cargo_package" => 7,
14101 "cargo_workspace" => 8,
14102 "session" => 9,
14103 "semantic_concept" => 10,
14104 "semantic_entity" => 11,
14105 _ => 12,
14106 }
14107}
14108
14109fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14110 let trimmed = query.trim();
14111 if trimmed.is_empty() {
14112 return None;
14113 }
14114 let kind_priority = traversal_query_kind_priority(&node.kind);
14115 if node.handle == trimmed {
14116 return Some((0, kind_priority, node.handle.clone()));
14117 }
14118 if node.path.as_deref() == Some(trimmed) {
14119 let path_priority = if node.kind == "file" {
14120 0
14121 } else {
14122 kind_priority.saturating_add(1)
14123 };
14124 return Some((1, path_priority, node.handle.clone()));
14125 }
14126 let normalized_backlog = trimmed.trim_start_matches('#');
14127 if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14128 {
14129 return Some((2, kind_priority, node.handle.clone()));
14130 }
14131 if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14132 return Some((3, kind_priority, node.handle.clone()));
14133 }
14134 None
14135}
14136
14137fn resolve_traversal_node<'a>(
14138 graph: &'a TraversalGraphBuild,
14139 query: &str,
14140) -> Option<&'a TraversalNode> {
14141 graph
14142 .nodes
14143 .values()
14144 .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14145 .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14146 .map(|(_, node)| node)
14147}
14148
14149fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14150 let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14151 for edge in edges {
14152 adj.entry(edge.from.clone())
14153 .or_default()
14154 .insert(edge.to.clone());
14155 adj.entry(edge.to.clone())
14156 .or_default()
14157 .insert(edge.from.clone());
14158 }
14159 adj.into_iter()
14160 .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14161 .collect()
14162}
14163
14164fn traversal_shortest_handles(
14165 edges: &[TraversalEdge],
14166 from: &str,
14167 to: &str,
14168) -> Option<Vec<String>> {
14169 if from == to {
14170 return Some(vec![from.to_string()]);
14171 }
14172 let adj = traversal_adjacency(edges);
14173 if !adj.contains_key(from) || !adj.contains_key(to) {
14174 return None;
14175 }
14176 let mut visited = BTreeSet::new();
14177 let mut queue = VecDeque::new();
14178 let mut parent = BTreeMap::<String, String>::new();
14179 visited.insert(from.to_string());
14180 queue.push_back(from.to_string());
14181 while let Some(current) = queue.pop_front() {
14182 if let Some(neighbors) = adj.get(¤t) {
14183 for neighbor in neighbors {
14184 if visited.insert(neighbor.clone()) {
14185 parent.insert(neighbor.clone(), current.clone());
14186 if neighbor == to {
14187 let mut path = vec![to.to_string()];
14188 let mut cursor = to.to_string();
14189 while let Some(prev) = parent.get(&cursor) {
14190 path.push(prev.clone());
14191 cursor = prev.clone();
14192 }
14193 path.reverse();
14194 return Some(path);
14195 }
14196 queue.push_back(neighbor.clone());
14197 }
14198 }
14199 }
14200 }
14201 None
14202}
14203
14204fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14205 let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14206 for edge in edges {
14207 let neighbor = if edge.from == current {
14208 edge.to.as_str()
14209 } else if edge.to == current {
14210 edge.from.as_str()
14211 } else {
14212 continue;
14213 };
14214 let score = traversal_relation_score(edge, current);
14215 best_score_by_neighbor
14216 .entry(neighbor.to_string())
14217 .and_modify(|best| *best = (*best).max(score))
14218 .or_insert(score);
14219 }
14220 let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14221 ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14222 right_score
14223 .cmp(left_score)
14224 .then_with(|| left_handle.cmp(right_handle))
14225 });
14226 ranked.into_iter().map(|(handle, _)| handle).collect()
14227}
14228
14229fn traversal_neighborhood_handles(
14230 edges: &[TraversalEdge],
14231 origin: &str,
14232 depth: usize,
14233 limit: usize,
14234) -> BTreeSet<String> {
14235 let mut seen = BTreeSet::new();
14236 let mut queue = VecDeque::new();
14237 seen.insert(origin.to_string());
14238 queue.push_back((origin.to_string(), 0usize));
14239 while let Some((current, current_depth)) = queue.pop_front() {
14240 if current_depth >= depth {
14241 continue;
14242 }
14243 for neighbor in traversal_scored_neighbors(edges, ¤t) {
14244 if limit > 0 && seen.len() >= limit {
14245 return seen;
14246 }
14247 if seen.insert(neighbor.clone()) {
14248 queue.push_back((neighbor, current_depth + 1));
14249 }
14250 }
14251 }
14252 seen
14253}
14254
14255fn traversal_edges_between(
14256 handles: &BTreeSet<String>,
14257 edges: &[TraversalEdge],
14258) -> Vec<TraversalEdge> {
14259 edges
14260 .iter()
14261 .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14262 .cloned()
14263 .collect()
14264}
14265
14266fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14267 let mut result = Vec::new();
14268 for pair in path.windows(2) {
14269 if let Some(edge) = edges.iter().find(|edge| {
14270 (edge.from == pair[0] && edge.to == pair[1])
14271 || (edge.from == pair[1] && edge.to == pair[0])
14272 }) {
14273 result.push(edge.clone());
14274 }
14275 }
14276 result
14277}
14278
14279fn sorted_traversal_nodes<'a>(
14280 nodes: impl IntoIterator<Item = &'a TraversalNode>,
14281) -> Vec<TraversalNode> {
14282 let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14283 nodes.sort_by(|left, right| {
14284 left.kind
14285 .cmp(&right.kind)
14286 .then_with(|| left.label.cmp(&right.label))
14287 .then_with(|| left.path.cmp(&right.path))
14288 .then_with(|| left.handle.cmp(&right.handle))
14289 });
14290 nodes
14291}
14292
14293fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14294 let base = match edge.relation.as_str() {
14295 "mentions" => 100,
14296 "contains" => 80,
14297 "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14298 "contains_embedded_symbol" | "embedded_in_fence" => 77,
14299 "contains_markdown_block"
14300 | "contains_embedded_code"
14301 | "enclosing_module"
14302 | "enclosing_section" => 76,
14303 "calls" => {
14304 if edge.from == origin {
14305 70
14306 } else {
14307 65
14308 }
14309 }
14310 "handled_by" | "handles_route" => 68,
14311 "defines_route" => 62,
14312 "imports" => 62,
14313 "previous_sibling" | "next_sibling" => 54,
14314 "mentions_concept" | "mentions_entity" => 66,
14315 "semantic_relation" => 64,
14316 "tagged_concept" | "related_concept" => 58,
14317 "defines" => {
14318 if edge.from == origin {
14319 60
14320 } else {
14321 55
14322 }
14323 }
14324 _ => 10,
14325 };
14326 base + edge.weight
14327}
14328
14329fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
14330 match edge.relation.as_str() {
14331 "mentions" => "matched from backlog/session text".to_string(),
14332 "contains" => "contained in the selected session artifact".to_string(),
14333 "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
14334 "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
14335 "parent" => "parent AST span".to_string(),
14336 "child" => "child AST span".to_string(),
14337 "previous_sibling" => "previous AST sibling".to_string(),
14338 "next_sibling" => "next AST sibling".to_string(),
14339 "contains_markdown_block" => "Markdown section block".to_string(),
14340 "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
14341 "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
14342 "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
14343 "enclosing_module" => "nearest enclosing module".to_string(),
14344 "enclosing_section" => "nearest enclosing Markdown section".to_string(),
14345 "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
14346 "defines" => "file that defines the selected symbol".to_string(),
14347 "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
14348 "defines_route" => "file that declares the selected route".to_string(),
14349 "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
14350 "handled_by" => "route handled by the selected symbol".to_string(),
14351 "handles_route" => "route handled by the selected AST span".to_string(),
14352 "imports" => "import dependency from the selected package".to_string(),
14353 "mentions_concept" => "cached summary concept for the selected source".to_string(),
14354 "mentions_entity" => "cached summary entity for the selected source".to_string(),
14355 "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
14356 "tagged_concept" => "concept label attached to the selected entity".to_string(),
14357 "related_concept" => "co-occurring cached summary concept".to_string(),
14358 "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
14359 "calls" => "caller of the selected symbol".to_string(),
14360 other => format!("connected by {other}"),
14361 }
14362}
14363
14364fn traversal_recommendations(
14365 graph: &TraversalGraphBuild,
14366 origin: Option<&str>,
14367 shortest_path: Option<&[String]>,
14368 limit: usize,
14369) -> Vec<TraversalRecommendation> {
14370 let Some(origin) = origin else {
14371 return Vec::new();
14372 };
14373 let mut recommendations = Vec::new();
14374 let mut seen = BTreeSet::new();
14375
14376 if let Some(path) = shortest_path
14377 && path.len() > 1
14378 && path.first().is_some_and(|handle| handle == origin)
14379 && let Some(next) = graph.nodes.get(&path[1])
14380 {
14381 seen.insert(next.handle.clone());
14382 recommendations.push(TraversalRecommendation {
14383 handle: next.handle.clone(),
14384 kind: next.kind.clone(),
14385 label: next.label.clone(),
14386 reason: "next hop on shortest path".to_string(),
14387 score: 1_000,
14388 expand: next.expand.clone(),
14389 });
14390 }
14391
14392 let mut candidates = graph
14393 .edges
14394 .iter()
14395 .filter_map(|edge| {
14396 let neighbor = if edge.from == origin {
14397 edge.to.as_str()
14398 } else if edge.to == origin {
14399 edge.from.as_str()
14400 } else {
14401 return None;
14402 };
14403 let node = graph.nodes.get(neighbor)?;
14404 Some((traversal_relation_score(edge, origin), edge, node))
14405 })
14406 .collect::<Vec<_>>();
14407 candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
14408 right_score
14409 .cmp(left_score)
14410 .then_with(|| left.kind.cmp(&right.kind))
14411 .then_with(|| left.label.cmp(&right.label))
14412 .then_with(|| left.handle.cmp(&right.handle))
14413 });
14414
14415 let max = if limit == 0 { usize::MAX } else { limit };
14416 for (score, edge, node) in candidates {
14417 if recommendations.len() >= max {
14418 break;
14419 }
14420 if seen.insert(node.handle.clone()) {
14421 recommendations.push(TraversalRecommendation {
14422 handle: node.handle.clone(),
14423 kind: node.kind.clone(),
14424 label: node.label.clone(),
14425 reason: traversal_recommendation_reason(edge, origin),
14426 score,
14427 expand: node.expand.clone(),
14428 });
14429 }
14430 }
14431
14432 recommendations
14433}
14434
14435fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
14436 let scale = nodes.saturating_add(edges);
14437 if scale <= 80 {
14438 ExplorationBudget {
14439 project_size: "small".to_string(),
14440 max_source_windows: 8,
14441 lines_per_window: 96,
14442 relationship_limit: 40,
14443 }
14444 } else if scale <= 800 {
14445 ExplorationBudget {
14446 project_size: "medium".to_string(),
14447 max_source_windows: 6,
14448 lines_per_window: 80,
14449 relationship_limit: 32,
14450 }
14451 } else {
14452 ExplorationBudget {
14453 project_size: "large".to_string(),
14454 max_source_windows: 4,
14455 lines_per_window: 64,
14456 relationship_limit: 24,
14457 }
14458 }
14459}
14460
14461fn exploration_node_label(node: &TraversalNode) -> String {
14462 format!("{}:{}", node.kind, node.label)
14463}
14464
14465fn exploration_source_window_for_node(
14466 root: &Path,
14467 node: &TraversalNode,
14468 budget: &ExplorationBudget,
14469) -> Option<ExplorationSourceWindow> {
14470 let file = node.path.as_ref()?;
14471 let anchor = node
14472 .line
14473 .and_then(|line| usize::try_from(line).ok())
14474 .and_then(|line| line.checked_add(1))
14475 .unwrap_or(1);
14476 let context_before = budget.lines_per_window / 3;
14477 let start = anchor.saturating_sub(context_before).max(1);
14478 let end = start
14479 .saturating_add(budget.lines_per_window)
14480 .saturating_sub(1);
14481 let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
14482 Some(ExplorationSourceWindow {
14483 handle,
14484 file: file.clone(),
14485 start,
14486 end,
14487 reason: format!("cluster around {}", exploration_node_label(node)),
14488 expand: source_read_command(root, file, start, budget.lines_per_window),
14489 })
14490}
14491
14492fn build_exploration_packet(
14493 root: &Path,
14494 totals: &TraversalTotals,
14495 selected_nodes: &[TraversalNode],
14496 selected_edges: &[TraversalEdge],
14497) -> ExplorationPacket {
14498 let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
14499 let node_by_handle = selected_nodes
14500 .iter()
14501 .map(|node| (node.handle.as_str(), node))
14502 .collect::<BTreeMap<_, _>>();
14503 let relationship_map = selected_edges
14504 .iter()
14505 .take(budget.relationship_limit)
14506 .filter_map(|edge| {
14507 let from = node_by_handle.get(edge.from.as_str())?;
14508 let to = node_by_handle.get(edge.to.as_str())?;
14509 Some(ExplorationRelation {
14510 from: exploration_node_label(from),
14511 relation: edge.relation.clone(),
14512 to: exploration_node_label(to),
14513 label: edge.label.clone(),
14514 })
14515 })
14516 .collect::<Vec<_>>();
14517
14518 let mut seen_windows = BTreeSet::new();
14519 let mut source_windows = Vec::new();
14520 for node in selected_nodes {
14521 if source_windows.len() >= budget.max_source_windows {
14522 break;
14523 }
14524 let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
14525 continue;
14526 };
14527 let key = (window.file.clone(), window.start, window.end);
14528 if seen_windows.insert(key) {
14529 source_windows.push(window);
14530 }
14531 }
14532
14533 ExplorationPacket {
14534 budget,
14535 relationship_map,
14536 source_windows,
14537 worker_context: Vec::new(),
14538 no_reread_guidance:
14539 "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
14540 .to_string(),
14541 }
14542}
14543
14544pub(crate) fn traversal_report(
14545 root: &Path,
14546 scope: Option<&str>,
14547 graph: TraversalGraphBuild,
14548 query: Option<&str>,
14549 target: Option<&str>,
14550 depth: usize,
14551 limit: usize,
14552) -> Result<TraversalReport> {
14553 let totals = TraversalTotals {
14554 nodes: graph.nodes.len(),
14555 edges: graph.edges.len(),
14556 };
14557 let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
14558 let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
14559 if let Some(query) = query
14560 && origin_node.is_none()
14561 {
14562 bail!("traversal node not found: {}", query);
14563 }
14564 if let Some(target) = target
14565 && target_node.is_none()
14566 {
14567 bail!("traversal target not found: {}", target);
14568 }
14569
14570 let (mode, selected_nodes, selected_edges, shortest_path) =
14571 if let (Some(origin), Some(target)) = (origin_node, target_node) {
14572 if let Some(handles) =
14573 traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
14574 {
14575 let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
14576 let nodes = handles
14577 .iter()
14578 .filter_map(|handle| graph.nodes.get(handle).cloned())
14579 .collect::<Vec<_>>();
14580 let edges = traversal_path_edges(&handles, &graph.edges);
14581 let path = TraversalPathReport {
14582 from: origin.clone(),
14583 to: target.clone(),
14584 hops: handles.len().saturating_sub(1),
14585 nodes: nodes.clone(),
14586 edges: edges.clone(),
14587 };
14588 (
14589 "path".to_string(),
14590 nodes,
14591 traversal_edges_between(&handle_set, &graph.edges),
14592 Some(path),
14593 )
14594 } else {
14595 (
14596 "path".to_string(),
14597 vec![origin.clone(), target.clone()],
14598 Vec::new(),
14599 None,
14600 )
14601 }
14602 } else if let Some(origin) = origin_node {
14603 let handles =
14604 traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
14605 let nodes =
14606 sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
14607 let edges = traversal_edges_between(&handles, &graph.edges);
14608 ("neighborhood".to_string(), nodes, edges, None)
14609 } else {
14610 let mut nodes = sorted_traversal_nodes(graph.nodes.values());
14611 let truncated_nodes = limit > 0 && nodes.len() > limit;
14612 if truncated_nodes {
14613 nodes.truncate(limit);
14614 }
14615 let handles = nodes
14616 .iter()
14617 .map(|node| node.handle.clone())
14618 .collect::<BTreeSet<_>>();
14619 let mut edges = traversal_edges_between(&handles, &graph.edges);
14620 let truncated_edges = limit > 0 && edges.len() > limit;
14621 if truncated_edges {
14622 edges.truncate(limit);
14623 }
14624 ("export".to_string(), nodes, edges, None)
14625 };
14626
14627 let shortest_handles = shortest_path.as_ref().map(|path| {
14628 path.nodes
14629 .iter()
14630 .map(|node| node.handle.clone())
14631 .collect::<Vec<_>>()
14632 });
14633 let recommendations = traversal_recommendations(
14634 &graph,
14635 origin_node.map(|node| node.handle.as_str()),
14636 shortest_handles.as_deref(),
14637 if limit == 0 { 10 } else { limit.min(10) },
14638 );
14639 let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
14640 let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
14641
14642 Ok(TraversalReport {
14643 root: root.to_string_lossy().to_string(),
14644 scope: scope.map(str::to_string),
14645 mode,
14646 totals,
14647 query: query.map(str::to_string),
14648 target: target.map(str::to_string),
14649 nodes: selected_nodes,
14650 edges: selected_edges,
14651 shortest_path,
14652 recommendations,
14653 exploration,
14654 truncated,
14655 warnings: graph.warnings,
14656 })
14657}
14658
14659fn html_escape(input: &str) -> String {
14660 input
14661 .replace('&', "&")
14662 .replace('<', "<")
14663 .replace('>', ">")
14664 .replace('"', """)
14665 .replace('\'', "'")
14666}
14667
14668pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
14669 let json = serde_json::to_string(report)?.replace("</", "<\\/");
14670 let mut html = String::new();
14671 html.push_str(
14672 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
14673 );
14674 html.push_str(
14675 r#"<style>
14676:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
14677@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
14678*{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}}
14679</style>"#,
14680 );
14681 html.push_str("</head><body>");
14682 html.push_str("<div class=\"page\">");
14683 html.push_str(&format!(
14684 "<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>",
14685 html_escape(&report.mode),
14686 report.nodes.len(),
14687 report.totals.nodes,
14688 report.edges.len(),
14689 report.totals.edges
14690 ));
14691 html.push_str(
14692 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>"#,
14693 );
14694 html.push_str("<script id=\"graph-data\" type=\"application/json\">");
14695 html.push_str(&json);
14696 html.push_str(
14697 r##"</script><script>
14698const report = JSON.parse(document.getElementById("graph-data").textContent);
14699const svg = document.getElementById("graph-canvas");
14700const list = document.getElementById("node-list");
14701const selected = document.getElementById("selected");
14702const filter = document.getElementById("filter");
14703const legend = document.getElementById("legend");
14704const nodes = report.nodes.map((node, index) => ({...node, index}));
14705const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
14706const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
14707const colorByKind = new Map([
14708 ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
14709 ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
14710 ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
14711 ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
14712]);
14713function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
14714function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
14715function text(value){ return value == null ? "" : String(value); }
14716function matches(node, query){
14717 if (!query) return true;
14718 const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
14719 return haystack.includes(query);
14720}
14721function layout(){
14722 const rect = svg.getBoundingClientRect();
14723 const width = rect.width || 900;
14724 const height = rect.height || 650;
14725 const cx = width / 2;
14726 const cy = height / 2;
14727 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14728 const counts = new Map();
14729 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
14730 const offsets = new Map();
14731 for (const node of nodes) {
14732 const group = kinds.indexOf(node.kind);
14733 const index = offsets.get(node.kind) || 0;
14734 offsets.set(node.kind, index + 1);
14735 const groupCount = counts.get(node.kind) || 1;
14736 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
14737 const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
14738 node.x = cx + Math.cos(angle) * ring;
14739 node.y = cy + Math.sin(angle) * ring;
14740 }
14741}
14742function draw(){
14743 const query = filter.value.trim().toLowerCase();
14744 const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
14745 svg.innerHTML = "";
14746 for (const edge of edges) {
14747 if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
14748 const from = nodeByHandle.get(edge.from);
14749 const to = nodeByHandle.get(edge.to);
14750 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
14751 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
14752 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
14753 line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
14754 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
14755 svg.appendChild(line);
14756 }
14757 for (const node of nodes) {
14758 if (!visible.has(node.handle)) continue;
14759 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
14760 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
14761 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
14762 circle.setAttribute("fill", color(node.kind));
14763 circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
14764 circle.addEventListener("click", () => selectNode(node));
14765 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
14766 svg.appendChild(circle);
14767 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
14768 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
14769 label.setAttribute("class", "node-label");
14770 label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
14771 svg.appendChild(label);
14772 }
14773 renderList(query);
14774}
14775function renderLegend(){
14776 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14777 legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">●</b> ${kind}</span>`).join("");
14778}
14779function renderList(query){
14780 const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
14781 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("");
14782 for (const row of list.querySelectorAll(".row")) {
14783 row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
14784 }
14785}
14786function selectNode(node){
14787 const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
14788 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>`;
14789}
14790function escapeHtml(value){
14791 return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));
14792}
14793filter.addEventListener("input", draw);
14794window.addEventListener("resize", () => { layout(); draw(); });
14795renderLegend();
14796layout();
14797draw();
14798if (nodes.length) selectNode(nodes[0]);
14799</script></div></body></html>"##,
14800 );
14801 Ok(html)
14802}
14803
14804fn semantic_related_report_from_store(
14805 root: &Path,
14806 scope: Option<&str>,
14807 query: &str,
14808 limit: usize,
14809 kind: SemanticRelatedKind,
14810 store: &impl GraphStore,
14811) -> Result<SemanticRelatedReport> {
14812 if query.trim().is_empty() {
14813 bail!("semantic query cannot be empty");
14814 }
14815
14816 let query_embedding = semantic_embedding(query);
14817 let node_kinds: &[&str] = match kind {
14818 SemanticRelatedKind::Concept => &["semantic_concept"],
14819 SemanticRelatedKind::Entity => &["semantic_entity"],
14820 SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
14821 };
14822
14823 let items = store
14824 .semantic_top_candidates(&query_embedding, node_kinds, limit)?
14825 .into_iter()
14826 .map(|candidate| {
14827 let node = candidate.node;
14828 SemanticRelatedItem {
14829 handle: node
14830 .properties
14831 .get("handle")
14832 .cloned()
14833 .unwrap_or_else(|| node.id.clone()),
14834 kind: node.kind,
14835 label: node.label,
14836 score: candidate.score,
14837 file_path: node
14838 .properties
14839 .get("source_file")
14840 .or_else(|| node.properties.get("path"))
14841 .cloned(),
14842 source_symbol: node.properties.get("source_symbol").cloned(),
14843 detail: node
14844 .properties
14845 .get("description")
14846 .or_else(|| node.properties.get("detail"))
14847 .cloned(),
14848 expand: node
14849 .properties
14850 .get("expand")
14851 .cloned()
14852 .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
14853 }
14854 })
14855 .collect::<Vec<_>>();
14856
14857 let mut warnings = Vec::new();
14858 if items.is_empty() {
14859 warnings.push(
14860 "no semantic graph rows found; run `tsift summarize --extract <path>` first"
14861 .to_string(),
14862 );
14863 }
14864
14865 Ok(SemanticRelatedReport {
14866 root: root.to_string_lossy().to_string(),
14867 scope: scope.map(str::to_string),
14868 query: query.to_string(),
14869 embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
14870 count: items.len(),
14871 items,
14872 warnings,
14873 })
14874}
14875
14876fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
14877 Ok(store.nodes_by_kind("semantic_concept")?.len()
14878 + store.nodes_by_kind("semantic_entity")?.len())
14879}
14880
14881fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
14882 if limit == 0 {
14883 return 0;
14884 }
14885 limit.saturating_mul(4).clamp(
14886 GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
14887 GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
14888 )
14889}
14890
14891fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
14892 if limit == 0 {
14893 return usize::MAX;
14894 }
14895 limit.saturating_mul(3).max(limit).max(seed_count)
14896}
14897
14898fn graph_db_semantic_seeded_neighborhood(
14899 store: &impl GraphStore,
14900 seed_ids: &[String],
14901 depth: usize,
14902 limit: usize,
14903) -> Result<GraphDbSemanticSeededSubgraph> {
14904 let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
14905 let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
14906 let mut diagnostics = vec![
14907 "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
14908 "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(),
14909 format!(
14910 "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
14911 if edge_scan_cap == 0 {
14912 "unbounded".to_string()
14913 } else {
14914 edge_scan_cap.to_string()
14915 },
14916 if node_discovery_cap == usize::MAX {
14917 "unbounded".to_string()
14918 } else {
14919 node_discovery_cap.to_string()
14920 }
14921 ),
14922 ];
14923
14924 let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
14925 .with_edge_scan_cap(edge_scan_cap)
14926 .with_node_discovery_cap(node_discovery_cap);
14927 let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
14928
14929 for seed_id in &result.missing_seed_ids {
14930 diagnostics.push(format!(
14931 "semantic seed {seed_id} was not present in the graph store"
14932 ));
14933 }
14934
14935 if result.skipped_by_edge_cap > 0 {
14936 diagnostics.push(format!(
14937 "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
14938 result.skipped_by_edge_cap
14939 ));
14940 }
14941 if result.skipped_by_node_cap > 0 {
14942 diagnostics.push(format!(
14943 "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
14944 result.skipped_by_node_cap
14945 ));
14946 }
14947
14948 if result.truncated {
14949 diagnostics.push(format!(
14950 "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
14951 result.total_discovered
14952 ));
14953 }
14954
14955 Ok(GraphDbSemanticSeededSubgraph {
14956 nodes: result.nodes,
14957 edges: result.edges,
14958 truncated: result.truncated,
14959 diagnostics,
14960 })
14961}
14962
14963#[allow(clippy::too_many_arguments)]
14964fn cmd_semantic_related(
14965 query: &str,
14966 path: &Path,
14967 scope: Option<&str>,
14968 limit: usize,
14969 kind: SemanticRelatedKind,
14970 json_output: bool,
14971 compact: bool,
14972 pretty: bool,
14973 terse: bool,
14974 schema: bool,
14975) -> Result<()> {
14976 let root = lint::resolve_project_root_or_canonical_path(path)?;
14977 write_traversal_graph_store(&root, path, scope)?;
14978 let graph_db = graph_substrate_db_path(&root, scope);
14979 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14980 let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
14981 if let Some(recovery) = store.read_only_recovery() {
14982 report
14983 .warnings
14984 .push(graph_db_read_recovery_diagnostic(recovery));
14985 }
14986
14987 if json_output {
14988 println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
14989 } else if compact {
14990 for item in &report.items {
14991 println!(
14992 "{:.3}\t{}\t{}\t{}",
14993 item.score, item.kind, item.label, item.handle
14994 );
14995 }
14996 for warning in &report.warnings {
14997 eprintln!("warning: {warning}");
14998 }
14999 } else {
15000 println!(
15001 "Related semantic graph rows for {:?} ({})",
15002 report.query, report.embedding_model
15003 );
15004 for item in &report.items {
15005 println!(
15006 " {:.3} [{}] {} ({})",
15007 item.score, item.kind, item.label, item.handle
15008 );
15009 if let Some(detail) = &item.detail {
15010 println!(" {}", detail);
15011 }
15012 if let Some(file_path) = &item.file_path {
15013 println!(" file: {}", file_path);
15014 }
15015 println!(" expand: {}", item.expand);
15016 }
15017 for warning in &report.warnings {
15018 eprintln!("warning: {warning}");
15019 }
15020 }
15021
15022 Ok(())
15023}
15024
15025#[derive(Serialize)]
15026struct SourceLinePreview {
15027 line: usize,
15028 text: String,
15029}
15030
15031#[derive(Serialize)]
15032pub(crate) struct SourceRangePreview {
15033 start: usize,
15034 end: usize,
15035 total_lines: usize,
15036 truncated_before: bool,
15037 truncated_after: bool,
15038}
15039
15040#[derive(Serialize)]
15041struct SourceExpandCommands {
15042 #[serde(skip_serializing_if = "Option::is_none")]
15043 before: Option<String>,
15044 #[serde(skip_serializing_if = "Option::is_none")]
15045 after: Option<String>,
15046 #[serde(skip_serializing_if = "Option::is_none")]
15047 body: Option<String>,
15048 file: String,
15049 #[serde(skip_serializing_if = "Option::is_none")]
15050 markdown_ast: Option<String>,
15051}
15052
15053#[derive(Serialize)]
15054struct SourceSymbolRef {
15055 handle: String,
15056 name: String,
15057 kind: String,
15058 language: String,
15059 file: String,
15060 line: usize,
15061 #[serde(skip_serializing_if = "Option::is_none")]
15062 end_line: Option<usize>,
15063 #[serde(skip_serializing_if = "Option::is_none")]
15064 signature: Option<String>,
15065 #[serde(skip_serializing_if = "Option::is_none")]
15066 span: Option<AstSpanPreview>,
15067 expand: String,
15068}
15069
15070#[derive(Serialize)]
15071struct SourceSummaryRef {
15072 handle: String,
15073 symbol_name: String,
15074 file_path: String,
15075 summary: String,
15076 expand: String,
15077}
15078
15079#[derive(Serialize)]
15080struct SourceReadReport {
15081 handle: String,
15082 root: String,
15083 file: String,
15084 range: SourceRangePreview,
15085 preview: Vec<SourceLinePreview>,
15086 symbols: Vec<SourceSymbolRef>,
15087 summaries: Vec<SourceSummaryRef>,
15088 #[serde(skip_serializing_if = "Option::is_none")]
15089 markdown: Option<SourceReadMarkdownProjection>,
15090 expand: SourceExpandCommands,
15091 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15092 warnings: Vec<String>,
15093}
15094
15095#[derive(Serialize)]
15096struct SourceReadAstExpandCommands {
15097 window: String,
15098 file_window: String,
15099 #[serde(skip_serializing_if = "Option::is_none")]
15100 markdown_ast: Option<String>,
15101}
15102
15103#[derive(Serialize)]
15104struct SourceReadAstReport {
15105 handle: String,
15106 root: String,
15107 file: String,
15108 range: SourceRangePreview,
15109 symbols: Vec<SourceSymbolRef>,
15110 summaries: Vec<SourceSummaryRef>,
15111 #[serde(skip_serializing_if = "Option::is_none")]
15112 markdown: Option<SourceReadMarkdownProjection>,
15113 expand: SourceReadAstExpandCommands,
15114 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15115 warnings: Vec<String>,
15116}
15117
15118#[derive(Serialize)]
15119struct SymbolReadTarget {
15120 handle: String,
15121 name: String,
15122 kind: String,
15123 language: String,
15124 file: String,
15125 line: usize,
15126 #[serde(skip_serializing_if = "Option::is_none")]
15127 end_line: Option<usize>,
15128 #[serde(skip_serializing_if = "Option::is_none")]
15129 signature: Option<String>,
15130 #[serde(skip_serializing_if = "Option::is_none")]
15131 parent_module: Option<String>,
15132 #[serde(skip_serializing_if = "Option::is_none")]
15133 visibility: Option<String>,
15134 #[serde(skip_serializing_if = "Option::is_none")]
15135 span: Option<AstSpanPreview>,
15136}
15137
15138#[derive(Serialize)]
15139struct SymbolReadExpandCommands {
15140 source_window: String,
15141 #[serde(skip_serializing_if = "Option::is_none")]
15142 body: Option<String>,
15143 file: String,
15144 explain: String,
15145 callers: String,
15146 callees: String,
15147 #[serde(skip_serializing_if = "Option::is_none")]
15148 markdown_ast: Option<String>,
15149}
15150
15151#[derive(Serialize)]
15152struct SymbolReadReport {
15153 handle: String,
15154 root: String,
15155 query: String,
15156 symbol: SymbolReadTarget,
15157 range: SourceRangePreview,
15158 body: Vec<SourceLinePreview>,
15159 child_symbols: Vec<SourceSymbolRef>,
15160 summaries: Vec<SourceSummaryRef>,
15161 expand: SymbolReadExpandCommands,
15162 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15163 warnings: Vec<String>,
15164}
15165
15166#[derive(Clone)]
15167pub(crate) struct MarkdownAstRawNode {
15168 handle: String,
15169 span_handle: String,
15170 name: String,
15171 kind: String,
15172 block_kind: String,
15173 node_kind: String,
15174 start_byte: usize,
15175 end_byte: usize,
15176 body_start_byte: Option<usize>,
15177 body_end_byte: Option<usize>,
15178}
15179
15180#[derive(Clone)]
15181pub(crate) struct MarkdownAstProjection {
15182 source_hash: String,
15183 nodes: Vec<MarkdownAstRawNode>,
15184 parse_duration_micros: u128,
15185 cache_hit: bool,
15186}
15187
15188#[derive(Clone)]
15189struct MarkdownAstCacheEntry {
15190 source_hash: String,
15191 nodes: Vec<MarkdownAstRawNode>,
15192 parse_duration_micros: u128,
15193}
15194
15195static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15196 OnceLock::new();
15197
15198#[derive(Serialize, Clone)]
15199struct MarkdownAstNodeMetadata {
15200 #[serde(skip_serializing_if = "Option::is_none")]
15201 heading_level: Option<usize>,
15202 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15203 section_path: Vec<String>,
15204 #[serde(skip_serializing_if = "Option::is_none")]
15205 section_handle: Option<String>,
15206 #[serde(skip_serializing_if = "Option::is_none")]
15207 list_depth: Option<usize>,
15208 #[serde(skip_serializing_if = "Option::is_none")]
15209 list_marker: Option<String>,
15210 #[serde(skip_serializing_if = "Option::is_none")]
15211 list_order: Option<usize>,
15212 #[serde(skip_serializing_if = "Option::is_none")]
15213 fence_language: Option<String>,
15214 #[serde(skip_serializing_if = "Option::is_none")]
15215 fence_marker: Option<String>,
15216 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15217 embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15218}
15219
15220#[derive(Serialize, Clone)]
15221struct MarkdownAstNodeExpand {
15222 source_window: String,
15223 source_body: String,
15224 symbol_read: String,
15225 edit_intents: String,
15226}
15227
15228#[derive(Serialize, Clone)]
15229struct MarkdownAstCacheReport {
15230 source_hash: String,
15231 cache_hit: bool,
15232 parse_duration_micros: u128,
15233 node_count: usize,
15234 section_count: usize,
15235 list_item_count: usize,
15236 code_block_count: usize,
15237}
15238
15239#[derive(Serialize, Clone)]
15240struct MarkdownAstPhaseTiming {
15241 name: String,
15242 duration_micros: u128,
15243 detail: String,
15244}
15245
15246#[derive(Serialize, Clone)]
15247struct MarkdownAstOutlineEntry {
15248 handle: String,
15249 span_handle: String,
15250 name: String,
15251 kind: String,
15252 block_kind: String,
15253 line: usize,
15254 end_line: usize,
15255 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15256 section_path: Vec<String>,
15257 child_count: usize,
15258 expand: String,
15259}
15260
15261#[derive(Serialize, Clone)]
15262struct MarkdownAstProjectionPreview {
15263 mode: String,
15264 total_nodes: usize,
15265 returned_nodes: usize,
15266 omitted_nodes: usize,
15267 selected_node: Option<String>,
15268 cache: MarkdownAstCacheReport,
15269 outline: Vec<MarkdownAstOutlineEntry>,
15270 phase_timings: Vec<MarkdownAstPhaseTiming>,
15271}
15272
15273#[derive(Serialize)]
15274struct SourceReadMarkdownProjection {
15275 handle: String,
15276 mode: String,
15277 total_nodes: usize,
15278 visible_nodes: usize,
15279 outline: Vec<MarkdownAstOutlineEntry>,
15280 expand: String,
15281}
15282
15283#[derive(Serialize, Clone)]
15284struct SourceByteRangePreview {
15285 start: usize,
15286 end: usize,
15287}
15288
15289#[derive(Serialize, Clone)]
15290struct MarkdownAstNode {
15291 handle: String,
15292 span_handle: String,
15293 name: String,
15294 kind: String,
15295 block_kind: String,
15296 node_kind: String,
15297 line: usize,
15298 end_line: usize,
15299 byte_span: SourceByteRangePreview,
15300 #[serde(skip_serializing_if = "Option::is_none")]
15301 body_byte_span: Option<SourceByteRangePreview>,
15302 parent_handle: Option<String>,
15303 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15304 child_handles: Vec<String>,
15305 metadata: MarkdownAstNodeMetadata,
15306 expand: MarkdownAstNodeExpand,
15307}
15308
15309#[derive(Serialize)]
15310struct MarkdownAstExpandCommands {
15311 file: String,
15312 source_read: String,
15313 edit_intents: String,
15314}
15315
15316#[derive(Serialize)]
15317struct MarkdownAstReport {
15318 handle: String,
15319 root: String,
15320 file: String,
15321 range: SourceRangePreview,
15322 projection: MarkdownAstProjectionPreview,
15323 nodes: Vec<MarkdownAstNode>,
15324 expand: MarkdownAstExpandCommands,
15325 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15326 warnings: Vec<String>,
15327}
15328
15329pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
15330 let candidate = if file.is_absolute() {
15331 file.to_path_buf()
15332 } else {
15333 root.join(file)
15334 };
15335 let canonical = candidate
15336 .canonicalize()
15337 .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
15338 if !canonical.is_file() {
15339 bail!("source file is not a regular file: {}", canonical.display());
15340 }
15341 let canonical_root = root
15342 .canonicalize()
15343 .with_context(|| format!("canonicalizing project root {}", root.display()))?;
15344 if !canonical.starts_with(&canonical_root) {
15345 bail!(
15346 "source file {} is outside project root {}",
15347 canonical.display(),
15348 canonical_root.display()
15349 );
15350 }
15351 Ok(canonical)
15352}
15353
15354pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
15355 source_read_window_command(root, file, start, lines)
15356}
15357
15358pub(crate) fn source_read_window_command(
15359 root: &Path,
15360 file: &str,
15361 start: usize,
15362 lines: usize,
15363) -> String {
15364 format!(
15365 "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
15366 shell_quote(file),
15367 shell_quote(&root.to_string_lossy()),
15368 start,
15369 lines
15370 )
15371}
15372
15373pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
15374 format!(
15375 "tsift --envelope source-read {} --path {} --budget normal",
15376 shell_quote(file),
15377 shell_quote(&root.to_string_lossy())
15378 )
15379}
15380
15381pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
15382 format!(
15383 "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
15384 shell_quote(symbol),
15385 shell_quote(&root.to_string_lossy()),
15386 shell_quote(file)
15387 )
15388}
15389
15390fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
15391 format!(
15392 "tsift --envelope explain {} --path {} --budget normal",
15393 shell_quote(symbol),
15394 shell_quote(&root.to_string_lossy())
15395 )
15396}
15397
15398fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
15399 format!(
15400 "tsift graph {} --path {} --{} --json",
15401 shell_quote(symbol),
15402 shell_quote(&root.to_string_lossy()),
15403 relation
15404 )
15405}
15406
15407fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
15408 format!(
15409 "tsift summarize {} --path {} --json",
15410 shell_quote(symbol),
15411 shell_quote(&root.to_string_lossy())
15412 )
15413}
15414
15415pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
15416 let mut command = format!(
15417 "tsift --envelope markdown-ast {} --path {} --budget normal",
15418 shell_quote(file),
15419 shell_quote(&root.to_string_lossy())
15420 );
15421 if let Some(node) = node {
15422 command.push_str(" --node ");
15423 command.push_str(&shell_quote(node));
15424 }
15425 command
15426}
15427
15428fn markdown_edit_intents_command(root: &Path) -> String {
15429 format!(
15430 "tsift --envelope edit-intents --path {} --budget normal",
15431 shell_quote(&root.to_string_lossy())
15432 )
15433}
15434
15435pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
15436 usize::try_from(symbol.line)
15437 .ok()
15438 .and_then(|line| line.checked_add(1))
15439 .unwrap_or(1)
15440}
15441
15442fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
15443 symbol
15444 .end_line
15445 .and_then(|line| usize::try_from(line).ok())
15446 .and_then(|line| line.checked_add(1))
15447}
15448
15449fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
15450 value.and_then(|byte| usize::try_from(byte).ok())
15451}
15452
15453fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
15454 let byte = byte.min(source.len());
15455 source[..byte]
15456 .iter()
15457 .filter(|value| **value == b'\n')
15458 .count()
15459 .saturating_add(1)
15460}
15461
15462fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
15463 source_line_for_byte(source, end_byte.saturating_sub(1))
15464}
15465
15466fn ast_span_handle(
15467 file: &str,
15468 name: &str,
15469 kind: &str,
15470 start_byte: usize,
15471 end_byte: usize,
15472) -> String {
15473 stable_handle(
15474 "span",
15475 &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
15476 )
15477}
15478
15479pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
15480 Some((
15481 symbol_span_byte(symbol.start_byte)?,
15482 symbol_span_byte(symbol.end_byte)?,
15483 ))
15484}
15485
15486pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
15487 Some((
15488 symbol_span_byte(symbol.start_byte)?,
15489 symbol_span_byte(symbol.end_byte)?,
15490 ))
15491}
15492
15493pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
15494 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15495 Some(ast_span_handle(
15496 &symbol.file,
15497 &symbol.name,
15498 &symbol.kind,
15499 start_byte,
15500 end_byte,
15501 ))
15502}
15503
15504fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
15505 left.file == right.file
15506 && left.name == right.name
15507 && left.kind == right.kind
15508 && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
15509}
15510
15511fn stored_symbol_parent_span_handle(
15512 symbol: &index::StoredSymbol,
15513 symbols: &[index::StoredSymbol],
15514) -> Option<String> {
15515 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15516 symbols
15517 .iter()
15518 .filter(|candidate| {
15519 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15520 return false;
15521 }
15522 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15523 else {
15524 return false;
15525 };
15526 candidate_start <= start_byte && candidate_end >= end_byte
15527 })
15528 .min_by_key(|candidate| {
15529 stored_symbol_span_bounds(candidate)
15530 .map(|(start, end)| end.saturating_sub(start))
15531 .unwrap_or(usize::MAX)
15532 })
15533 .and_then(stored_symbol_span_handle)
15534}
15535
15536fn stored_symbol_child_span_handles(
15537 symbol: &index::StoredSymbol,
15538 symbols: &[index::StoredSymbol],
15539 limit: usize,
15540) -> Vec<String> {
15541 let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
15542 return Vec::new();
15543 };
15544 symbols
15545 .iter()
15546 .filter(|candidate| {
15547 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15548 return false;
15549 }
15550 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15551 else {
15552 return false;
15553 };
15554 candidate_start >= start_byte && candidate_end <= end_byte
15555 })
15556 .take(limit)
15557 .filter_map(stored_symbol_span_handle)
15558 .collect()
15559}
15560
15561fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
15562 let start = start_byte.min(source.len());
15563 let line_end = source[start..]
15564 .iter()
15565 .position(|value| *value == b'\n')
15566 .map(|pos| start + pos)
15567 .unwrap_or(source.len());
15568 let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
15569 let marker = line.trim_start();
15570 let level = marker.chars().take_while(|ch| *ch == '#').count();
15571 (1..=6).contains(&level).then_some(level)
15572}
15573
15574fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
15575 let start = start_byte.min(source.len());
15576 let line_start = source[..start]
15577 .iter()
15578 .rposition(|value| *value == b'\n')
15579 .map(|pos| pos + 1)
15580 .unwrap_or(0);
15581 source[line_start..start]
15582 .iter()
15583 .map(|byte| match byte {
15584 b'\t' => 4,
15585 b' ' => 1,
15586 _ => 0,
15587 })
15588 .sum::<usize>()
15589 / 2
15590}
15591
15592fn markdown_enclosing_heading_symbols<'a>(
15593 file: &str,
15594 start_byte: usize,
15595 end_byte: usize,
15596 symbols: &'a [index::StoredSymbol],
15597) -> Vec<&'a index::StoredSymbol> {
15598 let mut headings = symbols
15599 .iter()
15600 .filter(|candidate| candidate.file == file && candidate.kind == "heading")
15601 .filter(|candidate| {
15602 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15603 else {
15604 return false;
15605 };
15606 candidate_start <= start_byte && candidate_end >= end_byte
15607 })
15608 .collect::<Vec<_>>();
15609 headings.sort_by(|left, right| {
15610 stored_symbol_span_bounds(left)
15611 .map(|(start, _)| start)
15612 .unwrap_or(usize::MAX)
15613 .cmp(
15614 &stored_symbol_span_bounds(right)
15615 .map(|(start, _)| start)
15616 .unwrap_or(usize::MAX),
15617 )
15618 .then(left.name.cmp(&right.name))
15619 });
15620 headings
15621}
15622
15623fn markdown_stored_symbol_metadata(
15624 symbol: &index::StoredSymbol,
15625 source: &[u8],
15626 symbols: &[index::StoredSymbol],
15627) -> Option<MarkdownSpanMetadata> {
15628 if symbol.language != "markdown" {
15629 return None;
15630 }
15631 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15632 let section_symbols =
15633 markdown_enclosing_heading_symbols(&symbol.file, start_byte, end_byte, symbols);
15634 let section_path = section_symbols
15635 .iter()
15636 .map(|heading| heading.name.clone())
15637 .collect::<Vec<_>>();
15638 let section_handle = section_symbols
15639 .last()
15640 .and_then(|heading| stored_symbol_span_handle(heading));
15641 let heading_level = (symbol.kind == "heading")
15642 .then(|| markdown_heading_level(source, start_byte))
15643 .flatten();
15644 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15645 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15646 let embedded_symbols = if symbol.kind == "code_block" {
15647 markdown_embedded_symbols(
15648 &symbol.file,
15649 source,
15650 symbol_span_byte(symbol.body_start_byte),
15651 symbol_span_byte(symbol.body_end_byte),
15652 fence_language.as_deref(),
15653 )
15654 } else {
15655 Vec::new()
15656 };
15657
15658 (heading_level.is_some()
15659 || !section_path.is_empty()
15660 || section_handle.is_some()
15661 || list_depth.is_some()
15662 || fence_language.is_some()
15663 || !embedded_symbols.is_empty())
15664 .then_some(MarkdownSpanMetadata {
15665 heading_level,
15666 section_path,
15667 section_handle,
15668 list_depth,
15669 fence_language,
15670 embedded_symbols,
15671 })
15672}
15673
15674fn markdown_symbol_hit_metadata(
15675 symbol: &index::SymbolHit,
15676 source: &[u8],
15677 start_byte: usize,
15678) -> Option<MarkdownSpanMetadata> {
15679 if symbol.language != "markdown" {
15680 return None;
15681 }
15682 let heading_level = (symbol.kind == "heading")
15683 .then(|| markdown_heading_level(source, start_byte))
15684 .flatten();
15685 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15686 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15687 let embedded_symbols = if symbol.kind == "code_block" {
15688 markdown_embedded_symbols(
15689 &symbol.file,
15690 source,
15691 symbol_span_byte(symbol.body_start_byte),
15692 symbol_span_byte(symbol.body_end_byte),
15693 fence_language.as_deref(),
15694 )
15695 } else {
15696 Vec::new()
15697 };
15698 (heading_level.is_some()
15699 || list_depth.is_some()
15700 || fence_language.is_some()
15701 || !embedded_symbols.is_empty())
15702 .then_some(MarkdownSpanMetadata {
15703 heading_level,
15704 section_path: Vec::new(),
15705 section_handle: None,
15706 list_depth,
15707 fence_language,
15708 embedded_symbols,
15709 })
15710}
15711
15712fn is_markdown_path(path: &Path) -> bool {
15713 path.extension()
15714 .and_then(|ext| ext.to_str())
15715 .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
15716 .unwrap_or(false)
15717}
15718
15719fn markdown_ast_block_kind(kind: &str) -> String {
15720 match kind {
15721 "heading" => "section",
15722 "code_block" => "fenced_code_block",
15723 "list_item" => "list_item",
15724 other => other,
15725 }
15726 .to_string()
15727}
15728
15729fn markdown_embedded_language_key(language: &str) -> Option<String> {
15730 let key = language
15731 .split_whitespace()
15732 .next()
15733 .unwrap_or("")
15734 .trim()
15735 .trim_start_matches("language-")
15736 .trim_start_matches("lang-")
15737 .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
15738 .to_ascii_lowercase();
15739 (!key.is_empty()).then_some(key)
15740}
15741
15742fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
15743 let key = markdown_embedded_language_key(language)?;
15744 let extension = match key.as_str() {
15745 "rust" => "rs",
15746 "python" => "py",
15747 "typescript" => "ts",
15748 "javascript" => "js",
15749 "kotlin" => "kt",
15750 "shell" | "sh" | "zsh" => "bash",
15751 other => other,
15752 };
15753 let lang = graph::Lang::from_extension(extension)?;
15754 (lang.name() != "markdown").then_some(lang)
15755}
15756
15757fn markdown_embedded_ast_span_handle(
15758 file: &str,
15759 language: &str,
15760 name: &str,
15761 kind: &str,
15762 start_byte: usize,
15763 end_byte: usize,
15764) -> String {
15765 stable_handle(
15766 "span",
15767 &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
15768 )
15769}
15770
15771fn markdown_embedded_symbols(
15772 file: &str,
15773 source: &[u8],
15774 body_start_byte: Option<usize>,
15775 body_end_byte: Option<usize>,
15776 fence_language: Option<&str>,
15777) -> Vec<MarkdownEmbeddedSymbol> {
15778 let Some(fence_language) = fence_language else {
15779 return Vec::new();
15780 };
15781 let Some(lang) = markdown_embedded_lang(fence_language) else {
15782 return Vec::new();
15783 };
15784 let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
15785 return Vec::new();
15786 };
15787 let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
15788 else {
15789 return Vec::new();
15790 };
15791 if body.is_empty() {
15792 return Vec::new();
15793 }
15794
15795 let Ok(symbols) = lang.extract_symbols(body) else {
15796 return Vec::new();
15797 };
15798 let language = lang.name().to_string();
15799 symbols
15800 .into_iter()
15801 .map(|symbol| {
15802 let start_byte = body_start_byte.saturating_add(symbol.start_byte);
15803 let end_byte = body_start_byte.saturating_add(symbol.end_byte);
15804 let body_start = symbol
15805 .body_start_byte
15806 .map(|byte| body_start_byte.saturating_add(byte));
15807 let body_end = symbol
15808 .body_end_byte
15809 .map(|byte| body_start_byte.saturating_add(byte));
15810 let start_line = source_line_for_byte(source, start_byte);
15811 let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
15812 MarkdownEmbeddedSymbol {
15813 handle: markdown_embedded_ast_span_handle(
15814 file,
15815 &language,
15816 &symbol.name,
15817 &symbol.kind,
15818 start_byte,
15819 end_byte,
15820 ),
15821 name: symbol.name,
15822 kind: symbol.kind,
15823 language: language.clone(),
15824 node_kind: symbol.node_kind,
15825 start_byte,
15826 end_byte,
15827 start_line,
15828 end_line,
15829 body_start_byte: body_start,
15830 body_end_byte: body_end,
15831 body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
15832 body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
15833 }
15834 })
15835 .collect()
15836}
15837
15838fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
15839 let start = start_byte.min(source.len());
15840 let line_start = source[..start]
15841 .iter()
15842 .rposition(|value| *value == b'\n')
15843 .map(|pos| pos + 1)
15844 .unwrap_or(0);
15845 let line_end = source[start..]
15846 .iter()
15847 .position(|value| *value == b'\n')
15848 .map(|pos| start + pos)
15849 .unwrap_or(source.len());
15850 std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
15851}
15852
15853fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
15854 let line = markdown_source_line(source, start_byte);
15855 let trimmed = line.trim_start();
15856 for marker in ["-", "*", "+"] {
15857 if trimmed
15858 .strip_prefix(marker)
15859 .and_then(|rest| rest.strip_prefix(' '))
15860 .is_some()
15861 {
15862 return (Some(marker.to_string()), None);
15863 }
15864 }
15865
15866 let digit_end = trimmed
15867 .find(|ch: char| !ch.is_ascii_digit())
15868 .unwrap_or(trimmed.len());
15869 let (digits, rest) = trimmed.split_at(digit_end);
15870 if !digits.is_empty() {
15871 for marker in [".", ")"] {
15872 if rest
15873 .strip_prefix(marker)
15874 .and_then(|value| value.strip_prefix(' '))
15875 .is_some()
15876 {
15877 return (
15878 Some(format!("{digits}{marker}")),
15879 digits.parse::<usize>().ok(),
15880 );
15881 }
15882 }
15883 }
15884 (None, None)
15885}
15886
15887fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
15888 let line = markdown_source_line(source, start_byte);
15889 let trimmed = line.trim_start();
15890 ["```", "~~~"]
15891 .into_iter()
15892 .find(|marker| trimmed.starts_with(marker))
15893 .map(str::to_string)
15894}
15895
15896fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
15897 let mut nodes = graph::Lang::Markdown
15898 .extract_symbols(source)
15899 .context("extracting Markdown AST nodes")?
15900 .into_iter()
15901 .map(|symbol| {
15902 let body_start_byte = symbol.body_start_byte;
15903 let body_end_byte = symbol.body_end_byte;
15904 let span_handle = ast_span_handle(
15905 file,
15906 &symbol.name,
15907 &symbol.kind,
15908 symbol.start_byte,
15909 symbol.end_byte,
15910 );
15911 MarkdownAstRawNode {
15912 handle: stable_handle(
15913 "mdast",
15914 &format!(
15915 "{}:{}:{}:{}:{}",
15916 file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
15917 ),
15918 ),
15919 span_handle,
15920 name: symbol.name,
15921 kind: symbol.kind.clone(),
15922 block_kind: markdown_ast_block_kind(&symbol.kind),
15923 node_kind: symbol.node_kind,
15924 start_byte: symbol.start_byte,
15925 end_byte: symbol.end_byte,
15926 body_start_byte,
15927 body_end_byte,
15928 }
15929 })
15930 .collect::<Vec<_>>();
15931 nodes.sort_by(|left, right| {
15932 left.start_byte
15933 .cmp(&right.start_byte)
15934 .then(left.end_byte.cmp(&right.end_byte))
15935 .then(left.kind.cmp(&right.kind))
15936 .then(left.name.cmp(&right.name))
15937 });
15938 Ok(nodes)
15939}
15940
15941pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
15942 let source_hash = blake3::hash(source).to_hex().to_string();
15943 let cache_key = format!("{file}:{source_hash}");
15944 let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
15945 if let Some(entry) = cache
15946 .lock()
15947 .expect("markdown ast cache poisoned")
15948 .get(&cache_key)
15949 {
15950 return Ok(MarkdownAstProjection {
15951 source_hash: entry.source_hash.clone(),
15952 nodes: entry.nodes.clone(),
15953 parse_duration_micros: entry.parse_duration_micros,
15954 cache_hit: true,
15955 });
15956 }
15957
15958 let started = Instant::now();
15959 let nodes = markdown_ast_extract_raw_nodes(file, source)?;
15960 let parse_duration_micros = started.elapsed().as_micros();
15961 cache.lock().expect("markdown ast cache poisoned").insert(
15962 cache_key,
15963 MarkdownAstCacheEntry {
15964 source_hash: source_hash.clone(),
15965 nodes: nodes.clone(),
15966 parse_duration_micros,
15967 },
15968 );
15969 Ok(MarkdownAstProjection {
15970 source_hash,
15971 nodes,
15972 parse_duration_micros,
15973 cache_hit: false,
15974 })
15975}
15976
15977fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
15978 MarkdownAstCacheReport {
15979 source_hash: projection.source_hash.clone(),
15980 cache_hit: projection.cache_hit,
15981 parse_duration_micros: projection.parse_duration_micros,
15982 node_count: projection.nodes.len(),
15983 section_count: projection
15984 .nodes
15985 .iter()
15986 .filter(|node| node.kind == "heading")
15987 .count(),
15988 list_item_count: projection
15989 .nodes
15990 .iter()
15991 .filter(|node| node.kind == "list_item")
15992 .count(),
15993 code_block_count: projection
15994 .nodes
15995 .iter()
15996 .filter(|node| node.kind == "code_block")
15997 .count(),
15998 }
15999}
16000
16001fn markdown_ast_node_direct_child_count(
16002 node: &MarkdownAstRawNode,
16003 nodes: &[MarkdownAstRawNode],
16004) -> usize {
16005 nodes
16006 .iter()
16007 .filter(|candidate| {
16008 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16009 })
16010 .count()
16011}
16012
16013fn markdown_ast_outline_entry(
16014 root: &Path,
16015 file: &str,
16016 source: &[u8],
16017 nodes: &[MarkdownAstRawNode],
16018 node: &MarkdownAstRawNode,
16019 max_bytes: usize,
16020) -> MarkdownAstOutlineEntry {
16021 let line = source_line_for_byte(source, node.start_byte);
16022 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16023 MarkdownAstOutlineEntry {
16024 handle: node.handle.clone(),
16025 span_handle: node.span_handle.clone(),
16026 name: truncate_for_budget(&node.name, max_bytes),
16027 kind: node.kind.clone(),
16028 block_kind: node.block_kind.clone(),
16029 line,
16030 end_line,
16031 section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16032 child_count: markdown_ast_node_direct_child_count(node, nodes),
16033 expand: markdown_ast_command(root, file, Some(&node.handle)),
16034 }
16035}
16036
16037fn markdown_ast_outline_entries(
16038 root: &Path,
16039 file: &str,
16040 source: &[u8],
16041 nodes: &[MarkdownAstRawNode],
16042 limit: usize,
16043 max_bytes: usize,
16044) -> Vec<MarkdownAstOutlineEntry> {
16045 let mut headings = nodes
16046 .iter()
16047 .filter(|node| node.kind == "heading")
16048 .collect::<Vec<_>>();
16049 let mut blocks = nodes
16050 .iter()
16051 .filter(|node| node.kind != "heading")
16052 .collect::<Vec<_>>();
16053 headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16054 blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16055 headings
16056 .into_iter()
16057 .chain(blocks)
16058 .take(limit)
16059 .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16060 .collect()
16061}
16062
16063fn markdown_ast_node_intersects_lines(
16064 source: &[u8],
16065 node: &MarkdownAstRawNode,
16066 start: usize,
16067 end: usize,
16068) -> bool {
16069 let line = source_line_for_byte(source, node.start_byte);
16070 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16071 line <= end && end_line >= start
16072}
16073
16074fn source_read_markdown_projection(
16075 root: &Path,
16076 file: &str,
16077 source: &[u8],
16078 start: usize,
16079 end: usize,
16080 budget: ResponseBudget,
16081) -> Result<SourceReadMarkdownProjection> {
16082 let projection = markdown_ast_projection(file, source)?;
16083 let visible_nodes = projection
16084 .nodes
16085 .iter()
16086 .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16087 .collect::<Vec<_>>();
16088 let mut outline_nodes = visible_nodes.clone();
16089 outline_nodes.sort_by_key(|node| {
16090 (
16091 node.kind != "heading",
16092 node.start_byte,
16093 node.end_byte,
16094 node.name.as_str(),
16095 )
16096 });
16097 let outline = outline_nodes
16098 .into_iter()
16099 .take(budget.preview_items())
16100 .map(|node| {
16101 markdown_ast_outline_entry(
16102 root,
16103 file,
16104 source,
16105 &projection.nodes,
16106 node,
16107 budget.preview_bytes(),
16108 )
16109 })
16110 .collect::<Vec<_>>();
16111 Ok(SourceReadMarkdownProjection {
16112 handle: stable_handle(
16113 "mdproj",
16114 &format!("{file}:{start}:{end}:{}", projection.source_hash),
16115 ),
16116 mode: "window_outline".to_string(),
16117 total_nodes: projection.nodes.len(),
16118 visible_nodes: visible_nodes.len(),
16119 outline,
16120 expand: markdown_ast_command(root, file, None),
16121 })
16122}
16123
16124fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16125 if parent.handle == child.handle {
16126 return false;
16127 }
16128 parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16129}
16130
16131fn markdown_ast_parent_handle(
16132 node: &MarkdownAstRawNode,
16133 nodes: &[MarkdownAstRawNode],
16134) -> Option<String> {
16135 nodes
16136 .iter()
16137 .filter(|candidate| markdown_ast_contains(candidate, node))
16138 .min_by_key(|candidate| {
16139 (
16140 candidate.end_byte.saturating_sub(candidate.start_byte),
16141 candidate.start_byte,
16142 )
16143 })
16144 .map(|candidate| candidate.handle.clone())
16145}
16146
16147fn markdown_ast_child_handles(
16148 node: &MarkdownAstRawNode,
16149 nodes: &[MarkdownAstRawNode],
16150 limit: usize,
16151) -> Vec<String> {
16152 nodes
16153 .iter()
16154 .filter(|candidate| {
16155 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16156 })
16157 .take(limit)
16158 .map(|candidate| candidate.handle.clone())
16159 .collect()
16160}
16161
16162fn markdown_ast_section_nodes<'a>(
16163 node: &MarkdownAstRawNode,
16164 nodes: &'a [MarkdownAstRawNode],
16165) -> Vec<&'a MarkdownAstRawNode> {
16166 let mut headings = nodes
16167 .iter()
16168 .filter(|candidate| candidate.kind == "heading")
16169 .filter(|candidate| {
16170 candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16171 })
16172 .collect::<Vec<_>>();
16173 headings.sort_by(|left, right| {
16174 left.start_byte
16175 .cmp(&right.start_byte)
16176 .then(left.end_byte.cmp(&right.end_byte))
16177 .then(left.name.cmp(&right.name))
16178 });
16179 headings
16180}
16181
16182fn markdown_ast_node_metadata(
16183 file: &str,
16184 node: &MarkdownAstRawNode,
16185 source: &[u8],
16186 nodes: &[MarkdownAstRawNode],
16187) -> MarkdownAstNodeMetadata {
16188 let section_nodes = markdown_ast_section_nodes(node, nodes);
16189 let section_path = section_nodes
16190 .iter()
16191 .map(|heading| heading.name.clone())
16192 .collect::<Vec<_>>();
16193 let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16194 let heading_level = (node.kind == "heading")
16195 .then(|| markdown_heading_level(source, node.start_byte))
16196 .flatten();
16197 let (list_marker, list_order) = if node.kind == "list_item" {
16198 markdown_list_attributes(source, node.start_byte)
16199 } else {
16200 (None, None)
16201 };
16202 let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16203 let embedded_symbols = if node.kind == "code_block" {
16204 markdown_embedded_symbols(
16205 file,
16206 source,
16207 node.body_start_byte,
16208 node.body_end_byte,
16209 fence_language.as_deref(),
16210 )
16211 } else {
16212 Vec::new()
16213 };
16214 MarkdownAstNodeMetadata {
16215 heading_level,
16216 section_path,
16217 section_handle,
16218 list_depth: (node.kind == "list_item")
16219 .then(|| markdown_list_depth(source, node.start_byte)),
16220 list_marker,
16221 list_order,
16222 fence_language,
16223 fence_marker: (node.kind == "code_block")
16224 .then(|| markdown_fence_marker(source, node.start_byte))
16225 .flatten(),
16226 embedded_symbols,
16227 }
16228}
16229
16230fn markdown_ast_node_expand(
16231 root: &Path,
16232 file: &str,
16233 node: &MarkdownAstRawNode,
16234 source: &[u8],
16235) -> MarkdownAstNodeExpand {
16236 let start_line = source_line_for_byte(source, node.start_byte);
16237 let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16238 let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16239 let body_start_line = node
16240 .body_start_byte
16241 .map(|byte| source_line_for_byte(source, byte))
16242 .unwrap_or(start_line);
16243 let body_end_line = node
16244 .body_end_byte
16245 .map(|byte| source_line_for_end_byte(source, byte))
16246 .unwrap_or(end_line)
16247 .max(body_start_line);
16248 let body_line_count = body_end_line
16249 .saturating_sub(body_start_line)
16250 .saturating_add(1)
16251 .max(1);
16252 MarkdownAstNodeExpand {
16253 source_window: source_read_command(root, file, start_line, line_count),
16254 source_body: source_read_command(root, file, body_start_line, body_line_count),
16255 symbol_read: source_symbol_read_command(root, &node.name, file),
16256 edit_intents: markdown_edit_intents_command(root),
16257 }
16258}
16259
16260fn markdown_ast_node(
16261 root: &Path,
16262 file: &str,
16263 node: &MarkdownAstRawNode,
16264 source: &[u8],
16265 nodes: &[MarkdownAstRawNode],
16266 child_limit: usize,
16267) -> MarkdownAstNode {
16268 let line = source_line_for_byte(source, node.start_byte);
16269 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16270 let body_byte_span = node
16271 .body_start_byte
16272 .zip(node.body_end_byte)
16273 .map(|(start, end)| SourceByteRangePreview { start, end });
16274 MarkdownAstNode {
16275 handle: node.handle.clone(),
16276 span_handle: node.span_handle.clone(),
16277 name: node.name.clone(),
16278 kind: node.kind.clone(),
16279 block_kind: node.block_kind.clone(),
16280 node_kind: node.node_kind.clone(),
16281 line,
16282 end_line,
16283 byte_span: SourceByteRangePreview {
16284 start: node.start_byte,
16285 end: node.end_byte,
16286 },
16287 body_byte_span,
16288 parent_handle: markdown_ast_parent_handle(node, nodes),
16289 child_handles: markdown_ast_child_handles(node, nodes, child_limit),
16290 metadata: markdown_ast_node_metadata(file, node, source, nodes),
16291 expand: markdown_ast_node_expand(root, file, node, source),
16292 }
16293}
16294
16295pub(crate) fn stored_symbol_ast_span(
16296 symbol: &index::StoredSymbol,
16297 source: &[u8],
16298 symbols: &[index::StoredSymbol],
16299 child_limit: usize,
16300) -> Option<AstSpanPreview> {
16301 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16302 let node_kind = symbol.node_kind.clone()?;
16303 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16304 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16305 Some(AstSpanPreview {
16306 handle: ast_span_handle(
16307 &symbol.file,
16308 &symbol.name,
16309 &symbol.kind,
16310 start_byte,
16311 end_byte,
16312 ),
16313 node_kind,
16314 start_byte,
16315 end_byte,
16316 start_line: source_line_for_byte(source, start_byte),
16317 end_line: source_line_for_end_byte(source, end_byte),
16318 body_start_byte,
16319 body_end_byte,
16320 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16321 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16322 parent_handle: stored_symbol_parent_span_handle(symbol, symbols),
16323 child_handles: stored_symbol_child_span_handles(symbol, symbols, child_limit),
16324 markdown: markdown_stored_symbol_metadata(symbol, source, symbols),
16325 })
16326}
16327
16328pub(crate) fn symbol_hit_ast_span(
16329 symbol: &index::SymbolHit,
16330 source: &[u8],
16331) -> Option<AstSpanPreview> {
16332 let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
16333 let node_kind = symbol.node_kind.clone()?;
16334 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16335 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16336 Some(AstSpanPreview {
16337 handle: ast_span_handle(
16338 &symbol.file,
16339 &symbol.name,
16340 &symbol.kind,
16341 start_byte,
16342 end_byte,
16343 ),
16344 node_kind,
16345 start_byte,
16346 end_byte,
16347 start_line: source_line_for_byte(source, start_byte),
16348 end_line: source_line_for_end_byte(source, end_byte),
16349 body_start_byte,
16350 body_end_byte,
16351 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16352 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16353 parent_handle: None,
16354 child_handles: Vec::new(),
16355 markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
16356 })
16357}
16358
16359pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
16360 usize::try_from(symbol.line)
16361 .ok()
16362 .and_then(|line| line.checked_add(1))
16363 .unwrap_or(1)
16364}
16365
16366pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
16367 symbol
16368 .end_line
16369 .and_then(|line| usize::try_from(line).ok())
16370 .and_then(|line| line.checked_add(1))
16371}
16372
16373fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
16374 if end == 0 {
16375 return false;
16376 }
16377 let symbol_start = source_symbol_line(symbol);
16378 let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
16379 symbol_start <= end && symbol_end >= start
16380}
16381
16382#[allow(clippy::too_many_arguments)]
16383fn load_source_symbols(
16384 root: &Path,
16385 file_abs: &Path,
16386 file_display: &str,
16387 source: &[u8],
16388 scope: Option<&str>,
16389 start: usize,
16390 end: usize,
16391 limit: usize,
16392 max_bytes: usize,
16393 warnings: &mut Vec<String>,
16394) -> Vec<SourceSymbolRef> {
16395 let db_path = match resolve_query_db_path(root, file_abs, scope) {
16396 Ok(path) => path,
16397 Err(err) => {
16398 warnings.push(format!("index refs unavailable: {err:#}"));
16399 return Vec::new();
16400 }
16401 };
16402 if !db_path.exists() {
16403 warnings.push(format!(
16404 "index refs unavailable: no index found at {}",
16405 db_path.display()
16406 ));
16407 return Vec::new();
16408 }
16409
16410 let db = match index::IndexDb::open_read_only_resilient(&db_path) {
16411 Ok(db) => db,
16412 Err(err) => {
16413 warnings.push(format!("index refs unavailable: {err:#}"));
16414 return Vec::new();
16415 }
16416 };
16417
16418 let file_key = file_abs.to_string_lossy().to_string();
16419 let symbols = match db.symbols_for_file(&file_key) {
16420 Ok(symbols) => symbols,
16421 Err(err) => {
16422 warnings.push(format!("symbol refs unavailable: {err:#}"));
16423 return Vec::new();
16424 }
16425 };
16426
16427 symbols
16428 .iter()
16429 .filter(|symbol| source_symbol_intersects(symbol, start, end))
16430 .take(limit)
16431 .map(|symbol| {
16432 let line = source_symbol_line(symbol);
16433 let end_line = source_symbol_end_line(symbol);
16434 let handle = stable_handle(
16435 "ssym",
16436 &format!("{}:{}:{}", file_display, symbol.name, line),
16437 );
16438 SourceSymbolRef {
16439 handle,
16440 name: truncate_for_budget(&symbol.name, max_bytes),
16441 kind: symbol.kind.clone(),
16442 language: symbol.language.clone(),
16443 file: file_display.to_string(),
16444 line,
16445 end_line,
16446 signature: symbol
16447 .signature
16448 .clone()
16449 .map(|signature| truncate_for_budget(&signature, max_bytes)),
16450 span: stored_symbol_ast_span(symbol, source, &symbols, limit),
16451 expand: source_symbol_read_command(root, &symbol.name, file_display),
16452 }
16453 })
16454 .collect()
16455}
16456
16457fn load_source_summaries(
16458 root: &Path,
16459 file_display: &str,
16460 limit: usize,
16461 max_bytes: usize,
16462 warnings: &mut Vec<String>,
16463) -> Vec<SourceSummaryRef> {
16464 let db_path = root.join(".tsift/summaries.db");
16465 if !db_path.exists() {
16466 return Vec::new();
16467 }
16468 let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
16469 Ok(db) => db,
16470 Err(err) => {
16471 warnings.push(format!("summary refs unavailable: {err:#}"));
16472 return Vec::new();
16473 }
16474 };
16475 let summaries = match db.get_by_file(file_display) {
16476 Ok(summaries) => summaries,
16477 Err(err) => {
16478 warnings.push(format!("summary refs unavailable: {err:#}"));
16479 return Vec::new();
16480 }
16481 };
16482
16483 summaries
16484 .into_iter()
16485 .take(limit)
16486 .map(|summary| SourceSummaryRef {
16487 handle: stable_handle(
16488 "sum",
16489 &format!(
16490 "{}:{}:{}",
16491 summary.file_path, summary.symbol_name, summary.id
16492 ),
16493 ),
16494 symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
16495 file_path: summary.file_path,
16496 summary: truncate_for_budget(&summary.summary, max_bytes),
16497 expand: source_summary_expand_command(root, &summary.symbol_name),
16498 })
16499 .collect()
16500}
16501
16502fn cmd_markdown_ast(
16503 file: &Path,
16504 path: &Path,
16505 node: Option<&str>,
16506 format: OutputFormat,
16507 absolute: bool,
16508 budget: ResponseBudget,
16509) -> Result<()> {
16510 let root = lint::resolve_project_root_or_canonical_path(path)?;
16511 let file_abs = resolve_source_file(&root, file)?;
16512 if !is_markdown_path(&file_abs) {
16513 bail!(
16514 "markdown-ast only supports Markdown files (.md/.mdx): {}",
16515 file_abs.display()
16516 );
16517 }
16518 let file_display = if absolute {
16519 file_abs.to_string_lossy().to_string()
16520 } else {
16521 relativize_pathbuf(&file_abs, &root)
16522 .to_string_lossy()
16523 .to_string()
16524 };
16525 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16526 let text = String::from_utf8_lossy(&source);
16527 let total_lines = text.lines().count();
16528 let projection = markdown_ast_projection(&file_display, &source)?;
16529 let raw_nodes = &projection.nodes;
16530 let max_items = budget.preview_items();
16531 let max_bytes = budget.preview_bytes();
16532
16533 let selected_nodes = if let Some(handle) = node {
16534 let matches = raw_nodes
16535 .iter()
16536 .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
16537 .collect::<Vec<_>>();
16538 if matches.is_empty() {
16539 bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
16540 }
16541 matches
16542 } else {
16543 raw_nodes.iter().take(max_items).collect::<Vec<_>>()
16544 };
16545 let nodes = selected_nodes
16546 .into_iter()
16547 .map(|raw| {
16548 let mut node =
16549 markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
16550 node.name = truncate_for_budget(&node.name, max_bytes);
16551 node
16552 })
16553 .collect::<Vec<_>>();
16554 let outline_started = Instant::now();
16555 let outline = markdown_ast_outline_entries(
16556 &root,
16557 &file_display,
16558 &source,
16559 raw_nodes,
16560 max_items,
16561 max_bytes,
16562 );
16563 let outline_duration_micros = outline_started.elapsed().as_micros();
16564 let projection_preview = MarkdownAstProjectionPreview {
16565 mode: if node.is_some() {
16566 "selected_node".to_string()
16567 } else {
16568 "outline_first".to_string()
16569 },
16570 total_nodes: raw_nodes.len(),
16571 returned_nodes: nodes.len(),
16572 omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
16573 selected_node: node.map(str::to_string),
16574 cache: markdown_ast_cache_report(&projection),
16575 outline,
16576 phase_timings: vec![
16577 MarkdownAstPhaseTiming {
16578 name: "parse_extract".to_string(),
16579 duration_micros: projection.parse_duration_micros,
16580 detail: if projection.cache_hit {
16581 "reused cached tree-sitter Markdown symbol extraction".to_string()
16582 } else {
16583 "tree-sitter Markdown symbol extraction".to_string()
16584 },
16585 },
16586 MarkdownAstPhaseTiming {
16587 name: "outline_projection".to_string(),
16588 duration_micros: outline_duration_micros,
16589 detail: "outline-first section/block preview construction".to_string(),
16590 },
16591 ],
16592 };
16593 let report = MarkdownAstReport {
16594 handle: stable_handle("mdastrep", &file_display),
16595 root: root.to_string_lossy().to_string(),
16596 file: file_display.clone(),
16597 range: SourceRangePreview {
16598 start: 1,
16599 end: total_lines,
16600 total_lines,
16601 truncated_before: false,
16602 truncated_after: false,
16603 },
16604 projection: projection_preview,
16605 nodes,
16606 expand: MarkdownAstExpandCommands {
16607 file: markdown_ast_command(&root, &file_display, None),
16608 source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
16609 edit_intents: markdown_edit_intents_command(&root),
16610 },
16611 warnings: Vec::new(),
16612 };
16613
16614 if format.json_output {
16615 let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
16616 let mut follow_up = vec![
16617 report.expand.file.clone(),
16618 report.expand.source_read.clone(),
16619 report.expand.edit_intents.clone(),
16620 ];
16621 follow_up.extend(
16622 report
16623 .nodes
16624 .iter()
16625 .map(|node| node.expand.source_window.clone()),
16626 );
16627 print_json_or_envelope(
16628 &report,
16629 &format,
16630 "markdown-ast",
16631 "ast",
16632 ToolEnvelopeSummary {
16633 text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
16634 metrics: vec![
16635 envelope_metric("nodes", report.nodes.len()),
16636 envelope_metric("total_nodes", report.projection.total_nodes),
16637 envelope_metric(
16638 "parse_duration_micros",
16639 report.projection.cache.parse_duration_micros,
16640 ),
16641 envelope_metric("total_lines", report.range.total_lines),
16642 ],
16643 },
16644 truncated,
16645 follow_up,
16646 )?;
16647 } else if format.compact {
16648 println!(
16649 "markdown-ast {} nodes:{} handle:{}",
16650 report.file,
16651 report.nodes.len(),
16652 report.handle
16653 );
16654 for node in &report.nodes {
16655 println!(
16656 " {} {} {}:{}-{}",
16657 node.handle, node.kind, node.name, node.line, node.end_line
16658 );
16659 }
16660 if node.is_none() && raw_nodes.len() > report.nodes.len() {
16661 println!("expand: {}", report.expand.file);
16662 }
16663 } else {
16664 println!(
16665 "Markdown AST `{}` nodes {} of {} ({})",
16666 report.file,
16667 report.nodes.len(),
16668 raw_nodes.len(),
16669 report.handle
16670 );
16671 for node in &report.nodes {
16672 println!(
16673 " {} `{}` {}:{}-{} — {}",
16674 node.handle,
16675 node.name,
16676 node.kind,
16677 node.line,
16678 node.end_line,
16679 node.expand.source_window
16680 );
16681 }
16682 if node.is_none() && raw_nodes.len() > report.nodes.len() {
16683 println!();
16684 println!("Expand:");
16685 println!(" file: {}", report.expand.file);
16686 }
16687 }
16688
16689 Ok(())
16690}
16691
16692#[allow(clippy::too_many_arguments)]
16693fn cmd_source_read(
16694 file: &Path,
16695 path: &Path,
16696 style: SourceReadStyle,
16697 start: usize,
16698 lines: usize,
16699 end: Option<usize>,
16700 scope: Option<&str>,
16701 format: OutputFormat,
16702 absolute: bool,
16703 budget: ResponseBudget,
16704) -> Result<()> {
16705 if start == 0 {
16706 bail!("--start is 1-based and must be greater than zero");
16707 }
16708 if lines == 0 {
16709 bail!("--lines must be greater than zero");
16710 }
16711 if let Some(end) = end
16712 && end < start
16713 {
16714 bail!("--end must be greater than or equal to --start");
16715 }
16716
16717 let root = lint::resolve_project_root_or_canonical_path(path)?;
16718 let file_abs = resolve_source_file(&root, file)?;
16719 let file_display = if absolute {
16720 file_abs.to_string_lossy().to_string()
16721 } else {
16722 relativize_pathbuf(&file_abs, &root)
16723 .to_string_lossy()
16724 .to_string()
16725 };
16726
16727 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16728 let text = String::from_utf8_lossy(&source);
16729 let all_lines: Vec<&str> = text.lines().collect();
16730 let total_lines = all_lines.len();
16731 if total_lines > 0 && start > total_lines {
16732 bail!(
16733 "--start {} is beyond end of {} ({} lines)",
16734 start,
16735 file_display,
16736 total_lines
16737 );
16738 }
16739 let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
16740 let end_line = requested_end.min(total_lines);
16741 let mut warnings = Vec::new();
16742 let max_items = budget.preview_items();
16743 let max_bytes = budget.preview_bytes();
16744 if style == SourceReadStyle::Ast {
16745 let symbols = load_source_symbols(
16746 &root,
16747 &file_abs,
16748 &file_display,
16749 &source,
16750 scope,
16751 start,
16752 end_line,
16753 max_items,
16754 max_bytes,
16755 &mut warnings,
16756 );
16757 let summaries =
16758 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
16759 let markdown = if is_markdown_path(&file_abs) {
16760 match source_read_markdown_projection(
16761 &root,
16762 &file_display,
16763 &source,
16764 start,
16765 end_line,
16766 budget,
16767 ) {
16768 Ok(markdown) => Some(markdown),
16769 Err(err) => {
16770 warnings.push(format!("markdown projection unavailable: {err:#}"));
16771 None
16772 }
16773 }
16774 } else {
16775 None
16776 };
16777 let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
16778 let report = SourceReadAstReport {
16779 handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
16780 root: root.to_string_lossy().to_string(),
16781 file: file_display.clone(),
16782 range: SourceRangePreview {
16783 start,
16784 end: end_line,
16785 total_lines,
16786 truncated_before: start > 1,
16787 truncated_after: end_line < total_lines,
16788 },
16789 symbols,
16790 summaries,
16791 markdown,
16792 expand: SourceReadAstExpandCommands {
16793 window: source_read_window_command(&root, &file_display, start, window_lines),
16794 file_window: source_read_window_command(
16795 &root,
16796 &file_display,
16797 1,
16798 total_lines.max(window_lines),
16799 ),
16800 markdown_ast: is_markdown_path(&file_abs)
16801 .then(|| markdown_ast_command(&root, &file_display, None)),
16802 },
16803 warnings,
16804 };
16805
16806 if format.json_output {
16807 let truncated = report.range.truncated_before
16808 || report.range.truncated_after
16809 || report.symbols.len() >= max_items
16810 || report.summaries.len() >= max_items;
16811 let follow_up = [
16812 Some(report.expand.window.clone()),
16813 Some(report.expand.file_window.clone()),
16814 report.expand.markdown_ast.clone(),
16815 ]
16816 .into_iter()
16817 .flatten()
16818 .collect::<Vec<_>>();
16819 print_json_or_envelope(
16820 &report,
16821 &format,
16822 "source-read",
16823 "ast",
16824 ToolEnvelopeSummary {
16825 text: format!(
16826 "source ast {}:{}-{}",
16827 report.file, report.range.start, report.range.end
16828 ),
16829 metrics: vec![
16830 envelope_metric("symbols", report.symbols.len()),
16831 envelope_metric("summaries", report.summaries.len()),
16832 envelope_metric(
16833 "markdown_nodes",
16834 report
16835 .markdown
16836 .as_ref()
16837 .map_or(0, |markdown| markdown.visible_nodes),
16838 ),
16839 ],
16840 },
16841 truncated,
16842 follow_up,
16843 )?;
16844 } else if format.compact {
16845 println!(
16846 "source-ast {}:{}-{} / {} handle:{}",
16847 report.file,
16848 report.range.start,
16849 report.range.end,
16850 report.range.total_lines,
16851 report.handle
16852 );
16853 for symbol in &report.symbols {
16854 println!(
16855 " {} {}:{} {}",
16856 symbol.name, symbol.file, symbol.line, symbol.expand
16857 );
16858 }
16859 if !report.summaries.is_empty() {
16860 println!("summaries[{}]", report.summaries.len());
16861 }
16862 for warning in &report.warnings {
16863 eprintln!("warning: {warning}");
16864 }
16865 } else {
16866 println!(
16867 "Source AST `{}` lines {}-{} of {} ({})",
16868 report.file,
16869 report.range.start,
16870 report.range.end,
16871 report.range.total_lines,
16872 report.handle
16873 );
16874 if !report.symbols.is_empty() {
16875 println!();
16876 println!("Symbol refs:");
16877 for symbol in &report.symbols {
16878 println!(
16879 " {} `{}` {}:{} — {}",
16880 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
16881 );
16882 }
16883 }
16884 if !report.summaries.is_empty() {
16885 println!();
16886 println!("Summary refs:");
16887 for summary in &report.summaries {
16888 println!(
16889 " {} `{}` — {}",
16890 summary.handle, summary.symbol_name, summary.expand
16891 );
16892 }
16893 }
16894 println!();
16895 println!("Expand:");
16896 println!(" window: {}", report.expand.window);
16897 println!(" file window: {}", report.expand.file_window);
16898 if let Some(markdown_ast) = &report.expand.markdown_ast {
16899 println!(" markdown: {}", markdown_ast);
16900 }
16901 for warning in &report.warnings {
16902 eprintln!("warning: {warning}");
16903 }
16904 }
16905
16906 return Ok(());
16907 }
16908 let max_bytes = budget.preview_bytes();
16909 let token_cap = budget.body_token_cap();
16910 let (preview, preview_end, body_truncated) = if total_lines == 0 {
16911 (Vec::new(), end_line, false)
16912 } else {
16913 let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
16914 (capped.preview, capped.capped_end, capped.was_capped)
16915 };
16916 let effective_end = if body_truncated {
16917 preview_end
16918 } else {
16919 end_line
16920 };
16921
16922 if body_truncated {
16923 warnings.push(format!(
16924 "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
16925 ));
16926 }
16927 let symbols = load_source_symbols(
16928 &root,
16929 &file_abs,
16930 &file_display,
16931 &source,
16932 scope,
16933 start,
16934 effective_end,
16935 max_items,
16936 max_bytes,
16937 &mut warnings,
16938 );
16939 let summaries =
16940 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
16941 let markdown = if is_markdown_path(&file_abs) {
16942 match source_read_markdown_projection(
16943 &root,
16944 &file_display,
16945 &source,
16946 start,
16947 effective_end,
16948 budget,
16949 ) {
16950 Ok(markdown) => Some(markdown),
16951 Err(err) => {
16952 warnings.push(format!("markdown projection unavailable: {err:#}"));
16953 None
16954 }
16955 }
16956 } else {
16957 None
16958 };
16959
16960 let expand = SourceExpandCommands {
16961 before: (start > 1).then(|| {
16962 let before_start = start.saturating_sub(lines).max(1);
16963 source_read_window_command(&root, &file_display, before_start, start - before_start)
16964 }),
16965 after: (effective_end < total_lines)
16966 .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
16967 body: body_truncated.then(|| {
16968 let remaining = end_line.saturating_sub(effective_end);
16969 source_read_window_command(&root, &file_display, effective_end + 1, remaining)
16970 }),
16971 file: source_read_ast_command(&root, &file_display),
16972 markdown_ast: is_markdown_path(&file_abs)
16973 .then(|| markdown_ast_command(&root, &file_display, None)),
16974 };
16975
16976 let report = SourceReadReport {
16977 handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
16978 root: root.to_string_lossy().to_string(),
16979 file: file_display,
16980 range: SourceRangePreview {
16981 start,
16982 end: effective_end,
16983 total_lines,
16984 truncated_before: start > 1,
16985 truncated_after: effective_end < total_lines,
16986 },
16987 preview,
16988 symbols,
16989 summaries,
16990 markdown,
16991 expand,
16992 warnings,
16993 };
16994
16995 if format.json_output {
16996 let truncated = report.range.truncated_before || report.range.truncated_after;
16997 let follow_up = [
16998 report.expand.before.clone(),
16999 report.expand.after.clone(),
17000 report.expand.body.clone(),
17001 Some(report.expand.file.clone()),
17002 report.expand.markdown_ast.clone(),
17003 ]
17004 .into_iter()
17005 .flatten()
17006 .collect::<Vec<_>>();
17007 print_json_or_envelope(
17008 &report,
17009 &format,
17010 "source-read",
17011 "window",
17012 ToolEnvelopeSummary {
17013 text: format!(
17014 "source window {}:{}-{}",
17015 report.file, report.range.start, report.range.end
17016 ),
17017 metrics: vec![
17018 envelope_metric("lines", report.preview.len()),
17019 envelope_metric("symbols", report.symbols.len()),
17020 envelope_metric("summaries", report.summaries.len()),
17021 envelope_metric(
17022 "markdown_nodes",
17023 report
17024 .markdown
17025 .as_ref()
17026 .map_or(0, |markdown| markdown.visible_nodes),
17027 ),
17028 ],
17029 },
17030 truncated,
17031 follow_up,
17032 )?;
17033 } else if format.compact {
17034 println!(
17035 "source {}:{}-{} / {} handle:{}",
17036 report.file,
17037 report.range.start,
17038 report.range.end,
17039 report.range.total_lines,
17040 report.handle
17041 );
17042 for line in &report.preview {
17043 println!("{:>5} {}", line.line, line.text);
17044 }
17045 if !report.symbols.is_empty() {
17046 println!("syms[{}]:", report.symbols.len());
17047 for symbol in &report.symbols {
17048 println!(" {} {}:{}", symbol.name, symbol.file, symbol.line);
17049 }
17050 }
17051 if report.range.truncated_before || report.range.truncated_after {
17052 println!("expand: {}", report.expand.file);
17053 }
17054 } else {
17055 println!(
17056 "Source window `{}` lines {}-{} of {} ({})",
17057 report.file,
17058 report.range.start,
17059 report.range.end,
17060 report.range.total_lines,
17061 report.handle
17062 );
17063 for line in &report.preview {
17064 println!("{:>5} | {}", line.line, line.text);
17065 }
17066 if !report.symbols.is_empty() {
17067 println!();
17068 println!("Symbol refs:");
17069 for symbol in &report.symbols {
17070 println!(
17071 " {} `{}` {}:{} — {}",
17072 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17073 );
17074 }
17075 }
17076 if !report.summaries.is_empty() {
17077 println!();
17078 println!("Summary refs:");
17079 for summary in &report.summaries {
17080 println!(
17081 " {} `{}` — {}",
17082 summary.handle, summary.symbol_name, summary.expand
17083 );
17084 }
17085 }
17086 if report.range.truncated_before || report.range.truncated_after {
17087 println!();
17088 println!("Expand:");
17089 if let Some(before) = &report.expand.before {
17090 println!(" before: {}", before);
17091 }
17092 if let Some(after) = &report.expand.after {
17093 println!(" after: {}", after);
17094 }
17095 println!(" file: {}", report.expand.file);
17096 }
17097 for warning in &report.warnings {
17098 eprintln!("warning: {warning}");
17099 }
17100 }
17101
17102 Ok(())
17103}
17104
17105#[allow(clippy::too_many_arguments)]
17106fn cmd_symbol_read(
17107 symbol: &str,
17108 file_hint: Option<&Path>,
17109 path: &Path,
17110 scope: Option<&str>,
17111 format: OutputFormat,
17112 absolute: bool,
17113 budget: ResponseBudget,
17114) -> Result<()> {
17115 let root = lint::resolve_project_root_or_canonical_path(path)?;
17116 let hinted_file_abs = file_hint
17117 .map(|file| resolve_source_file(&root, file))
17118 .transpose()?;
17119 let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17120 let db_path = resolve_query_db_path(&root, path_hint, scope)?;
17121 if !db_path.exists() {
17122 bail!(
17123 "index refs unavailable: no index found at {}",
17124 db_path.display()
17125 );
17126 }
17127 let db = index::IndexDb::open_read_only_resilient(&db_path)
17128 .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17129 let search_limit = budget.follow_up_items().max(10);
17130 let hits = db
17131 .symbol_search(symbol, search_limit)
17132 .with_context(|| format!("searching symbols for {symbol:?}"))?;
17133 let selected = hits
17134 .into_iter()
17135 .find(|hit| {
17136 let Some(hinted_file_abs) = &hinted_file_abs else {
17137 return true;
17138 };
17139 resolve_source_file(&root, Path::new(&hit.file))
17140 .map(|hit_file| hit_file == *hinted_file_abs)
17141 .unwrap_or(false)
17142 })
17143 .with_context(|| {
17144 let hint = file_hint
17145 .map(|file| format!(" in {}", file.display()))
17146 .unwrap_or_default();
17147 format!("no indexed symbol matched {symbol:?}{hint}")
17148 })?;
17149
17150 let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17151 let file_display = if absolute {
17152 file_abs.to_string_lossy().to_string()
17153 } else {
17154 relativize_pathbuf(&file_abs, &root)
17155 .to_string_lossy()
17156 .to_string()
17157 };
17158 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17159 let content_hash = blake3::hash(&source).to_hex().to_string();
17160 let text = String::from_utf8_lossy(&source);
17161 let all_lines: Vec<&str> = text.lines().collect();
17162 let total_lines = all_lines.len();
17163 let file_symbols = db
17164 .symbols_for_file(&file_abs.to_string_lossy())
17165 .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17166 let max_items = budget.preview_items();
17167 let max_bytes = budget.preview_bytes();
17168 let selected_start = symbol_hit_line(&selected);
17169 let selected_end = symbol_hit_end_line(&selected)
17170 .unwrap_or(selected_start)
17171 .max(selected_start);
17172 let stored_target = file_symbols.iter().find(|candidate| {
17173 candidate.name == selected.name
17174 && candidate.kind == selected.kind
17175 && source_symbol_line(candidate) == selected_start
17176 });
17177 let target_span = stored_target
17178 .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17179 .or_else(|| symbol_hit_ast_span(&selected, &source));
17180 let target_start = target_span
17181 .as_ref()
17182 .map(|span| span.start_line)
17183 .unwrap_or(selected_start);
17184 let target_end = target_span
17185 .as_ref()
17186 .map(|span| span.end_line)
17187 .or_else(|| stored_target.and_then(source_symbol_end_line))
17188 .unwrap_or(selected_end)
17189 .max(target_start);
17190 let target_bounds = stored_target
17191 .and_then(stored_symbol_span_bounds)
17192 .or_else(|| symbol_hit_span_bounds(&selected));
17193 let target_end = stored_target
17194 .and_then(source_symbol_end_line)
17195 .unwrap_or(target_end)
17196 .max(target_start);
17197 let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17198 let line_capped_end = target_start
17199 .saturating_add(body_line_budget)
17200 .saturating_sub(1)
17201 .min(target_end)
17202 .min(total_lines.max(target_start));
17203 let token_cap = budget.body_token_cap();
17204 let (body, effective_preview_end, body_truncated) =
17205 if total_lines == 0 || target_start > total_lines {
17206 (Vec::new(), line_capped_end, false)
17207 } else {
17208 let capped = build_token_capped_preview(
17209 &all_lines,
17210 target_start,
17211 line_capped_end,
17212 max_bytes,
17213 token_cap,
17214 );
17215 (capped.preview, capped.capped_end, capped.was_capped)
17216 };
17217 let preview_end = if body_truncated {
17218 effective_preview_end
17219 } else {
17220 line_capped_end
17221 };
17222 let child_symbols = file_symbols
17223 .iter()
17224 .filter(|candidate| {
17225 if let Some((target_start_byte, target_end_byte)) = target_bounds {
17226 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17227 else {
17228 return false;
17229 };
17230 return candidate_start >= target_start_byte
17231 && candidate_end <= target_end_byte
17232 && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17233 }
17234 let line = source_symbol_line(candidate);
17235 line > target_start && line <= target_end
17236 })
17237 .take(max_items)
17238 .map(|symbol| {
17239 let line = source_symbol_line(symbol);
17240 let end_line = source_symbol_end_line(symbol);
17241 SourceSymbolRef {
17242 handle: stable_handle(
17243 "ssym",
17244 &format!("{}:{}:{}", file_display, symbol.name, line),
17245 ),
17246 name: truncate_for_budget(&symbol.name, max_bytes),
17247 kind: symbol.kind.clone(),
17248 language: symbol.language.clone(),
17249 file: file_display.clone(),
17250 line,
17251 end_line,
17252 signature: symbol
17253 .signature
17254 .clone()
17255 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17256 span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
17257 expand: source_symbol_read_command(&root, &symbol.name, &file_display),
17258 }
17259 })
17260 .collect::<Vec<_>>();
17261 let mut warnings = Vec::new();
17262 if body_truncated {
17263 warnings.push(format!(
17264 "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
17265 ));
17266 }
17267 let summaries =
17268 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17269 let symbol_handle = stable_handle(
17270 "sread",
17271 &format!("{}:{}:{}", file_display, selected.name, target_start),
17272 );
17273 let source_lines = preview_end
17274 .saturating_sub(target_start)
17275 .saturating_add(1)
17276 .max(1);
17277 let expand = SymbolReadExpandCommands {
17278 source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
17279 body: body_truncated.then(|| {
17280 let remaining = target_end.saturating_sub(preview_end);
17281 source_read_window_command(&root, &file_display, preview_end + 1, remaining)
17282 }),
17283 file: source_read_ast_command(&root, &file_display),
17284 explain: source_symbol_expand_command(&root, &selected.name),
17285 callers: source_symbol_graph_command(&root, &selected.name, "callers"),
17286 callees: source_symbol_graph_command(&root, &selected.name, "callees"),
17287 markdown_ast: (selected.language == "markdown").then(|| {
17288 markdown_ast_command(
17289 &root,
17290 &file_display,
17291 target_span.as_ref().map(|span| span.handle.as_str()),
17292 )
17293 }),
17294 };
17295 let report = SymbolReadReport {
17296 handle: symbol_handle.clone(),
17297 root: root.to_string_lossy().to_string(),
17298 query: symbol.to_string(),
17299 symbol: SymbolReadTarget {
17300 handle: symbol_handle,
17301 name: selected.name.clone(),
17302 kind: selected.kind.clone(),
17303 language: selected.language.clone(),
17304 file: file_display.clone(),
17305 line: target_start,
17306 end_line: Some(target_end),
17307 signature: stored_target
17308 .and_then(|stored| stored.signature.clone())
17309 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17310 parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
17311 visibility: stored_target.and_then(|stored| stored.visibility.clone()),
17312 span: target_span,
17313 },
17314 range: SourceRangePreview {
17315 start: target_start,
17316 end: preview_end,
17317 total_lines,
17318 truncated_before: false,
17319 truncated_after: preview_end < target_end,
17320 },
17321 body,
17322 child_symbols,
17323 summaries,
17324 expand,
17325 warnings,
17326 };
17327
17328 if format.json_output {
17329 let truncated = report.range.truncated_after
17330 || report.body.iter().any(|line| line.text.len() >= max_bytes)
17331 || report.child_symbols.len() >= max_items;
17332 let follow_up = [
17333 Some(report.expand.source_window.clone()),
17334 report.expand.body.clone(),
17335 Some(report.expand.file.clone()),
17336 Some(report.expand.explain.clone()),
17337 Some(report.expand.callers.clone()),
17338 Some(report.expand.callees.clone()),
17339 ]
17340 .into_iter()
17341 .flatten()
17342 .chain(report.expand.markdown_ast.clone())
17343 .collect::<Vec<_>>();
17344 print_json_or_envelope(
17345 &report,
17346 &format,
17347 "symbol-read",
17348 "symbol",
17349 ToolEnvelopeSummary {
17350 text: format!(
17351 "symbol {} {}:{}-{}",
17352 report.symbol.name, report.symbol.file, report.range.start, report.range.end
17353 ),
17354 metrics: vec![
17355 envelope_metric("body_lines", report.body.len()),
17356 envelope_metric("child_symbols", report.child_symbols.len()),
17357 envelope_metric("summaries", report.summaries.len()),
17358 ],
17359 },
17360 truncated,
17361 follow_up,
17362 )?;
17363 } else if format.compact {
17364 println!(
17365 "symbol {} {}:{}-{} handle:{} hash:{}",
17366 report.symbol.name,
17367 report.symbol.file,
17368 report.range.start,
17369 report.range.end,
17370 report.handle,
17371 content_hash
17372 );
17373 for line in &report.body {
17374 println!("{:>5} {}", line.line, line.text);
17375 }
17376 if !report.child_symbols.is_empty() {
17377 println!("children[{}]:", report.child_symbols.len());
17378 for child in &report.child_symbols {
17379 println!(" {} {}:{}", child.name, child.file, child.line);
17380 }
17381 }
17382 } else {
17383 println!(
17384 "Symbol `{}` in `{}` lines {}-{} ({})",
17385 report.symbol.name,
17386 report.symbol.file,
17387 report.range.start,
17388 report.range.end,
17389 report.handle
17390 );
17391 for line in &report.body {
17392 println!("{:>5} | {}", line.line, line.text);
17393 }
17394 if !report.child_symbols.is_empty() {
17395 println!();
17396 println!("Child symbols:");
17397 for child in &report.child_symbols {
17398 println!(
17399 " {} `{}` {}:{} — {}",
17400 child.handle, child.name, child.file, child.line, child.expand
17401 );
17402 }
17403 }
17404 println!();
17405 println!("Expand:");
17406 println!(" source: {}", report.expand.source_window);
17407 println!(" file: {}", report.expand.file);
17408 println!(" explain: {}", report.expand.explain);
17409 println!(" callers: {}", report.expand.callers);
17410 println!(" callees: {}", report.expand.callees);
17411 for warning in &report.warnings {
17412 eprintln!("warning: {warning}");
17413 }
17414 }
17415
17416 Ok(())
17417}
17418
17419#[allow(clippy::too_many_arguments)]
17420#[derive(Serialize)]
17421struct ExplainBudgetDefinitionPreview {
17422 handle: String,
17423 #[serde(skip_serializing_if = "Option::is_none")]
17424 tag_alias: Option<String>,
17425 kind: String,
17426 name: String,
17427 file: String,
17428 line: i64,
17429 expand: String,
17430}
17431
17432#[derive(Serialize)]
17433struct ExplainBudgetEdgePreview {
17434 handle: String,
17435 #[serde(skip_serializing_if = "Option::is_none")]
17436 tag_alias: Option<String>,
17437 name: String,
17438 file: String,
17439 line: i64,
17440 expand: String,
17441}
17442
17443#[derive(Serialize)]
17444struct ExplainBudgetCommunityPreview {
17445 size: usize,
17446 members: Vec<String>,
17447}
17448
17449#[derive(Serialize)]
17450struct ExplainBudgetReport {
17451 symbol: String,
17452 max_items: usize,
17453 max_bytes: usize,
17454 definition_total: usize,
17455 callers_total: usize,
17456 callers_truncated_by_limit: bool,
17457 callees_total: usize,
17458 callees_truncated_by_limit: bool,
17459 truncated: bool,
17460 definitions: Vec<ExplainBudgetDefinitionPreview>,
17461 callers: Vec<ExplainBudgetEdgePreview>,
17462 callees: Vec<ExplainBudgetEdgePreview>,
17463 #[serde(skip_serializing_if = "Option::is_none")]
17464 community: Option<ExplainBudgetCommunityPreview>,
17465}
17466
17467#[allow(clippy::too_many_arguments)]
17468pub(crate) fn build_explain_budget_report(
17469 symbol: &str,
17470 _root: &Path,
17471 symbols: &[index::StoredSymbol],
17472 callers: &[index::StoredEdge],
17473 callers_total: usize,
17474 callers_truncated_by_limit: bool,
17475 callees: &[index::StoredEdge],
17476 callees_total: usize,
17477 callees_truncated_by_limit: bool,
17478 community: Option<&graph::Community>,
17479 budget: ResponseBudget,
17480) -> ExplainBudgetReport {
17481 let max_items = budget.preview_items();
17482 let max_bytes = budget.preview_bytes();
17483 let definitions = symbols
17484 .iter()
17485 .take(max_items)
17486 .map(|entry| {
17487 let symbol_ref = build_compact_symbol_ref(
17488 "edef",
17489 &format!(
17490 "{}:{}:{}:{}",
17491 entry.kind, entry.name, entry.file, entry.line
17492 ),
17493 &entry.name,
17494 entry.tags.as_deref(),
17495 max_bytes,
17496 );
17497 ExplainBudgetDefinitionPreview {
17498 handle: symbol_ref.handle,
17499 tag_alias: symbol_ref.tag_alias,
17500 kind: entry.kind.clone(),
17501 name: symbol_ref.name,
17502 file: truncate_for_budget(&entry.file, max_bytes),
17503 line: entry.line,
17504 expand: format!(
17505 "tsift search {} --exact --path {} --limit 20",
17506 shell_quote(&entry.name),
17507 shell_quote(&entry.file)
17508 ),
17509 }
17510 })
17511 .collect();
17512 let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
17513 .iter()
17514 .take(max_items)
17515 .map(|entry| {
17516 let symbol_ref = build_compact_symbol_ref(
17517 "ecall",
17518 &format!(
17519 "{}:{}:{}:{}",
17520 entry.caller_name, entry.caller_file, entry.call_site_line, symbol
17521 ),
17522 &entry.caller_name,
17523 None,
17524 max_bytes,
17525 );
17526 ExplainBudgetEdgePreview {
17527 handle: symbol_ref.handle,
17528 tag_alias: symbol_ref.tag_alias,
17529 name: symbol_ref.name,
17530 file: truncate_for_budget(&entry.caller_file, max_bytes),
17531 line: entry.call_site_line,
17532 expand: format!(
17533 "tsift explain {} --path {} --limit 0",
17534 shell_quote(&entry.caller_name),
17535 shell_quote(&entry.caller_file)
17536 ),
17537 }
17538 })
17539 .collect();
17540 let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
17541 .iter()
17542 .take(max_items)
17543 .map(|entry| {
17544 let symbol_ref = build_compact_symbol_ref(
17545 "eces",
17546 &format!(
17547 "{}:{}:{}:{}",
17548 entry.callee_name, entry.caller_file, entry.call_site_line, symbol
17549 ),
17550 &entry.callee_name,
17551 None,
17552 max_bytes,
17553 );
17554 ExplainBudgetEdgePreview {
17555 handle: symbol_ref.handle,
17556 tag_alias: symbol_ref.tag_alias,
17557 name: symbol_ref.name,
17558 file: truncate_for_budget(&entry.caller_file, max_bytes),
17559 line: entry.call_site_line,
17560 expand: format!(
17561 "tsift explain {} --path {} --limit 0",
17562 shell_quote(&entry.callee_name),
17563 shell_quote(&entry.caller_file)
17564 ),
17565 }
17566 })
17567 .collect();
17568 let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
17569 size: entry.members.len(),
17570 members: entry
17571 .members
17572 .iter()
17573 .take(max_items)
17574 .map(|member| truncate_for_budget(&member.name, max_bytes))
17575 .collect(),
17576 });
17577
17578 ExplainBudgetReport {
17579 symbol: symbol.to_string(),
17580 max_items,
17581 max_bytes,
17582 definition_total: symbols.len(),
17583 callers_total,
17584 callers_truncated_by_limit,
17585 callees_total,
17586 callees_truncated_by_limit,
17587 truncated: symbols.len() > max_items
17588 || callers_total > callers_preview.len()
17589 || callees_total > callees_preview.len()
17590 || community
17591 .map(|entry| entry.members.len() > max_items)
17592 .unwrap_or(false),
17593 definitions,
17594 callers: callers_preview,
17595 callees: callees_preview,
17596 community: community_preview,
17597 }
17598}
17599
17600pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
17601 println!(
17602 "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
17603 shell_quote(&report.symbol),
17604 report.definitions.len(),
17605 report.definition_total,
17606 report.callers.len(),
17607 report.callers_total,
17608 report.callees.len(),
17609 report.callees_total
17610 );
17611 for entry in &report.definitions {
17612 println!(
17613 "def {} {} {}:{} expand:{}",
17614 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17615 entry.kind,
17616 entry.file,
17617 entry.line,
17618 entry.expand
17619 );
17620 }
17621 for entry in &report.callers {
17622 println!(
17623 "caller {} {}:{} expand:{}",
17624 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17625 entry.file,
17626 entry.line,
17627 entry.expand
17628 );
17629 }
17630 for entry in &report.callees {
17631 println!(
17632 "callee {} {}:{} expand:{}",
17633 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17634 entry.file,
17635 entry.line,
17636 entry.expand
17637 );
17638 }
17639 if let Some(community) = &report.community {
17640 println!(
17641 "community size:{} members:{}",
17642 community.size,
17643 community.members.join(", ")
17644 );
17645 }
17646 if report.truncated {
17647 println!(
17648 "budget truncated items:{} bytes:{}",
17649 report.max_items, report.max_bytes
17650 );
17651 }
17652}
17653
17654const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
17664 ".git",
17665 "node_modules",
17666 "target",
17667 "__pycache__",
17668 ".venv",
17669 "vendor",
17670];
17671
17672const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
17673 "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
17674 "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
17675 "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
17676 "sh", "bash", "zsh", "sql", "css", "tsx",
17677];
17678
17679pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
17680 let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
17681 .iter()
17682 .map(|ext| (*ext).to_string())
17683 .collect::<BTreeSet<_>>();
17684
17685 let config_path = root.join(".naming.toml");
17686 if !config_path.exists() {
17687 return extensions;
17688 }
17689
17690 match tagpath::config::resolve(&config_path) {
17691 Ok(config) => {
17692 if let Some(grammars) = config.grammars {
17693 for grammar in grammars.languages.values() {
17694 for ext in &grammar.extensions {
17695 if let Some(normalized) = normalize_extension(ext) {
17696 extensions.insert(normalized);
17697 }
17698 }
17699 }
17700 }
17701 }
17702 Err(err) => {
17703 eprintln!("tagpath_policy_hint_config_unreadable: {err}");
17704 }
17705 }
17706 extensions
17707}
17708
17709pub(crate) fn tagpath_audit_policy_hints(
17710 rel_path: &str,
17711 supported_extensions: &BTreeSet<String>,
17712) -> Vec<String> {
17713 let path = Path::new(rel_path);
17714 let mut hints = BTreeSet::new();
17715 if let Some(parent) = path.parent() {
17716 for component in parent.components() {
17717 if let std::path::Component::Normal(name) = component {
17718 let name = name.to_string_lossy();
17719 if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
17720 hints.insert(format!("skip_dir:{name}"));
17721 }
17722 }
17723 }
17724 }
17725 if path
17726 .extension()
17727 .and_then(|ext| ext.to_str())
17728 .and_then(normalize_extension)
17729 .is_some_and(|ext| !supported_extensions.contains(&ext))
17730 {
17731 hints.insert("extension_unsupported".to_string());
17732 }
17733 hints.into_iter().collect()
17734}
17735
17736fn normalize_extension(ext: &str) -> Option<String> {
17737 let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
17738 if normalized.is_empty() {
17739 None
17740 } else {
17741 Some(normalized)
17742 }
17743}
17744
17745pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
17746 match status {
17747 diff_digest::DiffDigestFileStatus::Added => "added",
17748 diff_digest::DiffDigestFileStatus::Modified => "modified",
17749 diff_digest::DiffDigestFileStatus::Deleted => "deleted",
17750 }
17751}
17752
17753pub(crate) fn diff_digest_summary_label(
17754 state: diff_digest::DiffDigestSummaryState,
17755) -> &'static str {
17756 match state {
17757 diff_digest::DiffDigestSummaryState::Current => "current",
17758 diff_digest::DiffDigestSummaryState::Stale => "stale",
17759 diff_digest::DiffDigestSummaryState::Missing => "missing",
17760 diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
17761 }
17762}
17763
17764fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
17765 match state {
17766 test_digest::TestDigestSummaryState::Current => "current",
17767 test_digest::TestDigestSummaryState::Stale => "stale",
17768 test_digest::TestDigestSummaryState::Missing => "missing",
17769 test_digest::TestDigestSummaryState::Unavailable => "unavailable",
17770 }
17771}
17772
17773fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
17774 match state {
17775 log_digest::LogDigestSummaryState::Current => "current",
17776 log_digest::LogDigestSummaryState::Stale => "stale",
17777 log_digest::LogDigestSummaryState::Missing => "missing",
17778 log_digest::LogDigestSummaryState::Unavailable => "unavailable",
17779 }
17780}
17781
17782pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
17783 match mode {
17784 diff_digest::DiffDigestMode::WorkingTree => "worktree",
17785 diff_digest::DiffDigestMode::Cached => "cached",
17786 diff_digest::DiffDigestMode::Revision => "revision",
17787 }
17788}
17789
17790pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
17791 match (&report.mode, &report.revision) {
17792 (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
17793 (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
17794 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17795 format!("revision {revision}")
17796 }
17797 (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
17798 }
17799}
17800
17801pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
17802 match (&report.mode, &report.revision) {
17803 (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
17804 (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
17805 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17806 format!("No diff found for revision {revision}.")
17807 }
17808 (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
17809 }
17810}
17811
17812fn cmd_impact(
17813 path: &Path,
17814 cached: bool,
17815 revision: Option<&str>,
17816 scope: Option<&str>,
17817 limit: usize,
17818 format: OutputFormat,
17819) -> Result<()> {
17820 let report = impact::compute(
17821 path,
17822 impact::ImpactOptions {
17823 cached,
17824 revision,
17825 scope,
17826 limit,
17827 },
17828 )?;
17829 if format.json_output {
17830 println!(
17831 "{}",
17832 to_json_schema(
17833 &report,
17834 format.pretty,
17835 format.terse,
17836 format.ultra_terse,
17837 format.schema
17838 )?
17839 );
17840 return Ok(());
17841 }
17842
17843 if format.compact {
17844 println!(
17845 "impact mode:{} changed:{} symbols:{} tests:{}/{}",
17846 diff_digest_mode_label(report.mode),
17847 report.changed_files.len(),
17848 report.changed_symbols.len(),
17849 report.affected_tests.len(),
17850 report.affected_tests_total
17851 );
17852 for target in &report.affected_tests {
17853 println!(
17854 "{} reasons:{} command:{}",
17855 target.path,
17856 target.reasons.len(),
17857 target.commands.join(" && ")
17858 );
17859 }
17860 for warning in &report.warnings {
17861 println!("warning {warning}");
17862 }
17863 return Ok(());
17864 }
17865
17866 println!("Impact ({})", diff_digest_mode_label(report.mode));
17867 println!(" changed files: {}", report.changed_files.len());
17868 println!(" changed symbols: {}", report.changed_symbols.len());
17869 println!(
17870 " affected tests: {}/{}",
17871 report.affected_tests.len(),
17872 report.affected_tests_total
17873 );
17874 for target in &report.affected_tests {
17875 println!();
17876 println!("{}", target.path);
17877 for reason in &target.reasons {
17878 println!(" - {reason}");
17879 }
17880 if !target.symbols.is_empty() {
17881 println!(" symbols: {}", target.symbols.join(", "));
17882 }
17883 for command in &target.commands {
17884 println!(" run: {}", command);
17885 }
17886 }
17887 for warning in &report.warnings {
17888 println!("warning: {warning}");
17889 }
17890 Ok(())
17891}
17892
17893pub(crate) fn render_test_digest_from_input(
17894 path: &Path,
17895 input: &str,
17896 runner: Option<&str>,
17897 format: OutputFormat,
17898) -> Result<()> {
17899 let report = test_digest::compute(path, input, runner)?;
17900 if format.json_output {
17901 println!(
17902 "{}",
17903 to_json_schema(
17904 &report,
17905 format.pretty,
17906 format.terse,
17907 format.ultra_terse,
17908 format.schema
17909 )?
17910 );
17911 return Ok(());
17912 }
17913
17914 if report.failure_groups.is_empty() {
17915 println!("No failures detected (runner: {}).", report.runner);
17916 for warning in &report.warnings {
17917 println!("warning: {warning}");
17918 }
17919 return Ok(());
17920 }
17921
17922 if format.compact {
17923 println!(
17924 "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
17925 report.runner,
17926 report.failures,
17927 report.grouped_failures,
17928 report.counts.passed.unwrap_or(0),
17929 report.counts.failed.unwrap_or(report.grouped_failures),
17930 report.counts.skipped.unwrap_or(0),
17931 );
17932 for failure in &report.failure_groups {
17933 let tests = truncate_for_compact(&failure.tests.join(","), 60);
17934 let location = match (&failure.path, failure.line) {
17935 (Some(path), Some(line)) => format!("{path}:{line}"),
17936 (Some(path), None) => path.clone(),
17937 _ => "-".to_string(),
17938 };
17939 println!(
17940 "{} tests:{} count:{} summaries:{} msg:{}",
17941 location,
17942 tests,
17943 failure.occurrences,
17944 test_digest_summary_label(failure.summary_state),
17945 truncate_for_compact(&failure.message, 80)
17946 );
17947 }
17948 for warning in &report.warnings {
17949 println!("warning: {warning}");
17950 }
17951 return Ok(());
17952 }
17953
17954 println!("Test digest ({})", report.runner);
17955 println!(" failures: {}", report.failures);
17956 println!(" failure groups: {}", report.grouped_failures);
17957 if let Some(passed) = report.counts.passed {
17958 println!(" passed: {}", passed);
17959 }
17960 if let Some(failed) = report.counts.failed {
17961 println!(" failed: {}", failed);
17962 }
17963 if let Some(skipped) = report.counts.skipped {
17964 println!(" skipped: {}", skipped);
17965 }
17966
17967 for failure in &report.failure_groups {
17968 println!();
17969 match (&failure.path, failure.line, failure.column) {
17970 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
17971 (Some(path), Some(line), None) => println!("{path}:{line}"),
17972 (Some(path), None, _) => println!("{path}"),
17973 (None, _, _) => println!("(no file anchor)"),
17974 }
17975 println!(" tests: {}", failure.tests.join(", "));
17976 println!(" occurrences: {}", failure.occurrences);
17977 println!(" message: {}", failure.message);
17978 println!(
17979 " cached summaries: {}",
17980 test_digest_summary_label(failure.summary_state)
17981 );
17982 for summary in &failure.current_summaries {
17983 println!(
17984 " - {}: {}",
17985 summary.symbol,
17986 truncate_for_compact(&summary.summary, 160)
17987 );
17988 }
17989 }
17990 for warning in &report.warnings {
17991 println!("warning: {warning}");
17992 }
17993 Ok(())
17994}
17995
17996#[derive(Clone, Serialize, Deserialize)]
17997struct DispatchTraceSummary {
17998 backlog: usize,
17999 job_packet: usize,
18000 worker_result: usize,
18001 worker_context: usize,
18002 source_handle: usize,
18003 semantic_rows: usize,
18004}
18005
18006#[derive(Clone, Serialize, Deserialize)]
18007struct DispatchTraceReport {
18008 contract_version: String,
18009 root: String,
18010 #[serde(skip_serializing_if = "Option::is_none")]
18011 scope: Option<String>,
18012 targets: Vec<String>,
18013 projection_freshness: GraphDbFreshnessReport,
18014 projection_hashes: Vec<String>,
18015 evidence_packet_ids: Vec<String>,
18016 shared_preparation: ConflictMatrixSharedPreparationSummary,
18017 worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18018 worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18019 summary: DispatchTraceSummary,
18020 nodes: Vec<SubstrateTerseGraphNode>,
18021 edges: Vec<SubstrateTerseGraphEdge>,
18022 conflict_matrix_decisions: Vec<String>,
18023 replay_commands: Vec<String>,
18024 repair_commands: Vec<String>,
18025 truncated: bool,
18026 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18027 warnings: Vec<String>,
18028}
18029
18030fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18031 matches!(
18032 kind,
18033 "session"
18034 | "backlog"
18035 | "job_packet"
18036 | "worker_result"
18037 | "worker_context"
18038 | "source_handle"
18039 | "semantic_concept"
18040 | "semantic_entity"
18041 | "file"
18042 | "symbol"
18043 | "route"
18044 )
18045}
18046
18047fn dispatch_trace_kind_rank(kind: &str) -> usize {
18048 match kind {
18049 "backlog" => 0,
18050 "job_packet" => 1,
18051 "worker_result" => 2,
18052 "worker_context" => 3,
18053 "source_handle" => 4,
18054 "file" => 5,
18055 "symbol" => 6,
18056 "route" => 7,
18057 "semantic_concept" => 8,
18058 "semantic_entity" => 9,
18059 "session" => 10,
18060 _ => 99,
18061 }
18062}
18063
18064fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18065 DispatchTraceSummary {
18066 backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18067 job_packet: nodes
18068 .iter()
18069 .filter(|node| node.kind == "job_packet")
18070 .count(),
18071 worker_result: nodes
18072 .iter()
18073 .filter(|node| node.kind == "worker_result")
18074 .count(),
18075 worker_context: nodes
18076 .iter()
18077 .filter(|node| node.kind == "worker_context")
18078 .count(),
18079 source_handle: nodes
18080 .iter()
18081 .filter(|node| node.kind == "source_handle")
18082 .count(),
18083 semantic_rows: nodes
18084 .iter()
18085 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18086 .count(),
18087 }
18088}
18089
18090fn dispatch_trace_shared_preparation_summary(
18091 graph_nodes: &[SubstrateGraphNode],
18092 graph_edges: &[SubstrateGraphEdge],
18093 conflict: &ConflictMatrixReport,
18094) -> ConflictMatrixSharedPreparationSummary {
18095 ConflictMatrixSharedPreparationSummary {
18096 evidence_cache_status: conflict
18097 .inputs
18098 .shared_preparation
18099 .evidence_cache_status
18100 .clone(),
18101 graph_nodes: graph_nodes.len(),
18102 graph_edges: graph_edges.len(),
18103 evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18104 source_handles: conflict
18105 .candidates
18106 .iter()
18107 .map(|candidate| candidate.source_handles.len())
18108 .sum(),
18109 worker_context: conflict
18110 .candidates
18111 .iter()
18112 .map(|candidate| candidate.worker_context_handles.len())
18113 .sum(),
18114 worker_results: conflict
18115 .candidates
18116 .iter()
18117 .map(|candidate| candidate.worker_feedback.total)
18118 .sum(),
18119 semantic_rows: conflict
18120 .candidates
18121 .iter()
18122 .map(|candidate| candidate.semantic_related.len())
18123 .sum(),
18124 dispatch_trace_snapshot_nodes: graph_nodes.len(),
18125 dispatch_trace_snapshot_edges: graph_edges.len(),
18126 }
18127}
18128
18129fn dispatch_trace_collect_ids(
18130 targets: &[String],
18131 candidates: &[ConflictMatrixCandidate],
18132 graph_nodes: &[SubstrateGraphNode],
18133 graph_edges: &[SubstrateGraphEdge],
18134 depth: usize,
18135 limit: usize,
18136) -> (BTreeSet<String>, bool) {
18137 let target_refs = targets
18138 .iter()
18139 .map(|target| target.trim_start_matches('#').to_string())
18140 .collect::<BTreeSet<_>>();
18141 let mut ids = BTreeSet::new();
18142 for candidate in candidates {
18143 ids.insert(candidate.target_node_id.clone());
18144 for source in &candidate.source_handles {
18145 ids.insert(source.handle.clone());
18146 }
18147 for handle in &candidate.worker_context_handles {
18148 ids.insert(handle.clone());
18149 }
18150 for semantic in &candidate.semantic_related {
18151 ids.insert(semantic.handle.clone());
18152 }
18153 }
18154 for node in graph_nodes {
18155 if !dispatch_trace_allowed_node_kind(&node.kind) {
18156 continue;
18157 }
18158 if node
18159 .properties
18160 .get("ref_id")
18161 .is_some_and(|ref_id| target_refs.contains(ref_id))
18162 {
18163 ids.insert(node.id.clone());
18164 }
18165 }
18166
18167 let node_by_id = graph_nodes
18168 .iter()
18169 .map(|node| (node.id.as_str(), node))
18170 .collect::<BTreeMap<_, _>>();
18171 let max_nodes = if limit == 0 {
18172 usize::MAX
18173 } else {
18174 limit
18175 .saturating_mul(targets.len().max(1))
18176 .saturating_mul(12)
18177 .max(64)
18178 };
18179 let mut truncated = false;
18180 for _ in 0..depth.max(1) {
18181 let before = ids.len();
18182 let current_ids = ids.clone();
18183 for edge in graph_edges {
18184 if ids.len() >= max_nodes {
18185 truncated = true;
18186 break;
18187 }
18188 let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18189 if !touches {
18190 continue;
18191 }
18192 for endpoint in [&edge.from_id, &edge.to_id] {
18193 let Some(node) = node_by_id.get(endpoint.as_str()) else {
18194 continue;
18195 };
18196 if dispatch_trace_allowed_node_kind(&node.kind) {
18197 ids.insert(endpoint.clone());
18198 }
18199 }
18200 }
18201 if ids.len() == before || truncated {
18202 break;
18203 }
18204 }
18205 (ids, truncated)
18206}
18207
18208#[allow(clippy::too_many_arguments)]
18209fn build_dispatch_trace_report_from_conflict_snapshot(
18210 root: &Path,
18211 scope: Option<&str>,
18212 conflict: ConflictMatrixReport,
18213 graph_nodes: Vec<SubstrateGraphNode>,
18214 graph_edges: Vec<SubstrateGraphEdge>,
18215 depth: usize,
18216 limit: usize,
18217 extra_warnings: Vec<String>,
18218) -> Result<DispatchTraceReport> {
18219 let shared_preparation =
18220 dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18221 let (ids, truncated) = dispatch_trace_collect_ids(
18222 &conflict.targets,
18223 &conflict.candidates,
18224 &graph_nodes,
18225 &graph_edges,
18226 depth,
18227 limit,
18228 );
18229 let mut nodes = graph_nodes
18230 .into_iter()
18231 .filter(|node| ids.contains(&node.id))
18232 .collect::<Vec<_>>();
18233 nodes.sort_by(|left, right| {
18234 dispatch_trace_kind_rank(&left.kind)
18235 .cmp(&dispatch_trace_kind_rank(&right.kind))
18236 .then(left.id.cmp(&right.id))
18237 });
18238 let node_ids = nodes
18239 .iter()
18240 .map(|node| node.id.as_str())
18241 .collect::<BTreeSet<_>>();
18242 let mut edges = graph_edges
18243 .into_iter()
18244 .filter(|edge| {
18245 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18246 })
18247 .collect::<Vec<_>>();
18248 edges.sort_by(|left, right| {
18249 left.from_id
18250 .cmp(&right.from_id)
18251 .then(left.kind.cmp(&right.kind))
18252 .then(left.to_id.cmp(&right.to_id))
18253 });
18254 let mut warnings = conflict.warnings;
18255 warnings.extend(extra_warnings);
18256
18257 Ok(DispatchTraceReport {
18258 contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
18259 root: conflict.root,
18260 scope: conflict.scope,
18261 targets: conflict.targets,
18262 projection_freshness: conflict.orchestration.projection_freshness,
18263 projection_hashes: conflict.orchestration.projection_hashes,
18264 evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
18265 shared_preparation,
18266 worker_prompt_packets: conflict.worker_prompt_packets,
18267 worker_feedback: conflict
18268 .candidates
18269 .iter()
18270 .map(|candidate| candidate.worker_feedback.clone())
18271 .collect(),
18272 summary: dispatch_trace_summary(&nodes),
18273 nodes: nodes.into_iter().map(Into::into).collect(),
18274 edges: edges.into_iter().map(Into::into).collect(),
18275 conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
18276 replay_commands: conflict.next_commands,
18277 repair_commands: graph_db_repair_commands(root, scope),
18278 truncated,
18279 warnings,
18280 })
18281}
18282
18283fn build_dispatch_trace_report(
18284 path: &Path,
18285 scope: Option<&str>,
18286 raw_targets: &[String],
18287 depth: usize,
18288 limit: usize,
18289 impact_limit: usize,
18290) -> Result<DispatchTraceReport> {
18291 let root = lint::resolve_project_root_or_canonical_path(path)?;
18292 let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
18293 if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
18294 write_traversal_graph_store(&root, path, scope)
18295 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
18296 }
18297 let graph_db = graph_substrate_db_path(&root, scope);
18298 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
18299 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
18300 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
18301 let extra_warnings = store
18302 .read_only_recovery()
18303 .map(graph_db_read_recovery_diagnostic)
18304 .into_iter()
18305 .collect::<Vec<_>>();
18306 let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
18307 let graph_prepared = prepare_conflict_matrix_graph_orchestration(
18308 &root,
18309 scope,
18310 "sqlite",
18311 raw_targets,
18312 &prepared,
18313 depth,
18314 limit,
18315 &store,
18316 freshness.clone(),
18317 )?;
18318 let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
18319 &prepared.preparation_cache.source_watermark,
18320 &prepared.preparation_cache.document_watermark,
18321 &prepared.preparation_cache.staged_diff_watermark,
18322 &[
18323 &format!("targets:{}", raw_targets.join(",")),
18324 &format!("depth:{depth}"),
18325 &format!("limit:{limit}"),
18326 ],
18327 );
18328 if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
18329 &root,
18330 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18331 &dt_cache_key,
18332 ) {
18333 return Ok(cached_report);
18334 }
18335 let conflict = build_conflict_matrix_report_from_prepared_graph(
18336 &root,
18337 path,
18338 scope,
18339 depth,
18340 limit,
18341 impact_limit,
18342 freshness,
18343 extra_warnings.clone(),
18344 &prepared,
18345 &graph_prepared,
18346 )?;
18347 let report = build_dispatch_trace_report_from_conflict_snapshot(
18348 &root,
18349 scope,
18350 conflict,
18351 graph_prepared.graph.nodes,
18352 graph_prepared.graph.edges,
18353 depth,
18354 limit,
18355 extra_warnings,
18356 )?;
18357 cycle_packet_cache::cycle_packet_write_cache(
18358 &root,
18359 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18360 &dt_cache_key,
18361 &report,
18362 );
18363 Ok(report)
18364}
18365
18366fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
18367 let json = serde_json::to_string(report)?.replace("</", "<\\/");
18368 let mut html = String::new();
18369 html.push_str(
18370 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
18371 );
18372 html.push_str(
18373 r#"<style>
18374:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
18375@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
18376*{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}}
18377</style>"#,
18378 );
18379 html.push_str("</head><body><div class=\"page\">");
18380 html.push_str(&format!(
18381 "<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>",
18382 html_escape(&report.targets.join(", ")),
18383 report.evidence_packet_ids.len(),
18384 report.nodes.len(),
18385 report.worker_prompt_packets.len(),
18386 html_escape(&report.contract_version)
18387 ));
18388 html.push_str(
18389 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>"#,
18390 );
18391 html.push_str("<script id=\"trace-data\" type=\"application/json\">");
18392 html.push_str(&json);
18393 html.push_str(
18394 r##"</script><script>
18395const report = JSON.parse(document.getElementById("trace-data").textContent);
18396const svg = document.getElementById("graph-canvas");
18397const nodeList = document.getElementById("nodes");
18398const packets = document.getElementById("packets");
18399const feedback = document.getElementById("feedback");
18400const nodes = report.nodes.map((node, index) => ({...node, index}));
18401const nodeById = new Map(nodes.map(node => [node.id, node]));
18402const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
18403const 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"]]);
18404function color(kind){return colorByKind.get(kind)||"#6b7280";}
18405function text(value){return value == null ? "" : String(value);}
18406function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));}
18407function layout(){
18408 const rect = svg.getBoundingClientRect();
18409 const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
18410 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
18411 const counts = new Map();
18412 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
18413 const offsets = new Map();
18414 for (const node of nodes) {
18415 const group = kinds.indexOf(node.kind);
18416 const index = offsets.get(node.kind) || 0;
18417 offsets.set(node.kind, index + 1);
18418 const total = counts.get(node.kind) || 1;
18419 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
18420 const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
18421 node.x = cx + Math.cos(angle) * ring;
18422 node.y = cy + Math.sin(angle) * ring;
18423 }
18424}
18425function draw(){
18426 svg.innerHTML = "";
18427 for (const edge of edges) {
18428 const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
18429 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
18430 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
18431 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
18432 line.setAttribute("class", "edge");
18433 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
18434 svg.appendChild(line);
18435 }
18436 for (const node of nodes) {
18437 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
18438 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
18439 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
18440 circle.setAttribute("fill", color(node.kind));
18441 circle.setAttribute("class", "node");
18442 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
18443 svg.appendChild(circle);
18444 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
18445 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
18446 label.setAttribute("class", "node-label");
18447 label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
18448 svg.appendChild(label);
18449 }
18450}
18451packets.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>";
18452feedback.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>";
18453nodeList.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("");
18454window.addEventListener("resize", () => { layout(); draw(); });
18455layout(); draw();
18456</script></div></body></html>"##,
18457 );
18458 Ok(html)
18459}
18460
18461struct DispatchTraceOptions<'a> {
18462 path: &'a Path,
18463 scope: Option<&'a str>,
18464 raw_targets: &'a [String],
18465 depth: usize,
18466 limit: usize,
18467 impact_limit: usize,
18468 trace_format: DispatchTraceFormat,
18469}
18470
18471fn cmd_dispatch_trace(
18472 options: DispatchTraceOptions<'_>,
18473 output_format: OutputFormat,
18474) -> Result<()> {
18475 let report = build_dispatch_trace_report(
18476 options.path,
18477 options.scope,
18478 options.raw_targets,
18479 options.depth,
18480 options.limit,
18481 options.impact_limit,
18482 )?;
18483 match options.trace_format {
18484 DispatchTraceFormat::Json => {
18485 if output_format.envelope {
18486 print_json_or_envelope(
18487 &report,
18488 &output_format,
18489 "dispatch-trace",
18490 "operator-review",
18491 ToolEnvelopeSummary {
18492 text: format!(
18493 "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
18494 report.targets.len(),
18495 report.nodes.len(),
18496 report.worker_prompt_packets.len()
18497 ),
18498 metrics: vec![
18499 envelope_metric("targets", report.targets.len()),
18500 envelope_metric("nodes", report.nodes.len()),
18501 envelope_metric("edges", report.edges.len()),
18502 envelope_metric(
18503 "worker_prompt_packets",
18504 report.worker_prompt_packets.len(),
18505 ),
18506 ],
18507 },
18508 report.truncated,
18509 report.replay_commands.clone(),
18510 )
18511 } else {
18512 println!(
18513 "{}",
18514 to_json_schema(
18515 &report,
18516 output_format.pretty,
18517 output_format.terse,
18518 output_format.ultra_terse,
18519 output_format.schema
18520 )?
18521 );
18522 Ok(())
18523 }
18524 }
18525 DispatchTraceFormat::Html => {
18526 println!("{}", dispatch_trace_html(&report)?);
18527 Ok(())
18528 }
18529 }
18530}
18531
18532#[derive(Clone, Debug)]
18533struct DependencyDagProfile {
18534 id: String,
18535 graph_node_id: String,
18536 label: String,
18537 path: Option<String>,
18538 line: Option<i64>,
18539 detail: Option<String>,
18540 source_files: BTreeSet<String>,
18541 source_symbols: BTreeSet<String>,
18542 config_files: BTreeSet<String>,
18543 expected_tests: BTreeSet<String>,
18544 semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
18545 worker_feedback: ConflictMatrixWorkerFeedback,
18546}
18547
18548#[derive(Clone, Debug, Serialize)]
18549struct DependencyDagNode {
18550 id: String,
18551 graph_node_id: String,
18552 label: String,
18553 #[serde(skip_serializing_if = "Option::is_none")]
18554 path: Option<String>,
18555 #[serde(skip_serializing_if = "Option::is_none")]
18556 line: Option<i64>,
18557 #[serde(skip_serializing_if = "Option::is_none")]
18558 detail: Option<String>,
18559 source_files: Vec<String>,
18560 source_symbols: Vec<String>,
18561 config_files: Vec<String>,
18562 expected_tests: Vec<String>,
18563 semantic_refs: Vec<ConflictMatrixSemanticRef>,
18564 worker_feedback: ConflictMatrixWorkerFeedback,
18565}
18566
18567#[derive(Clone, Debug, Serialize)]
18568struct DependencyDagEdge {
18569 from: String,
18570 to: String,
18571 kind: String,
18572 weight: usize,
18573 reasons: Vec<String>,
18574 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18575 shared_files: Vec<String>,
18576 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18577 shared_symbols: Vec<String>,
18578 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18579 shared_tests: Vec<String>,
18580 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18581 shared_config_files: Vec<String>,
18582 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18583 shared_semantic_refs: Vec<String>,
18584}
18585
18586#[derive(Clone, Debug, Serialize)]
18587struct DependencyDagTopoBatch {
18588 batch: usize,
18589 targets: Vec<String>,
18590}
18591
18592#[derive(Clone, Debug, Serialize)]
18593struct DependencyDagCycleDiagnostics {
18594 has_cycles: bool,
18595 blocked_nodes: Vec<String>,
18596 cycle_edges: Vec<DependencyDagEdge>,
18597}
18598
18599#[derive(Serialize)]
18600struct DependencyDagSummary {
18601 nodes: usize,
18602 edges: usize,
18603 topo_batches: usize,
18604 has_cycles: bool,
18605}
18606
18607#[derive(Serialize)]
18608struct DependencyDagReport {
18609 contract_version: &'static str,
18610 root: String,
18611 #[serde(skip_serializing_if = "Option::is_none")]
18612 scope: Option<String>,
18613 path: String,
18614 targets: Vec<String>,
18615 projection_freshness: GraphDbFreshnessReport,
18616 projection_hashes: Vec<String>,
18617 nodes: Vec<DependencyDagNode>,
18618 edges: Vec<DependencyDagEdge>,
18619 topo_batches: Vec<DependencyDagTopoBatch>,
18620 cycle_diagnostics: DependencyDagCycleDiagnostics,
18621 summary: DependencyDagSummary,
18622 replay_commands: Vec<String>,
18623 repair_commands: Vec<String>,
18624 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18625 warnings: Vec<String>,
18626}
18627
18628fn dependency_dag_backlog_node_for_target(
18629 store: &impl GraphStore,
18630 target: &str,
18631) -> Result<SubstrateGraphNode> {
18632 let resolved = graph_db_resolve_evidence_target(store, target)?
18633 .with_context(|| format!("dependency-dag target not found: {target}"))?;
18634 if resolved.kind == "backlog" {
18635 return Ok(resolved);
18636 }
18637 let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
18638 bail!(
18639 "dependency-dag target {} resolved to {} without a backlog ref_id",
18640 target,
18641 resolved.kind
18642 );
18643 };
18644 store
18645 .nodes_by_kind("backlog")?
18646 .into_iter()
18647 .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
18648 .min_by(|left, right| {
18649 left.properties
18650 .get("line")
18651 .and_then(|value| value.parse::<i64>().ok())
18652 .cmp(
18653 &right
18654 .properties
18655 .get("line")
18656 .and_then(|value| value.parse::<i64>().ok()),
18657 )
18658 .then(left.id.cmp(&right.id))
18659 })
18660 .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
18661}
18662
18663fn dependency_dag_resolve_backlog_nodes(
18664 root: &Path,
18665 path: &Path,
18666 store: &impl GraphStore,
18667 raw_targets: &[String],
18668) -> Result<Vec<SubstrateGraphNode>> {
18669 let mut nodes = Vec::new();
18670 let mut seen = BTreeSet::new();
18671 if raw_targets.is_empty() {
18672 let hinted_path = if path.is_absolute() {
18673 path.to_path_buf()
18674 } else {
18675 root.join(path)
18676 };
18677 let hinted_markdown = hinted_path
18678 .extension()
18679 .and_then(|ext| ext.to_str())
18680 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
18681 let hinted_rel = hinted_markdown.then(|| {
18682 relativize_pathbuf(&hinted_path, root)
18683 .to_string_lossy()
18684 .replace('\\', "/")
18685 });
18686 for node in store.nodes_by_kind("backlog")? {
18687 if let Some(expected_path) = &hinted_rel
18688 && node.properties.get("path") != Some(expected_path)
18689 {
18690 continue;
18691 }
18692 if seen.insert(node.id.clone()) {
18693 nodes.push(node);
18694 }
18695 }
18696 if nodes.is_empty() && hinted_rel.is_some() {
18697 for node in store.nodes_by_kind("backlog")? {
18698 if seen.insert(node.id.clone()) {
18699 nodes.push(node);
18700 }
18701 }
18702 }
18703 } else {
18704 for target in raw_targets {
18705 let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
18706 let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
18707 if seen.insert(node.id.clone()) {
18708 nodes.push(node);
18709 }
18710 }
18711 }
18712 if nodes.is_empty() {
18713 bail!("dependency-dag needs at least one resolvable backlog id");
18714 }
18715 nodes.sort_by(|left, right| {
18716 left.properties
18717 .get("line")
18718 .and_then(|value| value.parse::<i64>().ok())
18719 .cmp(
18720 &right
18721 .properties
18722 .get("line")
18723 .and_then(|value| value.parse::<i64>().ok()),
18724 )
18725 .then(left.id.cmp(&right.id))
18726 });
18727 Ok(nodes)
18728}
18729
18730fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
18731 node.properties
18732 .get("ref_id")
18733 .cloned()
18734 .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
18735}
18736
18737fn dependency_dag_node_profile(
18738 root: &Path,
18739 store: &impl GraphStore,
18740 node: &SubstrateGraphNode,
18741 graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
18742 graph_edges: &[SubstrateGraphEdge],
18743 depth: usize,
18744 limit: usize,
18745) -> Result<DependencyDagProfile> {
18746 let id = dependency_dag_node_id(node);
18747 let mut source_files = BTreeSet::new();
18748 let mut source_symbols = BTreeSet::new();
18749 for edge in graph_edges
18750 .iter()
18751 .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
18752 {
18753 let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
18754 continue;
18755 };
18756 match target.kind.as_str() {
18757 "file" | "route" => {
18758 if let Some(path) = target.properties.get("path") {
18759 source_files.insert(path.clone());
18760 }
18761 }
18762 "symbol" => {
18763 source_symbols.insert(target.label.clone());
18764 if let Some(path) = target.properties.get("path") {
18765 source_files.insert(path.clone());
18766 }
18767 }
18768 _ => {}
18769 }
18770 }
18771
18772 let max_rows = if limit == 0 { usize::MAX } else { limit };
18773 for (source, _) in
18774 graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
18775 {
18776 let terse: SubstrateTerseGraphNode = (&source).into();
18777 if let Some(handle) = conflict_matrix_source_handle(&terse) {
18778 source_files.insert(handle.file);
18779 }
18780 }
18781
18782 let worker_results = graph_nodes_by_id
18783 .values()
18784 .filter(|candidate| {
18785 candidate.kind == "worker_result"
18786 && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
18787 })
18788 .map(SubstrateTerseGraphNode::from)
18789 .collect::<Vec<_>>();
18790 let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
18791 let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
18792 let config_files = source_files
18793 .iter()
18794 .filter(|file| is_planner_config_path(file))
18795 .cloned()
18796 .collect();
18797
18798 let mut semantic_refs = BTreeMap::new();
18799 for kind in ["semantic_concept", "semantic_entity"] {
18800 for (semantic, _) in
18801 graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
18802 {
18803 let terse: SubstrateTerseGraphNode = (&semantic).into();
18804 let item = conflict_matrix_semantic_ref(root, &terse);
18805 semantic_refs
18806 .entry(format!("{}:{}", item.kind, item.label))
18807 .or_insert(item);
18808 }
18809 }
18810
18811 Ok(DependencyDagProfile {
18812 id,
18813 graph_node_id: node.id.clone(),
18814 label: node.label.clone(),
18815 path: node.properties.get("path").cloned(),
18816 line: node
18817 .properties
18818 .get("line")
18819 .and_then(|value| value.parse::<i64>().ok()),
18820 detail: node.properties.get("detail").cloned(),
18821 source_files,
18822 source_symbols,
18823 config_files,
18824 expected_tests,
18825 semantic_refs,
18826 worker_feedback,
18827 })
18828}
18829
18830fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
18831 let lower = text.to_ascii_lowercase();
18832 let mut refs = Vec::new();
18833 for marker in markers {
18834 let mut offset = 0usize;
18835 while let Some(pos) = lower[offset..].find(marker) {
18836 let start = offset + pos + marker.len();
18837 let segment = text[start..]
18838 .split(['\n', '.'])
18839 .next()
18840 .unwrap_or(&text[start..]);
18841 refs.extend(extract_conflict_target_refs(segment));
18842 offset = start;
18843 }
18844 }
18845 dedupe_preserve_order(refs)
18846}
18847
18848fn dependency_dag_push_edge(
18849 edges: &mut Vec<DependencyDagEdge>,
18850 seen: &mut BTreeSet<(String, String, String)>,
18851 edge: DependencyDagEdge,
18852) {
18853 if edge.from == edge.to {
18854 return;
18855 }
18856 if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
18857 edges.push(edge);
18858 }
18859}
18860
18861fn dependency_dag_explicit_edges(
18862 profiles: &[DependencyDagProfile],
18863 target_ids: &BTreeSet<String>,
18864 edges: &mut Vec<DependencyDagEdge>,
18865 seen: &mut BTreeSet<(String, String, String)>,
18866) {
18867 for profile in profiles {
18868 let detail = profile.detail.as_deref().unwrap_or_default();
18869 for dep in dependency_dag_marker_refs(
18870 detail,
18871 &[
18872 "depends on",
18873 "depends-on",
18874 "deps:",
18875 "after",
18876 "blocked by",
18877 "requires",
18878 ],
18879 ) {
18880 if target_ids.contains(&dep) {
18881 dependency_dag_push_edge(
18882 edges,
18883 seen,
18884 DependencyDagEdge {
18885 from: dep.clone(),
18886 to: profile.id.clone(),
18887 kind: "explicit_depends_on".to_string(),
18888 weight: 1000,
18889 reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
18890 shared_files: Vec::new(),
18891 shared_symbols: Vec::new(),
18892 shared_tests: Vec::new(),
18893 shared_config_files: Vec::new(),
18894 shared_semantic_refs: Vec::new(),
18895 },
18896 );
18897 }
18898 }
18899 for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
18900 if target_ids.contains(&downstream) {
18901 dependency_dag_push_edge(
18902 edges,
18903 seen,
18904 DependencyDagEdge {
18905 from: profile.id.clone(),
18906 to: downstream.clone(),
18907 kind: "explicit_before".to_string(),
18908 weight: 900,
18909 reasons: vec![format!(
18910 "{} declares it should run before #{downstream}",
18911 profile.id
18912 )],
18913 shared_files: Vec::new(),
18914 shared_symbols: Vec::new(),
18915 shared_tests: Vec::new(),
18916 shared_config_files: Vec::new(),
18917 shared_semantic_refs: Vec::new(),
18918 },
18919 );
18920 }
18921 }
18922 }
18923}
18924
18925fn dependency_dag_worker_follow_up_edges(
18926 profiles: &[DependencyDagProfile],
18927 target_ids: &BTreeSet<String>,
18928 edges: &mut Vec<DependencyDagEdge>,
18929 seen: &mut BTreeSet<(String, String, String)>,
18930) {
18931 for profile in profiles {
18932 for follow_up in &profile.worker_feedback.follow_up_ids {
18933 if target_ids.contains(follow_up) {
18934 dependency_dag_push_edge(
18935 edges,
18936 seen,
18937 DependencyDagEdge {
18938 from: profile.id.clone(),
18939 to: follow_up.clone(),
18940 kind: "worker_result_follow_up".to_string(),
18941 weight: 700,
18942 reasons: vec![format!(
18943 "worker_result for #{} references follow-up #{}",
18944 profile.id, follow_up
18945 )],
18946 shared_files: Vec::new(),
18947 shared_symbols: Vec::new(),
18948 shared_tests: Vec::new(),
18949 shared_config_files: Vec::new(),
18950 shared_semantic_refs: Vec::new(),
18951 },
18952 );
18953 }
18954 }
18955 }
18956}
18957
18958fn dependency_dag_overlap_edges(
18959 profiles: &[DependencyDagProfile],
18960 edges: &mut Vec<DependencyDagEdge>,
18961 seen: &mut BTreeSet<(String, String, String)>,
18962) {
18963 for left_idx in 0..profiles.len() {
18964 for right_idx in (left_idx + 1)..profiles.len() {
18965 let left = &profiles[left_idx];
18966 let right = &profiles[right_idx];
18967 let shared_files = sorted_intersection(&left.source_files, &right.source_files);
18968 let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
18969 let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
18970 let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
18971 let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
18972 let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
18973 let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
18974 if shared_files.is_empty()
18975 && shared_symbols.is_empty()
18976 && shared_tests.is_empty()
18977 && shared_config_files.is_empty()
18978 && shared_semantic_refs.is_empty()
18979 {
18980 continue;
18981 }
18982 let kind = if shared_files.is_empty()
18983 && shared_symbols.is_empty()
18984 && shared_tests.is_empty()
18985 && shared_config_files.is_empty()
18986 {
18987 "semantic_relation"
18988 } else {
18989 "shared_resource"
18990 };
18991 let mut reasons = Vec::new();
18992 if !shared_files.is_empty() {
18993 reasons.push(format!("shared files: {}", shared_files.join(", ")));
18994 }
18995 if !shared_symbols.is_empty() {
18996 reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
18997 }
18998 if !shared_tests.is_empty() {
18999 reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19000 }
19001 if !shared_config_files.is_empty() {
19002 reasons.push(format!(
19003 "shared config files: {}",
19004 shared_config_files.join(", ")
19005 ));
19006 }
19007 if !shared_semantic_refs.is_empty() {
19008 reasons.push(format!(
19009 "shared semantic refs: {}",
19010 shared_semantic_refs.join(", ")
19011 ));
19012 }
19013 let weight = shared_files.len() * 100
19014 + shared_config_files.len() * 100
19015 + shared_symbols.len() * 40
19016 + shared_tests.len() * 10
19017 + shared_semantic_refs.len() * 5;
19018 dependency_dag_push_edge(
19019 edges,
19020 seen,
19021 DependencyDagEdge {
19022 from: left.id.clone(),
19023 to: right.id.clone(),
19024 kind: kind.to_string(),
19025 weight,
19026 reasons,
19027 shared_files,
19028 shared_symbols,
19029 shared_tests,
19030 shared_config_files,
19031 shared_semantic_refs,
19032 },
19033 );
19034 }
19035 }
19036}
19037
19038fn dependency_dag_topo_batches(
19039 targets: &[String],
19040 edges: &[DependencyDagEdge],
19041) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19042 let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19043 let order = targets
19044 .iter()
19045 .enumerate()
19046 .map(|(idx, id)| (id.clone(), idx))
19047 .collect::<BTreeMap<_, _>>();
19048 let mut indegree = targets
19049 .iter()
19050 .map(|id| (id.clone(), 0usize))
19051 .collect::<BTreeMap<_, _>>();
19052 let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19053 let mut seen_pairs = BTreeSet::<(String, String)>::new();
19054 for edge in edges {
19055 if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19056 continue;
19057 }
19058 if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19059 continue;
19060 }
19061 *indegree.entry(edge.to.clone()).or_default() += 1;
19062 outgoing
19063 .entry(edge.from.clone())
19064 .or_default()
19065 .push(edge.to.clone());
19066 }
19067 for values in outgoing.values_mut() {
19068 values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19069 values.dedup();
19070 }
19071
19072 let mut processed = BTreeSet::new();
19073 let mut batches = Vec::new();
19074 loop {
19075 let mut ready = targets
19076 .iter()
19077 .filter(|id| !processed.contains(*id))
19078 .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19079 .cloned()
19080 .collect::<Vec<_>>();
19081 ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19082 if ready.is_empty() {
19083 break;
19084 }
19085 for id in &ready {
19086 processed.insert(id.clone());
19087 for next in outgoing.get(id).into_iter().flatten() {
19088 if let Some(value) = indegree.get_mut(next) {
19089 *value = value.saturating_sub(1);
19090 }
19091 }
19092 }
19093 batches.push(DependencyDagTopoBatch {
19094 batch: batches.len() + 1,
19095 targets: ready,
19096 });
19097 }
19098
19099 let blocked_nodes = targets
19100 .iter()
19101 .filter(|id| !processed.contains(*id))
19102 .cloned()
19103 .collect::<Vec<_>>();
19104 let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19105 let cycle_edges = edges
19106 .iter()
19107 .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19108 .cloned()
19109 .collect::<Vec<_>>();
19110 (
19111 batches,
19112 DependencyDagCycleDiagnostics {
19113 has_cycles: !blocked_nodes.is_empty(),
19114 blocked_nodes,
19115 cycle_edges,
19116 },
19117 )
19118}
19119
19120fn dependency_dag_replay_commands(
19121 path: &Path,
19122 scope: Option<&str>,
19123 targets: &[String],
19124 depth: usize,
19125 limit: usize,
19126) -> Vec<String> {
19127 let target_args = targets
19128 .iter()
19129 .map(|target| shell_quote(target))
19130 .collect::<Vec<_>>()
19131 .join(" ");
19132 let mut command = format!(
19133 "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19134 shell_quote(path.to_string_lossy().as_ref()),
19135 scope
19136 .map(|scope| format!(" --scope {}", shell_quote(scope)))
19137 .unwrap_or_default(),
19138 depth,
19139 limit
19140 );
19141 if !target_args.is_empty() {
19142 command.push(' ');
19143 command.push_str(&target_args);
19144 }
19145 vec![command]
19146}
19147
19148fn build_dependency_dag_report(
19149 path: &Path,
19150 scope: Option<&str>,
19151 raw_targets: &[String],
19152 depth: usize,
19153 limit: usize,
19154) -> Result<DependencyDagReport> {
19155 let root = lint::resolve_project_root_or_canonical_path(path)?;
19156 write_traversal_graph_store(&root, path, scope)
19157 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19158 let graph_db = graph_substrate_db_path(&root, scope);
19159 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19160 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19161 let mut warnings = Vec::new();
19162 if let Some(recovery) = store.read_only_recovery() {
19163 warnings.push(graph_db_read_recovery_diagnostic(recovery));
19164 }
19165 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19166 if freshness.fail_closed {
19167 bail!(
19168 "dependency-dag graph projection failed closed: {}; repair: {}",
19169 freshness.diagnostics.join("; "),
19170 graph_db_repair_commands(&root, scope).join("; ")
19171 );
19172 }
19173
19174 let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19175 let graph_nodes = store.all_nodes()?;
19176 let graph_edges = store.all_edges()?;
19177 let graph_nodes_by_id = graph_nodes
19178 .into_iter()
19179 .map(|node| (node.id.clone(), node))
19180 .collect::<BTreeMap<_, _>>();
19181 let profiles = target_nodes
19182 .iter()
19183 .map(|node| {
19184 dependency_dag_node_profile(
19185 &root,
19186 &store,
19187 node,
19188 &graph_nodes_by_id,
19189 &graph_edges,
19190 depth,
19191 limit,
19192 )
19193 })
19194 .collect::<Result<Vec<_>>>()?;
19195 let targets = profiles
19196 .iter()
19197 .map(|profile| profile.id.clone())
19198 .collect::<Vec<_>>();
19199 let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19200
19201 let mut edges = Vec::new();
19202 let mut seen_edges = BTreeSet::new();
19203 dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19204 dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19205 dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19206 edges.sort_by(|left, right| {
19207 left.from
19208 .cmp(&right.from)
19209 .then(left.to.cmp(&right.to))
19210 .then(left.kind.cmp(&right.kind))
19211 });
19212 let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19213
19214 let nodes = profiles
19215 .into_iter()
19216 .map(|profile| DependencyDagNode {
19217 id: profile.id,
19218 graph_node_id: profile.graph_node_id,
19219 label: profile.label,
19220 path: profile.path,
19221 line: profile.line,
19222 detail: profile.detail,
19223 source_files: sorted_set(&profile.source_files),
19224 source_symbols: sorted_set(&profile.source_symbols),
19225 config_files: sorted_set(&profile.config_files),
19226 expected_tests: sorted_set(&profile.expected_tests),
19227 semantic_refs: profile.semantic_refs.into_values().collect(),
19228 worker_feedback: profile.worker_feedback,
19229 })
19230 .collect::<Vec<_>>();
19231 let projection_hashes = freshness
19232 .content_hash
19233 .clone()
19234 .into_iter()
19235 .collect::<Vec<_>>();
19236 let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19237 let repair_commands = graph_db_repair_commands(&root, scope);
19238 let summary = DependencyDagSummary {
19239 nodes: nodes.len(),
19240 edges: edges.len(),
19241 topo_batches: topo_batches.len(),
19242 has_cycles: cycle_diagnostics.has_cycles,
19243 };
19244
19245 Ok(DependencyDagReport {
19246 contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19247 root: root.to_string_lossy().to_string(),
19248 scope: scope.map(str::to_string),
19249 path: path.to_string_lossy().to_string(),
19250 targets,
19251 projection_freshness: freshness,
19252 projection_hashes,
19253 nodes,
19254 edges,
19255 topo_batches,
19256 cycle_diagnostics,
19257 summary,
19258 replay_commands,
19259 repair_commands,
19260 warnings,
19261 })
19262}
19263
19264fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
19265 if compact {
19266 println!(
19267 "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
19268 report.targets.len(),
19269 report.edges.len(),
19270 report.topo_batches.len(),
19271 report.cycle_diagnostics.has_cycles
19272 );
19273 } else {
19274 println!("Dependency DAG");
19275 println!(" targets: {}", report.targets.join(", "));
19276 println!(" edges: {}", report.edges.len());
19277 println!(" cycles: {}", report.cycle_diagnostics.has_cycles);
19278 }
19279 for batch in &report.topo_batches {
19280 println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
19281 }
19282 for edge in &report.edges {
19283 println!(
19284 "edge {} -> {} kind:{} weight:{}",
19285 edge.from, edge.to, edge.kind, edge.weight
19286 );
19287 for reason in &edge.reasons {
19288 println!(" reason: {reason}");
19289 }
19290 }
19291 if report.cycle_diagnostics.has_cycles {
19292 println!(
19293 "cycle blocked nodes: {}",
19294 report.cycle_diagnostics.blocked_nodes.join(", ")
19295 );
19296 }
19297 for command in &report.replay_commands {
19298 println!("replay: {command}");
19299 }
19300 for command in &report.repair_commands {
19301 println!("repair: {command}");
19302 }
19303 for warning in &report.warnings {
19304 println!("warning: {warning}");
19305 }
19306}
19307
19308fn cmd_dependency_dag(
19309 path: &Path,
19310 scope: Option<&str>,
19311 raw_targets: &[String],
19312 depth: usize,
19313 limit: usize,
19314 format: OutputFormat,
19315) -> Result<()> {
19316 let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
19317 if format.json_output {
19318 print_json_or_envelope(
19319 &report,
19320 &format,
19321 "dependency-dag",
19322 "topological-planning",
19323 ToolEnvelopeSummary {
19324 text: format!(
19325 "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
19326 report.targets.len(),
19327 report.edges.len(),
19328 report.topo_batches.len(),
19329 report.cycle_diagnostics.has_cycles
19330 ),
19331 metrics: vec![
19332 envelope_metric("targets", report.targets.len()),
19333 envelope_metric("edges", report.edges.len()),
19334 envelope_metric("topo_batches", report.topo_batches.len()),
19335 envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
19336 ],
19337 },
19338 report.cycle_diagnostics.has_cycles,
19339 report.replay_commands.clone(),
19340 )
19341 } else {
19342 print_dependency_dag_human(&report, format.compact);
19343 Ok(())
19344 }
19345}
19346
19347fn maybe_attach_log_digest_raw_artifact(
19352 root: &Path,
19353 report: &mut log_digest::LogDigestReport,
19354 input: &str,
19355) -> Result<()> {
19356 if input.trim().is_empty() || !log_digest::raw_log_artifact_recommended(report, input.len()) {
19357 return Ok(());
19358 }
19359 let key = format!("logdigest:{}:{}", report.total_lines, input.len());
19360 let artifact_path = root
19361 .join(".tsift/artifacts")
19362 .join(format!("{}.log", stable_handle("logdg", &key)));
19363 let expand = format!(
19364 "tsift log-digest --path {} --input {} --json",
19365 shell_quote(root.to_string_lossy().as_ref()),
19366 shell_quote(artifact_path.to_string_lossy().as_ref())
19367 );
19368 let artifact = persist_transcript_artifact(root, "logdg", "log", &key, input, expand)?;
19369 report.raw_log_artifact = Some(log_digest::LogDigestArtifactRef {
19370 handle: artifact.handle,
19371 path: artifact.path,
19372 bytes: artifact.bytes,
19373 lines: artifact.lines,
19374 expand: artifact.expand,
19375 });
19376 Ok(())
19377}
19378
19379pub(crate) fn render_log_digest_fixture(
19383 path: &Path,
19384 fixture_path: &Path,
19385 fail_under: bool,
19386 format: OutputFormat,
19387) -> Result<()> {
19388 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
19389 let fixture_body = fs::read_to_string(fixture_path)
19390 .with_context(|| format!("reading log-digest fixture: {}", fixture_path.display()))?;
19391 let fixture: log_digest::LogDigestFixture = serde_json::from_str(&fixture_body)
19392 .with_context(|| format!("parsing log-digest fixture: {}", fixture_path.display()))?;
19393 let report = log_digest::evaluate_fixture(&root, &fixture)?;
19394
19395 if format.json_output {
19396 print_json_or_envelope(
19397 &report,
19398 &format,
19399 "log-digest-fixture",
19400 "report",
19401 ToolEnvelopeSummary {
19402 text: if report.passed {
19403 format!("log-digest gate passed for {} case(s)", report.total_cases)
19404 } else {
19405 format!("log-digest gate failed {} case(s)", report.failed_cases)
19406 },
19407 metrics: vec![
19408 envelope_metric("cases", report.total_cases),
19409 envelope_metric("failed", report.failed_cases),
19410 envelope_metric("passed", report.passed),
19411 ],
19412 },
19413 false,
19414 vec![],
19415 )?;
19416 } else {
19417 println!("Log digest fixture gate");
19418 println!(" cases: {}", report.total_cases);
19419 println!(" failed: {}", report.failed_cases);
19420 println!(" status: {}", if report.passed { "pass" } else { "fail" });
19421 for case in &report.cases {
19422 println!(
19423 " [{}] {} ({}): savings {:.1}% (min {:.1}%) raw_tok {} digest_tok {}",
19424 if case.passed { "pass" } else { "FAIL" },
19425 case.name,
19426 case.ecosystem,
19427 case.savings_percent,
19428 case.minimum_savings_percent,
19429 case.raw_tokens,
19430 case.digest_tokens
19431 );
19432 if !case.missing_required_signals.is_empty() {
19433 println!(
19434 " missing required signals: {}",
19435 case.missing_required_signals.join(", ")
19436 );
19437 }
19438 if !case.present_forbidden_signals.is_empty() {
19439 println!(
19440 " present forbidden signals: {}",
19441 case.present_forbidden_signals.join(", ")
19442 );
19443 }
19444 }
19445 }
19446
19447 if fail_under && !report.passed {
19448 bail!("log-digest fixture gate failed");
19449 }
19450 Ok(())
19451}
19452
19453pub(crate) fn render_log_digest_from_input(
19454 path: &Path,
19455 input: &str,
19456 format: OutputFormat,
19457) -> Result<()> {
19458 let mut report = log_digest::compute(path, input)?;
19459 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
19460 maybe_attach_log_digest_raw_artifact(&root, &mut report, input)?;
19461 if format.json_output {
19462 println!(
19463 "{}",
19464 to_json_schema(
19465 &report,
19466 format.pretty,
19467 format.terse,
19468 format.ultra_terse,
19469 format.schema
19470 )?
19471 );
19472 return Ok(());
19473 }
19474
19475 if format.compact {
19476 println!(
19477 "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
19478 report.non_empty_lines,
19479 report.signal_groups,
19480 report.repeated_line_groups,
19481 report.file_ref_groups,
19482 report.symbol_ref_groups,
19483 report.stack_groups
19484 );
19485 for signal in &report.signals {
19486 let location = match (&signal.path, signal.line) {
19487 (Some(path), Some(line)) => format!("{path}:{line}"),
19488 (Some(path), None) => path.clone(),
19489 _ => "-".to_string(),
19490 };
19491 println!(
19492 "{} sev:{} count:{} sums:{} msg:{}",
19493 location,
19494 signal.severity,
19495 signal.occurrences,
19496 log_digest_summary_label(signal.summary_state),
19497 truncate_for_compact(&signal.message, 80)
19498 );
19499 }
19500 for repeated in &report.repeated_lines {
19501 println!(
19502 "repeat count:{} line:{}",
19503 repeated.occurrences,
19504 truncate_for_compact(&repeated.line, 80)
19505 );
19506 }
19507 for family in &report.line_families {
19508 println!(
19509 "family count:{} variants:{} template:{}",
19510 family.occurrences,
19511 family.variants,
19512 truncate_for_compact(&family.template, 80)
19513 );
19514 }
19515 for symbol in &report.symbol_refs {
19516 println!(
19517 "sym:{} count:{} sums:{}",
19518 symbol.symbol,
19519 symbol.occurrences,
19520 log_digest_summary_label(symbol.summary_state)
19521 );
19522 }
19523 if let Some(artifact) = &report.raw_log_artifact {
19524 println!(
19525 "raw-artifact handle:{} lines:{} bytes:{} expand:{}",
19526 artifact.handle, artifact.lines, artifact.bytes, artifact.expand
19527 );
19528 }
19529 for warning in &report.warnings {
19530 println!("warning: {warning}");
19531 }
19532 return Ok(());
19533 }
19534
19535 println!("Log digest");
19536 println!(" lines: {}", report.total_lines);
19537 println!(" non-empty lines: {}", report.non_empty_lines);
19538 println!(" signal groups: {}", report.signal_groups);
19539 println!(
19540 " repeated lines: {}",
19541 report.repeated_line_groups
19542 );
19543 println!(
19544 " repeated line instances: {}",
19545 report.repeated_line_occurrences
19546 );
19547 println!(
19548 " line families: {}",
19549 report.line_family_groups
19550 );
19551 println!(" file refs: {}", report.file_ref_groups);
19552 println!(" symbol refs: {}", report.symbol_ref_groups);
19553 println!(" stack groups: {}", report.stack_groups);
19554
19555 if !report.signals.is_empty() {
19556 println!();
19557 println!("Signals:");
19558 for signal in &report.signals {
19559 match (&signal.path, signal.line, signal.column) {
19560 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
19561 (Some(path), Some(line), None) => println!("{path}:{line}"),
19562 (Some(path), None, _) => println!("{path}"),
19563 (None, _, _) => println!("(no file anchor)"),
19564 }
19565 println!(" severity: {}", signal.severity);
19566 println!(" occurrences: {}", signal.occurrences);
19567 println!(" message: {}", signal.message);
19568 println!(
19569 " cached summaries: {}",
19570 log_digest_summary_label(signal.summary_state)
19571 );
19572 for summary in &signal.current_summaries {
19573 println!(
19574 " - {}: {}",
19575 summary.symbol,
19576 truncate_for_compact(&summary.summary, 160)
19577 );
19578 }
19579 }
19580 }
19581
19582 if !report.repeated_lines.is_empty() {
19583 println!();
19584 println!("Repeated lines:");
19585 for repeated in &report.repeated_lines {
19586 println!(
19587 " {}x {}",
19588 repeated.occurrences,
19589 truncate_for_compact(&repeated.line, 180)
19590 );
19591 }
19592 }
19593
19594 if !report.line_families.is_empty() {
19595 println!();
19596 println!("Line families (near-duplicate folds):");
19597 for family in &report.line_families {
19598 println!(
19599 " {}x ({} variants) {}",
19600 family.occurrences,
19601 family.variants,
19602 truncate_for_compact(&family.template, 180)
19603 );
19604 println!(
19605 " first: {}",
19606 truncate_for_compact(&family.first_sample, 180)
19607 );
19608 println!(
19609 " last: {}",
19610 truncate_for_compact(&family.last_sample, 180)
19611 );
19612 }
19613 }
19614
19615 if !report.file_refs.is_empty() {
19616 println!();
19617 println!("Anchored files:");
19618 for file_ref in &report.file_refs {
19619 match (file_ref.line, file_ref.column) {
19620 (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
19621 (Some(line), None) => println!("{}:{}", file_ref.path, line),
19622 (None, _) => println!("{}", file_ref.path),
19623 }
19624 println!(" occurrences: {}", file_ref.occurrences);
19625 println!(
19626 " cached summaries: {}",
19627 log_digest_summary_label(file_ref.summary_state)
19628 );
19629 for summary in &file_ref.current_summaries {
19630 println!(
19631 " - {}: {}",
19632 summary.symbol,
19633 truncate_for_compact(&summary.summary, 160)
19634 );
19635 }
19636 }
19637 }
19638
19639 if !report.symbol_refs.is_empty() {
19640 println!();
19641 println!("Symbol candidates:");
19642 for symbol in &report.symbol_refs {
19643 println!("{}", symbol.symbol);
19644 println!(" occurrences: {}", symbol.occurrences);
19645 println!(
19646 " cached summaries: {}",
19647 log_digest_summary_label(symbol.summary_state)
19648 );
19649 for summary in &symbol.current_summaries {
19650 println!(
19651 " - {}: {}",
19652 summary.symbol,
19653 truncate_for_compact(&summary.summary, 160)
19654 );
19655 }
19656 }
19657 }
19658
19659 if !report.stack_traces.is_empty() {
19660 println!();
19661 println!("Stack groups:");
19662 for stack in &report.stack_traces {
19663 println!(" occurrences: {}", stack.occurrences);
19664 for frame in &stack.frames {
19665 println!(" - {}", frame);
19666 }
19667 }
19668 }
19669
19670 if let Some(artifact) = &report.raw_log_artifact {
19671 println!();
19672 println!("Raw log artifact:");
19673 println!(" handle: {}", artifact.handle);
19674 println!(" path: {}", artifact.path);
19675 println!(" lines: {}", artifact.lines);
19676 println!(" bytes: {}", artifact.bytes);
19677 println!(" expand: {}", artifact.expand);
19678 }
19679
19680 for warning in &report.warnings {
19681 println!("warning: {warning}");
19682 }
19683 Ok(())
19684}
19685
19686pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
19687 match trend {
19688 metric_digest::MetricDigestTrend::Improved => "improved",
19689 metric_digest::MetricDigestTrend::Regressed => "regressed",
19690 metric_digest::MetricDigestTrend::Flat => "flat",
19691 metric_digest::MetricDigestTrend::Unknown => "changed",
19692 }
19693}
19694
19695pub(crate) fn metric_digest_gate_label(
19696 decision: metric_digest::CommunitySearchGateDecision,
19697) -> &'static str {
19698 match decision {
19699 metric_digest::CommunitySearchGateDecision::Pass => "pass",
19700 metric_digest::CommunitySearchGateDecision::Block => "block",
19701 }
19702}
19703
19704pub(crate) fn memgraphrag_metric_digest_gate_label(
19705 decision: metric_digest::MemGraphRagPerformanceGateDecision,
19706) -> &'static str {
19707 match decision {
19708 metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
19709 metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
19710 }
19711}
19712
19713fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
19714 let input = fs::read_to_string(fixture_path)
19715 .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
19716 let report = dci_benchmark::compute(&input)?;
19717
19718 if format.json_output {
19719 println!(
19720 "{}",
19721 to_json_schema(
19722 &report,
19723 format.pretty,
19724 format.terse,
19725 format.ultra_terse,
19726 format.schema
19727 )?
19728 );
19729 return Ok(());
19730 }
19731
19732 if format.compact {
19733 println!(
19734 "dci tasks:{} strategies:{} warnings:{}",
19735 report.tasks_loaded,
19736 report.strategies_compared,
19737 report.warnings.len()
19738 );
19739 for summary in &report.strategy_summaries {
19740 println!(
19741 "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
19742 summary.strategy,
19743 summary.rank,
19744 summary.localized,
19745 summary.task_runs,
19746 dci_benchmark::format_number(summary.localization_rate * 100.0),
19747 dci_benchmark::format_number(summary.avg_useful_hits),
19748 dci_benchmark::format_number(summary.zero_output_rate * 100.0),
19749 dci_benchmark::format_number(summary.avg_tool_calls),
19750 dci_benchmark::format_number(summary.avg_latency_ms),
19751 dci_benchmark::format_number(summary.avg_estimated_tokens),
19752 dci_benchmark::format_number(summary.avg_output_tokens)
19753 );
19754 }
19755 if let Some(gate) = &report.memory_retrieval_gate {
19756 println!(
19757 "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
19758 gate.decision,
19759 gate.baseline_strategy,
19760 dci_benchmark::format_number(gate.min_avg_useful_hits),
19761 gate.max_zero_output_failures,
19762 gate.diagnostics.len()
19763 );
19764 }
19765 for warning in &report.warnings {
19766 println!("warning: {warning}");
19767 }
19768 return Ok(());
19769 }
19770
19771 println!("DCI benchmark");
19772 if let Some(description) = &report.description {
19773 println!(" description: {}", description);
19774 }
19775 println!(" tasks loaded: {}", report.tasks_loaded);
19776 println!(" strategies compared: {}", report.strategies_compared);
19777
19778 println!();
19779 println!("Strategy summary:");
19780 for summary in &report.strategy_summaries {
19781 println!(
19782 " #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
19783 summary.rank,
19784 summary.strategy,
19785 summary.localized,
19786 summary.task_runs,
19787 summary.localization_rate * 100.0,
19788 dci_benchmark::format_number(summary.avg_useful_hits),
19789 summary.zero_output_rate * 100.0,
19790 dci_benchmark::format_number(summary.avg_tool_calls),
19791 dci_benchmark::format_number(summary.avg_latency_ms),
19792 dci_benchmark::format_number(summary.avg_estimated_tokens),
19793 dci_benchmark::format_number(summary.avg_output_tokens)
19794 );
19795 }
19796
19797 if let Some(gate) = &report.memory_retrieval_gate {
19798 println!();
19799 println!("Memory retrieval gate:");
19800 println!(" decision: {}", gate.decision);
19801 println!(
19802 " baseline: {}, min avg useful hits {}, max zero-output failures {}",
19803 gate.baseline_strategy,
19804 dci_benchmark::format_number(gate.min_avg_useful_hits),
19805 gate.max_zero_output_failures
19806 );
19807 for row in &gate.rows {
19808 println!(
19809 " {}: status {}, avg useful hits {}, zero-output failures {}",
19810 row.strategy,
19811 row.status,
19812 dci_benchmark::format_number(row.avg_useful_hits),
19813 row.zero_output_failures
19814 );
19815 }
19816 for diagnostic in &gate.diagnostics {
19817 println!(" diagnostic: {diagnostic}");
19818 }
19819 }
19820
19821 println!();
19822 println!("Task winners:");
19823 for row in &report.task_rows {
19824 let label = row
19825 .label
19826 .as_ref()
19827 .map(|value| format!(" ({value})"))
19828 .unwrap_or_default();
19829 println!(" {}{}", row.task_id, label);
19830 println!(" localized: {}", row.best_localization.join(", "));
19831 println!(" most useful hits: {}", row.most_useful_hits.join(", "));
19832 println!(
19833 " lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
19834 row.lowest_tool_calls.as_deref().unwrap_or("-"),
19835 row.lowest_latency.as_deref().unwrap_or("-"),
19836 row.lowest_token_budget.as_deref().unwrap_or("-"),
19837 row.lowest_output_tokens.as_deref().unwrap_or("-")
19838 );
19839 if !row.zero_output_failures.is_empty() {
19840 println!(" zero output: {}", row.zero_output_failures.join(", "));
19841 }
19842 }
19843
19844 for warning in &report.warnings {
19845 println!("warning: {warning}");
19846 }
19847 Ok(())
19848}
19849
19850pub(crate) fn format_compact_count(value: u64) -> String {
19851 if value >= 1_000_000 {
19852 format!("{:.1}M", value as f64 / 1_000_000.0)
19853 } else if value >= 1_000 {
19854 format!("{:.1}K", value as f64 / 1_000.0)
19855 } else {
19856 value.to_string()
19857 }
19858}
19859
19860fn cmd_digest_runner(
19861 kind: &str,
19862 path: &Path,
19863 runner: Option<&str>,
19864 shell_command: &str,
19865 format: OutputFormat,
19866) -> Result<()> {
19867 let digest_kind = DigestRunnerKind::parse(kind)?;
19868 let root = transcript_artifact_root(path)?;
19869 let execution = run_digest_runner_command(shell_command)?;
19870 let output = &execution.output;
19871 let captured = String::from_utf8_lossy(&output.stdout).into_owned();
19872 let exit_code = output.status.code().unwrap_or(-1);
19873 if format.json_output && format.envelope {
19874 let artifact_key = format!(
19875 "{}:{}:{}:{}",
19876 digest_kind.as_str(),
19877 shell_command,
19878 execution.executed_command,
19879 captured
19880 );
19881 let artifact = if captured.trim().is_empty() {
19882 None
19883 } else {
19884 let (suffix, expand) = match digest_kind {
19885 DigestRunnerKind::Test => (
19886 "test.log",
19887 format!(
19888 "tsift test-digest --path {} --input {}{} --json",
19889 shell_quote(root.to_string_lossy().as_ref()),
19890 shell_quote(
19891 root.join(".tsift/artifacts")
19892 .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
19893 .to_string_lossy()
19894 .as_ref()
19895 ),
19896 runner
19897 .map(|value| format!(" --runner {}", shell_quote(value)))
19898 .unwrap_or_default()
19899 ),
19900 ),
19901 DigestRunnerKind::Log => (
19902 "log",
19903 format!(
19904 "tsift log-digest --path {} --input {} --json",
19905 shell_quote(root.to_string_lossy().as_ref()),
19906 shell_quote(
19907 root.join(".tsift/artifacts")
19908 .join(format!("{}.log", stable_handle("tart", &artifact_key)))
19909 .to_string_lossy()
19910 .as_ref()
19911 )
19912 ),
19913 ),
19914 };
19915 Some(persist_transcript_artifact(
19916 &root,
19917 "tart",
19918 suffix,
19919 &artifact_key,
19920 &captured,
19921 expand,
19922 )?)
19923 };
19924 let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
19925
19926 match digest_kind {
19927 DigestRunnerKind::Test => {
19928 let digest_report = test_digest::compute(path, &captured, runner)?;
19929 let report = serde_json::json!({
19930 "kind": digest_kind.as_str(),
19931 "command": shell_command,
19932 "executed_command": execution.executed_command,
19933 "exit_code": exit_code,
19934 "success": output.status.success(),
19935 "filter": filter_report,
19936 "artifact": artifact,
19937 "digest": digest_report,
19938 });
19939 let mut follow_up = artifact
19940 .as_ref()
19941 .map(|entry| vec![entry.expand.clone()])
19942 .unwrap_or_default();
19943 follow_up.push(format!(
19944 "tsift rewrite --run {}",
19945 shell_quote(shell_command)
19946 ));
19947 let summary_text = if output.status.success() && digest_report.failures == 0 {
19948 format!("test run passed for {}", runner.unwrap_or("auto"))
19949 } else {
19950 format!("test run captured {} failure(s)", digest_report.failures)
19951 };
19952 print_json_or_envelope(
19953 &report,
19954 &format,
19955 "digest-runner",
19956 "test-run",
19957 ToolEnvelopeSummary {
19958 text: summary_text,
19959 metrics: vec![
19960 envelope_metric("runner", &digest_report.runner),
19961 envelope_metric("exit_code", exit_code),
19962 envelope_metric("filter", execution.filter_label()),
19963 envelope_metric("failures", digest_report.failures),
19964 envelope_metric("groups", digest_report.grouped_failures),
19965 envelope_metric(
19966 "artifact",
19967 artifact
19968 .as_ref()
19969 .map(|entry| entry.handle.as_str())
19970 .unwrap_or("-"),
19971 ),
19972 ],
19973 },
19974 false,
19975 follow_up,
19976 )?;
19977 }
19978 DigestRunnerKind::Log => {
19979 let digest_report = log_digest::compute(path, &captured)?;
19980 let report = serde_json::json!({
19981 "kind": digest_kind.as_str(),
19982 "command": shell_command,
19983 "executed_command": execution.executed_command,
19984 "exit_code": exit_code,
19985 "success": output.status.success(),
19986 "filter": filter_report,
19987 "artifact": artifact,
19988 "digest": digest_report,
19989 });
19990 let mut follow_up = artifact
19991 .as_ref()
19992 .map(|entry| vec![entry.expand.clone()])
19993 .unwrap_or_default();
19994 follow_up.push(format!(
19995 "tsift rewrite --run {}",
19996 shell_quote(shell_command)
19997 ));
19998 let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
19999 "command finished without log signals".to_string()
20000 } else {
20001 format!(
20002 "command emitted {} log signal group(s)",
20003 digest_report.signal_groups
20004 )
20005 };
20006 print_json_or_envelope(
20007 &report,
20008 &format,
20009 "digest-runner",
20010 "command-run",
20011 ToolEnvelopeSummary {
20012 text: summary_text,
20013 metrics: vec![
20014 envelope_metric("exit_code", exit_code),
20015 envelope_metric("filter", execution.filter_label()),
20016 envelope_metric("signals", digest_report.signal_groups),
20017 envelope_metric("file_refs", digest_report.file_ref_groups),
20018 envelope_metric(
20019 "artifact",
20020 artifact
20021 .as_ref()
20022 .map(|entry| entry.handle.as_str())
20023 .unwrap_or("-"),
20024 ),
20025 ],
20026 },
20027 false,
20028 follow_up,
20029 )?;
20030 }
20031 }
20032
20033 if output.status.success() {
20034 return Ok(());
20035 }
20036 if let Some(code) = output.status.code() {
20037 std::process::exit(code);
20038 }
20039 bail!("digest-wrapped command terminated by signal: {shell_command}");
20040 }
20041
20042 if captured.trim().is_empty() {
20043 let label = match digest_kind {
20044 DigestRunnerKind::Test => "test",
20045 DigestRunnerKind::Log => "log",
20046 };
20047 println!("No {label} output captured.");
20048 } else {
20049 match digest_kind {
20050 DigestRunnerKind::Test => {
20051 render_test_digest_from_input(path, &captured, runner, format)?
20052 }
20053 DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20054 }
20055 }
20056
20057 if output.status.success() {
20058 return Ok(());
20059 }
20060 if let Some(code) = output.status.code() {
20061 std::process::exit(code);
20062 }
20063 bail!("digest-wrapped command terminated by signal: {shell_command}");
20064}
20065
20066struct DigestRunnerExecution {
20067 output: std::process::Output,
20068 executed_command: String,
20069 filter: Option<DigestRunnerFilter>,
20070}
20071
20072impl DigestRunnerExecution {
20073 fn filter_label(&self) -> &'static str {
20074 self.filter
20075 .as_ref()
20076 .map(|filter| filter.tool)
20077 .unwrap_or("none")
20078 }
20079}
20080
20081struct DigestRunnerFilter {
20082 tool: &'static str,
20083 command: String,
20084}
20085
20086impl DigestRunnerFilter {
20087 fn to_json(&self) -> serde_json::Value {
20088 serde_json::json!({
20089 "tool": self.tool,
20090 "command": self.command,
20091 })
20092 }
20093}
20094
20095fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20096 let filter = rtk_rewrite_for_digest_runner(shell_command);
20097 let executed_command = filter
20098 .as_ref()
20099 .map(|filter| filter.command.as_str())
20100 .unwrap_or(shell_command);
20101 let output = Command::new("sh")
20102 .arg("-lc")
20103 .arg(format!("({executed_command}) 2>&1"))
20104 .stdout(Stdio::piped())
20105 .output()
20106 .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20107
20108 Ok(DigestRunnerExecution {
20109 output,
20110 executed_command: executed_command.to_string(),
20111 filter,
20112 })
20113}
20114
20115fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20116 if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20117 return None;
20118 }
20119 let output = Command::new("rtk")
20120 .arg("rewrite")
20121 .arg(shell_command)
20122 .output()
20123 .ok()?;
20124 if !output.status.success() {
20125 return None;
20126 }
20127 let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20128 if rewritten.is_empty() || rewritten == shell_command {
20129 return None;
20130 }
20131 Some(DigestRunnerFilter {
20132 tool: "rtk",
20133 command: rewritten,
20134 })
20135}
20136
20137fn find_command_on_path(command: &str) -> Option<PathBuf> {
20138 let path_var = std::env::var_os("PATH")?;
20139 std::env::split_paths(&path_var)
20140 .map(|dir| dir.join(command))
20141 .find(|candidate| candidate.is_file())
20142}
20143
20144pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20145 if !db_path.exists() {
20146 bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20147 }
20148 summarize::SummaryDb::open_read_only_resilient(db_path)
20149}
20150
20151fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20152 !matches!(report.index, status::IndexStatus::Fresh { .. })
20153}
20154
20155fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20156 !matches!(report.instructions, init::InstructionStatus::Current { .. })
20157}
20158
20159pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20160 if status_instructions_need_fix(report) {
20161 eprintln!("status fix: refreshing tsift instructions");
20162 init::init(root, false, false)?;
20163 }
20164
20165 let eviction = cycle_packet_cache::cycle_packet_cache_evict(
20166 root,
20167 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
20168 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
20169 );
20170 if eviction.evicted_entries > 0 {
20171 eprintln!(
20172 "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
20173 eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
20174 );
20175 }
20176
20177 if !status_index_needs_fix(report) {
20178 return Ok(());
20179 }
20180
20181 let scopes = config::Config::submodule_dirs(root)?;
20182 if scopes.is_empty() {
20183 eprintln!("status fix: refreshing index");
20184 run_index_update(
20185 &root.join(".tsift/index.db"),
20186 root,
20187 "status --fix refreshing index".to_string(),
20188 root,
20189 None,
20190 false,
20191 false,
20192 )?;
20193 return Ok(());
20194 }
20195
20196 let cfg = config::Config::load(root)?;
20197 for scope in scopes {
20198 if !scope.source_root.exists() {
20199 eprintln!(
20200 "status fix: skipping missing submodule `{}` ({})",
20201 scope.id,
20202 scope.source_root.display()
20203 );
20204 continue;
20205 }
20206 eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20207 run_index_update(
20208 &cfg.db_path_for(root, &scope.id),
20209 &scope.source_root,
20210 format!("status --fix refreshing submodule `{}` index", scope.id),
20211 root,
20212 Some(scope.id.as_str()),
20213 false,
20214 false,
20215 )?;
20216 }
20217
20218 Ok(())
20219}
20220
20221pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20222 match &report.index {
20223 status::IndexStatus::Fresh { missing_scopes, .. }
20224 | status::IndexStatus::Stale { missing_scopes, .. }
20225 | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20226 }
20227}
20228
20229pub(crate) fn autoindex_missing_workspace_scopes(
20230 root: &Path,
20231 report: &status::StatusReport,
20232) -> Result<()> {
20233 let missing_scopes = match &report.index {
20234 status::IndexStatus::Fresh { missing_scopes, .. }
20235 | status::IndexStatus::Stale { missing_scopes, .. }
20236 | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20237 };
20238 if missing_scopes.is_empty() {
20239 return Ok(());
20240 }
20241
20242 let missing_scope_ids = missing_scopes
20243 .iter()
20244 .map(|scope| scope.scope.as_str())
20245 .collect::<std::collections::HashSet<_>>();
20246 let cfg = config::Config::load(root)?;
20247 for scope in config::Config::submodule_dirs(root)? {
20248 if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20249 continue;
20250 }
20251 let db_path = cfg.db_path_for(root, &scope.id);
20252 run_index_update(
20253 &db_path,
20254 &scope.source_root,
20255 format!(
20256 "autoindexing missing submodule `{}` during status",
20257 scope.id
20258 ),
20259 root,
20260 Some(scope.id.as_str()),
20261 false,
20262 false,
20263 )?;
20264 }
20265 Ok(())
20266}
20267
20268pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
20269 for warning in &stats.warnings {
20270 let rel_path = relativize_pathbuf(&warning.path, root);
20271 eprintln!(
20272 "warning: summarize stats {}: {}",
20273 rel_path.display(),
20274 warning.message
20275 );
20276 }
20277}
20278
20279fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
20280 Result::<(), anyhow::Error>::Err(err)
20281 .context(context)
20282 .unwrap_err()
20283}
20284
20285fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
20286 let message = err.to_string();
20287 message.contains("another tsift index writer is already active")
20288 || substrate::error_mentions_locked_db(err)
20289}
20290
20291fn add_write_lock_context(
20292 err: anyhow::Error,
20293 action: String,
20294 root: &std::path::Path,
20295 scope: Option<&str>,
20296) -> anyhow::Error {
20297 if !should_attach_lock_diagnostics(&err) {
20298 return contextualize_error(err, action);
20299 }
20300
20301 let Ok(report) = status::check_locks(root, None, scope) else {
20302 return contextualize_error(err, action);
20303 };
20304
20305 contextualize_error(
20306 err,
20307 format!(
20308 "{}\n\nlock diagnostics:\n{}",
20309 action,
20310 status::format_locks_human(&report, false).trim_end()
20311 ),
20312 )
20313}
20314
20315pub(crate) fn run_index_update(
20316 db_path: &std::path::Path,
20317 source_root: &std::path::Path,
20318 action: String,
20319 root: &std::path::Path,
20320 scope: Option<&str>,
20321 rebuild: bool,
20322 prune: bool,
20323) -> Result<index::IndexSummary> {
20324 let result = (|| {
20325 let db = index::IndexDb::open(db_path)?;
20326 if rebuild {
20327 db.rebuild(source_root)
20328 } else if prune {
20329 db.apply_changes_pruned(source_root)
20330 } else {
20331 db.apply_changes(source_root)
20332 }
20333 })();
20334
20335 let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
20336 emit_index_warnings(&summary, source_root, scope);
20337 Ok(summary)
20338}
20339
20340pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
20341 for change in &mut summary.changes {
20342 change.path = relativize_pathbuf(&change.path, root);
20343 }
20344 for warning in &mut summary.warnings {
20345 warning.path = relativize_pathbuf(&warning.path, root);
20346 }
20347}
20348
20349fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
20350 for warning in &summary.warnings {
20351 let rel_path = relativize_pathbuf(&warning.path, root);
20352 let stage = match warning.stage {
20353 index::IndexWarningStage::ReadSource => "read failed",
20354 index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
20355 index::IndexWarningStage::ExtractCallSites => "call extraction failed",
20356 index::IndexWarningStage::ExtractRoutes => "route extraction failed",
20357 };
20358 let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
20359 let lang_suffix = warning
20360 .language
20361 .as_deref()
20362 .map(|lang| format!(" [{}]", lang))
20363 .unwrap_or_default();
20364 eprintln!(
20365 "warning: {}{}{}: {}: {}",
20366 scope_prefix,
20367 rel_path.display(),
20368 lang_suffix,
20369 stage,
20370 warning.message
20371 );
20372 }
20373}
20374
20375pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
20376 let config_path = root.join(".tsift/config.toml");
20377 if !config_path.exists() {
20378 return summarize::SummarizeConfig::default();
20379 }
20380 #[derive(serde::Deserialize, Default)]
20381 struct RawConfig {
20382 #[serde(default)]
20383 summarize: Option<RawSummarize>,
20384 }
20385 #[derive(serde::Deserialize)]
20386 struct RawSummarize {
20387 model: Option<String>,
20388 max_file_tokens: Option<usize>,
20389 api_key_env: Option<String>,
20390 }
20391 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
20392 let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
20393 let defaults = summarize::SummarizeConfig::default();
20394 match raw.summarize {
20395 Some(s) => summarize::SummarizeConfig {
20396 model: s.model.unwrap_or(defaults.model),
20397 max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
20398 api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
20399 },
20400 None => defaults,
20401 }
20402}
20403
20404#[derive(Debug, Clone, PartialEq, Eq)]
20405struct ExtractSymbolContext {
20406 db_path: PathBuf,
20407 source_root: PathBuf,
20408}
20409
20410pub(crate) fn find_symbols_db_for_file(
20411 root: &Path,
20412 file_path: &Path,
20413) -> Result<Option<ExtractSymbolContext>> {
20414 let cfg = config::Config::load(root)?;
20415 let mut submodules = config::Config::submodule_dirs(root)?;
20416 submodules.sort_by(|left, right| {
20417 right
20418 .source_root
20419 .components()
20420 .count()
20421 .cmp(&left.source_root.components().count())
20422 });
20423
20424 for scope in submodules {
20425 if !file_path.starts_with(&scope.source_root) {
20426 continue;
20427 }
20428 let db_path = cfg.db_path_for(root, &scope.id);
20429 if db_path.exists() {
20430 return Ok(Some(ExtractSymbolContext {
20431 db_path,
20432 source_root: scope.source_root,
20433 }));
20434 }
20435 }
20436
20437 let single = root.join(".tsift/index.db");
20438 if single.exists() && file_path.starts_with(root) {
20439 return Ok(Some(ExtractSymbolContext {
20440 db_path: single,
20441 source_root: root.to_path_buf(),
20442 }));
20443 }
20444
20445 Ok(None)
20446}
20447
20448pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
20449 let canonical = path
20450 .canonicalize()
20451 .with_context(|| format!("canonicalizing {}", path.display()))?;
20452
20453 Ok(if canonical.is_dir() {
20454 canonical
20455 } else {
20456 canonical
20457 .parent()
20458 .map(Path::to_path_buf)
20459 .unwrap_or(canonical)
20460 })
20461}
20462
20463fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
20464 if path.exists() {
20465 return path
20466 .canonicalize()
20467 .with_context(|| format!("canonicalizing extract scope {}", path.display()));
20468 }
20469
20470 Ok(summarize::normalize_lexical_path(path))
20471}
20472
20473pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
20474 let scope = if extract_path.is_absolute() {
20475 extract_path.to_path_buf()
20476 } else {
20477 root.join(extract_path)
20478 };
20479 normalize_extract_scope_path(&scope)
20480}
20481
20482pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
20483 normalize_extract_scope_path(changed_path)
20484 .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
20485 .starts_with(extract_scope)
20486}
20487
20488pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
20489 summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
20490}
20491
20492pub(crate) fn summarize_full_extract_deleted_summary_paths(
20493 summary_db: &summarize::SummaryDb,
20494 root: &Path,
20495 extract_scope: &Path,
20496 files_to_extract: &[PathBuf],
20497) -> Result<BTreeSet<String>> {
20498 let live_paths = files_to_extract
20499 .iter()
20500 .map(|file_path| summarize_relative_file_path(root, file_path))
20501 .collect::<BTreeSet<_>>();
20502 let mut deleted = BTreeSet::new();
20503
20504 for cached_path in summary_db.cached_file_paths()? {
20505 if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
20506 continue;
20507 }
20508 if !live_paths.contains(&cached_path) {
20509 deleted.insert(cached_path);
20510 }
20511 }
20512
20513 Ok(deleted)
20514}
20515
20516#[derive(Debug, Clone)]
20517struct SearchIndexTarget {
20518 label: String,
20519 db_path: PathBuf,
20520 source_root: PathBuf,
20521 scope_name: Option<String>,
20522 reindex_cmd: String,
20523}
20524
20525fn cargo_package_index_target(
20526 root: &Path,
20527 package: multiplicity::CargoPackageInfo,
20528) -> SearchIndexTarget {
20529 SearchIndexTarget {
20530 label: format!("cargo package `{}` index", package.scope_id),
20531 db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
20532 source_root: package.package_root.clone(),
20533 scope_name: Some(package.scope_id.clone()),
20534 reindex_cmd: format!(
20535 "tsift index --submodule {} {}",
20536 package.scope_id,
20537 root.display()
20538 ),
20539 }
20540}
20541
20542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20543enum SearchIndexState {
20544 Missing,
20545 Fresh,
20546 Stale { stale_files: usize },
20547}
20548
20549fn resolve_search_index_targets(
20550 root: &Path,
20551 path_hint: &Path,
20552 scope: Option<&str>,
20553 federated: bool,
20554) -> Result<Vec<SearchIndexTarget>> {
20555 if let Some(scope_name) = scope {
20556 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
20557 let cfg = config::Config::load(root)?;
20558 return Ok(vec![SearchIndexTarget {
20559 label: format!("submodule `{}` index", scope.id),
20560 db_path: cfg.db_path_for(root, &scope.id),
20561 source_root: scope.source_root.clone(),
20562 scope_name: Some(scope.id.clone()),
20563 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20564 }]);
20565 }
20566 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
20567 return Ok(vec![cargo_package_index_target(root, package)]);
20568 }
20569 config::Config::resolve_submodule(root, scope_name)?;
20570 }
20571
20572 if federated {
20573 let cfg = config::Config::load(root)?;
20574 let mut targets = Vec::new();
20575 for scope in config::Config::submodule_dirs(root)? {
20576 if !cfg.federation_for_scope(&scope) {
20577 continue;
20578 }
20579 targets.push(SearchIndexTarget {
20580 label: format!("submodule `{}` index", scope.id),
20581 db_path: cfg.db_path_for(root, &scope.id),
20582 source_root: scope.source_root.clone(),
20583 scope_name: Some(scope.id.clone()),
20584 reindex_cmd: format!("tsift index --workspace {}", root.display()),
20585 });
20586 }
20587 return Ok(targets);
20588 }
20589
20590 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
20591 let cfg = config::Config::load(root)?;
20592 return Ok(vec![SearchIndexTarget {
20593 label: format!("submodule `{}` index", scope.id),
20594 db_path: cfg.db_path_for(root, &scope.id),
20595 source_root: scope.source_root.clone(),
20596 scope_name: Some(scope.id.clone()),
20597 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20598 }]);
20599 }
20600
20601 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
20602 return Ok(vec![cargo_package_index_target(root, package)]);
20603 }
20604
20605 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
20606 let cfg = config::Config::load(root)?;
20607 return Ok(vec![SearchIndexTarget {
20608 label: format!("submodule `{}` index", scope.id),
20609 db_path: cfg.db_path_for(root, &scope.id),
20610 source_root: scope.source_root.clone(),
20611 scope_name: Some(scope.id.clone()),
20612 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20613 }]);
20614 }
20615
20616 let scopes = config::Config::submodule_dirs(root)?;
20617 if !scopes.is_empty() {
20618 let root_db = root.join(".tsift/index.db");
20619 if !root_db.exists() {
20620 let available_scopes = scopes
20621 .iter()
20622 .map(|scope| scope.id.as_str())
20623 .collect::<Vec<_>>()
20624 .join(", ");
20625 let cfg = config::Config::load(root)?;
20626 let indexed_scopes = scopes
20627 .iter()
20628 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
20629 .map(|scope| scope.id.as_str())
20630 .collect::<Vec<_>>();
20631 let indexed_label = if indexed_scopes.is_empty() {
20632 "none".to_string()
20633 } else {
20634 indexed_scopes.join(", ")
20635 };
20636 bail!(
20637 "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: {}.",
20638 root.display(),
20639 root_db.display(),
20640 available_scopes,
20641 indexed_label,
20642 );
20643 }
20644 }
20645
20646 Ok(vec![SearchIndexTarget {
20647 label: "index".to_string(),
20648 db_path: root.join(".tsift/index.db"),
20649 source_root: root.to_path_buf(),
20650 scope_name: None,
20651 reindex_cmd: format!("tsift index {}", root.display()),
20652 }])
20653}
20654
20655fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
20656 if !target.source_root.exists() || !target.db_path.exists() {
20657 return Ok(SearchIndexState::Missing);
20658 }
20659
20660 let inspection =
20661 index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
20662 let stale_files =
20663 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
20664 if stale_files == 0 {
20665 Ok(SearchIndexState::Fresh)
20666 } else {
20667 Ok(SearchIndexState::Stale { stale_files })
20668 }
20669}
20670
20671#[derive(Debug, Clone, PartialEq, Eq)]
20672struct RebuildSearchTarget {
20673 label: String,
20674 reason: RebuildSearchReason,
20675 reindex_cmd: String,
20676}
20677
20678#[derive(Debug, Clone, PartialEq, Eq)]
20679enum RebuildSearchReason {
20680 Missing,
20681 Stale { stale_files: usize },
20682}
20683
20684#[derive(Debug, Clone, PartialEq, Eq)]
20685struct DegradedSearchTarget {
20686 label: String,
20687 reason: RebuildSearchReason,
20688 reindex_cmd: String,
20689}
20690
20691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20692pub(crate) enum DegradedSearchMode {
20693 ReadOnly,
20694 Exact,
20695}
20696
20697#[derive(Debug)]
20698struct SearchPrecheck {
20699 targets: Vec<SearchIndexTarget>,
20700 degraded_targets: Vec<DegradedSearchTarget>,
20701}
20702
20703fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
20704 err.chain().any(|cause| {
20705 cause
20706 .to_string()
20707 .contains("another tsift index writer is already active")
20708 })
20709}
20710
20711fn infer_agent_doc_task_submodule(
20712 root: &Path,
20713 path_hint: &Path,
20714) -> Result<Option<config::WorkspaceScope>> {
20715 let hinted_path = if path_hint.is_absolute() {
20716 path_hint.to_path_buf()
20717 } else {
20718 root.join(path_hint)
20719 };
20720 let Ok(relative) = hinted_path.strip_prefix(root) else {
20721 return Ok(None);
20722 };
20723 let mut components = relative.components();
20724 let Some(std::path::Component::Normal(first)) = components.next() else {
20725 return Ok(None);
20726 };
20727 if first != "tasks" {
20728 return Ok(None);
20729 }
20730 let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
20731 return Ok(None);
20732 };
20733 config::Config::find_submodule(root, file_stem)
20734}
20735
20736fn degraded_search_target(
20737 target: &SearchIndexTarget,
20738 reason: RebuildSearchReason,
20739) -> DegradedSearchTarget {
20740 DegradedSearchTarget {
20741 label: target.label.clone(),
20742 reason,
20743 reindex_cmd: target.reindex_cmd.clone(),
20744 }
20745}
20746
20747fn apply_search_index_update(
20748 root: &Path,
20749 target: &SearchIndexTarget,
20750) -> Result<index::IndexSummary> {
20751 run_index_update(
20752 &target.db_path,
20753 &target.source_root,
20754 format!("autoindexing {}", target.label),
20755 root,
20756 target.scope_name.as_deref(),
20757 false,
20758 false,
20759 )
20760}
20761
20762fn collect_rebuild_search_targets(
20763 targets: &[SearchIndexTarget],
20764) -> Result<Vec<RebuildSearchTarget>> {
20765 let mut rebuild_targets = Vec::new();
20766 for target in targets {
20767 let reason = match inspect_search_index(target)? {
20768 SearchIndexState::Missing => RebuildSearchReason::Missing,
20769 SearchIndexState::Fresh => continue,
20770 SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
20771 };
20772 rebuild_targets.push(RebuildSearchTarget {
20773 label: target.label.clone(),
20774 reason,
20775 reindex_cmd: target.reindex_cmd.clone(),
20776 });
20777 }
20778 Ok(rebuild_targets)
20779}
20780
20781fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
20782 match target.reason {
20783 RebuildSearchReason::Missing => format!("{} is missing", target.label),
20784 RebuildSearchReason::Stale { stale_files } => {
20785 let file_suffix = if stale_files == 1 { "" } else { "s" };
20786 format!(
20787 "{} is stale ({} file{})",
20788 target.label, stale_files, file_suffix
20789 )
20790 }
20791 }
20792}
20793
20794fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
20795 if rebuild_targets.len() == 1 {
20796 let target = &rebuild_targets[0];
20797 return format!(
20798 "{}. Run `{}` to rebuild before retrying.",
20799 rebuild_search_target_detail(target),
20800 target.reindex_cmd
20801 );
20802 }
20803
20804 let summary: Vec<String> = rebuild_targets
20805 .iter()
20806 .take(3)
20807 .map(rebuild_search_target_detail)
20808 .collect();
20809 let overflow = rebuild_targets.len().saturating_sub(summary.len());
20810 let mut details = summary.join(", ");
20811 if overflow > 0 {
20812 details.push_str(&format!(", +{} more", overflow));
20813 }
20814 let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
20815 format!(
20816 "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
20817 rebuild_targets.len(),
20818 details,
20819 reindex_cmd
20820 )
20821}
20822
20823pub(crate) fn precheck_search_indexes(
20824 root: &Path,
20825 path_hint: &Path,
20826 scope: Option<&str>,
20827 federated: bool,
20828 autoindex: bool,
20829) -> Result<SearchPrecheck> {
20830 let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
20831 let mut stale_targets = Vec::new();
20832 let mut degraded_targets = Vec::new();
20833
20834 for target in &targets {
20835 match inspect_search_index(target)? {
20836 SearchIndexState::Missing => {
20837 if autoindex && let Err(err) = apply_search_index_update(root, target) {
20838 if is_active_writer_lock_error(&err) {
20839 degraded_targets
20840 .push(degraded_search_target(target, RebuildSearchReason::Missing));
20841 } else {
20842 return Err(err);
20843 }
20844 }
20845 }
20846 SearchIndexState::Fresh => {}
20847 SearchIndexState::Stale { stale_files } => {
20848 if autoindex {
20849 if let Err(err) = apply_search_index_update(root, target) {
20850 if is_active_writer_lock_error(&err) {
20851 degraded_targets.push(degraded_search_target(
20852 target,
20853 RebuildSearchReason::Stale { stale_files },
20854 ));
20855 } else {
20856 return Err(err);
20857 }
20858 }
20859 } else {
20860 stale_targets.push(RebuildSearchTarget {
20861 label: target.label.clone(),
20862 reason: RebuildSearchReason::Stale { stale_files },
20863 reindex_cmd: target.reindex_cmd.clone(),
20864 });
20865 }
20866 }
20867 }
20868 }
20869
20870 if stale_targets.is_empty() {
20871 return Ok(SearchPrecheck {
20872 targets,
20873 degraded_targets,
20874 });
20875 }
20876
20877 bail!(
20878 "tsift search aborted: {} \
20879 or re-run without `--no-autoindex`.",
20880 rebuild_search_targets_message(&stale_targets),
20881 );
20882}
20883
20884pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
20885 if targets.is_empty() {
20886 return None;
20887 }
20888
20889 if targets
20890 .iter()
20891 .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
20892 {
20893 Some(DegradedSearchMode::Exact)
20894 } else {
20895 Some(DegradedSearchMode::ReadOnly)
20896 }
20897}
20898
20899fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
20900 if targets.len() == 1 {
20901 let target = &targets[0];
20902 return match target.reason {
20903 RebuildSearchReason::Missing => format!("{} is missing", target.label),
20904 RebuildSearchReason::Stale { stale_files } => {
20905 let file_suffix = if stale_files == 1 { "" } else { "s" };
20906 format!(
20907 "{} is stale ({} file{})",
20908 target.label, stale_files, file_suffix
20909 )
20910 }
20911 };
20912 }
20913
20914 let missing = targets
20915 .iter()
20916 .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
20917 .count();
20918 let stale = targets.len().saturating_sub(missing);
20919 let mut parts = Vec::new();
20920 if stale > 0 {
20921 let suffix = if stale == 1 { "" } else { "es" };
20922 parts.push(format!("{stale} stale index{suffix}"));
20923 }
20924 if missing > 0 {
20925 let suffix = if missing == 1 { "" } else { "es" };
20926 parts.push(format!("{missing} missing index{suffix}"));
20927 }
20928 parts.join(", ")
20929}
20930
20931pub(crate) fn emit_degraded_search_note(
20932 targets: &[DegradedSearchTarget],
20933 mode: DegradedSearchMode,
20934) {
20935 let summary = degraded_search_targets_summary(targets);
20936 let reindex_cmd = &targets[0].reindex_cmd;
20937 match mode {
20938 DegradedSearchMode::ReadOnly => eprintln!(
20939 "note: active tsift writer detected; skipping autoindex because {}. \
20940 Continuing with read-only search and the current index snapshot; symbol hits may lag. \
20941 Retry `{}` after the active writer finishes for fresh index results.",
20942 summary, reindex_cmd
20943 ),
20944 DegradedSearchMode::Exact => eprintln!(
20945 "note: active tsift writer detected; skipping autoindex because {}. \
20946 Continuing with exact live-file search. Retry `{}` after the active writer finishes \
20947 for indexed symbol hits.",
20948 summary, reindex_cmd
20949 ),
20950 }
20951}
20952
20953fn search_timeout_message(
20954 timeout_secs: u64,
20955 strategy: &str,
20956 targets: &[SearchIndexTarget],
20957) -> Result<String> {
20958 let rebuild_targets = collect_rebuild_search_targets(targets)?;
20959 if rebuild_targets.is_empty() {
20960 return Ok(format!(
20961 "tsift search timed out after {}s (strategy: {}). \
20962 The search root looks fresh, so reindexing is unlikely to help. \
20963 Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
20964 or try a different strategy.",
20965 timeout_secs, strategy,
20966 ));
20967 }
20968
20969 Ok(format!(
20970 "tsift search timed out after {}s (strategy: {}). {}",
20971 timeout_secs,
20972 strategy,
20973 rebuild_search_targets_message(&rebuild_targets),
20974 ))
20975}
20976
20977fn is_exact_preferring_query_char(ch: char) -> bool {
20978 matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
20979}
20980
20981fn query_prefers_exact_search(query: &str) -> bool {
20982 let trimmed = query.trim();
20983 !trimmed.is_empty()
20984 && !trimmed.chars().any(char::is_whitespace)
20985 && trimmed.chars().any(|ch| ch.is_alphanumeric())
20986 && trimmed.chars().any(is_exact_preferring_query_char)
20987 && trimmed
20988 .chars()
20989 .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
20990}
20991
20992pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
20993 strategy.unwrap_or_else(|| {
20994 if query_prefers_exact_search(query) {
20995 "exact".to_string()
20996 } else {
20997 "lexical".to_string()
20998 }
20999 })
21000}
21001
21002pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21003 let mut files = Vec::new();
21004 if path.is_file() {
21005 files.push(path.to_path_buf());
21006 return Ok(files);
21007 }
21008 let walker = ignore::WalkBuilder::new(path)
21009 .hidden(true)
21010 .git_ignore(true)
21011 .build();
21012 for entry in walker {
21013 let entry = entry?;
21014 if entry.file_type().is_some_and(|ft| ft.is_file()) {
21015 let p = entry.path();
21016 if let Some(ext) = p.extension() {
21017 let ext = ext.to_string_lossy();
21018 if matches!(
21019 ext.as_ref(),
21020 "rs" | "py"
21021 | "ts"
21022 | "tsx"
21023 | "js"
21024 | "jsx"
21025 | "kt"
21026 | "kts"
21027 | "zig"
21028 | "sh"
21029 | "bash"
21030 | "zsh"
21031 ) {
21032 files.push(p.to_path_buf());
21033 }
21034 }
21035 }
21036 }
21037 Ok(files)
21038}
21039
21040#[cfg(test)]
21041mod tests {
21042 use super::semantic_edit::{
21043 EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21044 markdown_section_spans,
21045 };
21046 use super::*;
21047 use tsift_memory::{MemoryEventKind, MemoryStore};
21048
21049 use std::cell::RefCell;
21050 use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21051 fn parse_cli<I, T>(itr: I) -> Cli
21052 where
21053 I: IntoIterator<Item = T> + Send + 'static,
21054 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21055 {
21056 std::thread::Builder::new()
21057 .name("cli-parse".to_string())
21058 .stack_size(16 * 1024 * 1024)
21059 .spawn(move || Cli::parse_from(itr))
21060 .unwrap()
21061 .join()
21062 .unwrap()
21063 }
21064
21065 fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21066 where
21067 I: IntoIterator<Item = T> + Send + 'static,
21068 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21069 {
21070 std::thread::Builder::new()
21071 .name("cli-try-parse".to_string())
21072 .stack_size(16 * 1024 * 1024)
21073 .spawn(move || Cli::try_parse_from(itr))
21074 .unwrap()
21075 .join()
21076 .unwrap()
21077 }
21078
21079 fn build_relative_search_budget_report(
21080 query: &str,
21081 strategy: &str,
21082 root: &Path,
21083 response: &sift::SearchResponse,
21084 symbol_hits: &[index::SymbolHit],
21085 budget: ResponseBudget,
21086 filters: &SearchFacetFilters,
21087 ) -> SearchBudgetReport {
21088 build_search_budget_report(SearchBudgetReportInput {
21089 query,
21090 strategy,
21091 root,
21092 response,
21093 symbol_hits,
21094 absolute: false,
21095 budget,
21096 filters,
21097 })
21098 }
21099
21100 #[derive(Default)]
21101 struct MemoryConvexGraphClient {
21102 nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
21103 edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
21104 }
21105
21106 impl ConvexGraphClient for MemoryConvexGraphClient {
21107 fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
21108 self.nodes
21109 .borrow_mut()
21110 .insert(row.external_id.clone(), row.clone());
21111 Ok(())
21112 }
21113
21114 fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
21115 self.edges
21116 .borrow_mut()
21117 .insert(row.edge_key.clone(), row.clone());
21118 Ok(())
21119 }
21120
21121 fn delete_node_row(&self, external_id: &str) -> Result<usize> {
21122 Ok(usize::from(
21123 self.nodes.borrow_mut().remove(external_id).is_some(),
21124 ))
21125 }
21126
21127 fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
21128 Ok(usize::from(
21129 self.edges.borrow_mut().remove(edge_key).is_some(),
21130 ))
21131 }
21132
21133 fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
21134 Ok(self.nodes.borrow().get(external_id).cloned())
21135 }
21136
21137 fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
21138 Ok(self.nodes.borrow().values().cloned().collect())
21139 }
21140
21141 fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
21142 Ok(self.edges.borrow().values().cloned().collect())
21143 }
21144
21145 fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
21146 Ok(self
21147 .nodes
21148 .borrow()
21149 .values()
21150 .filter(|row| row.kind == kind)
21151 .cloned()
21152 .collect())
21153 }
21154
21155 fn outgoing_edge_rows(
21156 &self,
21157 from_external_id: &str,
21158 kind: Option<&str>,
21159 ) -> Result<Vec<ConvexEdgeRow>> {
21160 Ok(self
21161 .edges
21162 .borrow()
21163 .values()
21164 .filter(|row| row.from_external_id == from_external_id)
21165 .filter(|row| kind.is_none_or(|kind| row.kind == kind))
21166 .cloned()
21167 .collect())
21168 }
21169 }
21170
21171 fn init_git_repo(path: &Path) {
21172 let status = std::process::Command::new("git")
21173 .args(["init"])
21174 .current_dir(path)
21175 .status()
21176 .unwrap();
21177 assert!(status.success(), "git init failed");
21178
21179 let status = std::process::Command::new("git")
21180 .args(["add", "."])
21181 .current_dir(path)
21182 .status()
21183 .unwrap();
21184 assert!(status.success(), "git add failed");
21185
21186 let status = std::process::Command::new("git")
21187 .args([
21188 "-c",
21189 "user.name=tsift-tests",
21190 "-c",
21191 "user.email=tsift-tests@example.com",
21192 "commit",
21193 "--quiet",
21194 "-m",
21195 "init",
21196 ])
21197 .current_dir(path)
21198 .status()
21199 .unwrap();
21200 assert!(status.success(), "git commit failed");
21201 }
21202
21203 fn write_empty_root_index(root: &Path) {
21204 let index_dir = root.join(".tsift");
21205 fs::create_dir_all(&index_dir).unwrap();
21206 fs::write(index_dir.join("index.db"), "").unwrap();
21207 }
21208
21209 fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
21210 if let Some(parent) = path.parent() {
21211 fs::create_dir_all(parent).unwrap();
21212 }
21213 let body = std::iter::repeat_n(line, lines)
21214 .collect::<Vec<_>>()
21215 .join("\n");
21216 fs::write(path, format!("{body}\n")).unwrap();
21217 path.to_path_buf()
21218 }
21219
21220 #[test]
21223 fn token_capped_preview_returns_all_lines_when_under_cap() {
21224 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 1", "}"];
21225 let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
21226 assert!(!result.was_capped);
21227 assert_eq!(result.preview.len(), 3);
21228 assert_eq!(result.capped_end, 3);
21229 }
21230
21231 #[test]
21232 fn token_capped_preview_truncates_when_over_cap() {
21233 let lines: Vec<&str> = (0..200)
21234 .map(|_| " let x = some_very_long_expression_here();")
21235 .collect();
21236 let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
21237 assert!(result.was_capped);
21238 assert!(result.preview.len() < 200);
21239 assert!(result.capped_end < 200);
21240 }
21241
21242 #[test]
21243 fn token_capped_preview_keeps_at_least_one_line() {
21244 let long_line: String = "x".repeat(8000);
21245 let lines: Vec<&str> = vec![&long_line];
21246 let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
21247 assert!(!result.was_capped);
21248 assert_eq!(result.preview.len(), 1);
21249 }
21250
21251 #[test]
21252 fn token_capped_preview_cap_at_boundary() {
21253 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21254 let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
21255 assert!(!result.was_capped);
21256 assert_eq!(result.preview.len(), 4);
21257 }
21258
21259 #[test]
21260 fn token_capped_preview_cap_just_over_boundary() {
21261 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21262 let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
21263 assert!(result.was_capped);
21264 assert_eq!(result.preview.len(), 3);
21265 assert_eq!(result.capped_end, 3);
21266 }
21267
21268 #[test]
21269 fn token_capped_preview_empty_lines() {
21270 let lines: Vec<&str> = vec![];
21271 let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
21272 assert!(!result.was_capped);
21273 assert!(result.preview.is_empty());
21274 }
21275
21276 #[test]
21277 fn token_capped_preview_per_line_truncation_applied() {
21278 let long_line = "x".repeat(500);
21279 let lines: Vec<&str> = vec![&long_line, "short"];
21280 let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
21281 assert!(!result.was_capped);
21282 assert_eq!(result.preview.len(), 2);
21283 assert!(result.preview[0].text.len() <= 23);
21284 assert!(result.preview[0].text.ends_with("..."));
21285 }
21286
21287 #[test]
21290 fn route_search_defaults_to_haiku() {
21291 let (tier, model) = classify_task("find all uses of authenticate");
21292 assert_eq!(tier, "haiku");
21293 assert!(
21294 model.contains("haiku"),
21295 "expected haiku model, got {}",
21296 model
21297 );
21298 }
21299
21300 #[test]
21301 fn route_edit_keywords_to_sonnet() {
21302 for kw in &[
21303 "edit the file",
21304 "fix the bug",
21305 "update the config",
21306 "remove dead code",
21307 "create a new module",
21308 ] {
21309 let (tier, _) = classify_task(kw);
21310 assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
21311 }
21312 }
21313
21314 #[test]
21315 fn route_architecture_keywords_to_opus() {
21316 for kw in &[
21317 "design the API",
21318 "architecture review",
21319 "plan the migration",
21320 "analyze the system",
21321 "evaluate trade-offs",
21322 ] {
21323 let (tier, _) = classify_task(kw);
21324 assert_eq!(tier, "opus", "expected opus for {:?}", kw);
21325 }
21326 }
21327
21328 #[test]
21329 fn route_architecture_beats_edit() {
21330 let (tier, _) = classify_task("design and implement the new auth service");
21332 assert_eq!(tier, "opus");
21333 }
21334
21335 #[test]
21336 fn cli_accepts_global_compact_flag() {
21337 let cli = parse_cli(["tsift", "--compact", "status"]);
21338 assert!(cli.compact);
21339 assert!(matches!(cli.command, Some(Commands::Status { .. })));
21340 }
21341
21342 #[test]
21343 fn summarize_diff_scope_matches_relative_directory() {
21344 let root = Path::new("/repo");
21345 let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
21346
21347 assert!(summarize_diff_matches_scope(
21348 Path::new("/repo/src/feature/main.rs"),
21349 &extract_scope
21350 ));
21351 assert!(!summarize_diff_matches_scope(
21352 Path::new("/repo/src/other/main.rs"),
21353 &extract_scope
21354 ));
21355 }
21356
21357 #[test]
21358 fn summarize_diff_scope_matches_relative_file() {
21359 let root = Path::new("/repo");
21360 let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
21361
21362 assert!(summarize_diff_matches_scope(
21363 Path::new("/repo/src/feature/main.rs"),
21364 &extract_scope
21365 ));
21366 assert!(!summarize_diff_matches_scope(
21367 Path::new("/repo/src/feature/lib.rs"),
21368 &extract_scope
21369 ));
21370 }
21371
21372 #[test]
21373 fn summarize_extract_scope_walks_relative_paths_from_root() {
21374 let dir = tempfile::tempdir().unwrap();
21375 let source_dir = dir.path().join("src");
21376 std::fs::create_dir_all(&source_dir).unwrap();
21377 let main_rs = source_dir.join("main.rs");
21378 std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
21379
21380 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
21381 let files = collect_source_files(&extract_scope).unwrap();
21382
21383 assert_eq!(files, vec![main_rs]);
21384 }
21385
21386 #[test]
21387 fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
21388 let dir = tempfile::tempdir().unwrap();
21389 let nested = dir.path().join("src/nested");
21390 std::fs::create_dir_all(&nested).unwrap();
21391 std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
21392 let nested_file = nested.join("main.rs");
21393 std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
21394
21395 let extract_base = resolve_extract_base(&nested).unwrap();
21396 let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
21397 let files = collect_source_files(&extract_scope).unwrap();
21398
21399 assert_eq!(extract_scope, nested);
21400 assert_eq!(files, vec![nested_file]);
21401 }
21402
21403 #[test]
21404 fn summarize_extract_base_uses_parent_of_file_path() {
21405 let dir = tempfile::tempdir().unwrap();
21406 let nested = dir.path().join("src/nested");
21407 std::fs::create_dir_all(&nested).unwrap();
21408 let file_path = nested.join("main.rs");
21409 std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
21410
21411 let extract_base = resolve_extract_base(&file_path).unwrap();
21412
21413 assert_eq!(extract_base, nested);
21414 }
21415
21416 #[test]
21417 fn summarize_extract_scope_normalizes_dotdot_segments() {
21418 let dir = tempfile::tempdir().unwrap();
21419 let source_dir = dir.path().join("src");
21420 std::fs::create_dir_all(&source_dir).unwrap();
21421
21422 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
21423
21424 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21425 assert!(summarize_diff_matches_scope(
21426 &source_dir.join("main.rs"),
21427 &extract_scope
21428 ));
21429 }
21430
21431 #[cfg(unix)]
21432 #[test]
21433 fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
21434 use std::os::unix::fs::symlink;
21435
21436 let dir = tempfile::tempdir().unwrap();
21437 let real_root = dir.path().join("real");
21438 let source_dir = real_root.join("src");
21439 std::fs::create_dir_all(&source_dir).unwrap();
21440 let symlink_scope = dir.path().join("scope-link");
21441 symlink(&source_dir, &symlink_scope).unwrap();
21442
21443 let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
21444
21445 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21446 assert!(summarize_diff_matches_scope(
21447 &source_dir.join("lib.rs"),
21448 &extract_scope
21449 ));
21450 }
21451
21452 #[test]
21453 fn summarize_diff_extract_includes_untracked_files() {
21454 let dir = tempfile::tempdir().unwrap();
21455 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21456 init_git_repo(dir.path());
21457
21458 let source_dir = dir.path().join("src");
21459 std::fs::create_dir_all(&source_dir).unwrap();
21460 let new_file = source_dir.join("new.rs");
21461 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21462
21463 let files = summarize::git_changed_files(dir.path()).unwrap();
21464
21465 assert_eq!(files.existing, vec![new_file]);
21466 assert!(files.deleted.is_empty());
21467 }
21468
21469 #[test]
21470 fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
21471 let dir = tempfile::tempdir().unwrap();
21472 let status = std::process::Command::new("git")
21473 .args(["init"])
21474 .current_dir(dir.path())
21475 .status()
21476 .unwrap();
21477 assert!(status.success(), "git init failed");
21478
21479 let source_dir = dir.path().join("src");
21480 std::fs::create_dir_all(&source_dir).unwrap();
21481 let new_file = source_dir.join("new.rs");
21482 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21483
21484 let files = summarize::git_changed_files(dir.path()).unwrap();
21485
21486 assert_eq!(files.existing, vec![new_file]);
21487 assert!(files.deleted.is_empty());
21488 }
21489
21490 #[test]
21491 fn summarize_diff_extract_tracks_deleted_files() {
21492 let dir = tempfile::tempdir().unwrap();
21493 let source_dir = dir.path().join("src");
21494 std::fs::create_dir_all(&source_dir).unwrap();
21495 let deleted_file = source_dir.join("gone.rs");
21496 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21497 init_git_repo(dir.path());
21498
21499 std::fs::remove_file(&deleted_file).unwrap();
21500
21501 let files = summarize::git_changed_files(dir.path()).unwrap();
21502
21503 assert!(files.existing.is_empty());
21504 assert_eq!(files.deleted, vec![deleted_file]);
21505 }
21506
21507 #[test]
21508 fn summarize_diff_extract_tracks_git_renames() {
21509 let dir = tempfile::tempdir().unwrap();
21510 let source_dir = dir.path().join("src");
21511 std::fs::create_dir_all(&source_dir).unwrap();
21512 let old_file = source_dir.join("old.rs");
21513 let new_file = source_dir.join("new.rs");
21514 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21515 init_git_repo(dir.path());
21516
21517 let status = std::process::Command::new("git")
21518 .args(["mv", "src/old.rs", "src/new.rs"])
21519 .current_dir(dir.path())
21520 .status()
21521 .unwrap();
21522 assert!(status.success(), "git mv failed");
21523
21524 let files = summarize::git_changed_files(dir.path()).unwrap();
21525
21526 assert_eq!(files.existing, vec![new_file]);
21527 assert_eq!(files.deleted, vec![old_file]);
21528 }
21529
21530 #[test]
21531 fn summarize_diff_extract_deletes_removed_summary_rows() {
21532 let dir = tempfile::tempdir().unwrap();
21533 let source_dir = dir.path().join("src");
21534 std::fs::create_dir_all(&source_dir).unwrap();
21535 let deleted_file = source_dir.join("gone.rs");
21536 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21537 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21538 init_git_repo(dir.path());
21539
21540 let summary_db =
21541 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21542 summary_db
21543 .insert(&summarize::Summary {
21544 id: 0,
21545 symbol_name: "stale".to_string(),
21546 file_path: "src/gone.rs".to_string(),
21547 content_hash: "hash1".to_string(),
21548 summary: "stale summary".to_string(),
21549 entities: None,
21550 relationships: None,
21551 concept_labels: None,
21552 extracted_at: "1700000000".to_string(),
21553 model: "test".to_string(),
21554 tokens_input: Some(100),
21555 tokens_output: Some(50),
21556 })
21557 .unwrap();
21558
21559 std::fs::remove_file(&deleted_file).unwrap();
21560
21561 cmd_summarize(
21562 None,
21563 None,
21564 Some(PathBuf::from("src")),
21565 true,
21566 false,
21567 dir.path(),
21568 false,
21569 true,
21570 false,
21571 false,
21572 false,
21573 )
21574 .unwrap();
21575
21576 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21577 }
21578
21579 #[test]
21580 fn summarize_diff_extract_deletes_renamed_summary_rows() {
21581 let dir = tempfile::tempdir().unwrap();
21582 let source_dir = dir.path().join("src");
21583 std::fs::create_dir_all(&source_dir).unwrap();
21584 let old_file = source_dir.join("old.rs");
21585 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21586 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21587 init_git_repo(dir.path());
21588
21589 let summary_db =
21590 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21591 summary_db
21592 .insert(&summarize::Summary {
21593 id: 0,
21594 symbol_name: "stale".to_string(),
21595 file_path: "src/old.rs".to_string(),
21596 content_hash: "hash1".to_string(),
21597 summary: "stale summary".to_string(),
21598 entities: None,
21599 relationships: None,
21600 concept_labels: None,
21601 extracted_at: "1700000000".to_string(),
21602 model: "test".to_string(),
21603 tokens_input: Some(100),
21604 tokens_output: Some(50),
21605 })
21606 .unwrap();
21607
21608 let status = std::process::Command::new("git")
21609 .args(["mv", "src/old.rs", "src/new.rs"])
21610 .current_dir(dir.path())
21611 .status()
21612 .unwrap();
21613 assert!(status.success(), "git mv failed");
21614
21615 cmd_summarize(
21616 None,
21617 None,
21618 Some(PathBuf::from("src")),
21619 true,
21620 false,
21621 dir.path(),
21622 false,
21623 true,
21624 false,
21625 false,
21626 false,
21627 )
21628 .unwrap();
21629
21630 assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
21631 }
21632
21633 #[test]
21634 fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
21635 let dir = tempfile::tempdir().unwrap();
21636 let source_dir = dir.path().join("src");
21637 std::fs::create_dir_all(&source_dir).unwrap();
21638 let deleted_file = source_dir.join("gone.rs");
21639 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21640
21641 let summary_db =
21642 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21643 summary_db
21644 .insert(&summarize::Summary {
21645 id: 0,
21646 symbol_name: "stale".to_string(),
21647 file_path: "src/gone.rs".to_string(),
21648 content_hash: "hash1".to_string(),
21649 summary: "stale summary".to_string(),
21650 entities: None,
21651 relationships: None,
21652 concept_labels: None,
21653 extracted_at: "1700000000".to_string(),
21654 model: "test".to_string(),
21655 tokens_input: Some(100),
21656 tokens_output: Some(50),
21657 })
21658 .unwrap();
21659
21660 std::fs::remove_file(&deleted_file).unwrap();
21661
21662 cmd_summarize(
21663 None,
21664 None,
21665 Some(PathBuf::from("src")),
21666 false,
21667 false,
21668 dir.path(),
21669 false,
21670 true,
21671 false,
21672 false,
21673 false,
21674 )
21675 .unwrap();
21676
21677 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21678 }
21679
21680 #[test]
21681 fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
21682 let dir = tempfile::tempdir().unwrap();
21683 let source_dir = dir.path().join("src");
21684 std::fs::create_dir_all(&source_dir).unwrap();
21685 let file = source_dir.join("lib.rs");
21686 std::fs::write(&file, "fn helper() {}\n").unwrap();
21687
21688 let content = std::fs::read(&file).unwrap();
21689 let summary_db =
21690 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21691 summary_db
21692 .insert(&summarize::Summary {
21693 id: 0,
21694 symbol_name: "lib.rs".to_string(),
21695 file_path: "src/lib.rs".to_string(),
21696 content_hash: summarize::content_hash(&content),
21697 summary: "cached summary".to_string(),
21698 entities: None,
21699 relationships: None,
21700 concept_labels: None,
21701 extracted_at: "1700000000".to_string(),
21702 model: "test".to_string(),
21703 tokens_input: Some(100),
21704 tokens_output: Some(50),
21705 })
21706 .unwrap();
21707 drop(summary_db);
21708
21709 let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
21710 let _lock = hold_writer_lock(&lock_path);
21711
21712 let err = cmd_summarize(
21713 None,
21714 None,
21715 Some(PathBuf::from("src")),
21716 false,
21717 false,
21718 dir.path(),
21719 false,
21720 true,
21721 false,
21722 false,
21723 false,
21724 )
21725 .unwrap_err();
21726 let message = err.to_string();
21727
21728 assert!(message.contains("another tsift summarize extractor is already active"));
21729 assert!(message.contains("tsift summarize --extract"));
21730 }
21731
21732 #[test]
21733 fn summarize_stats_fails_closed_when_cache_missing() {
21734 let dir = tempfile::tempdir().unwrap();
21735 let err = cmd_summarize(
21736 None,
21737 None,
21738 None,
21739 false,
21740 true,
21741 dir.path(),
21742 false,
21743 false,
21744 false,
21745 false,
21746 false,
21747 )
21748 .unwrap_err();
21749
21750 assert!(
21751 err.to_string().contains("no summaries.db found"),
21752 "got: {err}"
21753 );
21754 assert!(!dir.path().join(".tsift/summaries.db").exists());
21755 }
21756
21757 #[test]
21758 fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21759 let dir = tempfile::tempdir().unwrap();
21760 let summary_db =
21761 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21762 summary_db
21763 .insert(&summarize::Summary {
21764 id: 0,
21765 symbol_name: "alpha_helper".to_string(),
21766 file_path: "src/lib.rs".to_string(),
21767 content_hash: "hash1".to_string(),
21768 summary: "cached summary".to_string(),
21769 entities: None,
21770 relationships: None,
21771 concept_labels: None,
21772 extracted_at: "1700000000".to_string(),
21773 model: "claude-haiku-4-5-20251001".to_string(),
21774 tokens_input: Some(100),
21775 tokens_output: Some(40),
21776 })
21777 .unwrap();
21778 drop(summary_db);
21779 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21780
21781 let result = cmd_summarize(
21782 None,
21783 None,
21784 None,
21785 false,
21786 true,
21787 dir.path(),
21788 false,
21789 false,
21790 false,
21791 false,
21792 false,
21793 );
21794
21795 assert!(result.is_ok());
21796 }
21797
21798 #[test]
21799 fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21800 let dir = tempfile::tempdir().unwrap();
21801 let summary_db =
21802 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21803 summary_db
21804 .insert(&summarize::Summary {
21805 id: 0,
21806 symbol_name: "alpha_helper".to_string(),
21807 file_path: "src/lib.rs".to_string(),
21808 content_hash: "hash1".to_string(),
21809 summary: "cached summary".to_string(),
21810 entities: None,
21811 relationships: None,
21812 concept_labels: None,
21813 extracted_at: "1700000000".to_string(),
21814 model: "claude-haiku-4-5-20251001".to_string(),
21815 tokens_input: Some(100),
21816 tokens_output: Some(40),
21817 })
21818 .unwrap();
21819 drop(summary_db);
21820 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21821
21822 let result = cmd_summarize(
21823 Some("alpha_helper".to_string()),
21824 None,
21825 None,
21826 false,
21827 false,
21828 dir.path(),
21829 false,
21830 true,
21831 false,
21832 false,
21833 false,
21834 );
21835
21836 assert!(result.is_ok());
21837 }
21838
21839 #[test]
21840 fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
21841 let dir = tempfile::tempdir().unwrap();
21842 let nested = dir.path().join("src/nested");
21843 std::fs::create_dir_all(&nested).unwrap();
21844
21845 let summary_db =
21846 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21847 summary_db
21848 .insert(&summarize::Summary {
21849 id: 0,
21850 symbol_name: "alpha_helper".to_string(),
21851 file_path: "src/lib.rs".to_string(),
21852 content_hash: "hash1".to_string(),
21853 summary: "cached summary".to_string(),
21854 entities: None,
21855 relationships: None,
21856 concept_labels: None,
21857 extracted_at: "1700000000".to_string(),
21858 model: "claude-haiku-4-5-20251001".to_string(),
21859 tokens_input: Some(100),
21860 tokens_output: Some(40),
21861 })
21862 .unwrap();
21863
21864 let result = cmd_summarize(
21865 Some("alpha_helper".to_string()),
21866 None,
21867 None,
21868 false,
21869 false,
21870 &nested,
21871 false,
21872 true,
21873 false,
21874 false,
21875 false,
21876 );
21877
21878 assert!(result.is_ok());
21879 assert!(!nested.join(".tsift/summaries.db").exists());
21880 }
21881
21882 #[test]
21883 fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
21884 let dir = tempfile::tempdir().unwrap();
21885 std::fs::write(
21886 dir.path().join(".gitmodules"),
21887 r#"[submodule "src/alpha"]
21888 path = src/alpha
21889 url = https://example.com/alpha
21890[submodule "src/beta"]
21891 path = src/beta
21892 url = https://example.com/beta
21893"#,
21894 )
21895 .unwrap();
21896
21897 let alpha_root = dir.path().join("src/alpha");
21898 let beta_root = dir.path().join("src/beta");
21899 std::fs::create_dir_all(alpha_root.join("src")).unwrap();
21900 std::fs::create_dir_all(beta_root.join("src")).unwrap();
21901 std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
21902 std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
21903 std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
21904 let beta_file = beta_root.join("src/lib.rs");
21905 std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
21906 std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
21907 std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
21908
21909 let context = find_symbols_db_for_file(dir.path(), &beta_file)
21910 .unwrap()
21911 .expect("expected matching scoped index");
21912
21913 assert_eq!(
21914 context.db_path,
21915 dir.path().join(".tsift/indexes/beta/index.db")
21916 );
21917 assert_eq!(context.source_root, beta_root);
21918 }
21919
21920 fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
21923 EditOp {
21924 file: PathBuf::from("dummy.txt"),
21925 old: old.to_string(),
21926 new: new.to_string(),
21927 replace_all,
21928 }
21929 }
21930
21931 #[test]
21932 fn edit_replaces_single_occurrence() {
21933 let content = "hello world";
21934 let op = make_op("world", "rust", false);
21935 let (result, count) = apply_edit_op(content, &op).unwrap();
21936 assert_eq!(result, "hello rust");
21937 assert_eq!(count, 1);
21938 }
21939
21940 #[test]
21941 fn edit_replace_all_replaces_every_occurrence() {
21942 let content = "foo foo foo";
21943 let op = make_op("foo", "bar", true);
21944 let (result, count) = apply_edit_op(content, &op).unwrap();
21945 assert_eq!(result, "bar bar bar");
21946 assert_eq!(count, 3);
21947 }
21948
21949 #[test]
21950 fn edit_fails_when_old_not_found() {
21951 let content = "hello world";
21952 let op = make_op("missing", "x", false);
21953 assert!(apply_edit_op(content, &op).is_err());
21954 }
21955
21956 #[test]
21957 fn edit_fails_when_ambiguous_without_replace_all() {
21958 let content = "foo foo";
21959 let op = make_op("foo", "bar", false);
21960 let err = apply_edit_op(content, &op).unwrap_err();
21961 assert!(err.to_string().contains("2 times"), "got: {}", err);
21962 }
21963
21964 #[test]
21965 fn edit_fails_when_old_equals_new() {
21966 let content = "hello";
21967 let op = make_op("hello", "hello", false);
21968 assert!(apply_edit_op(content, &op).is_err());
21969 }
21970
21971 #[test]
21972 fn edit_batch_rolls_back_when_later_swap_fails() {
21973 let dir = tempfile::tempdir().unwrap();
21974 let alpha = dir.path().join("alpha.txt");
21975 let beta = dir.path().join("beta.txt");
21976 fs::write(&alpha, "alpha old\n").unwrap();
21977 fs::write(&beta, "beta old\n").unwrap();
21978
21979 let batch = EditBatch {
21980 edits: vec![
21981 EditOp {
21982 file: alpha.clone(),
21983 old: "old".to_string(),
21984 new: "new".to_string(),
21985 replace_all: false,
21986 },
21987 EditOp {
21988 file: beta.clone(),
21989 old: "old".to_string(),
21990 new: "new".to_string(),
21991 replace_all: false,
21992 },
21993 ],
21994 };
21995
21996 let plan = build_edit_plan(&batch).unwrap();
21997 let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
21998 if commit_index == 1 {
21999 bail!("simulated swap failure");
22000 }
22001 Ok(())
22002 }) {
22003 Ok(_) => panic!("expected simulated swap failure"),
22004 Err(err) => err,
22005 };
22006
22007 assert!(err.to_string().contains("simulated swap failure"));
22008 assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22009 assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22010 }
22011
22012 fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22015 let tmp = tempfile::NamedTempFile::new().unwrap();
22016 let conn = Connection::open(tmp.path()).unwrap();
22017 conn.execute_batch(
22018 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22019 INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22020 INSERT INTO users VALUES (2, 'Bob', NULL);
22021 CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22022 FOREIGN KEY(user_id) REFERENCES users(id));
22023 INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22024 INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22025 INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22026 ).unwrap();
22027 (tmp, conn)
22028 }
22029
22030 #[test]
22033 fn rewrite_rg_simple_pattern() {
22034 let result = rewrite_command("rg authenticate");
22035 assert_eq!(
22036 result,
22037 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22038 );
22039 }
22040
22041 #[test]
22042 fn rewrite_rg_with_path() {
22043 let result = rewrite_command("rg authenticate src/");
22044 assert_eq!(
22045 result,
22046 Some(
22047 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22048 .to_string()
22049 )
22050 );
22051 }
22052
22053 #[test]
22054 fn rewrite_rg_with_flags_ignored() {
22055 let result = rewrite_command("rg -i authenticate src/");
22056 assert_eq!(
22057 result,
22058 Some(
22059 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22060 .to_string()
22061 )
22062 );
22063 }
22064
22065 #[test]
22066 fn rewrite_rg_with_type_flag() {
22067 let result = rewrite_command("rg -t rs authenticate");
22069 assert_eq!(
22070 result,
22071 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
22072 );
22073 }
22074
22075 #[test]
22076 fn rewrite_rg_pipe_passthrough() {
22077 let result = rewrite_command("rg authenticate | head -5");
22079 assert_eq!(result, None);
22080 }
22081
22082 #[test]
22083 fn rewrite_rg_files_passthrough() {
22084 let result = rewrite_command("rg --files src/tsift .agent-doc logs");
22085 assert_eq!(result, None);
22086 }
22087
22088 #[test]
22089 fn rewrite_find_passthrough() {
22090 let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
22091 assert_eq!(result, None);
22092 }
22093
22094 #[test]
22095 fn rewrite_grep_recursive() {
22096 let result = rewrite_command("grep -r authenticate src/");
22097 assert_eq!(
22098 result,
22099 Some(
22100 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22101 .to_string()
22102 )
22103 );
22104 }
22105
22106 #[test]
22107 fn rewrite_grep_non_recursive_passthrough() {
22108 let result = rewrite_command("grep authenticate file.txt");
22109 assert_eq!(result, None);
22110 }
22111
22112 #[test]
22113 fn rewrite_tsift_passthrough() {
22114 let result = rewrite_command("tsift search \"foo\"");
22115 assert_eq!(result, Some("tsift search \"foo\"".to_string()));
22116 }
22117
22118 #[test]
22119 fn rewrite_run_tsift_search_disables_timeout_by_default() {
22120 let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
22121 assert_eq!(
22122 result,
22123 "tsift search hookcaps --exact --path /tmp/x --timeout 0"
22124 );
22125 }
22126
22127 #[test]
22128 fn rewrite_run_preserves_explicit_search_timeout() {
22129 let result = effective_rewrite_run_command(
22130 "tsift search hookcaps --exact --path /tmp/x --timeout 5",
22131 );
22132 assert_eq!(
22133 result,
22134 "tsift search hookcaps --exact --path /tmp/x --timeout 5"
22135 );
22136 }
22137
22138 #[test]
22139 fn rewrite_unrelated_passthrough() {
22140 let result = rewrite_command("echo cargo build");
22141 assert_eq!(result, None);
22142 }
22143
22144 #[test]
22145 fn rewrite_rg_quoted_pattern() {
22146 let result = rewrite_command("rg \"fn main\"");
22147 assert_eq!(
22148 result,
22149 Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
22150 );
22151 }
22152
22153 #[test]
22154 fn rewrite_git_diff_to_diff_digest() {
22155 let result = rewrite_command("git diff");
22156 assert_eq!(result, Some("tsift diff-digest .".to_string()));
22157 }
22158
22159 #[test]
22160 fn rewrite_git_diff_cached_to_diff_digest() {
22161 let result = rewrite_command("git diff --cached");
22162 assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
22163 }
22164
22165 #[test]
22166 fn rewrite_git_diff_with_path_to_diff_digest() {
22167 let result = rewrite_command("git diff -- src/");
22168 assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
22169 }
22170
22171 #[test]
22172 fn rewrite_git_diff_with_revision_passthrough() {
22173 let result = rewrite_command("git diff HEAD~1");
22174 assert_eq!(result, None);
22175 }
22176
22177 #[test]
22178 fn rewrite_git_show_to_revision_diff_digest() {
22179 let result = rewrite_command("git show HEAD~1");
22180 assert_eq!(
22181 result,
22182 Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
22183 );
22184 }
22185
22186 #[test]
22187 fn rewrite_git_log_patch_history_to_revision_diff_digest() {
22188 let result = rewrite_command("git log -p -1 HEAD~2");
22189 assert_eq!(
22190 result,
22191 Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
22192 );
22193 }
22194
22195 #[test]
22196 fn rewrite_cat_long_agent_doc_session_to_session_digest() {
22197 let dir = tempfile::tempdir().unwrap();
22198 let session = dir.path().join("tsift.md");
22199 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22200 for index in 0..90 {
22201 body.push_str(&format!("❯ prompt {index}?\n"));
22202 }
22203 fs::write(&session, body).unwrap();
22204
22205 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22206 assert_eq!(
22207 result,
22208 Some(format!(
22209 "tsift session-digest --path {} --input {} --source markdown",
22210 shell_quote(&resolve_digest_context_path(&session)),
22211 shell_quote(session.to_str().unwrap())
22212 ))
22213 );
22214 }
22215
22216 #[test]
22217 fn rewrite_head_long_claude_jsonl_to_session_digest() {
22218 let dir = tempfile::tempdir().unwrap();
22219 let session = dir.path().join("session.jsonl");
22220 let line =
22221 r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
22222 let body = std::iter::repeat_n(line, 120)
22223 .collect::<Vec<_>>()
22224 .join("\n");
22225 fs::write(&session, format!("{body}\n")).unwrap();
22226
22227 let result = rewrite_command(&format!(
22228 "head -n 120 {}",
22229 shell_quote(session.to_str().unwrap())
22230 ));
22231 assert_eq!(
22232 result,
22233 Some(format!(
22234 "tsift session-digest --path {} --input {} --source claude-jsonl",
22235 shell_quote(&resolve_digest_context_path(&session)),
22236 shell_quote(session.to_str().unwrap())
22237 ))
22238 );
22239 }
22240
22241 #[test]
22242 fn rewrite_head_long_codex_jsonl_to_session_digest() {
22243 let dir = tempfile::tempdir().unwrap();
22244 let session = dir.path().join("codex.jsonl");
22245 let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
22246 let body = std::iter::repeat_n(line, 120)
22247 .collect::<Vec<_>>()
22248 .join("\n");
22249 fs::write(&session, format!("{body}\n")).unwrap();
22250
22251 let result = rewrite_command(&format!(
22252 "head -n 120 {}",
22253 shell_quote(session.to_str().unwrap())
22254 ));
22255 assert_eq!(
22256 result,
22257 Some(format!(
22258 "tsift session-digest --path {} --input {} --source codex-jsonl",
22259 shell_quote(&resolve_digest_context_path(&session)),
22260 shell_quote(session.to_str().unwrap())
22261 ))
22262 );
22263 }
22264
22265 #[test]
22266 fn rewrite_small_transcript_window_passthrough() {
22267 let dir = tempfile::tempdir().unwrap();
22268 let session = dir.path().join("session.jsonl");
22269 let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
22270 let body = std::iter::repeat_n(line, 120)
22271 .collect::<Vec<_>>()
22272 .join("\n");
22273 fs::write(&session, format!("{body}\n")).unwrap();
22274
22275 let result = rewrite_command(&format!(
22276 "tail -n 20 {}",
22277 shell_quote(session.to_str().unwrap())
22278 ));
22279 assert_eq!(result, None);
22280 }
22281
22282 #[test]
22283 fn rewrite_sed_large_agent_doc_range_to_session_digest() {
22284 let dir = tempfile::tempdir().unwrap();
22285 let session = dir.path().join("tsift.md");
22286 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22287 for index in 0..120 {
22288 body.push_str(&format!("### Re: topic {index}\n"));
22289 }
22290 fs::write(&session, body).unwrap();
22291
22292 let result = rewrite_command(&format!(
22293 "sed -n '1,120p' {}",
22294 shell_quote(session.to_str().unwrap())
22295 ));
22296 assert_eq!(
22297 result,
22298 Some(format!(
22299 "tsift session-digest --path {} --input {} --source markdown",
22300 shell_quote(&resolve_digest_context_path(&session)),
22301 shell_quote(session.to_str().unwrap())
22302 ))
22303 );
22304 }
22305
22306 #[test]
22307 fn rewrite_cat_large_agent_doc_log_to_session_digest() {
22308 let dir = tempfile::tempdir().unwrap();
22309 let session = dir.path().join("tsift.log");
22310 let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
22311 let body = std::iter::repeat_n(line, 120)
22312 .collect::<Vec<_>>()
22313 .join("\n");
22314 fs::write(&session, format!("{body}\n")).unwrap();
22315
22316 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22317 assert_eq!(
22318 result,
22319 Some(format!(
22320 "tsift session-digest --path {} --input {} --source agent-doc-log",
22321 shell_quote(&resolve_digest_context_path(&session)),
22322 shell_quote(session.to_str().unwrap())
22323 ))
22324 );
22325 }
22326
22327 #[test]
22328 fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
22329 let dir = tempfile::tempdir().unwrap();
22330 fs::write(
22331 dir.path().join(".gitmodules"),
22332 r#"[submodule "src/tsift"]
22333 path = src/tsift
22334 url = https://example.com/tsift
22335"#,
22336 )
22337 .unwrap();
22338 let submodule = dir.path().join("src/tsift");
22339 fs::create_dir_all(submodule.join("tasks")).unwrap();
22340 fs::write(
22341 submodule.join(".git"),
22342 "gitdir: ../../.git/modules/src/tsift\n",
22343 )
22344 .unwrap();
22345 let session = submodule.join("tasks/plan.md");
22346 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22347 for index in 0..90 {
22348 body.push_str(&format!("❯ prompt {index}?\n"));
22349 }
22350 fs::write(&session, body).unwrap();
22351
22352 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22353
22354 assert_eq!(
22355 result,
22356 Some(format!(
22357 "tsift session-digest --path {} --input {} --source markdown",
22358 shell_quote(submodule.to_str().unwrap()),
22359 shell_quote(session.to_str().unwrap())
22360 ))
22361 );
22362 }
22363
22364 #[test]
22365 fn rewrite_regular_markdown_read_passthrough() {
22366 let dir = tempfile::tempdir().unwrap();
22367 let readme = dir.path().join("README.md");
22368 let body = std::iter::repeat_n("plain markdown", 120)
22369 .collect::<Vec<_>>()
22370 .join("\n");
22371 fs::write(&readme, format!("{body}\n")).unwrap();
22372
22373 let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
22374 assert_eq!(result, None);
22375 }
22376
22377 #[test]
22378 fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
22379 let dir = tempfile::tempdir().unwrap();
22380 write_empty_root_index(dir.path());
22381 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22382
22383 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22384
22385 assert_eq!(
22386 result,
22387 Some(format!(
22388 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
22389 shell_quote(&dir.path().to_string_lossy())
22390 ))
22391 );
22392 }
22393
22394 #[test]
22395 fn rewrite_head_small_source_window_passthrough() {
22396 let dir = tempfile::tempdir().unwrap();
22397 write_empty_root_index(dir.path());
22398 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22399
22400 let result = rewrite_command(&format!(
22401 "head -n 20 {}",
22402 shell_quote(source.to_str().unwrap())
22403 ));
22404
22405 assert_eq!(result, None);
22406 }
22407
22408 #[test]
22409 fn rewrite_sed_large_source_range_to_source_read() {
22410 let dir = tempfile::tempdir().unwrap();
22411 write_empty_root_index(dir.path());
22412 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22413
22414 let result = rewrite_command(&format!(
22415 "sed -n '40,160p' {}",
22416 shell_quote(source.to_str().unwrap())
22417 ));
22418
22419 assert_eq!(
22420 result,
22421 Some(format!(
22422 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
22423 shell_quote(&dir.path().to_string_lossy())
22424 ))
22425 );
22426 }
22427
22428 #[test]
22429 fn rewrite_tail_large_source_window_preserves_tail_anchor() {
22430 let dir = tempfile::tempdir().unwrap();
22431 write_empty_root_index(dir.path());
22432 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22433
22434 let result = rewrite_command(&format!(
22435 "tail -n 120 {}",
22436 shell_quote(source.to_str().unwrap())
22437 ));
22438
22439 assert_eq!(
22440 result,
22441 Some(format!(
22442 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
22443 shell_quote(&dir.path().to_string_lossy())
22444 ))
22445 );
22446 }
22447
22448 #[test]
22449 fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
22450 let dir = tempfile::tempdir().unwrap();
22451 write_empty_root_index(dir.path());
22452 let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
22453
22454 let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
22455
22456 assert_eq!(result, None);
22457 }
22458
22459 #[test]
22460 fn rewrite_large_source_read_passthrough_without_index() {
22461 let dir = tempfile::tempdir().unwrap();
22462 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22463
22464 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22465
22466 assert_eq!(result, None);
22467 }
22468
22469 #[test]
22470 fn rewrite_cargo_test_to_digest_runner() {
22471 let result = rewrite_command("cargo test --lib");
22472 assert_eq!(
22473 result,
22474 Some(
22475 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
22476 )
22477 );
22478 }
22479
22480 #[test]
22481 fn rewrite_pytest_to_digest_runner() {
22482 let result = rewrite_command("pytest -q tests/test_cli.py");
22483 assert_eq!(
22484 result,
22485 Some(
22486 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
22487 )
22488 );
22489 }
22490
22491 #[test]
22492 fn rewrite_python_m_pytest_to_digest_runner() {
22493 let result = rewrite_command("python -m pytest tests/test_cli.py");
22494 assert_eq!(
22495 result,
22496 Some(
22497 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
22498 )
22499 );
22500 }
22501
22502 #[test]
22503 fn rewrite_cargo_build_to_log_digest_runner() {
22504 let result = rewrite_command("cargo build --release");
22505 assert_eq!(
22506 result,
22507 Some(
22508 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
22509 )
22510 );
22511 }
22512
22513 #[test]
22514 fn rewrite_cargo_install_to_log_digest_runner() {
22515 let result = rewrite_command("cargo install --path . --force");
22516 assert_eq!(
22517 result,
22518 Some(
22519 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
22520 )
22521 );
22522 }
22523
22524 #[test]
22525 fn rewrite_metacharacter_command_passthrough() {
22526 let result = rewrite_command("cargo test | head");
22527 assert_eq!(result, None);
22528 }
22529
22530 #[test]
22531 fn rewrite_output_cap_detects_search_even_with_global_flag() {
22532 let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
22533 assert_eq!(cap.max_lines, 50);
22534 assert_eq!(cap.strip_prefix, Some("Strategy:"));
22535 }
22536
22537 #[test]
22538 fn rewrite_output_cap_skips_structured_output() {
22539 assert!(rewrite_output_cap("tsift search foo --json").is_none());
22540 assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
22541 assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
22542 }
22543
22544 #[test]
22545 fn rewrite_output_format_forwards_envelope_to_digest_runner() {
22546 let command = rewrite_command("cargo test --lib").expect("rewrite");
22547 let forwarded = apply_rewrite_output_format(
22548 &command,
22549 OutputFormat {
22550 json_output: true,
22551 compact: false,
22552 pretty: false,
22553 terse: false,
22554 ultra_terse: false,
22555 schema: false,
22556 envelope: true,
22557 },
22558 );
22559 assert_eq!(
22560 forwarded,
22561 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
22562 );
22563 }
22564
22565 #[test]
22566 fn rewrite_output_format_forwards_json_when_requested() {
22567 let command = rewrite_command("cargo build --release").expect("rewrite");
22568 let forwarded = apply_rewrite_output_format(
22569 &command,
22570 OutputFormat {
22571 json_output: true,
22572 compact: false,
22573 pretty: true,
22574 terse: false,
22575 ultra_terse: false,
22576 schema: false,
22577 envelope: false,
22578 },
22579 );
22580 assert_eq!(
22581 forwarded,
22582 "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
22583 );
22584 }
22585
22586 #[test]
22587 fn output_cap_strips_search_header_and_truncates() {
22588 let capped = apply_output_cap(
22589 b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
22590 OutputCap {
22591 max_lines: 2,
22592 strip_prefix: Some("Strategy:"),
22593 },
22594 );
22595 assert_eq!(
22596 capped,
22597 "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
22598 );
22599 }
22600
22601 #[test]
22602 fn sql_schema_overview_lists_tables() {
22603 let (_tmp, conn) = setup_test_db();
22604 let tables = schema_overview(&conn).unwrap();
22605 let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
22606 assert_eq!(names, &["posts", "users"]);
22607 }
22608
22609 #[test]
22610 fn sql_schema_overview_row_counts() {
22611 let (_tmp, conn) = setup_test_db();
22612 let tables = schema_overview(&conn).unwrap();
22613 let users = tables.iter().find(|t| t.name == "users").unwrap();
22614 let posts = tables.iter().find(|t| t.name == "posts").unwrap();
22615 assert_eq!(users.row_count, 2);
22616 assert_eq!(posts.row_count, 3);
22617 }
22618
22619 #[test]
22620 fn sql_table_columns_metadata() {
22621 let (_tmp, conn) = setup_test_db();
22622 let cols = table_columns(&conn, "users").unwrap();
22623 assert_eq!(cols.len(), 3);
22624 assert_eq!(cols[0].name, "id");
22625 assert!(cols[0].pk);
22626 assert_eq!(cols[1].name, "name");
22627 assert!(cols[1].notnull);
22628 assert_eq!(cols[2].name, "email");
22629 assert!(!cols[2].notnull);
22630 }
22631
22632 #[test]
22633 fn sql_execute_query_returns_rows() {
22634 let (_tmp, conn) = setup_test_db();
22635 let (columns, rows) =
22636 execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
22637 assert_eq!(columns, &["name", "email"]);
22638 assert_eq!(rows.len(), 2);
22639 assert_eq!(rows[0][0], serde_json::json!("Alice"));
22640 assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
22641 assert_eq!(rows[1][1], serde_json::Value::Null);
22642 }
22643
22644 #[test]
22645 fn sql_execute_query_aggregate() {
22646 let (_tmp, conn) = setup_test_db();
22647 let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
22648 assert_eq!(columns, &["cnt"]);
22649 assert_eq!(rows[0][0], serde_json::json!(3));
22650 }
22651
22652 #[test]
22653 fn sql_execute_query_join() {
22654 let (_tmp, conn) = setup_test_db();
22655 let (_cols, rows) = execute_query(
22656 &conn,
22657 "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
22658 )
22659 .unwrap();
22660 assert_eq!(rows.len(), 3);
22661 assert_eq!(rows[0][0], serde_json::json!("Alice"));
22662 assert_eq!(rows[2][0], serde_json::json!("Bob"));
22663 }
22664
22665 #[test]
22666 fn sql_open_db_read_only() {
22667 let (tmp, _conn) = setup_test_db();
22668 drop(_conn);
22669 let ro_conn = open_db(tmp.path()).unwrap();
22670 let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
22671 assert!(result.is_err(), "read-only connection should reject writes");
22672 }
22673
22674 #[test]
22675 fn sql_empty_table_schema() {
22676 let tmp = tempfile::NamedTempFile::new().unwrap();
22677 let conn = Connection::open(tmp.path()).unwrap();
22678 conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
22679 .unwrap();
22680 let tables = schema_overview(&conn).unwrap();
22681 assert_eq!(tables[0].row_count, 0);
22682 assert_eq!(tables[0].columns.len(), 2);
22683 }
22684
22685 fn setup_graph_index() -> tempfile::TempDir {
22688 let dir = tempfile::tempdir().unwrap();
22689 std::fs::write(
22690 dir.path().join("main.rs"),
22691 "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
22692 )
22693 .unwrap();
22694 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22695 db.apply_changes(dir.path()).unwrap();
22696 dir
22697 }
22698
22699 fn setup_traversal_project() -> tempfile::TempDir {
22700 let dir = setup_graph_index();
22701 let task_dir = dir.path().join("tasks/software");
22702 std::fs::create_dir_all(&task_dir).unwrap();
22703 std::fs::write(
22704 task_dir.join("tsift.md"),
22705 r#"---
22706agent_doc_session: tsift-v0.1
22707agent_doc_format: template
22708---
22709
22710## Exchange
22711
22712<!-- agent:exchange patch=append -->
22713❯ do [#kgnv]
22714Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
22715<!-- /agent:exchange -->
22716
22717<!-- agent:queue -->
22718dispatch #spec-test-build-install-commit-push
22719- do [#kgnv]
22720<!-- /agent:queue -->
22721
22722## Backlog
22723
22724<!-- agent:backlog -->
22725- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
22726<!-- /agent:backlog -->
22727"#,
22728 )
22729 .unwrap();
22730 dir
22731 }
22732
22733 fn resolve_ast_span_node<'a>(
22734 graph: &'a TraversalGraphBuild,
22735 label: &str,
22736 symbol_kind: &str,
22737 ) -> &'a TraversalNode {
22738 graph
22739 .nodes
22740 .values()
22741 .find(|node| {
22742 node.kind == "ast_span"
22743 && node.label == label
22744 && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
22745 })
22746 .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
22747 }
22748
22749 fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
22750 let dir = tempfile::tempdir().unwrap();
22751 std::fs::write(
22752 dir.path().join("rust.rs"),
22753 r#"mod fixture_nav_rust_mod {
22754 pub fn fixture_nav_rust_helper() {}
22755 pub fn fixture_nav_rust_entry() {
22756 fixture_nav_rust_helper();
22757 }
22758}
22759"#,
22760 )
22761 .unwrap();
22762 std::fs::write(
22763 dir.path().join("python.py"),
22764 r#"def fixture_nav_python_helper():
22765 return 1
22766
22767def fixture_nav_python_entry():
22768 return fixture_nav_python_helper()
22769"#,
22770 )
22771 .unwrap();
22772 std::fs::write(
22773 dir.path().join("typescript.ts"),
22774 r#"export function fixture_nav_typescript_entry(): number {
22775 return fixtureNavTsHelper();
22776}
22777
22778function fixtureNavTsHelper(): number {
22779 return 1;
22780}
22781"#,
22782 )
22783 .unwrap();
22784 std::fs::write(
22785 dir.path().join("javascript.js"),
22786 r#"function fixture_nav_javascript_entry() {
22787 return fixtureNavJsHelper();
22788}
22789
22790function fixtureNavJsHelper() {
22791 return 1;
22792}
22793"#,
22794 )
22795 .unwrap();
22796 std::fs::write(
22797 dir.path().join("kotlin.kt"),
22798 r#"fun fixture_nav_kotlin_entry(): Int {
22799 return fixtureNavKotlinHelper()
22800}
22801
22802fun fixtureNavKotlinHelper(): Int = 1
22803"#,
22804 )
22805 .unwrap();
22806 std::fs::write(
22807 dir.path().join("zig.zig"),
22808 r#"pub fn fixture_nav_zig_entry() i32 {
22809 return fixtureNavZigHelper();
22810}
22811
22812fn fixtureNavZigHelper() i32 {
22813 return 1;
22814}
22815"#,
22816 )
22817 .unwrap();
22818 std::fs::write(
22819 dir.path().join("bash.sh"),
22820 r#"#!/usr/bin/env bash
22821fixture_nav_bash_entry() {
22822 fixture_nav_bash_helper
22823}
22824
22825fixture_nav_bash_helper() {
22826 echo ok
22827}
22828
22829alias fixture_nav_bash_alias='echo alias'
22830"#,
22831 )
22832 .unwrap();
22833 std::fs::write(
22834 dir.path().join("README.md"),
22835 r#"# Fixture Guide
22836
22837## Fixture Section
22838
22839- Fixture step
22840 - Nested fixture step
22841
22842```python
22843def fixture_nav_markdown_embedded():
22844 return 1
22845```
22846"#,
22847 )
22848 .unwrap();
22849
22850 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22851 db.apply_changes(dir.path()).unwrap();
22852 dir
22853 }
22854
22855 fn assert_cli_expand_command_parses(command: &str) {
22856 let args = shell_split(command)
22857 .into_iter()
22858 .map(str::to_string)
22859 .collect::<Vec<_>>();
22860 assert!(
22861 try_parse_cli(args).is_ok(),
22862 "expand command should parse as a tsift CLI command: {command}"
22863 );
22864 }
22865
22866 fn setup_multiplicity_project() -> tempfile::TempDir {
22867 let dir = tempfile::tempdir().unwrap();
22868 std::fs::write(
22869 dir.path().join("Cargo.toml"),
22870 r#"[workspace]
22871members = ["crates/core-lib", "crates/cli-app"]
22872"#,
22873 )
22874 .unwrap();
22875 std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
22876 std::fs::write(
22877 dir.path().join("crates/core-lib/Cargo.toml"),
22878 r#"[package]
22879name = "core-lib"
22880
22881[lib]
22882name = "core_lib"
22883
22884[features]
22885default = []
22886"#,
22887 )
22888 .unwrap();
22889 std::fs::write(
22890 dir.path().join("crates/core-lib/src/lib.rs"),
22891 "pub fn run() {}\n",
22892 )
22893 .unwrap();
22894 std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
22895 std::fs::write(
22896 dir.path().join("crates/cli-app/Cargo.toml"),
22897 r#"[package]
22898name = "cli-app"
22899
22900[[bin]]
22901name = "cli-app"
22902
22903[dependencies]
22904core-lib = { path = "../core-lib" }
22905"#,
22906 )
22907 .unwrap();
22908 std::fs::write(
22909 dir.path().join("crates/cli-app/src/main.rs"),
22910 "use core_lib::run;\nfn main() { run(); }\n",
22911 )
22912 .unwrap();
22913 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22914 db.apply_changes(dir.path()).unwrap();
22915
22916 let task_dir = dir.path().join("tasks/software");
22917 std::fs::create_dir_all(&task_dir).unwrap();
22918 std::fs::write(
22919 task_dir.join("tsift.md"),
22920 r#"---
22921agent_doc_session: tsift-multiplicity
22922agent_doc_format: template
22923---
22924
22925## Backlog
22926
22927<!-- agent:backlog -->
22928- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
22929<!-- /agent:backlog -->
22930"#,
22931 )
22932 .unwrap();
22933 init_git_repo(dir.path());
22934 dir
22935 }
22936
22937 fn setup_dependency_dag_project() -> tempfile::TempDir {
22938 let dir = tempfile::tempdir().unwrap();
22939 std::fs::write(
22940 dir.path().join("main.rs"),
22941 "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
22942 )
22943 .unwrap();
22944 std::fs::write(
22945 dir.path().join("Cargo.toml"),
22946 "[package]\nname = \"dag-fixture\"\n",
22947 )
22948 .unwrap();
22949 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22950 db.apply_changes(dir.path()).unwrap();
22951
22952 let task_dir = dir.path().join("tasks/software");
22953 std::fs::create_dir_all(&task_dir).unwrap();
22954 std::fs::write(
22955 task_dir.join("tsift.md"),
22956 r#"---
22957agent_doc_session: tsift-dag
22958agent_doc_format: template
22959---
22960
22961## Exchange
22962
22963<!-- agent:exchange patch=append -->
22964Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
22965<!-- /agent:exchange -->
22966
22967## Backlog
22968
22969<!-- agent:backlog -->
22970- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
22971- [ ] [#alpha] Update shared_helper in main.rs after #prep.
22972- [ ] [#beta] Refactor shared_helper tests in main.rs.
22973- [ ] [#gamma] Follow-up review for graph navigation.
22974<!-- /agent:backlog -->
22975"#,
22976 )
22977 .unwrap();
22978 dir
22979 }
22980
22981 fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
22982 let dir = setup_graph_index();
22983 let task_dir = dir.path().join("tasks/software");
22984 std::fs::create_dir_all(&task_dir).unwrap();
22985 std::fs::write(
22986 task_dir.join("tsift.md"),
22987 r#"---
22988agent_doc_session: tsift-dag-cycle
22989agent_doc_format: template
22990---
22991
22992## Backlog
22993
22994<!-- agent:backlog -->
22995- [ ] [#left] Left side depends on #right.
22996- [ ] [#right] Right side depends on #left.
22997<!-- /agent:backlog -->
22998"#,
22999 )
23000 .unwrap();
23001 dir
23002 }
23003
23004 fn seed_traversal_semantic_summaries(dir: &Path) {
23005 let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23006 summary_db
23007 .insert(&summarize::Summary {
23008 id: 0,
23009 symbol_name: "helper".to_string(),
23010 file_path: "main.rs".to_string(),
23011 content_hash: "hash-main".to_string(),
23012 summary: "helper builds graph navigation handles for traversal.".to_string(),
23013 entities: Some(vec![
23014 summarize::Entity {
23015 name: "helper".to_string(),
23016 kind: "function".to_string(),
23017 description: "Builds graph navigation handles.".to_string(),
23018 },
23019 summarize::Entity {
23020 name: "TraversalGraph".to_string(),
23021 kind: "type".to_string(),
23022 description: "Carries GraphStore-backed traversal rows.".to_string(),
23023 },
23024 ]),
23025 relationships: Some(vec![summarize::Relationship {
23026 from: "helper".to_string(),
23027 to: "TraversalGraph".to_string(),
23028 kind: "uses".to_string(),
23029 }]),
23030 concept_labels: Some(vec![
23031 "graph navigation".to_string(),
23032 "semantic extraction".to_string(),
23033 ]),
23034 extracted_at: "1700000000".to_string(),
23035 model: "test-model".to_string(),
23036 tokens_input: Some(10),
23037 tokens_output: Some(5),
23038 })
23039 .unwrap();
23040 }
23041
23042 fn seed_tsift_memory_graph_db(dir: &Path) {
23043 let db = dir.join(".tsift").join("memory.db");
23044 let store = MemoryStore::open_or_create(&db).unwrap();
23045 let project = dir.to_string_lossy().to_string();
23046 let observation = MemoryEvent::new(
23047 MemoryEventKind::ImportedObservation,
23048 "claude-mem:observations:1",
23049 [
23050 "Graph memory adapter",
23051 "read-only projection",
23052 "graph-db should retrieve tsift memory observations",
23053 "Project memory is queried from .tsift/memory.db",
23054 "graph memory, tsift memory, semantic query",
23055 ]
23056 .join("\n\n"),
23057 )
23058 .with_session_id("claude-session-a")
23059 .with_observed_at_unix(1_700_000_000)
23060 .with_import("claude-mem", "observations:1")
23061 .with_metadata("project", project.clone())
23062 .with_metadata("observation_type", "fact")
23063 .with_metadata("prompt_number", "7")
23064 .with_metadata("discovery_tokens", "42")
23065 .with_metadata("content_hash", "hash-observation-1");
23066 store.insert_event(&observation).unwrap();
23067
23068 let summary = MemoryEvent::new(
23069 MemoryEventKind::ImportedSessionSummary,
23070 "claude-mem:session_summaries:2",
23071 [
23072 "Query old memory from graph-db",
23073 "Read-only tsift memory SQLite projection",
23074 "Semantic graph rows can point at existing memory",
23075 "Projected source and session nodes",
23076 "Keep capture ownership inside tsift-memory",
23077 "summary note",
23078 ]
23079 .join("\n\n"),
23080 )
23081 .with_session_id("claude-session-a")
23082 .with_observed_at_unix(1_700_000_010)
23083 .with_import("claude-mem", "session_summaries:2")
23084 .with_metadata("project", project)
23085 .with_metadata("prompt_number", "8")
23086 .with_metadata("discovery_tokens", "36");
23087 store.insert_event(&summary).unwrap();
23088
23089 let prompt = MemoryEvent::new(
23090 MemoryEventKind::ImportedUserPrompt,
23091 "claude-mem:user_prompts:3",
23092 "How can graph-db query tsift memory semantic history?",
23093 )
23094 .with_session_id("claude-session-a")
23095 .with_observed_at_unix(1_700_000_020)
23096 .with_import("claude-mem", "user_prompts:3")
23097 .with_metadata("prompt_number", "9");
23098 store.insert_event(&prompt).unwrap();
23099 }
23100
23101 #[test]
23102 fn graph_callers_query() {
23103 let dir = setup_graph_index();
23104 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23105 let callers = db.callers_of("helper").unwrap();
23106 assert_eq!(callers.len(), 1);
23107 assert_eq!(callers[0].caller_name, "main");
23108 }
23109
23110 #[test]
23111 fn graph_callees_query() {
23112 let dir = setup_graph_index();
23113 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23114 let callees = db.callees_of("main").unwrap();
23115 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
23116 assert!(names.contains(&"helper"));
23117 assert!(names.contains(&"new"));
23118 }
23119
23120 #[test]
23121 fn graph_no_callers_returns_empty() {
23122 let dir = setup_graph_index();
23123 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23124 let callers = db.callers_of("nonexistent").unwrap();
23125 assert!(callers.is_empty());
23126 }
23127
23128 #[test]
23129 fn graph_cmd_autoindexes_missing_index_by_default() {
23130 let dir = tempfile::tempdir().unwrap();
23131 std::fs::write(
23132 dir.path().join("main.rs"),
23133 "fn helper() {}\nfn main() { helper(); }\n",
23134 )
23135 .unwrap();
23136 let result = cmd_graph(
23137 "helper",
23138 dir.path(),
23139 true,
23140 false,
23141 None,
23142 20,
23143 false,
23144 true,
23145 false,
23146 false,
23147 false,
23148 false,
23149 false,
23150 TagpathSearchOpts::default(),
23151 );
23152
23153 assert!(result.is_ok());
23154 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
23155 let summary = db.compute_changes(dir.path()).unwrap();
23156 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
23157 }
23158
23159 #[test]
23160 fn traversal_graph_has_stable_typed_handles() {
23161 let dir = setup_traversal_project();
23162 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23163 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23164
23165 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
23166 let symbol = resolve_traversal_node(&graph, "helper").unwrap();
23167 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23168 let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
23169
23170 assert!(file.handle.starts_with("gfil-"));
23171 assert!(symbol.handle.starts_with("gsym-"));
23172 assert!(backlog.handle.starts_with("gbak-"));
23173 assert!(session.handle.starts_with("gses-"));
23174
23175 assert_eq!(
23176 symbol.handle,
23177 resolve_traversal_node(&graph_again, "helper")
23178 .unwrap()
23179 .handle
23180 );
23181 assert_eq!(
23182 backlog.handle,
23183 resolve_traversal_node(&graph_again, "#kgnv")
23184 .unwrap()
23185 .handle
23186 );
23187 }
23188
23189 #[test]
23190 fn traversal_graph_links_backlog_items_to_code_tokens() {
23191 let dir = setup_traversal_project();
23192 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23193 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23194 let helper = resolve_traversal_node(&graph, "helper").unwrap();
23195
23196 assert!(graph.edges.iter().any(|edge| {
23197 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23198 }));
23199 }
23200
23201 #[test]
23202 fn session_hinted_traversal_skips_global_call_edges() {
23203 let dir = setup_traversal_project();
23204 let session = dir.path().join("tasks/software/tsift.md");
23205 let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
23206 let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
23207 let helper = resolve_traversal_node(&bounded, "helper").unwrap();
23208
23209 assert!(bounded.edges.iter().any(|edge| {
23210 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23211 }));
23212 assert!(
23213 !bounded.edges.iter().any(|edge| edge.relation == "calls"),
23214 "session-hinted graph-db projections should not materialize unrelated global call edges"
23215 );
23216
23217 let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
23218 assert!(
23219 full.edges.iter().any(|edge| edge.relation == "calls"),
23220 "root/full projections still carry the complete indexed call graph"
23221 );
23222 }
23223
23224 #[test]
23225 fn agent_doc_task_path_infers_matching_workspace_scope() {
23226 let dir = tempfile::tempdir().unwrap();
23227 std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
23228 std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
23229 std::fs::write(
23230 dir.path().join(".gitmodules"),
23231 "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
23232 )
23233 .unwrap();
23234 let task = dir.path().join("tasks/software/tsift.md");
23235 std::fs::write(&task, "# tsift\n").unwrap();
23236
23237 let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
23238 let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
23239 let cfg = config::Config::load(dir.path()).unwrap();
23240
23241 assert_eq!(targets.len(), 1);
23242 assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
23243 assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
23244 assert!(
23245 targets[0]
23246 .db_path
23247 .ends_with(".tsift/indexes/tsift/index.db")
23248 );
23249 assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
23250 }
23251
23252 #[test]
23253 fn cargo_package_scope_selector_indexes_package_db() {
23254 let dir = setup_multiplicity_project();
23255 let targets =
23256 resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
23257
23258 assert_eq!(targets.len(), 1);
23259 assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
23260 assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
23261 assert!(
23262 targets[0]
23263 .db_path
23264 .ends_with(".tsift/indexes/cargo/core-lib/index.db")
23265 );
23266
23267 cmd_index(
23268 dir.path(),
23269 false,
23270 false,
23271 false,
23272 false,
23273 true,
23274 false,
23275 Some("core_lib"),
23276 false,
23277 true,
23278 false,
23279 false,
23280 false,
23281 false,
23282 )
23283 .unwrap();
23284 assert!(targets[0].db_path.exists());
23285 }
23286
23287 #[test]
23288 fn path_inference_prefers_nested_cargo_package_without_submodule() {
23289 let dir = setup_multiplicity_project();
23290 let source = dir.path().join("crates/cli-app/src/main.rs");
23291 let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
23292
23293 assert_eq!(targets.len(), 1);
23294 assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
23295 assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
23296 }
23297
23298 #[test]
23299 fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
23300 let dir = setup_multiplicity_project();
23301 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23302 let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
23303 let core = resolve_traversal_node(&graph, "core-lib").unwrap();
23304 let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
23305 let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
23306
23307 assert_eq!(workspace.kind, "cargo_workspace");
23308 assert_eq!(core.kind, "cargo_package");
23309 assert_eq!(
23310 core.properties.get("features"),
23311 Some(&"default".to_string())
23312 );
23313 assert!(graph.edges.iter().any(|edge| {
23314 edge.from == workspace.handle
23315 && edge.to == core.handle
23316 && edge.relation == "contains_package"
23317 }));
23318 assert!(graph.edges.iter().any(|edge| {
23319 edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
23320 }));
23321 assert!(graph.edges.iter().any(|edge| {
23322 edge.from == cli.handle
23323 && edge.to == core.handle
23324 && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
23325 }));
23326 }
23327
23328 #[test]
23329 fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
23330 let dir = setup_multiplicity_project();
23331 let session = dir.path().join("tasks/software/tsift.md");
23332 let report =
23333 build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
23334 .unwrap();
23335
23336 assert!(report.per_target_fail_closed.is_empty());
23337 let candidate = report
23338 .candidates
23339 .iter()
23340 .find(|candidate| candidate.target == "corepkg")
23341 .unwrap();
23342 assert!(
23343 candidate
23344 .owned_files
23345 .iter()
23346 .any(|file| file == "crates/core-lib/Cargo.toml"),
23347 "{:?}",
23348 candidate.owned_files
23349 );
23350 }
23351
23352 #[test]
23353 fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
23354 let dir = setup_traversal_project();
23355 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23356 let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
23357 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23358
23359 assert_eq!(job.kind, "job_packet");
23360 assert!(job.handle.starts_with("gjob-"));
23361 assert!(graph.edges.iter().any(|edge| {
23362 edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
23363 }));
23364
23365 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23366 let jobs = store.nodes_by_kind("job_packet").unwrap();
23367 assert!(
23368 jobs.iter()
23369 .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23370 "expected queued job packet in graph store, got {jobs:?}"
23371 );
23372 }
23373
23374 #[test]
23375 fn traversal_graph_includes_routes_and_handler_edges() {
23376 let dir = tempfile::tempdir().unwrap();
23377 std::fs::write(
23378 dir.path().join("api.py"),
23379 r#"@router.get("/items")
23380def list_items():
23381 return []
23382"#,
23383 )
23384 .unwrap();
23385 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23386 db.apply_changes(dir.path()).unwrap();
23387
23388 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23389 let route = resolve_traversal_node(&graph, "/items").unwrap();
23390 let handler = resolve_traversal_node(&graph, "list_items").unwrap();
23391
23392 assert_eq!(route.kind, "route");
23393 assert!(graph.edges.iter().any(|edge| {
23394 edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
23395 }));
23396 }
23397
23398 #[test]
23399 fn traversal_graph_projects_rust_ast_navigation_edges() {
23400 let dir = tempfile::tempdir().unwrap();
23401 std::fs::write(
23402 dir.path().join("main.rs"),
23403 r#"mod api {
23404 pub fn helper() {}
23405 pub fn handler() { helper(); }
23406}
23407
23408fn main() { api::handler(); }
23409"#,
23410 )
23411 .unwrap();
23412 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23413 db.apply_changes(dir.path()).unwrap();
23414
23415 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23416 let api = resolve_ast_span_node(&graph, "api", "mod");
23417 let helper = resolve_ast_span_node(&graph, "helper", "function");
23418 let handler = resolve_ast_span_node(&graph, "handler", "function");
23419
23420 assert_eq!(helper.kind, "ast_span");
23421 assert!(helper.handle.starts_with("span-"));
23422 assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
23423 assert!(graph.edges.iter().any(|edge| {
23424 edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
23425 }));
23426 assert!(graph.edges.iter().any(|edge| {
23427 edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
23428 }));
23429 assert!(graph.edges.iter().any(|edge| {
23430 edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
23431 }));
23432 assert!(graph.edges.iter().any(|edge| {
23433 edge.from == helper.handle
23434 && edge.to == handler.handle
23435 && edge.relation == "next_sibling"
23436 }));
23437 assert!(graph.edges.iter().any(|edge| {
23438 edge.from == handler.handle
23439 && edge.to == helper.handle
23440 && edge.relation == "previous_sibling"
23441 }));
23442 assert!(graph.edges.iter().any(|edge| {
23443 edge.from == helper.handle
23444 && edge.to == api.handle
23445 && edge.relation == "enclosing_module"
23446 }));
23447 assert!(graph.edges.iter().any(|edge| {
23448 edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
23449 }));
23450
23451 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23452 let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
23453 assert!(
23454 ast_nodes.iter().any(|node| node.id == helper.handle
23455 && node.properties.get("symbol_kind") == Some(&"function".to_string())),
23456 "expected helper AST span in graph store, got {ast_nodes:?}"
23457 );
23458 assert!(
23459 store
23460 .outgoing_edges(&helper.handle, Some("parent"))
23461 .unwrap()
23462 .iter()
23463 .any(|edge| edge.to_id == api.handle),
23464 "expected persisted AST parent edge"
23465 );
23466 }
23467
23468 #[test]
23469 fn traversal_graph_projects_markdown_section_block_edges() {
23470 let dir = tempfile::tempdir().unwrap();
23471 std::fs::write(
23472 dir.path().join("README.md"),
23473 "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
23474 )
23475 .unwrap();
23476 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23477 db.apply_changes(dir.path()).unwrap();
23478
23479 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23480 let guide = resolve_ast_span_node(&graph, "Guide", "heading");
23481 let code = resolve_ast_span_node(&graph, "rust", "code_block");
23482 let embedded = resolve_ast_span_node(&graph, "demo", "function");
23483 let list_item = graph
23484 .nodes
23485 .values()
23486 .find(|node| {
23487 node.kind == "ast_span"
23488 && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
23489 && node.properties.get("section_handle") == Some(&guide.handle)
23490 })
23491 .expect("missing Markdown list item AST span");
23492
23493 assert_eq!(
23494 code.properties.get("markdown_block_kind"),
23495 Some(&"fenced_code_block".to_string())
23496 );
23497 assert_eq!(
23498 guide.properties.get("heading_level"),
23499 Some(&"1".to_string())
23500 );
23501 assert_eq!(
23502 embedded.properties.get("embedded"),
23503 Some(&"true".to_string())
23504 );
23505 assert_eq!(
23506 embedded.properties.get("language"),
23507 Some(&"rust".to_string())
23508 );
23509 assert_eq!(
23510 embedded.properties.get("markdown_block_handle"),
23511 Some(&code.handle)
23512 );
23513 assert!(graph.edges.iter().any(|edge| {
23514 edge.from == guide.handle
23515 && edge.to == code.handle
23516 && edge.relation == "contains_markdown_block"
23517 }));
23518 assert!(graph.edges.iter().any(|edge| {
23519 edge.from == code.handle
23520 && edge.to == guide.handle
23521 && edge.relation == "enclosing_section"
23522 }));
23523 assert!(graph.edges.iter().any(|edge| {
23524 edge.from == guide.handle
23525 && edge.to == list_item.handle
23526 && edge.relation == "contains_markdown_block"
23527 }));
23528 assert!(graph.edges.iter().any(|edge| {
23529 edge.from == code.handle
23530 && edge.to == embedded.handle
23531 && edge.relation == "contains_embedded_symbol"
23532 }));
23533 assert!(graph.edges.iter().any(|edge| {
23534 edge.from == embedded.handle
23535 && edge.to == code.handle
23536 && edge.relation == "embedded_in_fence"
23537 }));
23538 assert!(graph.edges.iter().any(|edge| {
23539 edge.from == guide.handle
23540 && edge.to == embedded.handle
23541 && edge.relation == "contains_embedded_code"
23542 }));
23543
23544 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23545 assert!(
23546 store
23547 .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
23548 .unwrap()
23549 .iter()
23550 .any(|edge| edge.to_id == code.handle),
23551 "expected persisted Markdown section/block edge"
23552 );
23553 assert!(
23554 store
23555 .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
23556 .unwrap()
23557 .iter()
23558 .any(|edge| edge.to_id == embedded.handle),
23559 "expected persisted Markdown fence/embedded symbol edge"
23560 );
23561 }
23562
23563 #[test]
23564 fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
23565 let dir = setup_multilingual_ast_navigation_project();
23566 let db =
23567 index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
23568 let symbols = db.all_symbols().unwrap();
23569 let expected_symbols = [
23570 ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
23571 (
23572 "python",
23573 "fixture_nav_python_entry",
23574 "function",
23575 "python.py",
23576 ),
23577 (
23578 "typescript",
23579 "fixture_nav_typescript_entry",
23580 "function",
23581 "typescript.ts",
23582 ),
23583 (
23584 "javascript",
23585 "fixture_nav_javascript_entry",
23586 "function",
23587 "javascript.js",
23588 ),
23589 (
23590 "kotlin",
23591 "fixture_nav_kotlin_entry",
23592 "function",
23593 "kotlin.kt",
23594 ),
23595 ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
23596 ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
23597 ("markdown", "Fixture Section", "heading", "README.md"),
23598 ("markdown", "Fixture step", "list_item", "README.md"),
23599 ("markdown", "python", "code_block", "README.md"),
23600 ];
23601
23602 for (language, name, kind, file) in expected_symbols {
23603 let symbol = symbols
23604 .iter()
23605 .find(|symbol| {
23606 symbol.language == language
23607 && symbol.name == name
23608 && symbol.kind == kind
23609 && symbol.file.ends_with(file)
23610 })
23611 .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
23612 assert!(
23613 symbol.start_byte.is_some() && symbol.end_byte.is_some(),
23614 "{language} {name} should carry AST byte spans"
23615 );
23616 }
23617
23618 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23619 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23620 let expected_ast_nodes = [
23621 ("fixture_nav_rust_entry", "function", "rust"),
23622 ("fixture_nav_python_entry", "function", "python"),
23623 ("fixture_nav_typescript_entry", "function", "typescript"),
23624 ("fixture_nav_javascript_entry", "function", "javascript"),
23625 ("fixture_nav_kotlin_entry", "function", "kotlin"),
23626 ("fixture_nav_zig_entry", "function", "zig"),
23627 ("fixture_nav_bash_entry", "function", "bash"),
23628 ("Fixture Section", "heading", "markdown"),
23629 ("Fixture step", "list_item", "markdown"),
23630 ("python", "code_block", "markdown"),
23631 ("fixture_nav_markdown_embedded", "function", "python"),
23632 ];
23633
23634 for (name, kind, language) in expected_ast_nodes {
23635 let node = resolve_ast_span_node(&graph, name, kind);
23636 let repeated = resolve_ast_span_node(&graph_again, name, kind);
23637 assert!(
23638 node.handle.starts_with("span-"),
23639 "{name} handle: {}",
23640 node.handle
23641 );
23642 assert_eq!(
23643 node.handle, repeated.handle,
23644 "{language} {name} handle drifted"
23645 );
23646 assert_eq!(
23647 node.properties.get("language"),
23648 Some(&language.to_string()),
23649 "{name} should keep its language label"
23650 );
23651 }
23652
23653 let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
23654 let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
23655 let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
23656 assert!(graph.edges.iter().any(|edge| {
23657 edge.from == markdown_section.handle
23658 && edge.to == markdown_code.handle
23659 && edge.relation == "contains_markdown_block"
23660 }));
23661 assert!(graph.edges.iter().any(|edge| {
23662 edge.from == markdown_code.handle
23663 && edge.to == embedded.handle
23664 && edge.relation == "contains_embedded_symbol"
23665 }));
23666 assert!(
23667 graph.nodes.len() <= 80,
23668 "multilingual AST fixture should stay bounded, got {} nodes",
23669 graph.nodes.len()
23670 );
23671 assert!(
23672 graph.edges.len() <= 180,
23673 "multilingual AST fixture should stay bounded, got {} edges",
23674 graph.edges.len()
23675 );
23676
23677 let response = empty_search_response(dir.path(), "lexical");
23678 let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
23679 let report = build_relative_search_budget_report(
23680 "fixture_nav_python_entry",
23681 "lexical",
23682 dir.path(),
23683 &response,
23684 &symbol_hits,
23685 ResponseBudget::new(Some(8), Some(120)),
23686 &SearchFacetFilters::default(),
23687 );
23688 let report_again = build_relative_search_budget_report(
23689 "fixture_nav_python_entry",
23690 "lexical",
23691 dir.path(),
23692 &response,
23693 &symbol_hits,
23694 ResponseBudget::new(Some(8), Some(120)),
23695 &SearchFacetFilters::default(),
23696 );
23697
23698 let top = report
23699 .ranked
23700 .first()
23701 .expect("ranked preview should not be empty");
23702 assert_eq!(top.source, "symbol_span");
23703 assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
23704 assert!(top.handle.starts_with("srnk-"));
23705 assert_eq!(top.handle, report_again.ranked[0].handle);
23706 assert!(
23707 top.reasons.iter().any(|reason| reason == "ast_span"),
23708 "expected AST span ranking reason, got {:?}",
23709 top.reasons
23710 );
23711 assert!(report.ranked.len() <= 8);
23712 assert!(report.symbols.len() <= 8);
23713
23714 let symbol = report
23715 .symbols
23716 .iter()
23717 .find(|symbol| symbol.name == "fixture_nav_python_entry")
23718 .expect("missing search preview symbol");
23719 assert_cli_expand_command_parses(&symbol.expand);
23720 let ast = symbol
23721 .ast
23722 .as_ref()
23723 .expect("search symbol should expose AST");
23724 assert_cli_expand_command_parses(&ast.expand.source_window);
23725 assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
23726 assert_cli_expand_command_parses(&ast.expand.symbol_read);
23727
23728 let markdown_hits = db.symbol_search("python", 20).unwrap();
23729 let markdown_report = build_relative_search_budget_report(
23730 "python",
23731 "lexical",
23732 dir.path(),
23733 &response,
23734 &markdown_hits,
23735 ResponseBudget::new(Some(8), Some(120)),
23736 &SearchFacetFilters::default(),
23737 );
23738 let markdown_symbol = markdown_report
23739 .symbols
23740 .iter()
23741 .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
23742 .expect("missing Markdown code-block symbol");
23743 let markdown_ast = markdown_symbol
23744 .ast
23745 .as_ref()
23746 .expect("Markdown code block should expose AST");
23747 assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
23748 assert_eq!(
23749 markdown_ast
23750 .span
23751 .markdown
23752 .as_ref()
23753 .unwrap()
23754 .embedded_symbols[0]
23755 .name,
23756 "fixture_nav_markdown_embedded"
23757 );
23758 }
23759
23760 #[test]
23761 fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
23762 let edges = vec![
23763 TraversalEdge {
23764 from: "origin".to_string(),
23765 to: "aaa_low".to_string(),
23766 relation: "unknown".to_string(),
23767 label: None,
23768 weight: 1,
23769 },
23770 TraversalEdge {
23771 from: "origin".to_string(),
23772 to: "zzz_high".to_string(),
23773 relation: "mentions".to_string(),
23774 label: None,
23775 weight: 1,
23776 },
23777 ];
23778
23779 let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
23780
23781 assert!(handles.contains("origin"));
23782 assert!(handles.contains("zzz_high"), "{handles:?}");
23783 assert!(!handles.contains("aaa_low"), "{handles:?}");
23784 }
23785
23786 #[test]
23787 fn traversal_materializes_provider_neutral_sqlite_graph() {
23788 let dir = setup_traversal_project();
23789 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23790 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23791
23792 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23793 let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
23794 assert!(
23795 backlog_nodes.iter().any(|node| node.id == backlog.handle
23796 && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23797 "expected materialized backlog node, got {backlog_nodes:?}"
23798 );
23799 assert!(
23800 store
23801 .all_nodes()
23802 .unwrap()
23803 .iter()
23804 .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
23805 && node.properties.get("projection_version")
23806 == Some(&GRAPH_PROJECTION_VERSION.to_string())),
23807 "expected projection metadata node"
23808 );
23809 let source_handles = store.nodes_by_kind("source_handle").unwrap();
23810 assert!(
23811 source_handles
23812 .iter()
23813 .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
23814 "expected bounded source_handle rows, got {source_handles:?}"
23815 );
23816 let worker_context = store.nodes_by_kind("worker_context").unwrap();
23817 assert!(
23818 worker_context
23819 .iter()
23820 .any(|node| node.properties.get("target")
23821 == Some(&"tasks/software/tsift.md".to_string())),
23822 "expected bounded worker_context rows, got {worker_context:?}"
23823 );
23824 let worker_results = store.nodes_by_kind("worker_result").unwrap();
23825 assert!(
23826 worker_results.iter().any(|node| {
23827 node.properties.get("ref_id") == Some(&"kgnv".to_string())
23828 && node.properties.get("status") == Some(&"completed".to_string())
23829 && node.properties.get("touched_files") == Some(&"main.rs".to_string())
23830 && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
23831 }),
23832 "expected worker_result rows, got {worker_results:?}"
23833 );
23834 }
23835
23836 #[test]
23837 fn traversal_projection_materializes_cached_semantic_rows() {
23838 let dir = setup_traversal_project();
23839 seed_traversal_semantic_summaries(dir.path());
23840 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23841 let helper = resolve_traversal_node(&graph, "helper").unwrap();
23842 let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
23843 let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
23844
23845 assert_eq!(concept.kind, "semantic_concept");
23846 assert_eq!(entity.kind, "semantic_entity");
23847 assert!(concept.handle.starts_with("gcon-"));
23848 assert!(entity.handle.starts_with("gent-"));
23849
23850 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23851 assert!(
23852 store
23853 .nodes_by_kind("semantic_concept")
23854 .unwrap()
23855 .iter()
23856 .any(|node| node.label == "semantic extraction"
23857 && node.properties.contains_key("embedding")),
23858 "expected persisted concept embeddings"
23859 );
23860 assert!(
23861 store
23862 .outgoing_edges(&helper.handle, Some("mentions_concept"))
23863 .unwrap()
23864 .iter()
23865 .any(|edge| edge.to_id == concept.handle),
23866 "expected helper symbol to link to cached summary concept"
23867 );
23868 assert!(
23869 store
23870 .outgoing_edges(
23871 &semantic_entity_handle("helper", "function"),
23872 Some("semantic_relation")
23873 )
23874 .unwrap()
23875 .iter()
23876 .any(|edge| edge.to_id == entity.handle
23877 && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
23878 "expected LLM relationship rows projected into GraphStore"
23879 );
23880 }
23881
23882 #[test]
23883 fn traversal_projection_materializes_tsift_memory_rows() {
23884 let dir = setup_traversal_project();
23885 seed_tsift_memory_graph_db(dir.path());
23886 let memory_db = dir.path().join(".tsift").join("memory.db");
23887 let store = MemoryStore::open_or_create(&memory_db).unwrap();
23888 for summary in ["first closeout", "second closeout"] {
23889 let event = MemoryEvent::new(
23890 MemoryEventKind::ResponseSummary,
23891 "tasks/software/tsift.md",
23892 summary,
23893 )
23894 .with_session_id("tasks/software/tsift.md")
23895 .with_observed_at_unix(1_700_000_100);
23896 store.insert_event(&event).unwrap();
23897 }
23898 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23899 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23900
23901 let native_sources = store
23902 .nodes_by_kind("source_handle")
23903 .unwrap()
23904 .into_iter()
23905 .filter(|node| {
23906 node.properties.get("provider") == Some(&"tsift-memory".to_string())
23907 && node.properties.get("source_ref")
23908 == Some(&"tasks/software/tsift.md".to_string())
23909 })
23910 .collect::<Vec<_>>();
23911 assert_eq!(
23912 native_sources.len(),
23913 2,
23914 "same-source native memory events must get distinct source handles"
23915 );
23916
23917 let source = store
23918 .nodes_by_kind("source_handle")
23919 .unwrap()
23920 .into_iter()
23921 .find(|node| {
23922 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23923 })
23924 .expect("expected tsift-memory source handle");
23925 let session = store
23926 .nodes_by_kind("memory_session")
23927 .unwrap()
23928 .into_iter()
23929 .find(|node| {
23930 node.properties.get("provider") == Some(&"tsift-memory".to_string())
23931 && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
23932 })
23933 .expect("expected tsift-memory session node");
23934 let event = store
23935 .nodes_by_kind("memory_event")
23936 .unwrap()
23937 .into_iter()
23938 .find(|node| {
23939 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23940 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
23941 && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
23942 })
23943 .expect("expected tsift-memory event node");
23944 let concept = store
23945 .nodes_by_kind("semantic_concept")
23946 .unwrap()
23947 .into_iter()
23948 .find(|node| {
23949 node.properties.get("provider") == Some(&"tsift-memory".to_string())
23950 && node.label.contains("Graph memory adapter")
23951 && node.properties.contains_key("embedding")
23952 })
23953 .expect("expected tsift-memory semantic concept");
23954
23955 assert!(
23956 store
23957 .outgoing_edges(&session.id, Some("records_memory_source"))
23958 .unwrap()
23959 .iter()
23960 .any(|edge| edge.to_id == source.id),
23961 "expected session to link to source handle"
23962 );
23963 assert!(
23964 store
23965 .outgoing_edges(&session.id, Some("records_memory_event"))
23966 .unwrap()
23967 .iter()
23968 .any(|edge| edge.to_id == event.id),
23969 "expected session to link to memory event"
23970 );
23971 assert!(
23972 store
23973 .outgoing_edges(&event.id, Some("projects_source"))
23974 .unwrap()
23975 .iter()
23976 .any(|edge| edge.to_id == source.id),
23977 "expected memory event to project source handle"
23978 );
23979 assert!(
23980 store
23981 .outgoing_edges(&source.id, Some("mentions_concept"))
23982 .unwrap()
23983 .iter()
23984 .any(|edge| edge.to_id == concept.id),
23985 "expected source handle to seed semantic concept"
23986 );
23987
23988 let related = semantic_related_report_from_store(
23989 dir.path(),
23990 None,
23991 "tsift memory graph adapter",
23992 5,
23993 SemanticRelatedKind::Concept,
23994 &store,
23995 )
23996 .unwrap();
23997 assert!(
23998 related
23999 .items
24000 .iter()
24001 .any(|item| item.handle == concept.id && item.score > 0.0),
24002 "expected semantic query to retrieve tsift-memory concept, got {:?}",
24003 related.items
24004 );
24005
24006 let graph_related = graph_db_report_from_store(
24007 dir.path(),
24008 None,
24009 "sqlite",
24010 GraphDbQuery::Related {
24011 query: "tsift memory graph adapter".to_string(),
24012 kind: SemanticRelatedKind::Concept,
24013 depth: 1,
24014 seed_limit: 5,
24015 limit: 20,
24016 },
24017 &store,
24018 sqlite_graph_freshness(&store, "root").unwrap(),
24019 Vec::new(),
24020 )
24021 .unwrap();
24022 assert_eq!(
24023 graph_related
24024 .readiness
24025 .as_ref()
24026 .map(|readiness| readiness.status.as_str()),
24027 Some("ready"),
24028 "tsift-memory semantic rows should satisfy graph-db related readiness"
24029 );
24030 assert!(
24031 graph_related.nodes.iter().any(|node| {
24032 node.kind == "semantic_concept"
24033 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24034 }),
24035 "expected related graph output to include tsift-memory semantic rows"
24036 );
24037 }
24038
24039 #[test]
24040 fn semantic_related_query_uses_persisted_graph_embeddings() {
24041 let dir = setup_traversal_project();
24042 seed_traversal_semantic_summaries(dir.path());
24043 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24044 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24045 let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
24046 .unwrap()
24047 .query_row(
24048 "SELECT COUNT(*) FROM graph_node_semantic_vectors",
24049 [],
24050 |row| row.get(0),
24051 )
24052 .unwrap();
24053 assert!(semantic_vector_rows > 0);
24054
24055 let report = semantic_related_report_from_store(
24056 dir.path(),
24057 None,
24058 "graph navigation",
24059 5,
24060 SemanticRelatedKind::Concept,
24061 &store,
24062 )
24063 .unwrap();
24064
24065 assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
24066 assert!(
24067 report
24068 .items
24069 .iter()
24070 .any(|item| item.label == "graph navigation"
24071 && item.kind == "semantic_concept"
24072 && item.score > 0.9),
24073 "expected nearest concept match from graph embeddings, got {:?}",
24074 report.items
24075 );
24076 }
24077
24078 #[test]
24079 fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
24080 let dir = setup_traversal_project();
24081 seed_traversal_semantic_summaries(dir.path());
24082 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24083 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24084
24085 let report = graph_db_report_from_store(
24086 dir.path(),
24087 None,
24088 "sqlite",
24089 GraphDbQuery::Related {
24090 query: "graph navigation".to_string(),
24091 kind: SemanticRelatedKind::All,
24092 depth: 1,
24093 seed_limit: 2,
24094 limit: 20,
24095 },
24096 &store,
24097 sqlite_graph_freshness(&store, "root").unwrap(),
24098 Vec::new(),
24099 )
24100 .unwrap();
24101
24102 let knowledge = report.knowledge_retrieval.as_ref().unwrap();
24103 assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
24104 assert_eq!(knowledge.seed_kind, "all");
24105 assert_eq!(knowledge.depth, 1);
24106 assert_eq!(
24107 report
24108 .readiness
24109 .as_ref()
24110 .map(|readiness| readiness.status.as_str()),
24111 Some("ready")
24112 );
24113 assert!(
24114 knowledge
24115 .diagnostics
24116 .iter()
24117 .any(|diagnostic| diagnostic.contains("incident"))
24118 );
24119 assert!(
24120 report
24121 .semantic_related
24122 .iter()
24123 .any(|item| item.label == "graph navigation"
24124 && item.kind == "semantic_concept"
24125 && item.score > 0.9),
24126 "expected natural-language query to seed the graph navigation concept, got {:?}",
24127 report.semantic_related
24128 );
24129 assert!(
24130 report
24131 .nodes
24132 .iter()
24133 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
24134 );
24135 assert!(
24136 report
24137 .nodes
24138 .iter()
24139 .any(|node| node.kind == "symbol" && node.label == "helper"),
24140 "incident expansion from semantic seed should recover source symbols, got {:?}",
24141 report
24142 .nodes
24143 .iter()
24144 .map(|node| (&node.kind, &node.label))
24145 .collect::<Vec<_>>()
24146 );
24147 assert!(
24148 report
24149 .edges
24150 .iter()
24151 .any(|edge| edge.kind == "mentions_concept")
24152 );
24153 assert!(
24154 report.output_budget.as_ref().is_some_and(|budget| budget
24155 .diagnostics
24156 .iter()
24157 .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
24158 "expected related output budget diagnostics, got {:?}",
24159 report.output_budget
24160 );
24161 }
24162
24163 #[test]
24164 fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
24165 let dir = setup_graph_index();
24166 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24167 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24168
24169 let report = graph_db_report_from_store(
24170 dir.path(),
24171 None,
24172 "sqlite",
24173 GraphDbQuery::Related {
24174 query: "graph navigation".to_string(),
24175 kind: SemanticRelatedKind::All,
24176 depth: 1,
24177 seed_limit: 2,
24178 limit: 20,
24179 },
24180 &store,
24181 sqlite_graph_freshness(&store, "root").unwrap(),
24182 Vec::new(),
24183 )
24184 .unwrap();
24185
24186 let readiness = report.readiness.as_ref().unwrap();
24187 assert_eq!(readiness.status, "blocked");
24188 assert_eq!(readiness.reason, "summary_cache_empty");
24189 assert!(readiness.fail_closed);
24190 assert_eq!(
24191 readiness.next_commands,
24192 vec![
24193 "tsift summarize --extract .".to_string(),
24194 graph_db_refresh_command(dir.path(), None)
24195 ]
24196 );
24197 assert!(
24198 report
24199 .knowledge_retrieval
24200 .as_ref()
24201 .unwrap()
24202 .diagnostics
24203 .iter()
24204 .any(|diagnostic| diagnostic.contains("summary cache empty")
24205 && diagnostic.contains("graph-db materialized code/session rows")),
24206 "expected related diagnostics to carry readiness gate, got {:?}",
24207 report.knowledge_retrieval.as_ref().unwrap().diagnostics
24208 );
24209 }
24210
24211 #[test]
24212 fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
24213 let mut nodes = vec![
24214 SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
24215 SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
24216 ];
24217 let mut edges = vec![SubstrateGraphEdge::new(
24218 "zzz_high",
24219 "seed",
24220 "mentions_concept",
24221 )];
24222 for idx in 0..24 {
24223 let id = format!("aaa_low_{idx:02}");
24224 nodes.push(SubstrateGraphNode::new(
24225 id.clone(),
24226 "note",
24227 format!("low {idx}"),
24228 ));
24229 edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
24230 }
24231 let mut store = SqliteGraphStore::in_memory().unwrap();
24232 store
24233 .replace_projection(&GraphProjection { nodes, edges })
24234 .unwrap();
24235
24236 let subgraph =
24237 graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
24238
24239 assert_eq!(subgraph.nodes.len(), 3);
24240 assert_eq!(subgraph.nodes[0].id, "seed");
24241 assert_eq!(
24242 subgraph.nodes[1].id, "zzz_high",
24243 "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
24244 subgraph.nodes
24245 );
24246 assert!(subgraph.truncated);
24247 assert!(
24248 subgraph
24249 .diagnostics
24250 .iter()
24251 .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
24252 "{:?}",
24253 subgraph.diagnostics
24254 );
24255 assert!(
24256 subgraph
24257 .diagnostics
24258 .iter()
24259 .any(|diagnostic| diagnostic.contains("skipped")),
24260 "{:?}",
24261 subgraph.diagnostics
24262 );
24263 }
24264
24265 #[test]
24266 fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
24267 let dir = setup_traversal_project();
24268 seed_traversal_semantic_summaries(dir.path());
24269 init_git_repo(dir.path());
24270 let session = dir.path().join("tasks/software/tsift.md");
24271 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24272 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24273 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24274 let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24275 root: dir.path(),
24276 scope: None,
24277 backend: "sqlite",
24278 target: "kgnv",
24279 preferred_path: None,
24280 depth: 4,
24281 limit: 8,
24282 cursor: None,
24283 store: &store,
24284 freshness,
24285 warnings: Vec::new(),
24286 })
24287 .unwrap();
24288 assert!(
24289 evidence
24290 .semantic_related
24291 .iter()
24292 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
24293 "expected semantic evidence rows, got {:?}",
24294 evidence
24295 .semantic_related
24296 .iter()
24297 .map(|node| (&node.kind, &node.label))
24298 .collect::<Vec<_>>()
24299 );
24300 assert!(
24301 evidence
24302 .output_budget
24303 .as_ref()
24304 .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
24305 diagnostic.contains("semantic_match")
24306 && diagnostic.contains("source_handle_coverage")
24307 })),
24308 "expected evidence output budget diagnostics, got {:?}",
24309 evidence.output_budget
24310 );
24311
24312 let cached_diff = diff_digest::compute(
24313 dir.path(),
24314 diff_digest::DiffDigestOptions {
24315 cached: true,
24316 revision: None,
24317 max_parsed_files: None,
24318 },
24319 )
24320 .unwrap();
24321 let impact_report = impact::compute(
24322 dir.path(),
24323 impact::ImpactOptions {
24324 cached: true,
24325 revision: None,
24326 scope: None,
24327 limit: 10,
24328 },
24329 )
24330 .unwrap();
24331 let graph_nodes = store.all_nodes().unwrap();
24332 let graph_index = conflict_matrix_graph_index(&graph_nodes);
24333 let semantic_candidate = conflict_matrix_candidate_from_evidence(
24334 dir.path(),
24335 &evidence,
24336 &graph_index,
24337 &cached_diff,
24338 &impact_report,
24339 );
24340 assert!(semantic_candidate.semantic_dispatch_score > 0);
24341 assert!(
24342 semantic_candidate
24343 .semantic_dispatch_reasons
24344 .iter()
24345 .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
24346 "expected semantic ranking explanations, got {:?}",
24347 semantic_candidate.semantic_dispatch_reasons
24348 );
24349 assert!(
24350 semantic_candidate
24351 .semantic_related
24352 .iter()
24353 .any(|item| item.label == "graph navigation")
24354 );
24355
24356 let mut plain_candidate = semantic_candidate.clone();
24357 plain_candidate.target = "plain".to_string();
24358 plain_candidate.semantic_related.clear();
24359 plain_candidate.semantic_dispatch_score = 0;
24360 plain_candidate.semantic_dispatch_reasons.clear();
24361 let mut ranked = [plain_candidate, semantic_candidate];
24362 ranked.sort_by(|left, right| {
24363 left.risk
24364 .cmp(&right.risk)
24365 .then_with(|| left.risk_score.cmp(&right.risk_score))
24366 .then_with(|| {
24367 right
24368 .semantic_dispatch_score
24369 .cmp(&left.semantic_dispatch_score)
24370 })
24371 .then_with(|| left.target.cmp(&right.target))
24372 });
24373 assert_eq!(ranked[0].target, "kgnv");
24374 }
24375
24376 #[test]
24377 fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
24378 let dir = setup_dependency_dag_project();
24379 let session = dir.path().join("tasks/software/tsift.md");
24380 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24381
24382 assert_eq!(report.contract_version, "dependency-dag-v1");
24383 assert_eq!(
24384 report.targets,
24385 vec![
24386 "prep".to_string(),
24387 "alpha".to_string(),
24388 "beta".to_string(),
24389 "gamma".to_string()
24390 ]
24391 );
24392 assert!(report.edges.iter().any(|edge| {
24393 edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
24394 }));
24395 assert!(report.edges.iter().any(|edge| {
24396 edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
24397 }));
24398 assert!(report.edges.iter().any(|edge| {
24399 edge.from == "alpha"
24400 && edge.to == "beta"
24401 && edge.kind == "shared_resource"
24402 && edge.shared_files.contains(&"main.rs".to_string())
24403 && edge.shared_symbols.contains(&"shared_helper".to_string())
24404 }));
24405 assert!(
24406 !report.cycle_diagnostics.has_cycles,
24407 "{:?}",
24408 report.cycle_diagnostics
24409 );
24410 assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
24411 assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
24412 assert!(
24413 report.replay_commands[0].contains("dependency-dag"),
24414 "{:?}",
24415 report.replay_commands
24416 );
24417
24418 cmd_dependency_dag(
24419 &session,
24420 None,
24421 &["alpha".to_string(), "beta".to_string()],
24422 4,
24423 12,
24424 OutputFormat {
24425 json_output: true,
24426 compact: false,
24427 pretty: false,
24428 terse: false,
24429 ultra_terse: false,
24430 schema: false,
24431 envelope: false,
24432 },
24433 )
24434 .unwrap();
24435 }
24436
24437 #[test]
24438 fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
24439 let dir = setup_dependency_dag_cycle_project();
24440 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24441
24442 assert!(report.cycle_diagnostics.has_cycles);
24443 assert_eq!(
24444 report.cycle_diagnostics.blocked_nodes,
24445 vec!["left".to_string(), "right".to_string()]
24446 );
24447 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24448 edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
24449 }));
24450 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24451 edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
24452 }));
24453 }
24454
24455 #[test]
24456 fn traversal_projection_queries_match_sqlite_and_convex_stores() {
24457 let dir = setup_traversal_project();
24458 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24459 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
24460
24461 let mut sqlite = SqliteGraphStore::in_memory().unwrap();
24462 sqlite.replace_projection(&projection).unwrap();
24463 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24464 projection.upsert_into(&convex).unwrap();
24465
24466 let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
24467 let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
24468 assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
24469 assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
24470
24471 let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
24472 let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
24473 assert!(convex_graph.edges.iter().any(|edge| {
24474 edge.from == sqlite_backlog.handle
24475 && edge.to == convex_helper.handle
24476 && edge.relation == "mentions"
24477 }));
24478 }
24479
24480 #[test]
24481 fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
24482 let dir = setup_traversal_project();
24483 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24484 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24485 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24486 assert_eq!(freshness.status, "current");
24487
24488 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24489 let report = graph_db_report_from_store(
24490 dir.path(),
24491 None,
24492 "sqlite",
24493 GraphDbQuery::Neighborhood {
24494 id: backlog.handle.clone(),
24495 depth: 1,
24496 edge_kind: Some("mentions".to_string()),
24497 cursor: None,
24498 limit: None,
24499 property_filters: Vec::new(),
24500 },
24501 &store,
24502 freshness,
24503 Vec::new(),
24504 )
24505 .unwrap();
24506 assert!(
24507 report
24508 .edges
24509 .iter()
24510 .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
24511 "expected backlog mention edge, got {:?}",
24512 report.edges
24513 );
24514 assert!(
24515 report.ranked_neighbors.iter().any(|neighbor| {
24516 neighbor.depth == Some(1)
24517 && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
24518 && neighbor.node_id != backlog.handle
24519 && neighbor.handle_coverage_pct >= 95.0
24520 && neighbor.duplicate_name_precision >= 0.99
24521 }),
24522 "expected ranked neighborhood neighbors with quality scores, got {:?}",
24523 report.ranked_neighbors
24524 );
24525 assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
24526 let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
24527 assert!(!ranking_gate.ranked_output_default);
24528 assert_eq!(ranking_gate.default_order, "stable_node_id");
24529 assert!(
24530 ranking_gate
24531 .diagnostics
24532 .iter()
24533 .any(|diagnostic| diagnostic.contains("score-capped")),
24534 "{ranking_gate:?}"
24535 );
24536 assert!(
24537 ranking_gate
24538 .required_metrics
24539 .iter()
24540 .any(|metric| metric == "handle_coverage_pct")
24541 );
24542 assert!(
24543 ranking_gate
24544 .required_metrics
24545 .iter()
24546 .any(|metric| metric == "duplicate_name_precision")
24547 );
24548 assert!(
24549 report
24550 .page
24551 .as_ref()
24552 .unwrap()
24553 .diagnostics
24554 .iter()
24555 .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
24556 "expected SQLite neighborhood query plan diagnostics, got {:?}",
24557 report.page.as_ref().unwrap().diagnostics
24558 );
24559 let edges_report = graph_db_report_from_store(
24560 dir.path(),
24561 None,
24562 "sqlite",
24563 GraphDbQuery::Edges {
24564 edge_kind: Some("mentions".to_string()),
24565 cursor: None,
24566 limit: Some(2),
24567 property_filters: Vec::new(),
24568 },
24569 &store,
24570 sqlite_graph_freshness(&store, "root").unwrap(),
24571 Vec::new(),
24572 )
24573 .unwrap();
24574 let edge_id = edges_report
24575 .edges
24576 .first()
24577 .map(|edge| edge.id.clone())
24578 .expect("expected at least one paged mentions edge");
24579 assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
24580 assert_eq!(
24581 edges_report.page.as_ref().unwrap().returned_edges,
24582 edges_report.edges.len()
24583 );
24584
24585 let edge_report = graph_db_report_from_store(
24586 dir.path(),
24587 None,
24588 "sqlite",
24589 GraphDbQuery::Edge {
24590 id: edge_id.clone(),
24591 },
24592 &store,
24593 sqlite_graph_freshness(&store, "root").unwrap(),
24594 Vec::new(),
24595 )
24596 .unwrap();
24597 assert_eq!(
24598 edge_report
24599 .edge
24600 .as_ref()
24601 .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
24602 Some(edge_id.clone())
24603 );
24604
24605 let incident_report = graph_db_report_from_store(
24606 dir.path(),
24607 None,
24608 "sqlite",
24609 GraphDbQuery::Incident {
24610 id: backlog.handle.clone(),
24611 edge_kind: Some("mentions".to_string()),
24612 cursor: None,
24613 limit: Some(1),
24614 property_filters: Vec::new(),
24615 },
24616 &store,
24617 sqlite_graph_freshness(&store, "root").unwrap(),
24618 Vec::new(),
24619 )
24620 .unwrap();
24621 assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
24622 assert!(
24623 incident_report
24624 .edges
24625 .iter()
24626 .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
24627 "{:?}",
24628 incident_report.edges
24629 );
24630
24631 let schema_report = graph_db_report_from_store(
24632 dir.path(),
24633 None,
24634 "sqlite",
24635 GraphDbQuery::Schema,
24636 &store,
24637 sqlite_graph_freshness(&store, "root").unwrap(),
24638 Vec::new(),
24639 )
24640 .unwrap();
24641 assert!(
24642 schema_report
24643 .schema
24644 .unwrap()
24645 .operations
24646 .iter()
24647 .any(|operation| operation.command.starts_with("neighborhood"))
24648 );
24649 }
24650
24651 #[test]
24652 fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
24653 let mut nodes = vec![SubstrateGraphNode::new(
24654 "origin",
24655 "backlog",
24656 "#budgeted-neighborhood",
24657 )];
24658 let mut edges = Vec::new();
24659 for idx in 0..32 {
24660 let id = format!("src-{idx:02}");
24661 nodes.push(
24662 SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
24663 .with_property("source_ref", format!("fixture:{idx}"))
24664 .with_property("detail", "x".repeat(600)),
24665 );
24666 edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
24667 }
24668 let store = SqliteGraphStore::in_memory().unwrap();
24669 GraphProjection { nodes, edges }
24670 .upsert_into(&store)
24671 .unwrap();
24672
24673 let report = graph_db_report_from_store(
24674 Path::new("."),
24675 None,
24676 "fixture",
24677 GraphDbQuery::Neighborhood {
24678 id: "origin".to_string(),
24679 depth: 1,
24680 edge_kind: None,
24681 cursor: None,
24682 limit: None,
24683 property_filters: Vec::new(),
24684 },
24685 &store,
24686 current_graph_db_freshness(),
24687 Vec::new(),
24688 )
24689 .unwrap();
24690 let budget = report.output_budget.as_ref().unwrap();
24691 assert!(budget.selected_nodes < budget.candidate_nodes);
24692 assert!(
24693 budget.dropped_by_budget.iter().any(|drop| {
24694 drop.item == "node"
24695 && drop.kind == "source_handle"
24696 && drop.reason == "per_kind_quota"
24697 }),
24698 "expected source_handle budget drops, got {:?}",
24699 budget.dropped_by_budget
24700 );
24701 assert!(report.page.as_ref().unwrap().truncated);
24702 assert!(
24703 report
24704 .page
24705 .as_ref()
24706 .unwrap()
24707 .diagnostics
24708 .iter()
24709 .any(|diagnostic| diagnostic.contains("budget ranking signals")),
24710 "{:?}",
24711 report.page
24712 );
24713 }
24714
24715 #[test]
24716 fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
24717 let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
24718 let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
24719 for idx in 0..8 {
24720 let id = format!("far-{idx:02}");
24721 nodes.push(SubstrateGraphNode::new(
24722 id.clone(),
24723 "note",
24724 format!("aaa deeper row {idx}"),
24725 ));
24726 depth_by_id.insert(id, 6);
24727 }
24728
24729 let origin_ids = vec!["target".to_string()];
24730 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
24731 &origin_ids,
24732 &BTreeMap::new(),
24733 nodes,
24734 Vec::new(),
24735 Some(3),
24736 Some(&depth_by_id),
24737 None,
24738 );
24739
24740 assert!(
24741 budgeted.nodes.iter().any(|node| node.id == "near"),
24742 "expected the shallow evidence row to outrank deeper rows, got {:?}",
24743 budgeted
24744 .nodes
24745 .iter()
24746 .map(|node| (&node.id, &node.label))
24747 .collect::<Vec<_>>()
24748 );
24749 assert!(
24750 budgeted.report.dropped_by_budget.iter().any(|drop| {
24751 drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
24752 }),
24753 "expected node quota drops, got {:?}",
24754 budgeted.report.dropped_by_budget
24755 );
24756 assert!(
24757 budgeted
24758 .report
24759 .diagnostics
24760 .iter()
24761 .any(|diagnostic| diagnostic.contains("depth")),
24762 "{:?}",
24763 budgeted.report.diagnostics
24764 );
24765 }
24766
24767 #[test]
24768 fn evidence_pagination_returns_next_cursor_when_truncated() {
24769 let mut nodes = vec![SubstrateGraphNode::new(
24770 "target".to_string(),
24771 "backlog_item",
24772 "target item".to_string(),
24773 )];
24774 let mut depth_by_id = BTreeMap::new();
24775 depth_by_id.insert("target".to_string(), 0);
24776 for idx in 0..20 {
24777 let id = format!("ev-{idx}");
24778 nodes.push(
24779 SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
24780 .with_property("detail", "x".repeat(400)),
24781 );
24782 depth_by_id.insert(id, 1);
24783 }
24784 let origin_ids = vec!["target".to_string()];
24785 let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
24786 &origin_ids,
24787 &BTreeMap::new(),
24788 nodes.clone(),
24789 Vec::new(),
24790 Some(3),
24791 Some(&depth_by_id),
24792 None,
24793 );
24794 assert!(
24795 first_page.truncated,
24796 "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
24797 first_page.nodes.len(),
24798 first_page.report.candidate_nodes
24799 );
24800 assert!(
24801 first_page.next_cursor.is_some(),
24802 "expected next_cursor when truncated"
24803 );
24804 let cursor = first_page.next_cursor.unwrap();
24805 assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
24806 let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
24807 let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
24808 &origin_ids,
24809 &BTreeMap::new(),
24810 nodes.clone(),
24811 Vec::new(),
24812 Some(3),
24813 Some(&depth_by_id),
24814 Some(&cursor),
24815 );
24816 let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
24817 let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
24818 assert!(
24819 overlap.is_empty(),
24820 "pages should not overlap, but found shared ids: {overlap:?}"
24821 );
24822 assert!(
24823 second_page
24824 .report
24825 .diagnostics
24826 .iter()
24827 .any(|d| d.contains("cursor skipped")),
24828 "expected cursor skip diagnostic, got {:?}",
24829 second_page.report.diagnostics
24830 );
24831 }
24832
24833 #[test]
24834 fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
24835 let mut nodes = vec![SubstrateGraphNode::new(
24836 "target".to_string(),
24837 "backlog_item",
24838 "target item".to_string(),
24839 )];
24840 let mut depth_by_id = BTreeMap::new();
24841 depth_by_id.insert("target".to_string(), 0);
24842 for idx in 0..3 {
24843 let id = format!("ev-{idx}");
24844 nodes.push(SubstrateGraphNode::new(
24845 id.clone(),
24846 "source_handle",
24847 format!("evidence row {idx}"),
24848 ));
24849 depth_by_id.insert(id, 1);
24850 }
24851 let origin_ids = vec!["target".to_string()];
24852 let result = graph_db_apply_output_budget_with_depths_and_cursor(
24853 &origin_ids,
24854 &BTreeMap::new(),
24855 nodes,
24856 Vec::new(),
24857 None,
24858 Some(&depth_by_id),
24859 None,
24860 );
24861 assert!(
24862 !result.truncated,
24863 "expected no truncation with small candidate set and default budget"
24864 );
24865 assert!(
24866 result.next_cursor.is_none(),
24867 "expected no next_cursor when not truncated"
24868 );
24869 }
24870
24871 #[test]
24872 fn evidence_pagination_invalid_cursor_returns_first_page() {
24873 let mut nodes = vec![SubstrateGraphNode::new(
24874 "target".to_string(),
24875 "backlog_item",
24876 "target item".to_string(),
24877 )];
24878 let mut depth_by_id = BTreeMap::new();
24879 depth_by_id.insert("target".to_string(), 0);
24880 for idx in 0..5 {
24881 let id = format!("ev-{idx}");
24882 nodes.push(SubstrateGraphNode::new(
24883 id.clone(),
24884 "source_handle",
24885 format!("evidence row {idx}"),
24886 ));
24887 depth_by_id.insert(id, 1);
24888 }
24889 let origin_ids = vec!["target".to_string()];
24890 let result = graph_db_apply_output_budget_with_depths_and_cursor(
24891 &origin_ids,
24892 &BTreeMap::new(),
24893 nodes.clone(),
24894 Vec::new(),
24895 None,
24896 Some(&depth_by_id),
24897 Some("nonexistent-id"),
24898 );
24899 assert!(
24900 result
24901 .report
24902 .diagnostics
24903 .iter()
24904 .any(|d| d.contains("cursor skipped 0")),
24905 "invalid cursor should skip 0 candidates, got {:?}",
24906 result.report.diagnostics
24907 );
24908 }
24909
24910 #[test]
24911 fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
24912 let dir = setup_traversal_project();
24913 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24914 let graph_db = dir.path().join(".tsift/graph.db");
24915 let _lock = hold_rollback_journal_lock(&graph_db);
24916
24917 let report =
24918 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24919 .unwrap();
24920
24921 assert_eq!(report.status, "current");
24922 assert_eq!(
24923 report.recovery,
24924 Some(index::ReadOnlyRecovery::SnapshotFallback)
24925 );
24926 assert!(
24927 report
24928 .warnings
24929 .iter()
24930 .any(|warning| warning.contains("rollback-journal lock")),
24931 "expected rollback-journal recovery warning, got {:?}",
24932 report.warnings
24933 );
24934 }
24935
24936 #[test]
24937 fn graph_db_status_copies_wal_sidecars_when_locked() {
24938 let dir = setup_traversal_project();
24939 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24940 let graph_db = dir.path().join(".tsift/graph.db");
24941 let _lock = hold_wal_database_lock(&graph_db);
24942
24943 let report =
24944 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24945 .unwrap();
24946
24947 assert_eq!(report.status, "current");
24948 assert_eq!(
24949 report.recovery,
24950 Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
24951 );
24952 assert!(
24953 report
24954 .warnings
24955 .iter()
24956 .any(|warning| warning.contains("WAL-aware snapshot fallback")),
24957 "expected WAL recovery warning, got {:?}",
24958 report.warnings
24959 );
24960 }
24961
24962 #[test]
24963 fn graph_db_doctor_reports_snapshot_fallback_when_rollback_journal_is_locked() {
24964 let dir = setup_traversal_project();
24965 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24966 let graph_db = dir.path().join(".tsift/graph.db");
24967 let _lock = hold_rollback_journal_lock(&graph_db);
24968
24969 let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
24970 append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
24971 report.finalize();
24972
24973 assert_eq!(report.status, "ok");
24974 assert!(!report.fail_closed);
24975 let recovery_check = report
24976 .checks
24977 .iter()
24978 .find(|check| check.name == "sqlite_graph_db_read_recovery")
24979 .expect("doctor should include read recovery diagnostic");
24980 assert_eq!(recovery_check.status, "recovered");
24981 assert!(
24982 recovery_check
24983 .diagnostics
24984 .iter()
24985 .any(|diagnostic| diagnostic.contains("rollback-journal lock")),
24986 "expected rollback-journal recovery diagnostic, got {:?}",
24987 recovery_check.diagnostics
24988 );
24989 }
24990
24991 #[test]
24992 fn graph_db_doctor_reports_wal_snapshot_fallback_when_locked() {
24993 let dir = setup_traversal_project();
24994 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24995 let graph_db = dir.path().join(".tsift/graph.db");
24996 let _lock = hold_wal_database_lock(&graph_db);
24997
24998 let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
24999 append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25000 report.finalize();
25001
25002 assert_eq!(report.status, "ok");
25003 assert!(!report.fail_closed);
25004 let recovery_check = report
25005 .checks
25006 .iter()
25007 .find(|check| check.name == "sqlite_graph_db_read_recovery")
25008 .expect("doctor should include WAL read recovery diagnostic");
25009 assert_eq!(recovery_check.status, "recovered");
25010 assert!(
25011 recovery_check
25012 .diagnostics
25013 .iter()
25014 .any(|diagnostic| diagnostic.contains("WAL-aware snapshot fallback")),
25015 "expected WAL recovery diagnostic, got {:?}",
25016 recovery_check.diagnostics
25017 );
25018 }
25019
25020 #[test]
25021 fn graph_db_snapshot_export_import_round_trip_preserves_projection_metadata() {
25022 let dir = setup_traversal_project();
25023 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25024 let artifact = dir.path().join("graph.db.gz");
25025
25026 let exported =
25027 commands::infra::graph_db_snapshot_export_report(dir.path(), None, &artifact, false)
25028 .unwrap();
25029 let exported_projection_version = exported.freshness.projection_version.clone();
25030 let exported_content_hash = exported.freshness.content_hash.clone();
25031 let exported_source_watermark = exported.freshness.source_watermark.clone();
25032 let exported_nodes = exported.counts.nodes;
25033 let exported_edges = exported.counts.edges;
25034 assert_eq!(exported.operation, "snapshot-export");
25035 assert!(exported.status.starts_with("exported"));
25036 assert!(artifact.exists());
25037 assert!(exported.artifact_bytes > 0);
25038 assert_eq!(exported.compression, "gzip");
25039
25040 fs::remove_file(dir.path().join(".tsift/graph.db")).unwrap();
25041
25042 let imported =
25043 commands::infra::graph_db_snapshot_import_report(dir.path(), None, &artifact, false)
25044 .unwrap();
25045 assert_eq!(imported.operation, "snapshot-import");
25046 assert!(imported.status.starts_with("imported"));
25047 assert_eq!(
25048 imported.freshness.projection_version,
25049 exported_projection_version
25050 );
25051 assert_eq!(imported.freshness.content_hash, exported_content_hash);
25052 assert_eq!(
25053 imported.freshness.source_watermark,
25054 exported_source_watermark
25055 );
25056 assert_eq!(imported.counts.nodes, exported_nodes);
25057 assert_eq!(imported.counts.edges, exported_edges);
25058 assert!(dir.path().join(".tsift/graph.db").exists());
25059 }
25060
25061 #[test]
25062 fn graph_db_snapshot_export_fails_closed_when_wal_lock_requires_recovery() {
25063 let dir = setup_traversal_project();
25064 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25065 let graph_db = dir.path().join(".tsift/graph.db");
25066 let _lock = hold_wal_database_lock(&graph_db);
25067
25068 let err = match commands::infra::graph_db_snapshot_export_report(
25069 dir.path(),
25070 None,
25071 &dir.path().join("graph.db.gz"),
25072 false,
25073 ) {
25074 Ok(report) => panic!("expected snapshot export to fail, got {}", report.status),
25075 Err(err) => err,
25076 };
25077
25078 assert!(
25079 err.to_string().contains("recovered read path"),
25080 "expected recovered read path diagnostic, got {err:#}"
25081 );
25082 }
25083
25084 #[test]
25085 fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
25086 let dir = setup_traversal_project();
25087 let session = dir.path().join("tasks/software/tsift.md");
25088 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25089 let graph_db = dir.path().join(".tsift/graph.db");
25090 let _lock = hold_rollback_journal_lock(&graph_db);
25091
25092 let result = cmd_graph_db(
25093 &session,
25094 None,
25095 GraphDbBackend::Sqlite,
25096 None,
25097 GraphDbQuery::Evidence {
25098 target: "kgnv".to_string(),
25099 depth: 3,
25100 limit: 8,
25101 cursor: None,
25102 },
25103 OutputFormat {
25104 json_output: false,
25105 compact: true,
25106 pretty: false,
25107 terse: false,
25108 ultra_terse: false,
25109 schema: false,
25110 envelope: false,
25111 },
25112 );
25113
25114 assert!(result.is_ok());
25115 }
25116
25117 fn current_graph_db_freshness() -> GraphDbFreshnessReport {
25118 GraphDbFreshnessReport {
25119 status: "current".to_string(),
25120 fail_closed: false,
25121 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
25122 content_hash: Some("fixture".to_string()),
25123 source_watermark: None,
25124 diagnostics: Vec::new(),
25125 }
25126 }
25127
25128 #[test]
25129 fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
25130 let dir = setup_traversal_project();
25131 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25132 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25133 let stale = GraphDbFreshnessReport {
25134 status: "stale".to_string(),
25135 fail_closed: true,
25136 projection_version: Some("old-v0".to_string()),
25137 content_hash: None,
25138 source_watermark: None,
25139 diagnostics: vec!["projection content hash is missing".to_string()],
25140 };
25141
25142 let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25143 root: dir.path(),
25144 scope: None,
25145 backend: "sqlite",
25146 target: "kgnv",
25147 preferred_path: None,
25148 depth: 3,
25149 limit: 8,
25150 cursor: None,
25151 store: &store,
25152 freshness: stale,
25153 warnings: Vec::new(),
25154 }) {
25155 Ok(_) => panic!("stale graph freshness should fail closed"),
25156 Err(err) => err,
25157 };
25158 let message = err.to_string();
25159 assert!(message.contains("failed closed"), "{message}");
25160 assert!(message.contains("graph-db --path"), "{message}");
25161 assert!(message.contains("refresh --json"), "{message}");
25162 }
25163
25164 fn paged_graph_ids(
25165 store: &impl GraphStore,
25166 cursor: Option<&str>,
25167 ) -> (Vec<String>, GraphDbPageReport) {
25168 let report = graph_db_report_from_store(
25169 Path::new("."),
25170 None,
25171 "fixture",
25172 GraphDbQuery::Kind {
25173 kind: "backlog".to_string(),
25174 cursor: cursor.map(str::to_string),
25175 limit: Some(2),
25176 property_filters: vec!["phase=open".to_string()],
25177 },
25178 store,
25179 current_graph_db_freshness(),
25180 Vec::new(),
25181 )
25182 .unwrap();
25183 (
25184 report.nodes.iter().map(|node| node.id.clone()).collect(),
25185 report.page.unwrap(),
25186 )
25187 }
25188
25189 #[test]
25190 fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
25191 let nodes = (0..5)
25192 .map(|idx| {
25193 let phase = if idx == 1 { "closed" } else { "open" };
25194 SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
25195 .with_property("phase", phase)
25196 })
25197 .collect::<Vec<_>>();
25198 let projection = GraphProjection {
25199 nodes,
25200 edges: Vec::new(),
25201 };
25202 let sqlite = SqliteGraphStore::in_memory().unwrap();
25203 projection.upsert_into(&sqlite).unwrap();
25204 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25205 projection.upsert_into(&convex).unwrap();
25206
25207 let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
25208 let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
25209 assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
25210 assert_eq!(sqlite_first_ids, convex_first_ids);
25211 assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
25212 assert!(sqlite_first_page.truncated);
25213 assert_eq!(
25214 sqlite_first_page.returned_nodes,
25215 convex_first_page.returned_nodes
25216 );
25217 assert_eq!(
25218 sqlite_first_page.property_filters,
25219 convex_first_page.property_filters
25220 );
25221 assert!(
25222 sqlite_first_page
25223 .diagnostics
25224 .iter()
25225 .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
25226 "expected SQLite kind query plan diagnostics, got {:?}",
25227 sqlite_first_page.diagnostics
25228 );
25229
25230 let cursor = sqlite_first_page.next_cursor.as_deref();
25231 let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
25232 let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
25233 assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
25234 assert_eq!(sqlite_next_ids, convex_next_ids);
25235 assert_eq!(sqlite_next_page.next_cursor, None);
25236 assert!(!sqlite_next_page.truncated);
25237 assert_eq!(
25238 sqlite_next_page.returned_nodes,
25239 convex_next_page.returned_nodes
25240 );
25241 assert_eq!(
25242 sqlite_next_page.property_filters,
25243 convex_next_page.property_filters
25244 );
25245 }
25246
25247 #[test]
25248 fn traversal_shortest_path_crosses_artifacts_and_symbols() {
25249 let dir = setup_traversal_project();
25250 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25251 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25252 let main = resolve_traversal_node(&graph, "main").unwrap();
25253
25254 let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
25255 assert_eq!(path.first(), Some(&backlog.handle));
25256 assert_eq!(path.last(), Some(&main.handle));
25257 assert!(
25258 path.len() >= 3,
25259 "expected backlog -> symbol -> main, got {path:?}"
25260 );
25261 }
25262
25263 #[test]
25264 fn traversal_report_recommends_next_bugfix_nodes() {
25265 let dir = setup_traversal_project();
25266 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25267 let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
25268
25269 assert_eq!(report.mode, "neighborhood");
25270 assert!(
25271 report
25272 .recommendations
25273 .iter()
25274 .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
25275 "expected helper recommendation, got {:?}",
25276 report.recommendations
25277 );
25278 assert!(
25279 !report.exploration.source_windows.is_empty(),
25280 "expected exploration source windows"
25281 );
25282 assert!(
25283 report
25284 .exploration
25285 .no_reread_guidance
25286 .contains("avoid whole-file reads")
25287 );
25288 }
25289
25290 #[test]
25291 fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
25292 let dir = setup_traversal_project();
25293 std::thread::sleep(std::time::Duration::from_millis(50));
25294 std::fs::write(
25295 dir.path().join("main.rs"),
25296 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25297 )
25298 .unwrap();
25299
25300 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25301
25302 assert!(
25303 graph
25304 .warnings
25305 .iter()
25306 .any(|warning| warning.contains("index refreshed")
25307 && warning.contains("graph traversal packet")),
25308 "expected refresh diagnostic, got {:?}",
25309 graph.warnings
25310 );
25311 assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
25312
25313 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
25314 let summary = db.compute_changes(dir.path()).unwrap();
25315 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
25316 }
25317
25318 #[test]
25319 fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
25320 let dir = setup_traversal_project();
25321 let db_path = dir.path().join(".tsift/index.db");
25322 let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
25323 std::thread::sleep(std::time::Duration::from_millis(50));
25324 std::fs::write(
25325 dir.path().join("main.rs"),
25326 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25327 )
25328 .unwrap();
25329
25330 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25331 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
25332
25333 assert!(
25334 graph
25335 .warnings
25336 .iter()
25337 .any(|warning| warning.contains("falling back to raw source file nodes")),
25338 "expected raw-source fallback diagnostic, got {:?}",
25339 graph.warnings
25340 );
25341 assert!(
25342 file.detail
25343 .as_deref()
25344 .is_some_and(|detail| detail.contains("raw source fallback")),
25345 "expected raw-source detail, got {:?}",
25346 file.detail
25347 );
25348 assert!(
25349 file.expand.contains("source-read"),
25350 "expected source-read fallback command, got {}",
25351 file.expand
25352 );
25353 assert!(
25354 resolve_traversal_node(&graph, "helper").is_none(),
25355 "stale symbol evidence should be skipped when refresh is blocked"
25356 );
25357 }
25358
25359 #[test]
25360 fn traversal_cmd_supports_json_and_html_outputs() {
25361 let dir = setup_traversal_project();
25362 cmd_traverse(
25363 Some("#kgnv"),
25364 Some("main"),
25365 dir.path(),
25366 None,
25367 1,
25368 50,
25369 TraverseFormat::Json,
25370 false,
25371 false,
25372 false,
25373 None,
25374 )
25375 .unwrap();
25376 cmd_traverse(
25377 None,
25378 None,
25379 dir.path(),
25380 None,
25381 1,
25382 50,
25383 TraverseFormat::Html,
25384 false,
25385 false,
25386 false,
25387 None,
25388 )
25389 .unwrap();
25390 }
25391
25392 #[test]
25393 fn traversal_html_renders_inline_graph_visualization() {
25394 let dir = setup_traversal_project();
25395 seed_traversal_semantic_summaries(dir.path());
25396 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25397 let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
25398 let html = traversal_report_html(&report).unwrap();
25399
25400 assert!(html.contains("id=\"graph-canvas\""));
25401 assert!(html.contains("semantic_concept"));
25402 assert!(html.contains("graph navigation"));
25403 assert!(html.contains("JSON.parse"));
25404 }
25405
25406 #[test]
25407 fn compact_helpers_trim_scores_and_snippets() {
25408 assert_eq!(format_score(0.12345, true), "0.12");
25409 assert_eq!(format_score(0.12345, false), "0.1235");
25410 let snippet = compact_snippet(" first line with useful context\nsecond");
25411 assert_eq!(snippet.as_deref(), Some("first line with useful context"));
25412 }
25413
25414 #[test]
25415 fn compact_members_caps_list() {
25416 let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
25417 .iter()
25418 .map(|n| graph::CommunityMember::new(*n))
25419 .collect();
25420 assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
25421 }
25422
25423 #[test]
25424 fn abbreviate_kind_maps_common_kinds() {
25425 assert_eq!(abbreviate_kind("function"), "fn");
25426 assert_eq!(abbreviate_kind("method"), "meth");
25427 assert_eq!(abbreviate_kind("class"), "cls");
25428 assert_eq!(abbreviate_kind("interface"), "iface");
25429 assert_eq!(abbreviate_kind("type_alias"), "type");
25430 assert_eq!(abbreviate_kind("data_class"), "data_cls");
25431 assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
25432 assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
25433 assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
25434 assert_eq!(abbreviate_kind("object"), "obj");
25435 assert_eq!(abbreviate_kind("heading"), "h");
25436 assert_eq!(abbreviate_kind("code_block"), "code");
25437 assert_eq!(abbreviate_kind("struct"), "struct");
25439 assert_eq!(abbreviate_kind("trait"), "trait");
25440 assert_eq!(abbreviate_kind("enum"), "enum");
25441 assert_eq!(abbreviate_kind("const"), "const");
25442 assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
25443 }
25444
25445 #[test]
25446 fn abbreviate_match_type_maps_search_types() {
25447 assert_eq!(abbreviate_match_type("exact_name"), "exact");
25448 assert_eq!(abbreviate_match_type("partial_tags"), "partial");
25449 assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
25450 assert_eq!(abbreviate_match_type("other_type"), "other_type");
25451 }
25452
25453 #[test]
25454 fn explain_compact_groups_edges_by_file() {
25455 let edges = vec![
25456 index::StoredEdge {
25457 caller_file: "src/main.rs".to_string(),
25458 caller_name: "main".to_string(),
25459 caller_line: 1,
25460 callee_name: "helper".to_string(),
25461 call_site_line: 2,
25462 tagpath_handle: None,
25463 },
25464 index::StoredEdge {
25465 caller_file: "src/main.rs".to_string(),
25466 caller_name: "main".to_string(),
25467 caller_line: 1,
25468 callee_name: "render".to_string(),
25469 call_site_line: 3,
25470 tagpath_handle: None,
25471 },
25472 ];
25473 let lines = format_edge_groups(&edges, false);
25474 assert_eq!(lines, vec![" src/main.rs (2): helper, render"]);
25475 }
25476
25477 #[test]
25478 fn search_hit_groups_preserve_file_counts_and_samples() {
25479 let dir = tempfile::tempdir().unwrap();
25480 let root = dir.path();
25481 let main_rs = root.join("src/main.rs");
25482 fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
25483 fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
25484 let freshness = exact_search_file_timestamp(&main_rs);
25485 let hits = vec![
25486 sift::SearchHit {
25487 artifact_id: "a".to_string(),
25488 artifact_kind: sift::ContextArtifactKind::File,
25489 path: main_rs.display().to_string(),
25490 rank: 1,
25491 score: 10.0,
25492 confidence: sift::ScoreConfidence::High,
25493 location: Some("line 3".to_string()),
25494 snippet: "claudescore-3 anchor".to_string(),
25495 provenance: sift::ArtifactProvenance {
25496 adapter: sift::AcquisitionAdapterKind::FileSystem,
25497 source: "ripgrep -F".to_string(),
25498 synthetic: false,
25499 },
25500 freshness: freshness.clone(),
25501 budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
25502 },
25503 sift::SearchHit {
25504 artifact_id: "b".to_string(),
25505 artifact_kind: sift::ContextArtifactKind::File,
25506 path: main_rs.display().to_string(),
25507 rank: 2,
25508 score: 9.0,
25509 confidence: sift::ScoreConfidence::High,
25510 location: Some("line 7".to_string()),
25511 snippet: "claudescore-3 follow-up".to_string(),
25512 provenance: sift::ArtifactProvenance {
25513 adapter: sift::AcquisitionAdapterKind::FileSystem,
25514 source: "ripgrep -F".to_string(),
25515 synthetic: false,
25516 },
25517 freshness: freshness.clone(),
25518 budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
25519 },
25520 sift::SearchHit {
25521 artifact_id: "c".to_string(),
25522 artifact_kind: sift::ContextArtifactKind::File,
25523 path: main_rs.display().to_string(),
25524 rank: 3,
25525 score: 8.0,
25526 confidence: sift::ScoreConfidence::High,
25527 location: Some("line 9".to_string()),
25528 snippet: "claudescore-3 tail".to_string(),
25529 provenance: sift::ArtifactProvenance {
25530 adapter: sift::AcquisitionAdapterKind::FileSystem,
25531 source: "ripgrep -F".to_string(),
25532 synthetic: false,
25533 },
25534 freshness,
25535 budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
25536 },
25537 ];
25538
25539 let groups = group_search_hits(&hits, root, false);
25540 assert_eq!(groups.len(), 1);
25541 assert_eq!(groups[0].path, "src/main.rs");
25542 assert_eq!(groups[0].hits, 3);
25543 assert_eq!(
25544 groups[0].samples,
25545 vec![
25546 "line 3: claudescore-3 anchor".to_string(),
25547 "line 7: claudescore-3 follow-up".to_string()
25548 ]
25549 );
25550 assert!(should_collapse_search_hits(&hits, root, false));
25551 }
25552
25553 #[test]
25554 fn dense_edge_groups_trigger_collapse() {
25555 let edges = vec![
25556 index::StoredEdge {
25557 caller_file: "src/main.rs".to_string(),
25558 caller_name: "main".to_string(),
25559 caller_line: 1,
25560 callee_name: "helper".to_string(),
25561 call_site_line: 2,
25562 tagpath_handle: None,
25563 },
25564 index::StoredEdge {
25565 caller_file: "src/main.rs".to_string(),
25566 caller_name: "beta".to_string(),
25567 caller_line: 5,
25568 callee_name: "helper".to_string(),
25569 call_site_line: 6,
25570 tagpath_handle: None,
25571 },
25572 index::StoredEdge {
25573 caller_file: "src/main.rs".to_string(),
25574 caller_name: "gamma".to_string(),
25575 caller_line: 9,
25576 callee_name: "helper".to_string(),
25577 call_site_line: 10,
25578 tagpath_handle: None,
25579 },
25580 ];
25581 assert!(should_collapse_edge_groups(&edges));
25582 }
25583
25584 fn setup_workspace() -> tempfile::TempDir {
25587 let dir = tempfile::tempdir().unwrap();
25588 let root = dir.path();
25589 std::fs::write(
25590 root.join(".gitmodules"),
25591 r#"[submodule "src/alpha"]
25592 path = src/alpha
25593 url = https://example.com/alpha
25594[submodule "src/beta"]
25595 path = src/beta
25596 url = https://example.com/beta
25597"#,
25598 )
25599 .unwrap();
25600 let alpha = root.join("src/alpha");
25601 let beta = root.join("src/beta");
25602 std::fs::create_dir_all(&alpha).unwrap();
25603 std::fs::create_dir_all(&beta).unwrap();
25604 std::fs::write(
25605 alpha.join("lib.rs"),
25606 "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
25607 )
25608 .unwrap();
25609 std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
25610 dir
25611 }
25612
25613 fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
25614 let dir = tempfile::tempdir().unwrap();
25615 let root = dir.path();
25616 std::fs::write(
25617 root.join(".gitmodules"),
25618 r#"[submodule "pkg/app/foo"]
25619 path = pkg/app/foo
25620 url = https://example.com/pkg-app-foo
25621[submodule "vendor/foo"]
25622 path = vendor/foo
25623 url = https://example.com/vendor-foo
25624"#,
25625 )
25626 .unwrap();
25627 let pkg_foo = root.join("pkg/app/foo");
25628 let vendor_foo = root.join("vendor/foo");
25629 std::fs::create_dir_all(&pkg_foo).unwrap();
25630 std::fs::create_dir_all(&vendor_foo).unwrap();
25631 std::fs::write(
25632 pkg_foo.join("lib.rs"),
25633 "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
25634 )
25635 .unwrap();
25636 std::fs::write(
25637 vendor_foo.join("lib.rs"),
25638 "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
25639 )
25640 .unwrap();
25641 dir
25642 }
25643
25644 #[test]
25645 fn workspace_index_creates_per_submodule_dbs() {
25646 let dir = setup_workspace();
25647 cmd_index(
25648 dir.path(),
25649 false,
25650 false,
25651 false,
25652 false,
25653 false,
25654 true,
25655 None,
25656 false,
25657 false,
25658 false,
25659 false,
25660 false,
25661 false,
25662 )
25663 .unwrap();
25664 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25665 assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
25666 }
25667
25668 #[test]
25669 fn workspace_index_single_submodule() {
25670 let dir = setup_workspace();
25671 cmd_index(
25672 dir.path(),
25673 false,
25674 false,
25675 false,
25676 false,
25677 false,
25678 false,
25679 Some("alpha"),
25680 false,
25681 false,
25682 false,
25683 false,
25684 false,
25685 false,
25686 )
25687 .unwrap();
25688 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25689 assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
25690 }
25691
25692 #[test]
25693 fn workspace_index_single_submodule_errors_on_unknown_scope() {
25694 let dir = setup_workspace();
25695
25696 let err = cmd_index(
25697 dir.path(),
25698 false,
25699 false,
25700 false,
25701 false,
25702 false,
25703 false,
25704 Some("missing"),
25705 false,
25706 false,
25707 false,
25708 false,
25709 false,
25710 false,
25711 )
25712 .unwrap_err();
25713
25714 let msg = err.to_string();
25715 assert!(msg.contains("unknown scope `missing`"));
25716 assert!(msg.contains("Available scopes: alpha, beta"));
25717 assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
25718 }
25719
25720 #[test]
25721 fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
25722 let dir = setup_workspace_with_duplicate_leaf_names();
25723 cmd_index(
25724 dir.path(),
25725 false,
25726 false,
25727 false,
25728 false,
25729 false,
25730 true,
25731 None,
25732 false,
25733 false,
25734 false,
25735 false,
25736 false,
25737 false,
25738 )
25739 .unwrap();
25740
25741 assert!(
25742 dir.path()
25743 .join(".tsift/indexes/pkg/app/foo/index.db")
25744 .exists()
25745 );
25746 assert!(
25747 dir.path()
25748 .join(".tsift/indexes/vendor/foo/index.db")
25749 .exists()
25750 );
25751 }
25752
25753 #[test]
25754 fn federated_search_across_submodules() {
25755 let dir = setup_workspace();
25756 cmd_index(
25757 dir.path(),
25758 false,
25759 false,
25760 false,
25761 false,
25762 false,
25763 true,
25764 None,
25765 false,
25766 false,
25767 false,
25768 false,
25769 false,
25770 false,
25771 )
25772 .unwrap();
25773 let (hits, _diag) = federated_symbol_search(
25774 dir.path(),
25775 "alpha_helper",
25776 10,
25777 &TagpathSearchOpts {
25778 no_tagpath: true,
25779 strict: false,
25780 },
25781 )
25782 .unwrap();
25783 assert!(
25784 !hits.is_empty(),
25785 "should find alpha_helper via federated search"
25786 );
25787 }
25788
25789 #[test]
25790 fn federated_search_respects_isolation() {
25791 let dir = setup_workspace();
25792 let tsift_dir = dir.path().join(".tsift");
25793 std::fs::create_dir_all(&tsift_dir).unwrap();
25794 std::fs::write(
25795 tsift_dir.join("config.toml"),
25796 r#"
25797[overrides.alpha]
25798tier = "isolated"
25799"#,
25800 )
25801 .unwrap();
25802 cmd_index(
25803 dir.path(),
25804 false,
25805 false,
25806 false,
25807 false,
25808 false,
25809 true,
25810 None,
25811 false,
25812 false,
25813 false,
25814 false,
25815 false,
25816 false,
25817 )
25818 .unwrap();
25819 let (hits, _diag) = federated_symbol_search(
25820 dir.path(),
25821 "alpha_helper",
25822 10,
25823 &TagpathSearchOpts {
25824 no_tagpath: true,
25825 strict: false,
25826 },
25827 )
25828 .unwrap();
25829 assert!(
25830 hits.is_empty(),
25831 "isolated submodule should not appear in federated search"
25832 );
25833 }
25834
25835 #[test]
25836 fn federated_lexical_search_respects_isolation() {
25837 let dir = setup_workspace();
25838 let tsift_dir = dir.path().join(".tsift");
25839 std::fs::create_dir_all(&tsift_dir).unwrap();
25840 std::fs::write(
25841 tsift_dir.join("config.toml"),
25842 r#"
25843[overrides.alpha]
25844tier = "isolated"
25845"#,
25846 )
25847 .unwrap();
25848 cmd_index(
25849 dir.path(),
25850 false,
25851 false,
25852 false,
25853 false,
25854 false,
25855 true,
25856 None,
25857 false,
25858 false,
25859 false,
25860 false,
25861 false,
25862 false,
25863 )
25864 .unwrap();
25865
25866 let response = federated_sift_search(
25867 dir.path(),
25868 &dir.path().join(".tsift/search-cache"),
25869 "fn",
25870 10,
25871 0,
25872 "lexical",
25873 )
25874 .unwrap();
25875
25876 assert!(
25877 !response.hits.is_empty(),
25878 "shared scopes should still contribute lexical hits"
25879 );
25880 assert!(
25881 response
25882 .hits
25883 .iter()
25884 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25885 "isolated scope should not leak lexical hits: {:?}",
25886 response.hits
25887 );
25888 }
25889
25890 #[test]
25891 fn federated_lexical_search_respects_private_tier() {
25892 let dir = setup_workspace();
25893 let tsift_dir = dir.path().join(".tsift");
25894 std::fs::create_dir_all(&tsift_dir).unwrap();
25895 std::fs::write(
25896 tsift_dir.join("config.toml"),
25897 r#"
25898[overrides.alpha]
25899tier = "private"
25900"#,
25901 )
25902 .unwrap();
25903 cmd_index(
25904 dir.path(),
25905 false,
25906 false,
25907 false,
25908 false,
25909 false,
25910 true,
25911 None,
25912 false,
25913 false,
25914 false,
25915 false,
25916 false,
25917 false,
25918 )
25919 .unwrap();
25920
25921 let response = federated_sift_search(
25922 dir.path(),
25923 &dir.path().join(".tsift/search-cache"),
25924 "fn",
25925 10,
25926 0,
25927 "lexical",
25928 )
25929 .unwrap();
25930
25931 assert!(
25932 !response.hits.is_empty(),
25933 "shared scopes should still contribute lexical hits"
25934 );
25935 assert!(
25936 response
25937 .hits
25938 .iter()
25939 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25940 "private scope should not leak lexical hits: {:?}",
25941 response.hits
25942 );
25943 }
25944
25945 #[test]
25946 fn scoped_search_finds_submodule_symbols() {
25947 let dir = setup_workspace();
25948 cmd_index(
25949 dir.path(),
25950 false,
25951 false,
25952 false,
25953 false,
25954 false,
25955 true,
25956 None,
25957 false,
25958 false,
25959 false,
25960 false,
25961 false,
25962 false,
25963 )
25964 .unwrap();
25965 let cfg = config::Config::load(dir.path()).unwrap();
25966 let db_path = cfg.db_path_for(dir.path(), "alpha");
25967 let db = index::IndexDb::open(&db_path).unwrap();
25968 let hits = db.symbol_search("alpha_main", 10).unwrap();
25969 assert!(!hits.is_empty());
25970 assert_eq!(hits[0].name, "alpha_main");
25971 }
25972
25973 #[test]
25974 fn scoped_search_cmd_errors_on_unknown_scope() {
25975 let dir = setup_workspace();
25976
25977 let err = cmd_search(
25978 "alpha_main".to_string(),
25979 Some(dir.path().to_path_buf()),
25980 5,
25981 Some("lexical".to_string()),
25982 Some("missing".to_string()),
25983 false,
25984 false,
25985 false,
25986 0,
25987 false,
25988 false,
25989 false,
25990 false,
25991 false,
25992 false,
25993 false,
25994 )
25995 .unwrap_err();
25996
25997 let msg = err.to_string();
25998 assert!(msg.contains("unknown scope `missing`"));
25999 assert!(msg.contains("Available scopes: alpha, beta"));
26000 }
26001
26002 #[test]
26003 fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
26004 let dir = setup_workspace_with_duplicate_leaf_names();
26005 cmd_index(
26006 dir.path(),
26007 false,
26008 false,
26009 false,
26010 false,
26011 false,
26012 true,
26013 None,
26014 false,
26015 false,
26016 false,
26017 false,
26018 false,
26019 false,
26020 )
26021 .unwrap();
26022
26023 let err = cmd_search(
26024 "vendor_only".to_string(),
26025 Some(dir.path().to_path_buf()),
26026 5,
26027 Some("lexical".to_string()),
26028 Some("foo".to_string()),
26029 false,
26030 false,
26031 false,
26032 0,
26033 false,
26034 false,
26035 false,
26036 false,
26037 false,
26038 false,
26039 false,
26040 )
26041 .unwrap_err();
26042
26043 let msg = err.to_string();
26044 assert!(msg.contains("ambiguous scope `foo`"));
26045 assert!(msg.contains("pkg/app/foo"));
26046 assert!(msg.contains("vendor/foo"));
26047 }
26048
26049 #[test]
26050 fn scoped_graph_query() {
26051 let dir = setup_workspace();
26052 cmd_index(
26053 dir.path(),
26054 false,
26055 false,
26056 false,
26057 false,
26058 false,
26059 true,
26060 None,
26061 false,
26062 false,
26063 false,
26064 false,
26065 false,
26066 false,
26067 )
26068 .unwrap();
26069 let cfg = config::Config::load(dir.path()).unwrap();
26070 let db_path = cfg.db_path_for(dir.path(), "alpha");
26071 let db = index::IndexDb::open(&db_path).unwrap();
26072 let callees = db.callees_of("alpha_main").unwrap();
26073 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
26074 assert!(names.contains(&"alpha_helper"));
26075 }
26076
26077 fn assert_workspace_query_requires_scope(err: anyhow::Error) {
26078 let msg = err.to_string();
26079 assert!(msg.contains("require `--scope <scope>`"), "{msg}");
26080 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26081 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26082 assert!(
26083 !msg.contains("no index found at"),
26084 "workspace query should fail with scope guidance, got: {msg}"
26085 );
26086 }
26087
26088 fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
26089 let msg = err.to_string();
26090 assert!(
26091 msg.contains("requires `--scope <scope>` or `--federated`"),
26092 "{msg}"
26093 );
26094 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26095 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26096 assert!(
26097 !msg.contains("autoindexing index"),
26098 "workspace search should fail before creating a shared root index: {msg}"
26099 );
26100 }
26101
26102 #[test]
26103 fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
26104 let dir = setup_workspace();
26105 cmd_index(
26106 dir.path(),
26107 false,
26108 false,
26109 false,
26110 false,
26111 false,
26112 true,
26113 None,
26114 false,
26115 false,
26116 false,
26117 false,
26118 false,
26119 false,
26120 )
26121 .unwrap();
26122
26123 let err = cmd_graph(
26124 "alpha_main",
26125 dir.path(),
26126 false,
26127 false,
26128 None,
26129 20,
26130 false,
26131 false,
26132 false,
26133 false,
26134 false,
26135 false,
26136 false,
26137 TagpathSearchOpts::default(),
26138 )
26139 .unwrap_err();
26140
26141 assert_workspace_query_requires_scope(err);
26142 }
26143
26144 #[test]
26145 fn graph_cmd_infers_scope_from_nested_workspace_path() {
26146 let dir = setup_workspace();
26147 cmd_index(
26148 dir.path(),
26149 false,
26150 false,
26151 false,
26152 false,
26153 false,
26154 true,
26155 None,
26156 false,
26157 false,
26158 false,
26159 false,
26160 false,
26161 false,
26162 )
26163 .unwrap();
26164 let nested = dir.path().join("src/alpha/nested");
26165 std::fs::create_dir_all(&nested).unwrap();
26166
26167 let result = cmd_graph(
26168 "alpha_main",
26169 &nested,
26170 false,
26171 false,
26172 None,
26173 20,
26174 false,
26175 false,
26176 false,
26177 false,
26178 false,
26179 false,
26180 false,
26181 TagpathSearchOpts::default(),
26182 );
26183
26184 assert!(result.is_ok());
26185 }
26186
26187 #[test]
26188 fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
26189 let dir = setup_workspace();
26190 cmd_index(
26191 dir.path(),
26192 false,
26193 false,
26194 false,
26195 false,
26196 false,
26197 true,
26198 None,
26199 false,
26200 false,
26201 false,
26202 false,
26203 false,
26204 false,
26205 )
26206 .unwrap();
26207
26208 let err = cmd_communities(
26209 dir.path(),
26210 None,
26211 1,
26212 10,
26213 false,
26214 false,
26215 false,
26216 false,
26217 false,
26218 false,
26219 TagpathSearchOpts::default(),
26220 )
26221 .unwrap_err();
26222
26223 assert_workspace_query_requires_scope(err);
26224 }
26225
26226 #[test]
26227 fn communities_cmd_infers_scope_from_nested_workspace_path() {
26228 let dir = setup_workspace();
26229 cmd_index(
26230 dir.path(),
26231 false,
26232 false,
26233 false,
26234 false,
26235 false,
26236 true,
26237 None,
26238 false,
26239 false,
26240 false,
26241 false,
26242 false,
26243 false,
26244 )
26245 .unwrap();
26246 let nested = dir.path().join("src/alpha/nested");
26247 std::fs::create_dir_all(&nested).unwrap();
26248
26249 let result = cmd_communities(
26250 &nested,
26251 None,
26252 1,
26253 10,
26254 false,
26255 false,
26256 false,
26257 false,
26258 false,
26259 false,
26260 TagpathSearchOpts::default(),
26261 );
26262
26263 assert!(result.is_ok());
26264 }
26265
26266 #[test]
26267 fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
26268 let dir = setup_workspace();
26269 cmd_index(
26270 dir.path(),
26271 false,
26272 false,
26273 false,
26274 false,
26275 false,
26276 true,
26277 None,
26278 false,
26279 false,
26280 false,
26281 false,
26282 false,
26283 false,
26284 )
26285 .unwrap();
26286
26287 let err = cmd_path(
26288 "alpha_main",
26289 "alpha_helper",
26290 dir.path(),
26291 None,
26292 false,
26293 false,
26294 false,
26295 false,
26296 false,
26297 TagpathSearchOpts::default(),
26298 )
26299 .unwrap_err();
26300
26301 assert_workspace_query_requires_scope(err);
26302 }
26303
26304 #[test]
26305 fn path_cmd_infers_scope_from_nested_workspace_path() {
26306 let dir = setup_workspace();
26307 cmd_index(
26308 dir.path(),
26309 false,
26310 false,
26311 false,
26312 false,
26313 false,
26314 true,
26315 None,
26316 false,
26317 false,
26318 false,
26319 false,
26320 false,
26321 false,
26322 )
26323 .unwrap();
26324 let nested = dir.path().join("src/alpha/nested");
26325 std::fs::create_dir_all(&nested).unwrap();
26326
26327 let result = cmd_path(
26328 "alpha_main",
26329 "alpha_helper",
26330 &nested,
26331 None,
26332 false,
26333 false,
26334 false,
26335 false,
26336 false,
26337 TagpathSearchOpts::default(),
26338 );
26339
26340 assert!(result.is_ok());
26341 }
26342
26343 #[test]
26344 fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26345 let dir = setup_graph_index();
26346 let db_path = dir.path().join(".tsift/index.db");
26347 let _lock = hold_rollback_journal_lock(&db_path);
26348
26349 let result = cmd_path(
26350 "main",
26351 "helper",
26352 dir.path(),
26353 None,
26354 false,
26355 false,
26356 false,
26357 false,
26358 false,
26359 TagpathSearchOpts::default(),
26360 );
26361
26362 assert!(result.is_ok());
26363 }
26364
26365 #[test]
26366 fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
26367 let dir = setup_workspace();
26368 cmd_index(
26369 dir.path(),
26370 false,
26371 false,
26372 false,
26373 false,
26374 false,
26375 true,
26376 None,
26377 false,
26378 false,
26379 false,
26380 false,
26381 false,
26382 false,
26383 )
26384 .unwrap();
26385
26386 let err = cmd_explain(
26387 "alpha_main",
26388 dir.path(),
26389 None,
26390 15,
26391 false,
26392 false,
26393 false,
26394 false,
26395 false,
26396 false,
26397 false,
26398 false,
26399 )
26400 .unwrap_err();
26401
26402 assert_workspace_query_requires_scope(err);
26403 }
26404
26405 #[test]
26406 fn explain_cmd_infers_scope_from_nested_workspace_path() {
26407 let dir = setup_workspace();
26408 cmd_index(
26409 dir.path(),
26410 false,
26411 false,
26412 false,
26413 false,
26414 false,
26415 true,
26416 None,
26417 false,
26418 false,
26419 false,
26420 false,
26421 false,
26422 false,
26423 )
26424 .unwrap();
26425 let nested = dir.path().join("src/alpha/nested");
26426 std::fs::create_dir_all(&nested).unwrap();
26427
26428 let result = cmd_explain(
26429 "alpha_main",
26430 &nested,
26431 None,
26432 15,
26433 false,
26434 false,
26435 false,
26436 false,
26437 false,
26438 false,
26439 false,
26440 false,
26441 );
26442
26443 assert!(result.is_ok());
26444 }
26445
26446 #[test]
26447 fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26448 let dir = setup_graph_index();
26449 let db_path = dir.path().join(".tsift/index.db");
26450 let _lock = hold_rollback_journal_lock(&db_path);
26451
26452 let result = cmd_explain(
26453 "main",
26454 dir.path(),
26455 None,
26456 15,
26457 false,
26458 false,
26459 false,
26460 false,
26461 false,
26462 false,
26463 false,
26464 false,
26465 );
26466
26467 assert!(result.is_ok());
26468 }
26469
26470 #[test]
26473 fn community_detection_groups_related() {
26474 let dir = setup_graph_index();
26475 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26476 let edges = db.all_edges().unwrap();
26477 let result = graph::detect_communities(&edges);
26478 assert!(result.node_count > 0);
26479 assert!(!result.communities.is_empty());
26480 }
26481
26482 #[test]
26483 fn community_cmd_autoindexes_missing_index_by_default() {
26484 let dir = tempfile::tempdir().unwrap();
26485 let result = cmd_communities(
26486 dir.path(),
26487 None,
26488 2,
26489 10,
26490 false,
26491 false,
26492 false,
26493 false,
26494 false,
26495 false,
26496 TagpathSearchOpts::default(),
26497 );
26498
26499 assert!(result.is_ok());
26500 assert!(dir.path().join(".tsift/index.db").exists());
26501 }
26502
26503 #[test]
26506 fn path_finds_connected_symbols() {
26507 let dir = setup_graph_index();
26508 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26509 let edges = db.all_edges().unwrap();
26510 let result = graph::shortest_path(&edges, "main", "helper");
26511 assert!(result.is_some());
26512 let path = result.unwrap();
26513 assert_eq!(path.hops, 1);
26514 }
26515
26516 #[test]
26517 fn path_returns_none_for_unknown() {
26518 let dir = setup_graph_index();
26519 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26520 let edges = db.all_edges().unwrap();
26521 assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
26522 }
26523
26524 #[test]
26525 fn path_cmd_autoindexes_missing_index_by_default() {
26526 let dir = tempfile::tempdir().unwrap();
26527 let result = cmd_path(
26528 "a",
26529 "b",
26530 dir.path(),
26531 None,
26532 false,
26533 false,
26534 false,
26535 false,
26536 false,
26537 TagpathSearchOpts::default(),
26538 );
26539
26540 assert!(result.is_ok());
26541 assert!(dir.path().join(".tsift/index.db").exists());
26542 }
26543
26544 #[test]
26547 fn explain_shows_symbol_info() {
26548 let dir = setup_graph_index();
26549 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26550 let symbols = db.symbol_info("main").unwrap();
26551 assert!(!symbols.is_empty());
26552 assert_eq!(symbols[0].name, "main");
26553 assert_eq!(symbols[0].kind, "function");
26554 }
26555
26556 #[test]
26557 fn explain_cmd_autoindexes_missing_index_by_default() {
26558 let dir = tempfile::tempdir().unwrap();
26559 let result = cmd_explain(
26560 "main",
26561 dir.path(),
26562 None,
26563 15,
26564 false,
26565 false,
26566 false,
26567 false,
26568 false,
26569 false,
26570 false,
26571 false,
26572 );
26573
26574 assert!(result.is_ok());
26575 assert!(dir.path().join(".tsift/index.db").exists());
26576 }
26577
26578 fn hold_write_lock(db_path: &std::path::Path) -> Connection {
26579 let conn = Connection::open(db_path).unwrap();
26580 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
26581 conn
26582 }
26583
26584 fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
26585 use fs4::fs_std::FileExt;
26586 use std::io::Write;
26587
26588 let mut file = std::fs::OpenOptions::new()
26589 .read(true)
26590 .write(true)
26591 .create(true)
26592 .truncate(false)
26593 .open(lock_path)
26594 .unwrap();
26595 assert!(file.try_lock_exclusive().unwrap());
26596 writeln!(file, "{}", std::process::id()).unwrap();
26597 file
26598 }
26599
26600 fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
26601 let conn = Connection::open(db_path).unwrap();
26602 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26603 .unwrap();
26604 std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
26605 conn
26606 }
26607
26608 fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
26609 let conn = Connection::open(db_path).unwrap();
26610 conn.execute_batch(
26611 "PRAGMA journal_mode=WAL;
26612 PRAGMA wal_autocheckpoint=0;
26613 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
26614 INSERT INTO wal_lock_probe DEFAULT VALUES;
26615 PRAGMA locking_mode=EXCLUSIVE;
26616 BEGIN EXCLUSIVE;",
26617 )
26618 .unwrap();
26619 assert!(substrate::wal_sidecar_path(db_path).exists());
26620 conn
26621 }
26622
26623 #[test]
26624 fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
26625 let dir = setup_graph_index();
26626 let db_path = dir.path().join(".tsift/index.db");
26627 let _lock = hold_wal_database_lock(&db_path);
26628
26629 let err = cmd_index(
26630 dir.path(),
26631 false,
26632 false,
26633 false,
26634 false,
26635 false,
26636 false,
26637 None,
26638 false,
26639 false,
26640 false,
26641 false,
26642 false,
26643 false,
26644 )
26645 .unwrap_err();
26646
26647 let msg = err.to_string();
26648 assert!(msg.contains("indexing"));
26649 assert!(msg.contains("lock diagnostics:"));
26650 assert!(msg.contains("lock: absent"));
26651 assert!(msg.contains("wal: present") || msg.contains("shm: present"));
26652 assert!(msg.contains("wedged writer holding live WAL sidecars"));
26653 assert!(msg.contains("snapshot fallback"));
26654 }
26655
26656 #[test]
26657 fn search_cmd_succeeds_while_writer_lock_is_held() {
26658 let dir = setup_graph_index();
26659 let db_path = dir.path().join(".tsift/index.db");
26660 let _lock = hold_write_lock(&db_path);
26661
26662 let result = cmd_search(
26663 "main".to_string(),
26664 Some(dir.path().to_path_buf()),
26665 5,
26666 Some("lexical".to_string()),
26667 None,
26668 false,
26669 false,
26670 false,
26671 0,
26672 true,
26673 false,
26674 false,
26675 false,
26676 false,
26677 false,
26678 false,
26679 );
26680
26681 assert!(result.is_ok());
26682 }
26683
26684 #[test]
26685 fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
26686 let dir = setup_graph_index();
26687 let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
26688
26689 let result = cmd_search(
26690 "main".to_string(),
26691 Some(dir.path().to_path_buf()),
26692 5,
26693 Some("lexical".to_string()),
26694 None,
26695 false,
26696 false,
26697 false,
26698 0,
26699 true,
26700 false,
26701 false,
26702 false,
26703 false,
26704 false,
26705 false,
26706 );
26707
26708 assert!(result.is_ok());
26709 }
26710
26711 #[test]
26712 fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
26713 let dir = setup_graph_index();
26714 let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
26715
26716 let result = cmd_search(
26717 "main".to_string(),
26718 Some(dir.path().to_path_buf()),
26719 5,
26720 Some("lexical".to_string()),
26721 None,
26722 false,
26723 false,
26724 false,
26725 0,
26726 true,
26727 false,
26728 false,
26729 false,
26730 false,
26731 false,
26732 false,
26733 );
26734
26735 assert!(result.is_ok());
26736 }
26737
26738 #[test]
26739 fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
26740 let dir = setup_graph_index();
26741 std::thread::sleep(std::time::Duration::from_millis(50));
26742 std::fs::write(
26743 dir.path().join("main.rs"),
26744 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26745 )
26746 .unwrap();
26747
26748 let err = cmd_search(
26749 "helper".to_string(),
26750 Some(dir.path().to_path_buf()),
26751 5,
26752 Some("lexical".to_string()),
26753 None,
26754 false,
26755 false,
26756 false,
26757 0,
26758 false,
26759 false,
26760 false,
26761 false,
26762 false,
26763 false,
26764 false,
26765 )
26766 .unwrap_err();
26767
26768 assert!(err.to_string().contains("search aborted"));
26769 assert!(err.to_string().contains("index is stale"));
26770 assert!(err.to_string().contains("--no-autoindex"));
26771 }
26772
26773 #[test]
26774 fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
26775 let dir = setup_graph_index();
26776 std::thread::sleep(std::time::Duration::from_millis(50));
26777 std::fs::write(
26778 dir.path().join("main.rs"),
26779 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26780 )
26781 .unwrap();
26782 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26783
26784 let err = cmd_search(
26785 "helper".to_string(),
26786 Some(dir.path().to_path_buf()),
26787 5,
26788 Some("lexical".to_string()),
26789 None,
26790 false,
26791 false,
26792 false,
26793 0,
26794 false,
26795 false,
26796 false,
26797 false,
26798 false,
26799 false,
26800 false,
26801 )
26802 .unwrap_err();
26803
26804 assert!(err.to_string().contains("search aborted"));
26805 assert!(err.to_string().contains("index is stale"));
26806 assert!(!err.to_string().contains("database is locked"));
26807 }
26808
26809 #[test]
26810 fn search_cmd_autoindexes_stale_index_by_default() {
26811 let dir = setup_graph_index();
26812 std::thread::sleep(std::time::Duration::from_millis(50));
26813 std::fs::write(
26814 dir.path().join("main.rs"),
26815 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26816 )
26817 .unwrap();
26818
26819 let result = cmd_search(
26820 "helper".to_string(),
26821 Some(dir.path().to_path_buf()),
26822 5,
26823 Some("lexical".to_string()),
26824 None,
26825 false,
26826 false,
26827 true,
26828 0,
26829 false,
26830 false,
26831 false,
26832 false,
26833 false,
26834 false,
26835 false,
26836 );
26837
26838 assert!(result.is_ok());
26839
26840 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26841 let summary = db.compute_changes(dir.path()).unwrap();
26842 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26843 }
26844
26845 #[test]
26846 fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
26847 let dir = setup_graph_index();
26848 std::thread::sleep(std::time::Duration::from_millis(50));
26849 std::fs::write(
26850 dir.path().join("main.rs"),
26851 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26852 )
26853 .unwrap();
26854 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
26855
26856 let result = cmd_search(
26857 "helper".to_string(),
26858 Some(dir.path().to_path_buf()),
26859 5,
26860 Some("lexical".to_string()),
26861 None,
26862 false,
26863 false,
26864 true,
26865 0,
26866 false,
26867 false,
26868 false,
26869 false,
26870 false,
26871 false,
26872 false,
26873 );
26874
26875 assert!(result.is_ok());
26876
26877 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26878 let summary = db.compute_changes(dir.path()).unwrap();
26879 assert_eq!(summary.modified, 1);
26880 }
26881
26882 #[test]
26883 fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
26884 let dir = setup_graph_index();
26885 std::thread::sleep(std::time::Duration::from_millis(50));
26886 std::fs::write(
26887 dir.path().join("main.rs"),
26888 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26889 )
26890 .unwrap();
26891 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26892
26893 let err = cmd_search(
26894 "helper".to_string(),
26895 Some(dir.path().to_path_buf()),
26896 5,
26897 Some("lexical".to_string()),
26898 None,
26899 false,
26900 false,
26901 true,
26902 0,
26903 false,
26904 false,
26905 false,
26906 false,
26907 false,
26908 false,
26909 false,
26910 )
26911 .unwrap_err();
26912
26913 let msg = err.to_string();
26914 assert!(msg.contains("autoindexing index"));
26915 assert!(msg.contains("lock diagnostics:"));
26916 assert!(msg.contains("journal: present"));
26917 assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
26918 }
26919
26920 #[test]
26921 fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
26922 let dir = setup_graph_index();
26923 let nested = dir.path().join("src/nested");
26924 std::fs::create_dir_all(&nested).unwrap();
26925
26926 let result = cmd_search(
26927 "helper".to_string(),
26928 Some(nested.clone()),
26929 5,
26930 Some("lexical".to_string()),
26931 None,
26932 false,
26933 false,
26934 true,
26935 0,
26936 false,
26937 false,
26938 false,
26939 false,
26940 false,
26941 false,
26942 false,
26943 );
26944
26945 assert!(result.is_ok());
26946 assert!(!nested.join(".tsift/index.db").exists());
26947 }
26948
26949 #[test]
26950 fn exact_search_returns_literal_matches() {
26951 let dir = tempfile::tempdir().unwrap();
26952 std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
26953
26954 let response = run_exact_search_with_timeout(dir.path(), "claudescore-3", 5, 0).unwrap();
26955
26956 assert_eq!(response.strategy, "exact");
26957 assert_eq!(response.hits.len(), 1);
26958 assert!(response.hits[0].path.ends_with("notes.txt"));
26959 assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
26960 assert!(response.hits[0].snippet.contains("claudescore-3"));
26961 }
26962
26963 #[test]
26964 fn exact_search_skips_stale_index_precheck() {
26965 let dir = setup_graph_index();
26966 std::thread::sleep(std::time::Duration::from_millis(50));
26967 std::fs::write(
26968 dir.path().join("main.rs"),
26969 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
26970 )
26971 .unwrap();
26972
26973 let result = cmd_search(
26974 "println!(\"updated\")".to_string(),
26975 Some(dir.path().to_path_buf()),
26976 5,
26977 Some("exact".to_string()),
26978 None,
26979 false,
26980 false,
26981 false,
26982 0,
26983 false,
26984 false,
26985 false,
26986 false,
26987 false,
26988 false,
26989 false,
26990 );
26991
26992 assert!(result.is_ok());
26993 }
26994
26995 #[test]
26996 fn workspace_exact_search_does_not_require_shared_root_index() {
26997 let dir = setup_workspace();
26998 cmd_index(
26999 dir.path(),
27000 false,
27001 false,
27002 false,
27003 false,
27004 false,
27005 true,
27006 None,
27007 false,
27008 false,
27009 false,
27010 false,
27011 false,
27012 false,
27013 )
27014 .unwrap();
27015
27016 let result = cmd_search(
27017 "alpha_helper".to_string(),
27018 Some(dir.path().to_path_buf()),
27019 5,
27020 Some("exact".to_string()),
27021 None,
27022 false,
27023 false,
27024 false,
27025 0,
27026 false,
27027 false,
27028 false,
27029 false,
27030 false,
27031 false,
27032 false,
27033 );
27034
27035 assert!(result.is_ok());
27036 assert!(!dir.path().join(".tsift/index.db").exists());
27037 }
27038
27039 #[test]
27040 fn identifier_like_query_prefers_exact_search() {
27041 assert!(query_prefers_exact_search("claudescore-3"));
27042 assert!(query_prefers_exact_search("alpha_helper"));
27043 assert!(query_prefers_exact_search("src/main.rs"));
27044 assert!(query_prefers_exact_search("crate::module"));
27045 assert!(!query_prefers_exact_search("authenticate"));
27046 assert!(!query_prefers_exact_search("fn main"));
27047 assert!(!query_prefers_exact_search("."));
27048 }
27049
27050 #[test]
27051 fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
27052 assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
27053 assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
27054 assert_eq!(
27055 resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
27056 "hybrid"
27057 );
27058 }
27059
27060 #[test]
27061 fn workspace_identifier_like_search_auto_uses_exact_backend() {
27062 let dir = setup_workspace();
27063 cmd_index(
27064 dir.path(),
27065 false,
27066 false,
27067 false,
27068 false,
27069 false,
27070 true,
27071 None,
27072 false,
27073 false,
27074 false,
27075 false,
27076 false,
27077 false,
27078 )
27079 .unwrap();
27080
27081 let result = cmd_search(
27082 "alpha_helper".to_string(),
27083 Some(dir.path().to_path_buf()),
27084 5,
27085 None,
27086 None,
27087 false,
27088 false,
27089 false,
27090 0,
27091 false,
27092 false,
27093 false,
27094 false,
27095 false,
27096 false,
27097 false,
27098 );
27099
27100 assert!(result.is_ok());
27101 assert!(!dir.path().join(".tsift/index.db").exists());
27102 }
27103
27104 #[test]
27105 fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
27106 let dir = setup_graph_index();
27107 let nested = dir.path().join("src/nested");
27108 std::fs::create_dir_all(&nested).unwrap();
27109 std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
27110
27111 let result = cmd_index(
27112 &nested, false, false, false, false, false, false, None, false, false, false, false,
27113 false, false,
27114 );
27115
27116 assert!(result.is_ok());
27117 assert!(dir.path().join(".tsift/index.db").exists());
27118 assert!(!nested.join(".tsift/index.db").exists());
27119 }
27120
27121 #[test]
27122 fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
27123 let dir = setup_workspace();
27124 let nested = dir.path().join("docs/nested");
27125 std::fs::create_dir_all(&nested).unwrap();
27126
27127 let result = cmd_index(
27128 &nested, false, false, false, false, false, true, None, false, false, false, false,
27129 false, false,
27130 );
27131
27132 let cfg = config::Config::load(dir.path()).unwrap();
27133
27134 assert!(result.is_ok());
27135 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
27136 assert!(cfg.db_path_for(dir.path(), "beta").exists());
27137 }
27138
27139 #[test]
27140 fn status_cmd_autoindexes_missing_workspace_scopes() {
27141 let dir = setup_workspace();
27142 let cfg = config::Config::load(dir.path()).unwrap();
27143 let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
27144 let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
27145 let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
27146 alpha_db.apply_changes(&alpha.source_root).unwrap();
27147
27148 let beta_db_path = cfg.db_path_for(dir.path(), "beta");
27149 assert!(!beta_db_path.exists());
27150
27151 cmd_status(
27152 dir.path(),
27153 StatusCommandOptions {
27154 fix: false,
27155 no_fix: false,
27156 json_output: true,
27157 compact: false,
27158 pretty: false,
27159 terse: false,
27160 schema: false,
27161 },
27162 )
27163 .unwrap();
27164
27165 assert!(beta_db_path.exists());
27166 let report = status::check_status(dir.path()).unwrap();
27167 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27168 }
27169
27170 #[test]
27171 fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
27172 let dir = setup_workspace();
27173 let cfg = config::Config::load(dir.path()).unwrap();
27174
27175 cmd_status(
27176 dir.path(),
27177 StatusCommandOptions {
27178 fix: false,
27179 no_fix: false,
27180 json_output: true,
27181 compact: false,
27182 pretty: false,
27183 terse: false,
27184 schema: false,
27185 },
27186 )
27187 .unwrap();
27188
27189 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
27190 assert!(cfg.db_path_for(dir.path(), "beta").exists());
27191 let report = status::check_status(dir.path()).unwrap();
27192 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27193 }
27194
27195 #[test]
27196 fn status_cmd_fix_refreshes_stale_index() {
27197 let dir = setup_graph_index();
27198 std::thread::sleep(std::time::Duration::from_millis(50));
27199 std::fs::write(
27200 dir.path().join("main.rs"),
27201 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27202 )
27203 .unwrap();
27204
27205 let report = status::check_status(dir.path()).unwrap();
27206 assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
27207
27208 cmd_status(
27209 dir.path(),
27210 StatusCommandOptions {
27211 fix: false,
27212 no_fix: false,
27213 json_output: true,
27214 compact: false,
27215 pretty: false,
27216 terse: false,
27217 schema: false,
27218 },
27219 )
27220 .unwrap();
27221
27222 let report = status::check_status(dir.path()).unwrap();
27223 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27224 }
27225
27226 #[test]
27227 fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
27228 let dir = setup_graph_index();
27229 let db_path = dir.path().join(".tsift/index.db");
27230 let _lock = hold_wal_database_lock(&db_path);
27231
27232 cmd_status(
27233 dir.path(),
27234 StatusCommandOptions {
27235 fix: false,
27236 no_fix: false,
27237 json_output: true,
27238 compact: false,
27239 pretty: false,
27240 terse: false,
27241 schema: false,
27242 },
27243 )
27244 .unwrap();
27245
27246 let report = status::check_status(dir.path()).unwrap();
27247 assert!(matches!(
27248 report.index,
27249 status::IndexStatus::Fresh {
27250 recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
27251 ..
27252 }
27253 ));
27254 let locks = status::check_locks(dir.path(), None, None).unwrap();
27255 assert!(matches!(
27256 locks.writer_lock,
27257 status::WriterLockStatus::Absent { .. }
27258 ));
27259 assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
27260 assert!(
27261 locks
27262 .recommended_action
27263 .contains("wedged writer holding live WAL sidecars")
27264 );
27265 }
27266
27267 #[test]
27268 fn locks_report_uses_ancestor_project_root_for_nested_paths() {
27269 let dir = setup_graph_index();
27270 let nested = dir.path().join("src/nested");
27271 std::fs::create_dir_all(&nested).unwrap();
27272
27273 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27274 let report = status::check_locks(&root, Some(&nested), None).unwrap();
27275
27276 assert_eq!(report.source_root, dir.path());
27277 assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
27278 }
27279
27280 #[test]
27281 fn workspace_locks_report_infers_scope_from_nested_path() {
27282 let dir = setup_workspace();
27283 cmd_index(
27284 dir.path(),
27285 false,
27286 false,
27287 false,
27288 false,
27289 false,
27290 true,
27291 None,
27292 false,
27293 false,
27294 false,
27295 false,
27296 false,
27297 false,
27298 )
27299 .unwrap();
27300 let nested = dir.path().join("src/alpha/nested");
27301 std::fs::create_dir_all(&nested).unwrap();
27302
27303 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27304 let report = status::check_locks(&root, Some(&nested), None).unwrap();
27305 let cfg = config::Config::load(dir.path()).unwrap();
27306
27307 assert_eq!(report.label, "submodule `alpha` index");
27308 assert_eq!(report.source_root, dir.path().join("src/alpha"));
27309 assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
27310 assert_eq!(
27311 report.reindex_command,
27312 format!("tsift index --submodule alpha {}", dir.path().display())
27313 );
27314 }
27315
27316 #[test]
27317 fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
27318 let dir = setup_workspace();
27319 cmd_index(
27320 dir.path(),
27321 false,
27322 false,
27323 false,
27324 false,
27325 false,
27326 true,
27327 None,
27328 false,
27329 false,
27330 false,
27331 false,
27332 false,
27333 false,
27334 )
27335 .unwrap();
27336
27337 let alpha = dir.path().join("src/alpha/lib.rs");
27338 std::thread::sleep(std::time::Duration::from_millis(50));
27339 std::fs::write(
27340 &alpha,
27341 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27342 )
27343 .unwrap();
27344
27345 let result = cmd_search(
27346 "alpha_helper".to_string(),
27347 Some(dir.path().to_path_buf()),
27348 5,
27349 Some("lexical".to_string()),
27350 Some("alpha".to_string()),
27351 false,
27352 false,
27353 true,
27354 0,
27355 false,
27356 false,
27357 false,
27358 false,
27359 false,
27360 false,
27361 false,
27362 );
27363
27364 assert!(result.is_ok());
27365
27366 let cfg = config::Config::load(dir.path()).unwrap();
27367 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27368 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27369 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27370 }
27371
27372 #[test]
27373 fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27374 let dir = setup_workspace();
27375 cmd_index(
27376 dir.path(),
27377 false,
27378 false,
27379 false,
27380 false,
27381 false,
27382 true,
27383 None,
27384 false,
27385 false,
27386 false,
27387 false,
27388 false,
27389 false,
27390 )
27391 .unwrap();
27392
27393 let alpha = dir.path().join("src/alpha/lib.rs");
27394 std::thread::sleep(std::time::Duration::from_millis(50));
27395 std::fs::write(
27396 &alpha,
27397 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27398 )
27399 .unwrap();
27400
27401 let cfg = config::Config::load(dir.path()).unwrap();
27402 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27403
27404 let err = cmd_search(
27405 "alpha_helper".to_string(),
27406 Some(dir.path().to_path_buf()),
27407 5,
27408 Some("lexical".to_string()),
27409 Some("alpha".to_string()),
27410 false,
27411 false,
27412 false,
27413 0,
27414 false,
27415 false,
27416 false,
27417 false,
27418 false,
27419 false,
27420 false,
27421 )
27422 .unwrap_err();
27423
27424 assert!(err.to_string().contains("search aborted"));
27425 assert!(err.to_string().contains("submodule `alpha` index"));
27426 assert!(!err.to_string().contains("database is locked"));
27427 }
27428
27429 #[test]
27430 fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
27431 let dir = setup_workspace();
27432 cmd_index(
27433 dir.path(),
27434 false,
27435 false,
27436 false,
27437 false,
27438 false,
27439 true,
27440 None,
27441 false,
27442 false,
27443 false,
27444 false,
27445 false,
27446 false,
27447 )
27448 .unwrap();
27449
27450 let alpha = dir.path().join("src/alpha/lib.rs");
27451 std::thread::sleep(std::time::Duration::from_millis(50));
27452 std::fs::write(
27453 &alpha,
27454 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27455 )
27456 .unwrap();
27457
27458 let result = cmd_search(
27459 "alpha_helper".to_string(),
27460 Some(dir.path().to_path_buf()),
27461 5,
27462 Some("lexical".to_string()),
27463 None,
27464 true,
27465 false,
27466 true,
27467 0,
27468 false,
27469 false,
27470 false,
27471 false,
27472 false,
27473 false,
27474 false,
27475 );
27476
27477 assert!(result.is_ok());
27478
27479 let cfg = config::Config::load(dir.path()).unwrap();
27480 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27481 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27482 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27483 }
27484
27485 #[test]
27486 fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27487 let dir = setup_workspace();
27488 cmd_index(
27489 dir.path(),
27490 false,
27491 false,
27492 false,
27493 false,
27494 false,
27495 true,
27496 None,
27497 false,
27498 false,
27499 false,
27500 false,
27501 false,
27502 false,
27503 )
27504 .unwrap();
27505
27506 let alpha = dir.path().join("src/alpha/lib.rs");
27507 std::thread::sleep(std::time::Duration::from_millis(50));
27508 std::fs::write(
27509 &alpha,
27510 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27511 )
27512 .unwrap();
27513
27514 let cfg = config::Config::load(dir.path()).unwrap();
27515 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27516
27517 let err = cmd_search(
27518 "alpha_helper".to_string(),
27519 Some(dir.path().to_path_buf()),
27520 5,
27521 Some("lexical".to_string()),
27522 None,
27523 true,
27524 false,
27525 false,
27526 30,
27527 false,
27528 false,
27529 false,
27530 false,
27531 false,
27532 false,
27533 false,
27534 )
27535 .unwrap_err();
27536
27537 assert!(err.to_string().contains("stale"));
27538 assert!(err.to_string().contains("submodule `alpha` index"));
27539 assert!(!err.to_string().contains("database is locked"));
27540 }
27541
27542 #[test]
27543 fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
27544 let dir = setup_workspace();
27545 cmd_index(
27546 dir.path(),
27547 false,
27548 false,
27549 false,
27550 false,
27551 false,
27552 true,
27553 None,
27554 false,
27555 false,
27556 false,
27557 false,
27558 false,
27559 false,
27560 )
27561 .unwrap();
27562
27563 let err = cmd_search(
27564 "alpha_helper".to_string(),
27565 Some(dir.path().to_path_buf()),
27566 5,
27567 Some("lexical".to_string()),
27568 None,
27569 false,
27570 false,
27571 true,
27572 0,
27573 false,
27574 false,
27575 false,
27576 false,
27577 false,
27578 false,
27579 false,
27580 )
27581 .unwrap_err();
27582
27583 assert_workspace_search_requires_explicit_target(err);
27584 assert!(!dir.path().join(".tsift/index.db").exists());
27585 }
27586
27587 #[test]
27588 fn workspace_search_cmd_infers_scope_from_nested_path() {
27589 let dir = setup_workspace();
27590 cmd_index(
27591 dir.path(),
27592 false,
27593 false,
27594 false,
27595 false,
27596 false,
27597 true,
27598 None,
27599 false,
27600 false,
27601 false,
27602 false,
27603 false,
27604 false,
27605 )
27606 .unwrap();
27607 let nested = dir.path().join("src/alpha/nested");
27608 std::fs::create_dir_all(&nested).unwrap();
27609
27610 let result = cmd_search(
27611 "alpha_helper".to_string(),
27612 Some(nested),
27613 5,
27614 Some("lexical".to_string()),
27615 None,
27616 false,
27617 false,
27618 false,
27619 0,
27620 false,
27621 false,
27622 false,
27623 false,
27624 false,
27625 false,
27626 false,
27627 );
27628
27629 assert!(result.is_ok());
27630 }
27631
27632 #[test]
27633 fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
27634 let dir = setup_workspace_with_duplicate_leaf_names();
27635 cmd_index(
27636 dir.path(),
27637 false,
27638 false,
27639 false,
27640 false,
27641 false,
27642 true,
27643 None,
27644 false,
27645 false,
27646 false,
27647 false,
27648 false,
27649 false,
27650 )
27651 .unwrap();
27652 let nested = dir.path().join("vendor/foo/nested");
27653 std::fs::create_dir_all(&nested).unwrap();
27654
27655 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27656 let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
27657 let cfg = config::Config::load(dir.path()).unwrap();
27658
27659 assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
27660 }
27661
27662 #[test]
27663 fn graph_cmd_succeeds_while_writer_lock_is_held() {
27664 let dir = setup_graph_index();
27665 let db_path = dir.path().join(".tsift/index.db");
27666 let _lock = hold_write_lock(&db_path);
27667
27668 let result = cmd_graph(
27669 "main",
27670 dir.path(),
27671 false,
27672 false,
27673 None,
27674 20,
27675 false,
27676 true,
27677 false,
27678 false,
27679 false,
27680 false,
27681 false,
27682 TagpathSearchOpts::default(),
27683 );
27684
27685 assert!(result.is_ok());
27686 }
27687
27688 #[test]
27689 fn graph_cmd_autoindexes_stale_index_by_default() {
27690 let dir = setup_graph_index();
27691 std::thread::sleep(std::time::Duration::from_millis(50));
27692 std::fs::write(
27693 dir.path().join("main.rs"),
27694 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27695 )
27696 .unwrap();
27697
27698 let result = cmd_graph(
27699 "helper",
27700 dir.path(),
27701 true,
27702 false,
27703 None,
27704 20,
27705 false,
27706 true,
27707 false,
27708 false,
27709 false,
27710 false,
27711 false,
27712 TagpathSearchOpts::default(),
27713 );
27714
27715 assert!(result.is_ok());
27716 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27717 let summary = db.compute_changes(dir.path()).unwrap();
27718 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27719 }
27720
27721 #[test]
27722 fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27723 let dir = setup_graph_index();
27724 let db_path = dir.path().join(".tsift/index.db");
27725 let _lock = hold_rollback_journal_lock(&db_path);
27726
27727 let result = cmd_graph(
27728 "main",
27729 dir.path(),
27730 false,
27731 false,
27732 None,
27733 20,
27734 false,
27735 true,
27736 false,
27737 false,
27738 false,
27739 false,
27740 false,
27741 TagpathSearchOpts::default(),
27742 );
27743
27744 assert!(result.is_ok());
27745 }
27746
27747 #[test]
27748 fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
27749 let dir = setup_graph_index();
27750 let nested = dir.path().join("src/nested");
27751 std::fs::create_dir_all(&nested).unwrap();
27752
27753 let result = cmd_graph(
27754 "helper",
27755 &nested,
27756 true,
27757 false,
27758 None,
27759 20,
27760 false,
27761 false,
27762 false,
27763 false,
27764 false,
27765 false,
27766 false,
27767 TagpathSearchOpts::default(),
27768 );
27769
27770 assert!(result.is_ok());
27771 }
27772
27773 #[test]
27774 fn communities_cmd_succeeds_while_writer_lock_is_held() {
27775 let dir = setup_graph_index();
27776 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27777
27778 let result = cmd_communities(
27779 dir.path(),
27780 None,
27781 1,
27782 10,
27783 false,
27784 false,
27785 false,
27786 false,
27787 false,
27788 false,
27789 TagpathSearchOpts::default(),
27790 );
27791
27792 assert!(result.is_ok());
27793 }
27794
27795 #[test]
27796 fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27797 let dir = setup_graph_index();
27798 let db_path = dir.path().join(".tsift/index.db");
27799 let _lock = hold_rollback_journal_lock(&db_path);
27800
27801 let result = cmd_communities(
27802 dir.path(),
27803 None,
27804 1,
27805 10,
27806 false,
27807 false,
27808 false,
27809 false,
27810 false,
27811 false,
27812 TagpathSearchOpts::default(),
27813 );
27814
27815 assert!(result.is_ok());
27816 }
27817
27818 #[test]
27819 fn lint_finds_entities_from_project_root_index_db() {
27820 let dir = tempfile::tempdir().unwrap();
27821 std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
27822 std::fs::write(
27823 dir.path().join("README.md"),
27824 "alpha_helper should be backticked.\n",
27825 )
27826 .unwrap();
27827 cmd_index(
27828 dir.path(),
27829 false,
27830 false,
27831 false,
27832 false,
27833 false,
27834 false,
27835 None,
27836 false,
27837 false,
27838 false,
27839 false,
27840 false,
27841 false,
27842 )
27843 .unwrap();
27844
27845 let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
27846 .unwrap()
27847 .unwrap();
27848 let entities = lint::collect_entities_from_index_path(&root).unwrap();
27849 let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
27850
27851 assert!(
27852 result
27853 .annotations
27854 .iter()
27855 .any(|ann| ann.text == "alpha_helper")
27856 );
27857 }
27858
27859 #[test]
27862 fn search_direct_runs_ok() {
27863 let dir = tempfile::tempdir().unwrap();
27864 let search_dir = dir.path().to_path_buf();
27865 let cache_dir = search_dir.join(".tsift/search-cache");
27866 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27867 let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical");
27868 assert!(result.is_ok(), "direct search should succeed");
27869 assert!(
27870 cache_dir.exists(),
27871 "search should create the configured cache dir"
27872 );
27873 }
27874
27875 #[test]
27876 fn search_timeout_zero_disables_timeout() {
27877 let dir = tempfile::tempdir().unwrap();
27878 let search_dir = dir.path().to_path_buf();
27879 let cache_dir = search_dir.join(".tsift/search-cache");
27880 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27881 let result = run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[]);
27882 assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
27883 assert!(
27884 cache_dir.exists(),
27885 "timeout=0 should keep using the stable search cache dir"
27886 );
27887 }
27888
27889 #[test]
27890 fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
27891 let dir = tempfile::tempdir().unwrap();
27892 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
27893 cmd_index(
27894 dir.path(),
27895 false,
27896 false,
27897 false,
27898 false,
27899 false,
27900 false,
27901 None,
27902 false,
27903 false,
27904 false,
27905 false,
27906 false,
27907 false,
27908 )
27909 .unwrap();
27910 let db_path = dir.path().join(".tsift/index.db");
27911 std::fs::remove_file(&db_path).unwrap();
27912 let search_target = SearchIndexTarget {
27913 label: "index".to_string(),
27914 db_path,
27915 source_root: dir.path().to_path_buf(),
27916 scope_name: None,
27917 reindex_cmd: format!("tsift index {}", dir.path().display()),
27918 };
27919
27920 let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
27921
27922 assert!(message.contains("timed out after 1s"));
27923 assert!(message.contains("index is missing"));
27924 assert!(message.contains("Run `tsift index"));
27925 assert!(!message.contains("search root looks fresh"));
27926 }
27927
27928 #[test]
27929 fn search_worker_output_path_uses_json_suffix() {
27930 let path = next_search_worker_output_path();
27931 assert!(path.extension().is_some_and(|ext| ext == "json"));
27932 }
27933
27934 #[test]
27937 fn index_quiet_suppresses_file_list() {
27938 let dir = setup_graph_index();
27939 let result = cmd_index(
27940 dir.path(),
27941 false,
27942 true,
27943 false,
27944 false,
27945 true,
27946 false,
27947 None,
27948 false,
27949 false,
27950 false,
27951 false,
27952 false,
27953 false,
27954 );
27955 assert!(result.is_ok());
27956 }
27957
27958 #[test]
27959 fn index_exit_code_implies_quiet() {
27960 let dir = setup_graph_index();
27961 let result = cmd_index(
27962 dir.path(),
27963 false,
27964 true,
27965 false,
27966 false,
27967 false,
27968 false,
27969 None,
27970 false,
27971 false,
27972 false,
27973 false,
27974 false,
27975 false,
27976 );
27977 assert!(result.is_ok());
27978 }
27979
27980 #[test]
27981 fn index_quiet_json_omits_changes() {
27982 let dir = setup_graph_index();
27983 let result = cmd_index(
27984 dir.path(),
27985 false,
27986 true,
27987 false,
27988 false,
27989 true,
27990 false,
27991 None,
27992 true,
27993 false,
27994 false,
27995 false,
27996 false,
27997 false,
27998 );
27999 assert!(result.is_ok());
28000 }
28001
28002 #[test]
28003 fn cli_workflow_defaults_to_search_topic() {
28004 let cli = parse_cli(["tsift", "workflow"]);
28005 match cli.command {
28006 Some(Commands::Workflow { topic, json }) => {
28007 assert_eq!(topic, "search");
28008 assert!(!json);
28009 }
28010 _ => panic!("expected Workflow command"),
28011 }
28012 }
28013
28014 #[test]
28015 fn search_workflow_recipe_preserves_handles_across_expansions() {
28016 let recipe = workflow::search_workflow_recipe();
28017 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
28018 assert_eq!(
28019 step_names,
28020 vec![
28021 "exact-anchor",
28022 "semantic-search",
28023 "explain-symbol",
28024 "summarize-selection",
28025 "digest-expansion"
28026 ]
28027 );
28028 assert!(
28029 recipe
28030 .handle_contract
28031 .iter()
28032 .any(|item| item.contains("originating command"))
28033 );
28034 assert!(
28035 recipe.steps[1]
28036 .preserves
28037 .iter()
28038 .any(|item| item.contains("sfam-*"))
28039 );
28040 assert!(
28041 recipe.steps[2]
28042 .preserves
28043 .iter()
28044 .any(|item| item.contains("ecall-*"))
28045 );
28046 assert!(
28047 recipe.steps[4]
28048 .preserves
28049 .iter()
28050 .any(|item| item.contains("artifact handles"))
28051 );
28052 }
28053
28054 #[test]
28057 fn to_json_compact_default() {
28058 let val = serde_json::json!({"a": 1, "b": [2, 3]});
28059 let compact = to_json(&val, false, false).unwrap();
28060 assert!(!compact.contains('\n'));
28061 assert!(
28062 compact.contains("\"a\":1")
28063 || compact.contains("\"a\": 1")
28064 || compact.contains("\"a\":")
28065 );
28066 }
28067
28068 #[test]
28069 fn to_json_pretty_indents() {
28070 let val = serde_json::json!({"a": 1, "b": [2, 3]});
28071 let pretty = to_json(&val, true, false).unwrap();
28072 assert!(pretty.contains('\n'));
28073 assert!(pretty.contains(" "));
28074 }
28075
28076 #[test]
28077 fn to_json_compact_is_shorter() {
28078 let val =
28079 serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
28080 let compact = to_json(&val, false, false).unwrap();
28081 let pretty = to_json(&val, true, false).unwrap();
28082 assert!(compact.len() < pretty.len());
28083 }
28084
28085 #[test]
28086 fn terse_renames_keys() {
28087 let val =
28088 serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
28089 let result = to_json(&val, false, true).unwrap();
28090 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28091 assert!(parsed["_s"].is_object());
28092 let d = &parsed["d"];
28093 assert_eq!(d["cf"], "a.rs");
28094 assert_eq!(d["cn"], "main");
28095 assert_eq!(d["csl"], 10);
28096 }
28097
28098 #[test]
28099 fn terse_schema_only_includes_used_keys() {
28100 let val = serde_json::json!({"name": "test", "score": 0.5});
28101 let result = to_json(&val, false, true).unwrap();
28102 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28103 let schema = parsed["_s"].as_object().unwrap();
28104 assert_eq!(schema["n"], "name");
28105 assert_eq!(schema["sc"], "score");
28106 assert!(!schema.contains_key("cf"));
28107 }
28108
28109 #[test]
28110 fn terse_nested_arrays() {
28111 let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
28112 let result = to_json(&val, false, true).unwrap();
28113 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28114 let d = &parsed["d"];
28115 assert_eq!(d["crs"][0]["cn"], "a");
28116 assert_eq!(d["crs"][0]["cf"], "b.rs");
28117 }
28118
28119 #[test]
28120 fn terse_preserves_unknown_keys() {
28121 let val = serde_json::json!({"custom_field": "value", "name": "test"});
28122 let result = to_json(&val, false, true).unwrap();
28123 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28124 let d = &parsed["d"];
28125 assert_eq!(d["custom_field"], "value");
28126 assert_eq!(d["n"], "test");
28127 }
28128
28129 #[test]
28132 fn ultra_terse_strips_properties_from_graph_nodes() {
28133 let val = serde_json::json!({
28134 "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
28135 });
28136 let result = to_json_schema(&val, false, true, true, false).unwrap();
28137 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28138 let node = &parsed["d"]["nodes"][0];
28139 assert_eq!(node["id"], "fn:main");
28140 assert_eq!(node["k"], "fn");
28141 assert_eq!(node["n"], "main");
28142 assert!(node.get("properties").is_none());
28143 }
28144
28145 #[test]
28146 fn ultra_terse_strips_properties_from_graph_edges() {
28147 let val = serde_json::json!({
28148 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
28149 });
28150 let result = to_json_schema(&val, false, true, true, false).unwrap();
28151 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28152 let edge = &parsed["d"]["edges"][0];
28153 assert_eq!(edge["from_id"], "a");
28154 assert_eq!(edge["to_id"], "b");
28155 assert_eq!(edge["k"], "c");
28156 assert!(edge.get("properties").is_none());
28157 }
28158
28159 #[test]
28160 fn ultra_terse_abbreviates_edge_kinds() {
28161 let val = serde_json::json!({
28162 "edges": [
28163 {"from_id": "a", "to_id": "b", "kind": "defines"},
28164 {"from_id": "a", "to_id": "c", "kind": "contains"},
28165 {"from_id": "a", "to_id": "d", "kind": "imports"},
28166 {"from_id": "a", "to_id": "e", "kind": "mentions"},
28167 {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
28168 {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
28169 {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
28170 {"from_id": "a", "to_id": "i", "kind": "uses"},
28171 {"from_id": "a", "to_id": "j", "kind": "parent"},
28172 {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
28173 ]
28174 });
28175 let result = to_json_schema(&val, false, true, true, false).unwrap();
28176 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28177 let edges = &parsed["d"]["edges"].as_array().unwrap();
28178 assert_eq!(edges[0]["k"], "d");
28179 assert_eq!(edges[1]["k"], "ct");
28180 assert_eq!(edges[2]["k"], "i");
28181 assert_eq!(edges[3]["k"], "m");
28182 assert_eq!(edges[4]["k"], "sr");
28183 assert_eq!(edges[5]["k"], "bt");
28184 assert_eq!(edges[6]["k"], "sctx");
28185 assert_eq!(edges[7]["k"], "u");
28186 assert_eq!(edges[8]["k"], "p");
28187 assert_eq!(edges[9]["k"], "unknown_edge");
28188 }
28189
28190 #[test]
28191 fn ultra_terse_strips_provenance_freshness_from_edges() {
28192 let val = serde_json::json!({
28193 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
28194 });
28195 let result = to_json_schema(&val, false, true, true, false).unwrap();
28196 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28197 let edge = &parsed["d"]["edges"][0];
28198 assert!(edge.get("provenance").is_none());
28199 assert!(edge.get("freshness").is_none());
28200 assert_eq!(edge["k"], "c");
28201 }
28202
28203 #[test]
28204 fn ultra_terse_truncates_snippets() {
28205 let long_snippet = "x".repeat(120);
28206 let val = serde_json::json!({"snippet": long_snippet});
28207 let result = to_json_schema(&val, false, true, true, false).unwrap();
28208 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28209 let snipped = parsed["d"]["sn"].as_str().unwrap();
28210 assert_eq!(snipped.len(), 80);
28211 assert!(snipped.ends_with("..."));
28212 }
28213
28214 #[test]
28215 fn ultra_terse_truncates_abbreviated_snippet_key() {
28216 let long_snippet = "y".repeat(100);
28217 let val = serde_json::json!({"snippet": long_snippet});
28218 let result = to_json_schema(&val, false, true, true, false).unwrap();
28219 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28220 let snipped = parsed["d"]["sn"].as_str().unwrap();
28221 assert_eq!(snipped.len(), 80);
28222 assert!(snipped.ends_with("..."));
28223 }
28224
28225 #[test]
28226 fn ultra_terse_compacts_coverage_snapshot() {
28227 let val = serde_json::json!({
28228 "mode": "incremental",
28229 "total_sector_count": 10,
28230 "dirty_sector_count": 2,
28231 "active_rebuild": Some("rebuild-1"),
28232 "completed_dirty_sector_count": 1,
28233 "mounted_sector_count": 8,
28234 "rebuilding_sector_count": 1,
28235 "resumed_sector_count": 3,
28236 "reused_sector_count": 5
28237 });
28238 let result = to_json_schema(&val, false, true, true, false).unwrap();
28239 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28240 let d = &parsed["d"];
28241 assert_eq!(d["mode"], "incremental");
28242 assert_eq!(d["total_sector_count"], 10);
28243 assert_eq!(d["dirty_sector_count"], 2);
28244 assert!(d.get("active_rebuild").is_none());
28245 assert!(d.get("completed_dirty_sector_count").is_none());
28246 assert!(d.get("mounted_sector_count").is_none());
28247 assert!(d.get("rebuilding_sector_count").is_none());
28248 assert!(d.get("resumed_sector_count").is_none());
28249 assert!(d.get("reused_sector_count").is_none());
28250 }
28251
28252 #[test]
28253 fn ultra_terse_short_snippet_unchanged() {
28254 let val = serde_json::json!({"snippet": "short text"});
28255 let result = to_json_schema(&val, false, true, true, false).unwrap();
28256 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28257 assert_eq!(parsed["d"]["sn"], "short text");
28258 }
28259
28260 #[test]
28261 fn ultra_terse_non_graph_object_properties_preserved() {
28262 let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
28263 let result = to_json_schema(&val, false, true, true, false).unwrap();
28264 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28265 assert!(parsed["d"]["config"]["properties"].is_object());
28266 }
28267
28268 #[test]
28271 fn schema_converts_homogeneous_arrays() {
28272 let val = serde_json::json!({"symbols": [
28273 {"name": "foo", "kind": "fn", "line": 10},
28274 {"name": "bar", "kind": "fn", "line": 20}
28275 ]});
28276 let result = to_json_schema(&val, false, false, false, true).unwrap();
28277 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28278 let syms = &parsed["symbols"];
28279 let columns = syms["_c"]
28280 .as_array()
28281 .unwrap()
28282 .iter()
28283 .map(|value| value.as_str().unwrap())
28284 .collect::<Vec<_>>();
28285 let row0 = syms["_r"][0].as_array().unwrap();
28286 let row1 = syms["_r"][1].as_array().unwrap();
28287 let name_index = columns.iter().position(|column| *column == "name").unwrap();
28288 let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
28289 let line_index = columns.iter().position(|column| *column == "line").unwrap();
28290 assert_eq!(row0[name_index], "foo");
28291 assert_eq!(row0[kind_index], "fn");
28292 assert_eq!(row0[line_index], 10);
28293 assert_eq!(row1[name_index], "bar");
28294 assert_eq!(row1[kind_index], "fn");
28295 assert_eq!(row1[line_index], 20);
28296 }
28297
28298 #[test]
28299 fn schema_skips_short_arrays() {
28300 let val = serde_json::json!({"items": [{"name": "only"}]});
28301 let result = to_json_schema(&val, false, false, false, true).unwrap();
28302 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28303 assert!(parsed["items"].is_array());
28304 assert_eq!(parsed["items"][0]["name"], "only");
28305 }
28306
28307 #[test]
28308 fn schema_skips_heterogeneous_arrays() {
28309 let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
28310 let result = to_json_schema(&val, false, false, false, true).unwrap();
28311 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28312 assert!(parsed["items"].is_array());
28313 assert_eq!(parsed["items"][0]["a"], 1);
28314 }
28315
28316 #[test]
28317 fn schema_with_terse_combines() {
28318 let val = serde_json::json!({"callers": [
28319 {"caller_name": "a", "caller_file": "x.rs"},
28320 {"caller_name": "b", "caller_file": "y.rs"}
28321 ]});
28322 let result = to_json_schema(&val, false, true, false, true).unwrap();
28323 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28324 assert!(parsed["_s"].is_object());
28325 let d = &parsed["d"];
28326 let crs = &d["crs"];
28327 assert!(crs["_c"].is_array());
28328 assert!(crs["_r"].is_array());
28329 let columns = crs["_c"]
28330 .as_array()
28331 .unwrap()
28332 .iter()
28333 .map(|value| value.as_str().unwrap())
28334 .collect::<Vec<_>>();
28335 let row = crs["_r"][0].as_array().unwrap();
28336 let name_index = columns.iter().position(|column| *column == "cn").unwrap();
28337 let file_index = columns.iter().position(|column| *column == "cf").unwrap();
28338 assert_eq!(row[name_index], "a");
28339 assert_eq!(row[file_index], "x.rs");
28340 }
28341
28342 #[test]
28343 fn schema_preserves_non_object_arrays() {
28344 let val = serde_json::json!({"tags": ["a", "b", "c"]});
28345 let result = to_json_schema(&val, false, false, false, true).unwrap();
28346 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28347 assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
28348 }
28349
28350 #[test]
28351 fn cli_accepts_global_schema_flag() {
28352 let cli = parse_cli(["tsift", "--schema", "search", "test"]);
28353 assert!(cli.schema);
28354 assert!(matches!(cli.command, Some(Commands::Search { .. })));
28355 }
28356
28357 #[test]
28358 fn cli_accepts_global_envelope_flag() {
28359 let cli = parse_cli([
28360 "tsift",
28361 "--envelope",
28362 "context-pack",
28363 "tasks/software/tsift.md",
28364 ]);
28365 assert!(cli.envelope);
28366 assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
28367 }
28368
28369 #[test]
28370 fn cli_accepts_locks_command() {
28371 let cli = parse_cli(["tsift", "locks"]);
28372 assert!(matches!(cli.command, Some(Commands::Locks { .. })));
28373 }
28374
28375 #[test]
28376 fn cli_parses_memory_budget_guard_command() {
28377 let cli = parse_cli([
28378 "tsift",
28379 "memory",
28380 "budget-guard",
28381 "--file",
28382 "tool.log",
28383 "--budget-tokens",
28384 "1000",
28385 "--json",
28386 ]);
28387 match cli.command {
28388 Some(Commands::Memory {
28389 command:
28390 crate::cli::MemoryCommand::BudgetGuard {
28391 file,
28392 budget_tokens,
28393 json,
28394 ..
28395 },
28396 }) => {
28397 assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
28398 assert_eq!(budget_tokens, 1000);
28399 assert!(json);
28400 }
28401 _ => panic!("expected memory budget-guard command"),
28402 }
28403 }
28404
28405 #[test]
28406 fn cli_parses_memory_capture_agent_doc_closeout_command() {
28407 let cli = parse_cli([
28408 "tsift",
28409 "memory",
28410 "capture-agent-doc-closeout",
28411 ".",
28412 "--session-path",
28413 "tasks/software/tsift.md",
28414 "--prompt-target",
28415 "do [#tsiftmemhooks]",
28416 "--response-summary",
28417 "wired closeout capture",
28418 "--commit-hash",
28419 "abc123",
28420 "--session-check-status",
28421 "clean",
28422 "--json",
28423 ]);
28424 match cli.command {
28425 Some(Commands::Memory {
28426 command:
28427 crate::cli::MemoryCommand::CaptureAgentDocCloseout {
28428 path,
28429 session_path,
28430 prompt_target,
28431 response_summary,
28432 commit_hash,
28433 session_check_status,
28434 json,
28435 },
28436 }) => {
28437 assert_eq!(path, std::path::PathBuf::from("."));
28438 assert_eq!(
28439 session_path,
28440 std::path::PathBuf::from("tasks/software/tsift.md")
28441 );
28442 assert_eq!(prompt_target, "do [#tsiftmemhooks]");
28443 assert_eq!(response_summary, "wired closeout capture");
28444 assert_eq!(commit_hash.as_deref(), Some("abc123"));
28445 assert_eq!(session_check_status, "clean");
28446 assert!(json);
28447 }
28448 _ => panic!("expected memory capture-agent-doc-closeout command"),
28449 }
28450 }
28451
28452 #[test]
28453 fn cli_parses_memory_project_graph_read_policy() {
28454 let cli = parse_cli([
28455 "tsift",
28456 "memory",
28457 "project-graph",
28458 ".",
28459 "--read-policy",
28460 "query-relevant",
28461 "--query",
28462 "semantic memory",
28463 "--limit",
28464 "7",
28465 "--json",
28466 ]);
28467 match cli.command {
28468 Some(Commands::Memory {
28469 command:
28470 crate::cli::MemoryCommand::ProjectGraph {
28471 read_policy,
28472 query,
28473 limit,
28474 json,
28475 ..
28476 },
28477 }) => {
28478 assert_eq!(
28479 read_policy,
28480 crate::cli::MemoryProjectReadPolicy::QueryRelevant
28481 );
28482 assert_eq!(query.as_deref(), Some("semantic memory"));
28483 assert_eq!(limit, 7);
28484 assert!(json);
28485 }
28486 _ => panic!("expected memory project-graph command"),
28487 }
28488 }
28489
28490 #[test]
28491 fn cli_locks_accepts_scope_flag() {
28492 let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
28493 match cli.command {
28494 Some(Commands::Locks { scope, .. }) => {
28495 assert_eq!(scope.as_deref(), Some("alpha"));
28496 }
28497 _ => panic!("expected Locks command"),
28498 }
28499 }
28500
28501 #[test]
28502 fn cli_search_accepts_autoindex_flag() {
28503 let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
28504 match cli.command {
28505 Some(Commands::Search {
28506 autoindex,
28507 no_autoindex,
28508 ..
28509 }) => {
28510 assert!(autoindex);
28511 assert!(!no_autoindex);
28512 }
28513 _ => panic!("expected Search command"),
28514 }
28515 }
28516
28517 #[test]
28518 fn cli_search_accepts_exact_flag() {
28519 let cli = parse_cli(["tsift", "search", "test", "--exact"]);
28520 match cli.command {
28521 Some(Commands::Search {
28522 exact, strategy, ..
28523 }) => {
28524 assert!(exact);
28525 assert!(strategy.is_none());
28526 }
28527 _ => panic!("expected Search command"),
28528 }
28529 }
28530
28531 #[test]
28532 fn cli_parses_diff_digest_command() {
28533 let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
28534 match cli.command {
28535 Some(Commands::DiffDigest {
28536 json,
28537 path,
28538 cached,
28539 revision,
28540 max_parsed_files,
28541 }) => {
28542 assert!(json);
28543 assert_eq!(path, PathBuf::from("."));
28544 assert!(!cached);
28545 assert!(revision.is_none());
28546 assert_eq!(max_parsed_files, 25);
28547 }
28548 _ => panic!("expected DiffDigest command"),
28549 }
28550 }
28551
28552 #[test]
28553 fn cli_rejects_conflicting_diff_digest_modes() {
28554 match try_parse_cli([
28555 "tsift",
28556 "diff-digest",
28557 "--cached",
28558 "--revision",
28559 "HEAD",
28560 ".",
28561 ]) {
28562 Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
28563 Err(err) => {
28564 assert!(err.to_string().contains("--cached"));
28565 assert!(err.to_string().contains("--revision"));
28566 }
28567 }
28568 }
28569
28570 #[test]
28571 fn cli_parses_test_digest_command() {
28572 let cli = parse_cli([
28573 "tsift",
28574 "test-digest",
28575 "--path",
28576 ".",
28577 "--input",
28578 "target/test.log",
28579 "--runner",
28580 "cargo",
28581 "--json",
28582 ]);
28583 match cli.command {
28584 Some(Commands::TestDigest {
28585 json,
28586 path,
28587 input,
28588 runner,
28589 }) => {
28590 assert!(json);
28591 assert_eq!(path, PathBuf::from("."));
28592 assert_eq!(input, Some(PathBuf::from("target/test.log")));
28593 assert_eq!(runner.as_deref(), Some("cargo"));
28594 }
28595 _ => panic!("expected TestDigest command"),
28596 }
28597 }
28598
28599 #[test]
28600 fn cli_parses_log_digest_command() {
28601 let cli = parse_cli([
28602 "tsift",
28603 "log-digest",
28604 "--path",
28605 ".",
28606 "--input",
28607 "target/build.log",
28608 "--json",
28609 ]);
28610 match cli.command {
28611 Some(Commands::LogDigest {
28612 json,
28613 path,
28614 input,
28615 fixture,
28616 fail_under,
28617 }) => {
28618 assert!(json);
28619 assert_eq!(path, PathBuf::from("."));
28620 assert_eq!(input, Some(PathBuf::from("target/build.log")));
28621 assert!(fixture.is_none());
28622 assert!(!fail_under);
28623 }
28624 _ => panic!("expected LogDigest command"),
28625 }
28626 }
28627
28628 #[test]
28629 fn cli_parses_metric_digest_command() {
28630 let cli = parse_cli([
28631 "tsift",
28632 "metric-digest",
28633 "--input",
28634 "target/runs.json",
28635 "--baseline",
28636 "target/prior.json",
28637 "--metric",
28638 "session_mae",
28639 "--lower-is-better",
28640 "session_mae",
28641 "--history",
28642 "4",
28643 "--top",
28644 "2",
28645 "--json",
28646 ]);
28647 match cli.command {
28648 Some(Commands::MetricDigest {
28649 input,
28650 baseline,
28651 metrics,
28652 lower_is_better,
28653 history,
28654 top,
28655 json,
28656 ..
28657 }) => {
28658 assert!(json);
28659 assert_eq!(input, Some(PathBuf::from("target/runs.json")));
28660 assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
28661 assert_eq!(metrics, vec!["session_mae"]);
28662 assert_eq!(lower_is_better, vec!["session_mae"]);
28663 assert_eq!(history, 4);
28664 assert_eq!(top, 2);
28665 }
28666 _ => panic!("expected MetricDigest command"),
28667 }
28668 }
28669
28670 #[test]
28671 fn cli_parses_dci_benchmark_command() {
28672 let cli = parse_cli([
28673 "tsift",
28674 "dci-benchmark",
28675 "--fixture",
28676 "fixtures/dci-search-benchmark.json",
28677 "--json",
28678 ]);
28679 match cli.command {
28680 Some(Commands::DciBenchmark { fixture, json }) => {
28681 assert!(json);
28682 assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
28683 }
28684 _ => panic!("expected DciBenchmark command"),
28685 }
28686 }
28687
28688 #[test]
28689 fn cli_parses_session_digest_command() {
28690 let cli = parse_cli([
28691 "tsift",
28692 "session-digest",
28693 "--path",
28694 ".",
28695 "--input",
28696 "target/session.md",
28697 "--source",
28698 "markdown",
28699 "--json",
28700 ]);
28701 match cli.command {
28702 Some(Commands::SessionDigest {
28703 json,
28704 path,
28705 input,
28706 source,
28707 }) => {
28708 assert!(json);
28709 assert_eq!(path, PathBuf::from("."));
28710 assert_eq!(input, Some(PathBuf::from("target/session.md")));
28711 assert_eq!(source.as_deref(), Some("markdown"));
28712 }
28713 _ => panic!("expected SessionDigest command"),
28714 }
28715 }
28716
28717 #[test]
28718 fn cli_parses_session_cost_command() {
28719 let cli = parse_cli([
28720 "tsift",
28721 "session-cost",
28722 "--input",
28723 "target/session.jsonl",
28724 "--source",
28725 "codex-jsonl",
28726 "--json",
28727 ]);
28728 match cli.command {
28729 Some(Commands::SessionCost {
28730 json,
28731 input,
28732 fixture,
28733 fail_under,
28734 source,
28735 }) => {
28736 assert!(json);
28737 assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
28738 assert_eq!(fixture, None);
28739 assert!(!fail_under);
28740 assert_eq!(source.as_deref(), Some("codex-jsonl"));
28741 }
28742 _ => panic!("expected SessionCost command"),
28743 }
28744
28745 let cli = parse_cli([
28746 "tsift",
28747 "session-cost",
28748 "--fixture",
28749 "fixtures/real-session-prompt-cache-effectiveness.json",
28750 "--fail-under",
28751 "--json",
28752 ]);
28753 match cli.command {
28754 Some(Commands::SessionCost {
28755 json,
28756 input,
28757 fixture,
28758 fail_under,
28759 source,
28760 }) => {
28761 assert!(json);
28762 assert_eq!(input, None);
28763 assert_eq!(
28764 fixture,
28765 Some(PathBuf::from(
28766 "fixtures/real-session-prompt-cache-effectiveness.json"
28767 ))
28768 );
28769 assert!(fail_under);
28770 assert_eq!(source, None);
28771 }
28772 _ => panic!("expected SessionCost command"),
28773 }
28774 }
28775
28776 #[test]
28777 fn cli_parses_session_review_command() {
28778 let cli = parse_cli([
28779 "tsift",
28780 "session-review",
28781 "tasks/software/tsift.md",
28782 "--next-context",
28783 "--json",
28784 ]);
28785 match cli.command {
28786 Some(Commands::SessionReview {
28787 json,
28788 next_context,
28789 path,
28790 ..
28791 }) => {
28792 assert!(json);
28793 assert!(next_context);
28794 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28795 }
28796 _ => panic!("expected SessionReview command"),
28797 }
28798 }
28799
28800 #[test]
28801 fn cli_search_accepts_budget_flags() {
28802 let cli = parse_cli([
28803 "tsift",
28804 "search",
28805 "alpha_helper",
28806 "--max-items",
28807 "3",
28808 "--max-bytes",
28809 "96",
28810 ]);
28811 match cli.command {
28812 Some(Commands::Search {
28813 max_items,
28814 max_bytes,
28815 ..
28816 }) => {
28817 assert_eq!(max_items, Some(3));
28818 assert_eq!(max_bytes, Some(96));
28819 }
28820 _ => panic!("expected Search command"),
28821 }
28822 }
28823
28824 #[test]
28825 fn cli_search_accepts_budget_preset() {
28826 let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
28827 match cli.command {
28828 Some(Commands::Search { budget, .. }) => {
28829 assert_eq!(budget, Some(ResponseBudgetPreset::Small));
28830 }
28831 _ => panic!("expected Search command"),
28832 }
28833 }
28834
28835 #[test]
28836 fn cli_search_accepts_ast_facet_filters() {
28837 let cli = parse_cli([
28838 "tsift",
28839 "search",
28840 "setup",
28841 "--lang",
28842 "markdown",
28843 "--kind",
28844 "list_item",
28845 "--node-kind",
28846 "list_item",
28847 "--section",
28848 "Install",
28849 "--parent",
28850 "Run setup.",
28851 "--child",
28852 "Confirm setup.",
28853 "--fence-language",
28854 "rust",
28855 "--list-depth",
28856 "1",
28857 "--heading-level",
28858 "2",
28859 ]);
28860 match cli.command {
28861 Some(Commands::Search {
28862 lang,
28863 kind,
28864 node_kind,
28865 section,
28866 parent,
28867 child,
28868 fence_language,
28869 list_depth,
28870 heading_level,
28871 ..
28872 }) => {
28873 assert_eq!(lang, vec!["markdown"]);
28874 assert_eq!(kind, vec!["list_item"]);
28875 assert_eq!(node_kind, vec!["list_item"]);
28876 assert_eq!(section, vec!["Install"]);
28877 assert_eq!(parent, vec!["Run setup."]);
28878 assert_eq!(child, vec!["Confirm setup."]);
28879 assert_eq!(fence_language, vec!["rust"]);
28880 assert_eq!(list_depth, vec![1]);
28881 assert_eq!(heading_level, vec![2]);
28882 }
28883 _ => panic!("expected Search command"),
28884 }
28885 }
28886
28887 #[test]
28888 fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
28889 let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
28890 assert_eq!(small.preview_items(), 3);
28891 assert_eq!(small.preview_bytes(), 120);
28892 assert_eq!(small.follow_up_items(), 4);
28893
28894 let overridden =
28895 ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
28896 assert_eq!(overridden.preview_items(), 7);
28897 assert_eq!(overridden.preview_bytes(), 120);
28898 assert_eq!(overridden.follow_up_items(), 7);
28899
28900 let envelope_default = ResponseBudget::from_cli(None, None, None, true);
28901 assert!(envelope_default.is_active());
28902 }
28903
28904 #[test]
28905 fn cli_explain_accepts_budget_flags() {
28906 let cli = parse_cli([
28907 "tsift",
28908 "explain",
28909 "alpha_helper",
28910 "--max-items",
28911 "2",
28912 "--max-bytes",
28913 "80",
28914 ]);
28915 match cli.command {
28916 Some(Commands::Explain {
28917 max_items,
28918 max_bytes,
28919 ..
28920 }) => {
28921 assert_eq!(max_items, Some(2));
28922 assert_eq!(max_bytes, Some(80));
28923 }
28924 _ => panic!("expected Explain command"),
28925 }
28926 }
28927
28928 #[test]
28929 fn cli_session_review_accepts_budget_flags() {
28930 let cli = parse_cli([
28931 "tsift",
28932 "session-review",
28933 "tasks/software/tsift.md",
28934 "--max-items",
28935 "4",
28936 "--max-bytes",
28937 "120",
28938 ]);
28939 match cli.command {
28940 Some(Commands::SessionReview {
28941 max_items,
28942 max_bytes,
28943 ..
28944 }) => {
28945 assert_eq!(max_items, Some(4));
28946 assert_eq!(max_bytes, Some(120));
28947 }
28948 _ => panic!("expected SessionReview command"),
28949 }
28950 }
28951
28952 #[test]
28953 fn cli_parses_context_pack_command() {
28954 let cli = parse_cli([
28955 "tsift",
28956 "context-pack",
28957 "tasks/software/tsift.md",
28958 "--test-input",
28959 "target/test.log",
28960 "--runner",
28961 "cargo",
28962 "--log-input",
28963 "target/build.log",
28964 "--max-items",
28965 "3",
28966 "--max-bytes",
28967 "96",
28968 "--json",
28969 ]);
28970 match cli.command {
28971 Some(Commands::ContextPack {
28972 path,
28973 test_input,
28974 runner,
28975 log_input,
28976 json,
28977 max_items,
28978 max_bytes,
28979 budget,
28980 convex_snapshot,
28981 }) => {
28982 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28983 assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
28984 assert_eq!(runner.as_deref(), Some("cargo"));
28985 assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
28986 assert!(json);
28987 assert_eq!(max_items, Some(3));
28988 assert_eq!(max_bytes, Some(96));
28989 assert!(budget.is_none());
28990 assert!(convex_snapshot.is_none());
28991 }
28992 _ => panic!("expected ContextPack command"),
28993 }
28994 }
28995
28996 #[test]
28997 fn cli_parses_token_savings_command() {
28998 let cli = parse_cli([
28999 "tsift",
29000 "token-savings",
29001 "--fixture",
29002 "fixtures/tsift-token-savings.json",
29003 "--fail-under",
29004 "--json",
29005 ]);
29006 match cli.command {
29007 Some(Commands::TokenSavings {
29008 fixture,
29009 fail_under,
29010 json,
29011 }) => {
29012 assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
29013 assert!(fail_under);
29014 assert!(json);
29015 }
29016 _ => panic!("expected TokenSavings command"),
29017 }
29018 }
29019
29020 #[test]
29021 fn token_savings_report_records_fixture_thresholds() {
29022 let raw_symbols = [
29023 "validate_user",
29024 "validateUser",
29025 "ValidateUser",
29026 "validate-user",
29027 "VALIDATE_USER",
29028 "Validate_User",
29029 "raw_symbol",
29030 "rawSymbol",
29031 "RawSymbol",
29032 "raw-symbol",
29033 "RAW_SYMBOL",
29034 "Raw_Symbol",
29035 ]
29036 .iter()
29037 .enumerate()
29038 .map(|(idx, identifier)| TokenSavingsRawSymbol {
29039 identifier: (*identifier).to_string(),
29040 file: format!("src/example_{idx}.rs"),
29041 line: (idx + 1) as u64,
29042 context: "function".to_string(),
29043 })
29044 .collect();
29045 let fixture = TokenSavingsFixture {
29046 schema_version: 1,
29047 description: "fixture".to_string(),
29048 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29049 cases: vec![TokenSavingsFixtureCase {
29050 name: "search-preview".to_string(),
29051 surface: "search".to_string(),
29052 minimum_savings_percent: 40.0,
29053 raw_symbols,
29054 tagpath_families: vec![
29055 TokenSavingsFamily {
29056 canonical: "validate_user".to_string(),
29057 count: 6,
29058 aliases: BTreeMap::new(),
29059 },
29060 TokenSavingsFamily {
29061 canonical: "raw_symbol".to_string(),
29062 count: 6,
29063 aliases: BTreeMap::new(),
29064 },
29065 ],
29066 context_pack_inputs: None,
29067 session_review_inputs: None,
29068 source_read_inputs: None,
29069 markdown_projection_inputs: None,
29070 }],
29071 };
29072
29073 let report = build_token_savings_report(&fixture).unwrap();
29074
29075 assert!(report.pass);
29076 assert_eq!(report.cases[0].raw_symbol_count, 12);
29077 assert_eq!(report.cases[0].family_count, 2);
29078 assert_eq!(report.cases[0].status, "pass");
29079 assert!(report.cases[0].byte_delta > 0);
29080 assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
29081 assert!(report.cases[0].savings_percent >= 40.0);
29082 }
29083
29084 #[test]
29085 fn token_savings_source_read_inputs_preserve_required_anchors() {
29086 let fixture = TokenSavingsFixture {
29087 schema_version: 1,
29088 description: "fixture".to_string(),
29089 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29090 cases: vec![TokenSavingsFixtureCase {
29091 name: "source-read".to_string(),
29092 surface: "source-read".to_string(),
29093 minimum_savings_percent: 40.0,
29094 raw_symbols: Vec::new(),
29095 tagpath_families: Vec::new(),
29096 context_pack_inputs: None,
29097 session_review_inputs: None,
29098 source_read_inputs: Some(TokenSavingsSourceReadInputs {
29099 reads: vec![TokenSavingsSourceReadInput {
29100 command: "sed -n '40,160p' src/main.rs".to_string(),
29101 file: "src/main.rs".to_string(),
29102 raw_start: 40,
29103 raw_lines: 121,
29104 raw_excerpt: "line 40\n".repeat(121),
29105 envelope_start: 40,
29106 envelope_lines: 121,
29107 required_line_anchors: vec![40, 120, 160],
29108 }],
29109 }),
29110 markdown_projection_inputs: None,
29111 }],
29112 };
29113
29114 let report = build_token_savings_report(&fixture).unwrap();
29115
29116 assert!(report.pass);
29117 assert_eq!(report.cases[0].surface, "source-read");
29118 assert!(report.cases[0].savings_percent >= 40.0);
29119 }
29120
29121 #[test]
29122 fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
29123 let fixture = TokenSavingsFixture {
29124 schema_version: 1,
29125 description: "fixture".to_string(),
29126 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29127 cases: vec![TokenSavingsFixtureCase {
29128 name: "source-read".to_string(),
29129 surface: "source-read".to_string(),
29130 minimum_savings_percent: 40.0,
29131 raw_symbols: Vec::new(),
29132 tagpath_families: Vec::new(),
29133 context_pack_inputs: None,
29134 session_review_inputs: None,
29135 source_read_inputs: Some(TokenSavingsSourceReadInputs {
29136 reads: vec![TokenSavingsSourceReadInput {
29137 command: "cat src/main.rs".to_string(),
29138 file: "src/main.rs".to_string(),
29139 raw_start: 1,
29140 raw_lines: 200,
29141 raw_excerpt: "line\n".repeat(200),
29142 envelope_start: 1,
29143 envelope_lines: 80,
29144 required_line_anchors: vec![120],
29145 }],
29146 }),
29147 markdown_projection_inputs: None,
29148 }],
29149 };
29150
29151 let err = match build_token_savings_report(&fixture) {
29152 Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
29153 Err(err) => err,
29154 };
29155
29156 assert!(err.to_string().contains("hides required line anchor 120"));
29157 }
29158
29159 #[test]
29160 fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
29161 let fixture = TokenSavingsFixture {
29162 schema_version: 1,
29163 description: "fixture".to_string(),
29164 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29165 cases: vec![TokenSavingsFixtureCase {
29166 name: "markdown-projection".to_string(),
29167 surface: "context-pack".to_string(),
29168 minimum_savings_percent: 40.0,
29169 raw_symbols: Vec::new(),
29170 tagpath_families: Vec::new(),
29171 context_pack_inputs: None,
29172 session_review_inputs: None,
29173 source_read_inputs: None,
29174 markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
29175 documents: vec![TokenSavingsMarkdownProjectionInput {
29176 command: "context-pack markdown body".to_string(),
29177 file: "tasks/software/tsift.md".to_string(),
29178 raw_markdown: "# Heading\n\n".repeat(120),
29179 outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
29180 selected_nodes: vec!["mdast-selected".to_string()],
29181 expand:
29182 "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
29183 .to_string(),
29184 }],
29185 }),
29186 }],
29187 };
29188
29189 let report = build_token_savings_report(&fixture).unwrap();
29190
29191 assert!(report.pass);
29192 assert_eq!(report.cases[0].surface, "context-pack");
29193 assert!(report.cases[0].savings_percent >= 40.0);
29194 }
29195
29196 #[test]
29197 fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
29198 let mut content = String::from("# Cache Root\n\n");
29199 for idx in 0..96 {
29200 content.push_str(&format!(
29201 "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
29202 ));
29203 }
29204
29205 let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
29206 assert!(!first.cache_hit);
29207 assert!(first.nodes.len() > 200);
29208
29209 let sections = markdown_section_spans(&content).unwrap();
29210 let list_items = markdown_block_spans(&content, "list_item").unwrap();
29211 let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
29212 let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
29213
29214 assert!(second.cache_hit);
29215 assert_eq!(second.nodes.len(), first.nodes.len());
29216 assert_eq!(sections.len(), 97);
29217 assert_eq!(list_items.len(), 96);
29218 assert_eq!(code_blocks.len(), 96);
29219 let first_code = first
29220 .nodes
29221 .iter()
29222 .find(|node| node.kind == "code_block")
29223 .expect("expected a Markdown code block");
29224 let first_code_node = markdown_ast_node(
29225 Path::new("/repo"),
29226 "semantic-edit",
29227 first_code,
29228 content.as_bytes(),
29229 &first.nodes,
29230 8,
29231 );
29232 assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
29233 assert_eq!(
29234 first_code_node.metadata.embedded_symbols[0].name,
29235 "sample_0"
29236 );
29237 assert_eq!(
29238 first_code_node.metadata.embedded_symbols[0].language,
29239 "rust"
29240 );
29241 }
29242
29243 #[test]
29244 fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
29245 let response = empty_search_response(Path::new("/repo"), "lexical");
29246 let symbol_hits = vec![index::SymbolHit {
29247 name: "alpha_helper_with_a_long_name".to_string(),
29248 kind: "function".to_string(),
29249 language: "rust".to_string(),
29250 file: "/repo/src/lib.rs".to_string(),
29251 line: 12,
29252 end_line: None,
29253 node_kind: None,
29254 start_byte: None,
29255 end_byte: None,
29256 body_start_byte: None,
29257 body_end_byte: None,
29258 tags: None,
29259 score: 0.98,
29260 match_type: "exact_name".to_string(),
29261 tagpath_handle: None,
29262 }];
29263
29264 let report = build_relative_search_budget_report(
29265 "alpha_helper_with_a_long_name",
29266 "lexical",
29267 Path::new("/repo"),
29268 &response,
29269 &symbol_hits,
29270 ResponseBudget::new(Some(1), Some(12)),
29271 &SearchFacetFilters::default(),
29272 );
29273
29274 assert_eq!(report.symbols.len(), 1);
29275 assert!(report.symbols[0].handle.starts_with("sfam-"));
29276 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
29277 assert_eq!(report.symbols[0].name, "alpha_hel...");
29278 assert_eq!(report.symbols[0].file, "src/lib.rs");
29279 assert!(report.symbols[0].expand.contains("tsift search"));
29280 }
29281
29282 #[test]
29283 fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
29284 let dir = tempfile::tempdir().unwrap();
29285 let src_dir = dir.path().join("src");
29286 fs::create_dir_all(&src_dir).unwrap();
29287 let source = "fn alpha_helper() {\n beta();\n}\n";
29288 let file = src_dir.join("lib.rs");
29289 fs::write(&file, source).unwrap();
29290 let body_start = source.find("{\n").unwrap() + 1;
29291 let body_end = source.rfind("\n}").unwrap() + 1;
29292
29293 let response = empty_search_response(dir.path(), "lexical");
29294 let symbol_hits = vec![index::SymbolHit {
29295 name: "alpha_helper".to_string(),
29296 kind: "function".to_string(),
29297 language: "rust".to_string(),
29298 file: file.to_string_lossy().to_string(),
29299 line: 0,
29300 end_line: Some(2),
29301 node_kind: Some("function_item".to_string()),
29302 start_byte: Some(0),
29303 end_byte: Some(i64::try_from(source.len()).unwrap()),
29304 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29305 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29306 tags: Some("alpha,helper".to_string()),
29307 score: 0.98,
29308 match_type: "exact_name".to_string(),
29309 tagpath_handle: None,
29310 }];
29311
29312 let report = build_relative_search_budget_report(
29313 "alpha helper",
29314 "lexical",
29315 dir.path(),
29316 &response,
29317 &symbol_hits,
29318 ResponseBudget::new(Some(5), Some(96)),
29319 &SearchFacetFilters::default(),
29320 );
29321
29322 let symbol = &report.symbols[0];
29323 assert_eq!(symbol.language, "rust");
29324 assert_eq!(symbol.end_line, Some(2));
29325 let ast = symbol
29326 .ast
29327 .as_ref()
29328 .expect("search symbol preview should expose an AST span artifact");
29329 assert_eq!(ast.artifact_kind, "ast_span");
29330 assert!(ast.span.handle.starts_with("span-"));
29331 assert_eq!(ast.span.node_kind, "function_item");
29332 assert_eq!(ast.span.start_byte, 0);
29333 assert_eq!(ast.span.end_byte, source.len());
29334 assert_eq!(ast.span.body_start_byte, Some(body_start));
29335 assert_eq!(ast.span.body_end_byte, Some(body_end));
29336 assert!(ast.expand.source_window.contains("source-read"));
29337 assert!(
29338 ast.expand
29339 .source_body
29340 .as_ref()
29341 .unwrap()
29342 .contains("source-read")
29343 );
29344 assert!(ast.expand.symbol_read.contains("symbol-read"));
29345 assert!(ast.expand.markdown_ast.is_none());
29346 }
29347
29348 #[test]
29349 fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
29350 let dir = tempfile::tempdir().unwrap();
29351 let source = "# Guide\n\n## Install\n\n- Run setup.\n";
29352 let file = dir.path().join("README.md");
29353 fs::write(&file, source).unwrap();
29354 let heading_start = source.find("## Install").unwrap();
29355 let heading_end = source.len();
29356
29357 let response = empty_search_response(dir.path(), "lexical");
29358 let symbol_hits = vec![index::SymbolHit {
29359 name: "Install".to_string(),
29360 kind: "heading".to_string(),
29361 language: "markdown".to_string(),
29362 file: file.to_string_lossy().to_string(),
29363 line: 2,
29364 end_line: Some(4),
29365 node_kind: Some("atx_heading".to_string()),
29366 start_byte: Some(i64::try_from(heading_start).unwrap()),
29367 end_byte: Some(i64::try_from(heading_end).unwrap()),
29368 body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
29369 body_end_byte: Some(i64::try_from(heading_end).unwrap()),
29370 tags: Some("install".to_string()),
29371 score: 1.0,
29372 match_type: "exact_name".to_string(),
29373 tagpath_handle: None,
29374 }];
29375
29376 let report = build_relative_search_budget_report(
29377 "Install",
29378 "lexical",
29379 dir.path(),
29380 &response,
29381 &symbol_hits,
29382 ResponseBudget::new(Some(5), Some(96)),
29383 &SearchFacetFilters::default(),
29384 );
29385
29386 let ast = report.symbols[0]
29387 .ast
29388 .as_ref()
29389 .expect("Markdown search symbol should expose an AST span artifact");
29390 assert_eq!(ast.span.node_kind, "atx_heading");
29391 assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
29392 let markdown_ast = ast
29393 .expand
29394 .markdown_ast
29395 .as_ref()
29396 .expect("Markdown symbols should include markdown-ast expansion");
29397 assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
29398 assert!(markdown_ast.contains("--node"), "{markdown_ast}");
29399 assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
29400 assert!(ast.expand.source_window.contains("source-read"));
29401 assert!(ast.expand.symbol_read.contains("symbol-read"));
29402 }
29403
29404 #[test]
29405 fn search_budget_report_exposes_markdown_embedded_code_symbols() {
29406 let dir = tempfile::tempdir().unwrap();
29407 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29408 let file = dir.path().join("README.md");
29409 fs::write(&file, source).unwrap();
29410 let fence_start = source.find("```rust").unwrap();
29411 let body_start = source.find("fn sample").unwrap();
29412 let body_end = body_start + "fn sample() {}\n".len();
29413
29414 let response = empty_search_response(dir.path(), "lexical");
29415 let symbol_hits = vec![index::SymbolHit {
29416 name: "rust".to_string(),
29417 kind: "code_block".to_string(),
29418 language: "markdown".to_string(),
29419 file: file.to_string_lossy().to_string(),
29420 line: 2,
29421 end_line: Some(4),
29422 node_kind: Some("fenced_code_block".to_string()),
29423 start_byte: Some(i64::try_from(fence_start).unwrap()),
29424 end_byte: Some(i64::try_from(source.len()).unwrap()),
29425 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29426 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29427 tags: Some("rust".to_string()),
29428 score: 1.0,
29429 match_type: "exact_name".to_string(),
29430 tagpath_handle: None,
29431 }];
29432
29433 let report = build_relative_search_budget_report(
29434 "rust",
29435 "lexical",
29436 dir.path(),
29437 &response,
29438 &symbol_hits,
29439 ResponseBudget::new(Some(5), Some(96)),
29440 &SearchFacetFilters::default(),
29441 );
29442
29443 let embedded = &report.symbols[0]
29444 .ast
29445 .as_ref()
29446 .unwrap()
29447 .span
29448 .markdown
29449 .as_ref()
29450 .unwrap()
29451 .embedded_symbols;
29452 assert_eq!(embedded.len(), 1);
29453 assert_eq!(embedded[0].name, "sample");
29454 assert_eq!(embedded[0].kind, "function");
29455 assert_eq!(embedded[0].language, "rust");
29456 assert_eq!(embedded[0].node_kind, "function_item");
29457 assert!(embedded[0].handle.starts_with("span-"));
29458 assert_eq!(embedded[0].start_byte, body_start);
29459 assert_eq!(embedded[0].start_line, 4);
29460 }
29461
29462 fn test_lexical_search_hit(
29463 path: &Path,
29464 rank: usize,
29465 score: f64,
29466 snippet: &str,
29467 ) -> sift::SearchHit {
29468 sift::SearchHit {
29469 artifact_id: format!("hit-{rank}"),
29470 artifact_kind: sift::ContextArtifactKind::File,
29471 budget: sift::ArtifactBudget::from_text(snippet, 1),
29472 confidence: sift::ScoreConfidence::High,
29473 freshness: sift::ArtifactFreshness {
29474 modified_unix_secs: None,
29475 observed_unix_secs: 0,
29476 },
29477 location: Some("line 1".to_string()),
29478 path: path.to_string_lossy().to_string(),
29479 provenance: sift::ArtifactProvenance {
29480 adapter: sift::AcquisitionAdapterKind::FileSystem,
29481 source: "test lexical hit".to_string(),
29482 synthetic: false,
29483 },
29484 rank,
29485 score,
29486 snippet: snippet.to_string(),
29487 }
29488 }
29489
29490 fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
29491 summarize::Summary {
29492 id: 0,
29493 symbol_name: symbol_name.to_string(),
29494 file_path: file_path.to_string(),
29495 content_hash: "hash".to_string(),
29496 summary: summary.to_string(),
29497 entities: None,
29498 relationships: None,
29499 concept_labels: None,
29500 extracted_at: "2026-06-02T00:00:00Z".to_string(),
29501 model: "test".to_string(),
29502 tokens_input: None,
29503 tokens_output: None,
29504 }
29505 }
29506
29507 #[test]
29508 fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
29509 let dir = tempfile::tempdir().unwrap();
29510 let src_dir = dir.path().join("src");
29511 fs::create_dir_all(&src_dir).unwrap();
29512 let source = "fn alpha_helper() {}\n";
29513 let file = src_dir.join("lib.rs");
29514 let broad_file = dir.path().join("README.md");
29515 fs::write(&file, source).unwrap();
29516 fs::write(
29517 &broad_file,
29518 "alpha helper alpha helper alpha helper in prose\n",
29519 )
29520 .unwrap();
29521
29522 let mut response = empty_search_response(dir.path(), "lexical");
29523 response.hits.push(test_lexical_search_hit(
29524 &broad_file,
29525 1,
29526 240.0,
29527 "alpha helper alpha helper alpha helper in prose",
29528 ));
29529 let symbol_hits = vec![index::SymbolHit {
29530 name: "alpha_helper".to_string(),
29531 kind: "function".to_string(),
29532 language: "rust".to_string(),
29533 file: file.to_string_lossy().to_string(),
29534 line: 0,
29535 end_line: Some(0),
29536 node_kind: Some("function_item".to_string()),
29537 start_byte: Some(0),
29538 end_byte: Some(i64::try_from(source.len()).unwrap()),
29539 body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29540 body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29541 tags: Some("alpha,helper".to_string()),
29542 score: 0.8,
29543 match_type: "all_tags".to_string(),
29544 tagpath_handle: None,
29545 }];
29546
29547 let report = build_relative_search_budget_report(
29548 "alpha helper",
29549 "lexical",
29550 dir.path(),
29551 &response,
29552 &symbol_hits,
29553 ResponseBudget::new(Some(5), Some(128)),
29554 &SearchFacetFilters::default(),
29555 );
29556
29557 assert_eq!(report.ranked[0].source, "symbol_span");
29558 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
29559 assert!(report.ranked[0].score > report.ranked[1].score);
29560 assert_eq!(report.ranked[1].source, "lexical_file");
29561 }
29562
29563 #[test]
29564 fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
29565 let dir = tempfile::tempdir().unwrap();
29566 let src_dir = dir.path().join("src");
29567 fs::create_dir_all(&src_dir).unwrap();
29568 let source = "fn alpha_helper() {\n let needle = \"needle\";\n}\n\nfn other() {}\n";
29569 let file = src_dir.join("lib.rs");
29570 fs::write(&file, source).unwrap();
29571
29572 let mut response = empty_search_response(dir.path(), "exact");
29573 let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
29574 hit.location = Some("line 2".to_string());
29575 response.hits.push(hit);
29576
29577 let symbol_hits = vec![index::SymbolHit {
29578 name: "alpha_helper".to_string(),
29579 kind: "function".to_string(),
29580 language: "rust".to_string(),
29581 file: file.to_string_lossy().to_string(),
29582 line: 0,
29583 end_line: Some(2),
29584 node_kind: Some("function_item".to_string()),
29585 start_byte: Some(0),
29586 end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
29587 body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
29588 body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
29589 tags: Some("alpha,helper".to_string()),
29590 score: 0.9,
29591 match_type: "all_tags".to_string(),
29592 tagpath_handle: None,
29593 }];
29594
29595 let report = build_relative_search_budget_report(
29596 "needle",
29597 "exact",
29598 dir.path(),
29599 &response,
29600 &symbol_hits,
29601 ResponseBudget::new(Some(5), Some(128)),
29602 &SearchFacetFilters::default(),
29603 );
29604
29605 let hit = &report.hits[0];
29606 assert_eq!(hit.line, Some(2));
29607 let source_handle = hit
29608 .source_handle
29609 .as_ref()
29610 .expect("exact hit should expose a bounded source_handle window");
29611 assert!(source_handle.handle.starts_with("xwin-"));
29612 assert_eq!(source_handle.kind, "source_handle");
29613 assert_eq!(source_handle.file, "src/lib.rs");
29614 assert_eq!(source_handle.start_line, 1);
29615 assert_eq!(source_handle.end_line, 3);
29616 assert!(source_handle.expand.contains("source-read"));
29617
29618 let containing_symbol = hit
29619 .containing_symbol
29620 .as_ref()
29621 .expect("exact hit should expose its containing symbol when indexed");
29622 assert_eq!(containing_symbol.name, "alpha_helper");
29623 assert_eq!(containing_symbol.kind, "function");
29624 assert_eq!(containing_symbol.line, 1);
29625 assert_eq!(containing_symbol.end_line, Some(3));
29626 assert!(containing_symbol.expand.contains("symbol-read"));
29627
29628 let lexical_rank = report
29629 .ranked
29630 .iter()
29631 .find(|item| item.source == "lexical_file")
29632 .expect("ranked preview should retain the lexical retrieval handle");
29633 assert!(
29634 lexical_rank
29635 .reasons
29636 .iter()
29637 .any(|reason| reason == "source_handle")
29638 );
29639 assert!(
29640 lexical_rank
29641 .reasons
29642 .iter()
29643 .any(|reason| reason == "containing_symbol")
29644 );
29645 }
29646
29647 #[test]
29648 fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
29649 let dir = tempfile::tempdir().unwrap();
29650 let src_dir = dir.path().join("src");
29651 let tests_dir = dir.path().join("tests");
29652 fs::create_dir_all(&src_dir).unwrap();
29653 fs::create_dir_all(&tests_dir).unwrap();
29654 let source_file = src_dir.join("lib.rs");
29655 let test_file = tests_dir.join("alpha_test.rs");
29656 fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
29657 fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
29658
29659 let response = empty_search_response(dir.path(), "lexical");
29660 let symbol_hits = vec![
29661 index::SymbolHit {
29662 name: "alpha_helper_test".to_string(),
29663 kind: "function".to_string(),
29664 language: "rust".to_string(),
29665 file: test_file.to_string_lossy().to_string(),
29666 line: 1,
29667 end_line: Some(1),
29668 node_kind: Some("function_item".to_string()),
29669 start_byte: Some(8),
29670 end_byte: Some(33),
29671 body_start_byte: Some(31),
29672 body_end_byte: Some(31),
29673 tags: Some("alpha,helper,test".to_string()),
29674 score: 1.0,
29675 match_type: "exact_name".to_string(),
29676 tagpath_handle: None,
29677 },
29678 index::SymbolHit {
29679 name: "alpha_helper".to_string(),
29680 kind: "function".to_string(),
29681 language: "rust".to_string(),
29682 file: source_file.to_string_lossy().to_string(),
29683 line: 0,
29684 end_line: Some(0),
29685 node_kind: Some("function_item".to_string()),
29686 start_byte: Some(0),
29687 end_byte: Some(20),
29688 body_start_byte: Some(18),
29689 body_end_byte: Some(18),
29690 tags: Some("alpha,helper".to_string()),
29691 score: 0.78,
29692 match_type: "all_tags".to_string(),
29693 tagpath_handle: None,
29694 },
29695 ];
29696
29697 let report = build_relative_search_budget_report(
29698 "alpha helper",
29699 "lexical",
29700 dir.path(),
29701 &response,
29702 &symbol_hits,
29703 ResponseBudget::new(Some(5), Some(128)),
29704 &SearchFacetFilters::default(),
29705 );
29706
29707 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
29708 assert_eq!(report.ranked[0].path, "src/lib.rs");
29709 assert!(
29710 report.ranked[0]
29711 .reasons
29712 .iter()
29713 .any(|reason| reason == "definition_kind")
29714 );
29715 assert!(
29716 report.ranked[0]
29717 .reasons
29718 .iter()
29719 .any(|reason| reason == "source_path")
29720 );
29721 let test_rank = report
29722 .ranked
29723 .iter()
29724 .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
29725 .expect("test symbol should still be present in the ranked preview");
29726 assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
29727 }
29728
29729 #[test]
29730 fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
29731 let dir = tempfile::tempdir().unwrap();
29732 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29733 let file = dir.path().join("README.md");
29734 fs::write(&file, source).unwrap();
29735 let summary_db =
29736 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
29737 summary_db
29738 .insert(&test_summary(
29739 "rust",
29740 "README.md",
29741 "Rust fence contains a sample function.",
29742 ))
29743 .unwrap();
29744
29745 let fence_start = source.find("```rust").unwrap();
29746 let body_start = source.find("fn sample").unwrap();
29747 let body_end = body_start + "fn sample() {}\n".len();
29748 let response = empty_search_response(dir.path(), "lexical");
29749 let symbol_hits = vec![index::SymbolHit {
29750 name: "rust".to_string(),
29751 kind: "code_block".to_string(),
29752 language: "markdown".to_string(),
29753 file: file.to_string_lossy().to_string(),
29754 line: 2,
29755 end_line: Some(4),
29756 node_kind: Some("fenced_code_block".to_string()),
29757 start_byte: Some(i64::try_from(fence_start).unwrap()),
29758 end_byte: Some(i64::try_from(source.len()).unwrap()),
29759 body_start_byte: Some(i64::try_from(body_start).unwrap()),
29760 body_end_byte: Some(i64::try_from(body_end).unwrap()),
29761 tags: Some("rust".to_string()),
29762 score: 1.0,
29763 match_type: "exact_name".to_string(),
29764 tagpath_handle: None,
29765 }];
29766
29767 let report = build_relative_search_budget_report(
29768 "rust",
29769 "lexical",
29770 dir.path(),
29771 &response,
29772 &symbol_hits,
29773 ResponseBudget::new(Some(5), Some(128)),
29774 &SearchFacetFilters::default(),
29775 );
29776
29777 let symbol = &report.symbols[0];
29778 assert_eq!(symbol.summary_refs, 1);
29779 assert_eq!(symbol.graph_neighbors, 1);
29780 assert!(
29781 report.ranked[0]
29782 .reasons
29783 .iter()
29784 .any(|reason| reason == "summary_refs:1")
29785 );
29786 assert!(
29787 report.ranked[0]
29788 .reasons
29789 .iter()
29790 .any(|reason| reason == "graph_neighbors:1")
29791 );
29792 }
29793
29794 fn markdown_search_facet_fixture() -> tempfile::TempDir {
29795 let dir = tempfile::tempdir().unwrap();
29796 let source = r#"# Guide
29797
29798## Install
29799
29800- Run setup.
29801 - Confirm setup.
29802
29803```rust
29804fn sample() {}
29805```
29806"#;
29807 fs::write(dir.path().join("README.md"), source).unwrap();
29808 let index_dir = dir.path().join(".tsift");
29809 fs::create_dir_all(&index_dir).unwrap();
29810 run_index_update(
29811 &index_dir.join("index.db"),
29812 dir.path(),
29813 "indexing markdown search facet fixture".to_string(),
29814 dir.path(),
29815 None,
29816 false,
29817 false,
29818 )
29819 .unwrap();
29820 dir
29821 }
29822
29823 fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
29824 let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
29825 db.symbol_search(query, 20).unwrap()
29826 }
29827
29828 #[test]
29829 fn search_facet_filters_match_scalar_symbol_fields() {
29830 let dir = tempfile::tempdir().unwrap();
29831 let hits = vec![
29832 index::SymbolHit {
29833 name: "alpha_helper".to_string(),
29834 kind: "function".to_string(),
29835 language: "rust".to_string(),
29836 file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
29837 line: 0,
29838 end_line: None,
29839 node_kind: Some("function_item".to_string()),
29840 start_byte: None,
29841 end_byte: None,
29842 body_start_byte: None,
29843 body_end_byte: None,
29844 tags: None,
29845 score: 1.0,
29846 match_type: "exact_name".to_string(),
29847 tagpath_handle: None,
29848 },
29849 index::SymbolHit {
29850 name: "Install".to_string(),
29851 kind: "heading".to_string(),
29852 language: "markdown".to_string(),
29853 file: dir.path().join("README.md").to_string_lossy().to_string(),
29854 line: 0,
29855 end_line: None,
29856 node_kind: Some("atx_heading".to_string()),
29857 start_byte: None,
29858 end_byte: None,
29859 body_start_byte: None,
29860 body_end_byte: None,
29861 tags: None,
29862 score: 0.9,
29863 match_type: "exact_name".to_string(),
29864 tagpath_handle: None,
29865 },
29866 ];
29867
29868 let filtered = apply_search_facet_filters(
29869 dir.path(),
29870 hits,
29871 &SearchFacetFilters {
29872 languages: vec!["rust".to_string()],
29873 kinds: vec!["function".to_string()],
29874 node_kinds: vec!["function_item".to_string()],
29875 ..SearchFacetFilters::default()
29876 },
29877 );
29878
29879 assert_eq!(filtered.len(), 1);
29880 assert_eq!(filtered[0].name, "alpha_helper");
29881 }
29882
29883 #[test]
29884 fn search_facet_filters_match_markdown_sections_and_block_metadata() {
29885 let dir = markdown_search_facet_fixture();
29886
29887 let nested_list = apply_search_facet_filters(
29888 dir.path(),
29889 markdown_search_facet_hits(dir.path(), "setup"),
29890 &SearchFacetFilters {
29891 sections: vec!["Install".to_string()],
29892 parents: vec!["Run setup.".to_string()],
29893 list_depths: vec![1],
29894 ..SearchFacetFilters::default()
29895 },
29896 );
29897 assert_eq!(nested_list.len(), 1);
29898 assert_eq!(nested_list[0].name, "Confirm setup.");
29899
29900 let parent_list = apply_search_facet_filters(
29901 dir.path(),
29902 markdown_search_facet_hits(dir.path(), "setup"),
29903 &SearchFacetFilters {
29904 children: vec!["Confirm setup.".to_string()],
29905 ..SearchFacetFilters::default()
29906 },
29907 );
29908 assert_eq!(parent_list.len(), 1);
29909 assert_eq!(parent_list[0].name, "Run setup.");
29910
29911 let heading = apply_search_facet_filters(
29912 dir.path(),
29913 markdown_search_facet_hits(dir.path(), "Install"),
29914 &SearchFacetFilters {
29915 heading_levels: vec![2],
29916 node_kinds: vec!["atx_heading".to_string()],
29917 ..SearchFacetFilters::default()
29918 },
29919 );
29920 assert_eq!(heading.len(), 1);
29921 assert_eq!(heading[0].name, "Install");
29922
29923 let fence = apply_search_facet_filters(
29924 dir.path(),
29925 markdown_search_facet_hits(dir.path(), "rust"),
29926 &SearchFacetFilters {
29927 fence_languages: vec!["rust".to_string()],
29928 kinds: vec!["code_block".to_string()],
29929 ..SearchFacetFilters::default()
29930 },
29931 );
29932 assert_eq!(fence.len(), 1);
29933 assert_eq!(fence[0].kind, "code_block");
29934
29935 let embedded_child = apply_search_facet_filters(
29936 dir.path(),
29937 markdown_search_facet_hits(dir.path(), "rust"),
29938 &SearchFacetFilters {
29939 children: vec!["sample".to_string()],
29940 kinds: vec!["code_block".to_string()],
29941 ..SearchFacetFilters::default()
29942 },
29943 );
29944 assert_eq!(embedded_child.len(), 1);
29945 assert_eq!(embedded_child[0].name, "rust");
29946 }
29947
29948 #[test]
29949 fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
29950 let response = empty_search_response(Path::new("/repo"), "lexical");
29951 let symbol_hits = vec![
29952 index::SymbolHit {
29953 name: "alpha_helper".to_string(),
29954 kind: "function".to_string(),
29955 language: "rust".to_string(),
29956 file: "/repo/src/lib.rs".to_string(),
29957 line: 12,
29958 end_line: None,
29959 node_kind: None,
29960 start_byte: None,
29961 end_byte: None,
29962 body_start_byte: None,
29963 body_end_byte: None,
29964 tags: Some("alpha,helper".to_string()),
29965 score: 0.98,
29966 match_type: "exact_name".to_string(),
29967 tagpath_handle: None,
29968 },
29969 index::SymbolHit {
29970 name: "alphaHelper".to_string(),
29971 kind: "method".to_string(),
29972 language: "rust".to_string(),
29973 file: "/repo/src/main.rs".to_string(),
29974 line: 34,
29975 end_line: None,
29976 node_kind: None,
29977 start_byte: None,
29978 end_byte: None,
29979 body_start_byte: None,
29980 body_end_byte: None,
29981 tags: Some("alpha,helper".to_string()),
29982 score: 0.93,
29983 match_type: "tag_overlap".to_string(),
29984 tagpath_handle: None,
29985 },
29986 index::SymbolHit {
29987 name: "alpha_helper".to_string(),
29988 kind: "function".to_string(),
29989 language: "rust".to_string(),
29990 file: "/repo/src/worker.rs".to_string(),
29991 line: 56,
29992 end_line: None,
29993 node_kind: None,
29994 start_byte: None,
29995 end_byte: None,
29996 body_start_byte: None,
29997 body_end_byte: None,
29998 tags: Some("alpha,helper".to_string()),
29999 score: 0.91,
30000 match_type: "tag_overlap".to_string(),
30001 tagpath_handle: None,
30002 },
30003 ];
30004
30005 let report = build_relative_search_budget_report(
30006 "alpha helper",
30007 "lexical",
30008 Path::new("/repo"),
30009 &response,
30010 &symbol_hits,
30011 ResponseBudget::new(Some(5), Some(48)),
30012 &SearchFacetFilters::default(),
30013 );
30014
30015 assert_eq!(report.symbol_total, 1);
30016 assert_eq!(report.raw_symbol_total, 3);
30017 assert_eq!(report.symbols.len(), 1);
30018 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
30019 assert_eq!(report.symbols[0].match_count, 3);
30020 assert_eq!(report.symbols[0].surface_count, 2);
30021 assert_eq!(report.symbols[0].file_count, 3);
30022 assert_eq!(
30023 report.symbols[0].surface_examples,
30024 vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
30025 );
30026 assert!(report.symbols[0].name.contains("(+1 variant)"));
30027 assert!(report.symbols[0].file.contains("(+2 files)"));
30028 assert!(report.symbols[0].expand.contains("tsift search"));
30029 assert!(report.symbols[0].expand.contains("alpha helper"));
30030 }
30031
30032 #[test]
30033 fn search_budget_report_carries_active_filters() {
30034 let response = empty_search_response(Path::new("/repo"), "lexical");
30035 let symbol_hits = vec![index::SymbolHit {
30036 name: "alpha_helper".to_string(),
30037 kind: "function".to_string(),
30038 language: "rust".to_string(),
30039 file: "/repo/src/lib.rs".to_string(),
30040 line: 12,
30041 end_line: None,
30042 node_kind: Some("function_item".to_string()),
30043 start_byte: None,
30044 end_byte: None,
30045 body_start_byte: None,
30046 body_end_byte: None,
30047 tags: Some("alpha,helper".to_string()),
30048 score: 0.98,
30049 match_type: "exact_name".to_string(),
30050 tagpath_handle: None,
30051 }];
30052 let filters = SearchFacetFilters {
30053 languages: vec!["rust".to_string()],
30054 kinds: vec!["function".to_string()],
30055 node_kinds: vec!["function_item".to_string()],
30056 ..SearchFacetFilters::default()
30057 };
30058
30059 let report = build_relative_search_budget_report(
30060 "alpha helper",
30061 "lexical",
30062 Path::new("/repo"),
30063 &response,
30064 &symbol_hits,
30065 ResponseBudget::new(Some(5), Some(48)),
30066 &filters,
30067 );
30068
30069 assert_eq!(report.filters, filters);
30070 assert_eq!(
30071 search_facet_filters_summary(&report.filters),
30072 "lang=rust kind=function node-kind=function_item"
30073 );
30074 }
30075
30076 #[test]
30077 fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
30078 let mut response = empty_search_response(Path::new("/repo"), "lexical");
30079 response.indexed_artifacts = 450;
30080 let symbol_hits = vec![
30081 index::SymbolHit {
30082 name: "alpha_helper".to_string(),
30083 kind: "function".to_string(),
30084 language: "rust".to_string(),
30085 file: "/repo/src/lib.rs".to_string(),
30086 line: 12,
30087 end_line: None,
30088 node_kind: None,
30089 start_byte: None,
30090 end_byte: None,
30091 body_start_byte: None,
30092 body_end_byte: None,
30093 tags: Some("alpha,helper".to_string()),
30094 score: 0.98,
30095 match_type: "exact_name".to_string(),
30096 tagpath_handle: None,
30097 },
30098 index::SymbolHit {
30099 name: "beta_helper".to_string(),
30100 kind: "function".to_string(),
30101 language: "rust".to_string(),
30102 file: "/repo/src/beta.rs".to_string(),
30103 line: 21,
30104 end_line: None,
30105 node_kind: None,
30106 start_byte: None,
30107 end_byte: None,
30108 body_start_byte: None,
30109 body_end_byte: None,
30110 tags: Some("beta,helper".to_string()),
30111 score: 0.92,
30112 match_type: "tag_overlap".to_string(),
30113 tagpath_handle: None,
30114 },
30115 ];
30116
30117 let report = build_relative_search_budget_report(
30118 "helper",
30119 "lexical",
30120 Path::new("/repo"),
30121 &response,
30122 &symbol_hits,
30123 ResponseBudget::new(Some(1), Some(64)),
30124 &SearchFacetFilters::default(),
30125 );
30126
30127 let guard = report
30128 .scale_guard
30129 .as_ref()
30130 .expect("broad previews should emit a scale guard");
30131 assert_eq!(guard.level, "high-hit");
30132 assert_eq!(guard.signals.indexed_artifacts, 450);
30133 assert_eq!(guard.signals.raw_symbol_matches, 2);
30134 assert!(
30135 guard
30136 .narrow_commands
30137 .iter()
30138 .any(|command| command.contains("--exact"))
30139 );
30140 assert!(
30141 guard
30142 .narrow_commands
30143 .iter()
30144 .any(|command| command.contains("alpha helper"))
30145 );
30146 assert!(
30147 guard
30148 .narrow_commands
30149 .last()
30150 .unwrap()
30151 .contains("workflow search")
30152 );
30153 }
30154
30155 #[test]
30156 fn explain_budget_report_limits_edges_and_members() {
30157 let symbols = vec![index::StoredSymbol {
30158 name: "alpha_helper".to_string(),
30159 kind: "function".to_string(),
30160 language: "rust".to_string(),
30161 signature: None,
30162 file: "src/lib.rs".to_string(),
30163 line: 10,
30164 end_line: None,
30165 node_kind: None,
30166 start_byte: None,
30167 end_byte: None,
30168 body_start_byte: None,
30169 body_end_byte: None,
30170 parent_module: None,
30171 visibility: None,
30172 tags: None,
30173 tagpath_handle: None,
30174 }];
30175 let callers = vec![
30176 index::StoredEdge {
30177 caller_file: "src/main.rs".to_string(),
30178 caller_name: "main".to_string(),
30179 caller_line: 1,
30180 callee_name: "alpha_helper".to_string(),
30181 call_site_line: 3,
30182 tagpath_handle: None,
30183 },
30184 index::StoredEdge {
30185 caller_file: "src/worker.rs".to_string(),
30186 caller_name: "worker".to_string(),
30187 caller_line: 5,
30188 callee_name: "alpha_helper".to_string(),
30189 call_site_line: 8,
30190 tagpath_handle: None,
30191 },
30192 ];
30193 let community = graph::Community {
30194 id: 1,
30195 members: vec![
30196 graph::CommunityMember::new("alpha_helper"),
30197 graph::CommunityMember::new("main"),
30198 graph::CommunityMember::new("worker"),
30199 ],
30200 modularity_contribution: 0.5,
30201 };
30202
30203 let report = build_explain_budget_report(
30204 "alpha_helper",
30205 Path::new("/repo"),
30206 &symbols,
30207 &callers,
30208 2,
30209 false,
30210 &[],
30211 0,
30212 false,
30213 Some(&community),
30214 ResponseBudget::new(Some(1), Some(24)),
30215 );
30216
30217 assert_eq!(report.definitions.len(), 1);
30218 assert_eq!(report.callers.len(), 1);
30219 assert!(report.truncated);
30220 assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
30221 assert_eq!(
30222 report.definitions[0].tag_alias.as_deref(),
30223 Some("alpha/helper")
30224 );
30225 assert!(report.callers[0].handle.starts_with("ecall-"));
30226 assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
30227 }
30228
30229 #[test]
30230 fn session_review_next_context_budget_limits_lists() {
30231 let report = session_review::SessionReviewReport {
30232 root: "/repo".to_string(),
30233 target: "tasks/software/tsift.md".to_string(),
30234 target_kind: "file".to_string(),
30235 sessions_considered: 1,
30236 sessions_matched: 1,
30237 claude_sessions: 1,
30238 codex_sessions: 0,
30239 agent_doc_logs: 0,
30240 prompt_target_count: 2,
30241 command_groups: 0,
30242 file_groups: 2,
30243 symbol_groups: 1,
30244 failure_groups: 1,
30245 runtime_event_groups: 0,
30246 restart_churn_groups: 0,
30247 closeout_groups: 0,
30248 usage_samples: 1,
30249 prompt_tokens: 120,
30250 cached_input_tokens: 80,
30251 cache_creation_input_tokens: 0,
30252 output_tokens: 40,
30253 reasoning_output_tokens: 0,
30254 total_tokens: 240,
30255 cached_input_ratio: Some(40.0),
30256 largest_turn_total_tokens: 240,
30257 aggregate_cost: session_review::SessionReviewCostSummary {
30258 scope: "bounded_matched_sessions".to_string(),
30259 sessions: 1,
30260 usage_samples: 1,
30261 prompt_tokens: 120,
30262 cached_input_tokens: 80,
30263 cache_creation_input_tokens: 0,
30264 output_tokens: 40,
30265 reasoning_output_tokens: 0,
30266 total_tokens: 240,
30267 cached_input_ratio: Some(40.0),
30268 largest_turn_total_tokens: 240,
30269 },
30270 latest_session_cost: Some(session_review::SessionReviewCostSummary {
30271 scope: "latest_matched_session".to_string(),
30272 sessions: 1,
30273 usage_samples: 1,
30274 prompt_tokens: 120,
30275 cached_input_tokens: 80,
30276 cache_creation_input_tokens: 0,
30277 output_tokens: 40,
30278 reasoning_output_tokens: 0,
30279 total_tokens: 240,
30280 cached_input_ratio: Some(66.67),
30281 largest_turn_total_tokens: 240,
30282 }),
30283 prompt_cache_roi_scorecard: vec![],
30284 guardrails: vec![
30285 session_cost::SessionCostGuardrail {
30286 kind: "cache_resend".to_string(),
30287 severity: "warn".to_string(),
30288 message: "cached input ratio was high".to_string(),
30289 guidance: "compact or restart the session".to_string(),
30290 },
30291 session_cost::SessionCostGuardrail {
30292 kind: "prompt_budget".to_string(),
30293 severity: "warn".to_string(),
30294 message: "largest prompt turn reached 999999 tokens".to_string(),
30295 guidance: "compact the session before another large turn".to_string(),
30296 },
30297 session_cost::SessionCostGuardrail {
30298 kind: "restart_loop".to_string(),
30299 severity: "warn".to_string(),
30300 message: "restart churn detected".to_string(),
30301 guidance: "restart cleanly".to_string(),
30302 },
30303 session_cost::SessionCostGuardrail {
30304 kind: "noop_closeout".to_string(),
30305 severity: "warn".to_string(),
30306 message: "commit_already_current appeared 8 times".to_string(),
30307 guidance: "avoid reopening without new edits".to_string(),
30308 },
30309 ],
30310 loop_clusters: vec![session_cost::SessionCostLoopCluster {
30311 kind: "command_bundle".to_string(),
30312 label: "cargo test -> cargo build --release".to_string(),
30313 occurrences: 2,
30314 max_consecutive: 2,
30315 }],
30316 file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
30317 path: "src/lib.rs".to_string(),
30318 range: "12-40".to_string(),
30319 occurrences: 3,
30320 estimated_tokens: 1200,
30321 duplicate_estimated_tokens: 800,
30322 follow_up_commands: vec![
30323 "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
30324 .to_string(),
30325 ],
30326 }],
30327 prompt_targets: vec![
30328 session_review::SessionReviewPromptTarget {
30329 text: "do one".to_string(),
30330 occurrences: 1,
30331 },
30332 session_review::SessionReviewPromptTarget {
30333 text: "do two".to_string(),
30334 occurrences: 1,
30335 },
30336 ],
30337 commands: vec![],
30338 touched_files: vec![],
30339 touched_symbols: vec![],
30340 failures: vec![],
30341 runtime_events: vec![],
30342 restart_churn: vec![],
30343 closeout: vec![],
30344 largest_turns: vec![],
30345 sessions: vec![session_review::SessionReviewSession {
30346 source: "claude_jsonl".to_string(),
30347 path: "/tmp/session.jsonl".to_string(),
30348 matched_by: vec!["path".to_string()],
30349 modified_unix_secs: None,
30350 prompt_target_count: 2,
30351 command_groups: 0,
30352 file_groups: 2,
30353 symbol_groups: 1,
30354 failure_groups: 1,
30355 runtime_event_groups: 0,
30356 restart_churn_groups: 0,
30357 closeout_groups: 0,
30358 usage_samples: 1,
30359 prompt_tokens: 120,
30360 cached_input_tokens: 80,
30361 cache_creation_input_tokens: 0,
30362 output_tokens: 40,
30363 reasoning_output_tokens: 0,
30364 total_tokens: 240,
30365 largest_turn_total_tokens: 240,
30366 }],
30367 next_context: session_review::SessionReviewNextContext {
30368 target: "tasks/software/tsift.md".to_string(),
30369 active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
30370 last_verification: session_review::SessionReviewVerificationState {
30371 status: "green".to_string(),
30372 detail: "cargo test".to_string(),
30373 },
30374 touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
30375 touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
30376 unresolved_failures: vec![session_review::SessionReviewFailure {
30377 kind: "timeout".to_string(),
30378 message: "search timed out".to_string(),
30379 occurrences: 1,
30380 command: None,
30381 session_path: None,
30382 }],
30383 agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
30384 active_queue_prompt: Some(
30385 "[#one] do one with enough detail to truncate".to_string(),
30386 ),
30387 live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
30388 backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
30389 review_rows: vec![
30390 "[#review] review one".to_string(),
30391 "[#review2] review two".to_string(),
30392 ],
30393 prompt_presets: vec![
30394 "#spec-test-build-install-commit-push: update spec + tests"
30395 .to_string(),
30396 "#next-steps: collect follow-ups".to_string(),
30397 ],
30398 expansion_handles: vec![
30399 session_review::SessionReviewAgentDocExpansionHandle {
30400 handle: "adq-next-context".to_string(),
30401 label: "refresh next-context".to_string(),
30402 expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
30403 },
30404 session_review::SessionReviewAgentDocExpansionHandle {
30405 handle: "adq-context-pack".to_string(),
30406 label: "refresh context-pack".to_string(),
30407 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
30408 },
30409 ],
30410 }),
30411 next_digest_commands: vec![
30412 "tsift session-review --next-context tasks/software/tsift.md".to_string(),
30413 "tsift diff-digest .".to_string(),
30414 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
30415 "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
30416 ],
30417 },
30418 warnings: vec![],
30419 };
30420
30421 let budget_report = build_session_review_next_context_budget_report(
30422 &report,
30423 ResponseBudget::new(Some(1), Some(12)),
30424 None,
30425 );
30426
30427 assert!(budget_report.truncated);
30428 assert_eq!(budget_report.prompt_targets, vec!["do one"]);
30429 assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
30430 assert!(
30431 budget_report.touched_symbol_refs[0]
30432 .handle
30433 .starts_with("ncsym-")
30434 );
30435 assert_eq!(
30436 budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
30437 Some("alpha/helper")
30438 );
30439 assert!(
30440 budget_report.unresolved_failures[0]
30441 .handle
30442 .starts_with("snf-")
30443 );
30444 assert_eq!(budget_report.next_digest_commands.len(), 4);
30445 assert_eq!(
30446 budget_report.next_digest_commands[2],
30447 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
30448 );
30449 let queue = budget_report
30450 .agent_doc_queue
30451 .as_ref()
30452 .expect("agent-doc queue budget profile should be present");
30453 assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
30454 assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
30455 assert_eq!(queue.review_row_total, 2);
30456 assert_eq!(queue.prompt_presets.len(), 1);
30457 assert_eq!(queue.expansion_handles.len(), 2);
30458 assert!(queue.truncated);
30459 assert_eq!(budget_report.next_token_actions.len(), 1);
30460 assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
30461
30462 let full_action_report = build_session_review_next_context_budget_report(
30463 &report,
30464 ResponseBudget::new(Some(6), Some(120)),
30465 None,
30466 );
30467 assert_eq!(
30468 full_action_report
30469 .next_token_actions
30470 .iter()
30471 .map(|action| action.kind.as_str())
30472 .collect::<Vec<_>>(),
30473 vec![
30474 "prompt_budget",
30475 "cache_resend",
30476 "repeated_raw_read",
30477 "repeated_command_bundle",
30478 "restart_loop",
30479 "noop_closeout"
30480 ]
30481 );
30482 assert_eq!(
30483 full_action_report.next_token_actions[0]
30484 .compact_command
30485 .as_deref(),
30486 Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
30487 );
30488 assert_eq!(
30489 full_action_report.next_token_actions[0]
30490 .restart_command
30491 .as_deref(),
30492 Some("agent-doc start \"tasks/software/tsift.md\"")
30493 );
30494 assert!(
30495 full_action_report.next_token_actions[0]
30496 .digest_commands
30497 .iter()
30498 .any(|command| command
30499 == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
30500 );
30501 let raw_read_action = full_action_report
30502 .next_token_actions
30503 .iter()
30504 .find(|action| action.kind == "repeated_raw_read")
30505 .expect("raw read action");
30506 assert!(
30507 raw_read_action.rewrite_commands.iter().any(
30508 |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
30509 ),
30510 "raw read rewrite commands: {:?}",
30511 raw_read_action.rewrite_commands
30512 );
30513 assert!(raw_read_action.rewrite_commands.iter().any(|command| command
30514 == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
30515 let command_bundle_action = full_action_report
30516 .next_token_actions
30517 .iter()
30518 .find(|action| action.kind == "repeated_command_bundle")
30519 .expect("command bundle action");
30520 assert!(
30521 command_bundle_action
30522 .rewrite_commands
30523 .iter()
30524 .any(|command| command == "tsift rewrite --run \"cargo test\"")
30525 );
30526 assert!(
30527 command_bundle_action
30528 .rewrite_commands
30529 .iter()
30530 .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
30531 );
30532 }
30533
30534 #[test]
30535 fn context_pack_diff_preview_limits_files_and_symbols() {
30536 let report = diff_digest::DiffDigestReport {
30537 root: "/repo".to_string(),
30538 mode: diff_digest::DiffDigestMode::WorkingTree,
30539 revision: None,
30540 files_changed: 2,
30541 files_with_current_summaries: 1,
30542 symbols_touched: 3,
30543 call_edges_added: 1,
30544 call_edges_removed: 0,
30545 files: vec![
30546 diff_digest::DiffDigestFile {
30547 path: "src/lib.rs".to_string(),
30548 status: diff_digest::DiffDigestFileStatus::Modified,
30549 touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
30550 summary_state: diff_digest::DiffDigestSummaryState::Current,
30551 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30552 symbol: "alpha_helper".to_string(),
30553 summary: "alpha helper handles the main alpha workflow".to_string(),
30554 }],
30555 added_call_edges: vec!["alpha->beta".to_string()],
30556 removed_call_edges: vec![],
30557 warnings: vec!["stale parse".to_string()],
30558 },
30559 diff_digest::DiffDigestFile {
30560 path: "src/main.rs".to_string(),
30561 status: diff_digest::DiffDigestFileStatus::Added,
30562 touched_symbols: vec!["main".to_string()],
30563 summary_state: diff_digest::DiffDigestSummaryState::Missing,
30564 current_summaries: vec![],
30565 added_call_edges: vec![],
30566 removed_call_edges: vec![],
30567 warnings: vec![],
30568 },
30569 ],
30570 };
30571
30572 let preview =
30573 build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
30574
30575 assert!(preview.truncated);
30576 assert_eq!(preview.files.len(), 1);
30577 assert_eq!(preview.files[0].path, "src/lib.rs");
30578 assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
30579 assert!(
30580 preview.files[0].touched_symbol_refs[0]
30581 .handle
30582 .starts_with("cdsym-")
30583 );
30584 assert_eq!(
30585 preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
30586 Some("alpha/he...")
30587 );
30588 assert!(
30589 preview.files[0].summary_refs[0]
30590 .handle
30591 .starts_with("cdsum-")
30592 );
30593 assert_eq!(
30594 preview.files[0].summary_refs[0].tag_alias.as_deref(),
30595 Some("alpha/he...")
30596 );
30597 assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
30598 assert_eq!(
30599 preview.files[0].summary_refs[0].expand,
30600 "tsift summarize --file \"src/lib.rs\""
30601 );
30602 assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
30603 }
30604
30605 #[test]
30606 fn context_pack_status_reminders_include_stale_index_state() {
30607 let dir = setup_graph_index();
30608 std::thread::sleep(std::time::Duration::from_millis(50));
30609 std::fs::write(
30610 dir.path().join("main.rs"),
30611 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
30612 )
30613 .unwrap();
30614
30615 let reminders = context_pack_status_reminders(dir.path());
30616
30617 assert_eq!(reminders.len(), 1);
30618 assert!(reminders[0].contains("index stale"));
30619 assert!(reminders[0].contains("tsift index ."));
30620 }
30621
30622 #[test]
30629 fn build_context_pack_reuses_inspect_within_scope() {
30630 let dir = setup_graph_index();
30631 init_git_repo(dir.path());
30632 let _guard = index::InspectScopeGuard::new();
30633 let _ = build_context_pack_report(
30634 dir.path(),
30635 None,
30636 None,
30637 None,
30638 ResponseBudget::new(Some(2), Some(96)),
30639 )
30640 .unwrap();
30641 let (hits, misses) = index::inspect_scope_stats();
30642 assert!(
30643 hits >= 1,
30644 "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
30645 );
30646 assert!(
30647 misses >= 1,
30648 "expected at least one initial inspect miss (hits={hits}, misses={misses})"
30649 );
30650 }
30651
30652 #[test]
30657 fn inspect_read_only_outside_scope_does_not_cache() {
30658 let dir = setup_graph_index();
30659 let db_path = dir.path().join(".tsift/index.db");
30660 let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30661 let (hits, misses) = index::inspect_scope_stats();
30662 assert_eq!(
30663 (hits, misses),
30664 (0, 0),
30665 "no scope guard => no hits/misses recorded"
30666 );
30667 let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30668 let (hits, _) = index::inspect_scope_stats();
30669 assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
30670 }
30671
30672 #[test]
30673 fn context_pack_refreshes_stale_index_before_handoff() {
30674 let dir = setup_graph_index();
30675 init_git_repo(dir.path());
30676 std::thread::sleep(std::time::Duration::from_millis(50));
30677 std::fs::write(
30678 dir.path().join("main.rs"),
30679 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
30680 )
30681 .unwrap();
30682
30683 let report = build_context_pack_report(
30684 dir.path(),
30685 None,
30686 None,
30687 None,
30688 ResponseBudget::new(Some(2), Some(96)),
30689 )
30690 .unwrap();
30691
30692 assert!(
30693 report
30694 .status_reminders
30695 .iter()
30696 .any(|reminder| reminder.contains("index refreshed")
30697 && reminder.contains("context-pack handoff")),
30698 "expected context-pack refresh diagnostic, got {:?}",
30699 report.status_reminders
30700 );
30701 assert!(
30702 !report
30703 .status_reminders
30704 .iter()
30705 .any(|reminder| reminder.contains("index stale")),
30706 "stale reminder should be gone after refresh: {:?}",
30707 report.status_reminders
30708 );
30709
30710 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
30711 let summary = db.compute_changes(dir.path()).unwrap();
30712 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
30713 }
30714
30715 #[test]
30716 fn context_pack_materializes_source_handles_into_graph_store() {
30717 let dir = tempfile::tempdir().unwrap();
30718 let packet = ExplorationPacket {
30719 budget: exploration_budget_for_counts(2, 1),
30720 relationship_map: vec![ExplorationRelation {
30721 from: "file:main.rs".to_string(),
30722 relation: "touches_symbol".to_string(),
30723 to: "symbol:helper".to_string(),
30724 label: Some("modified diff".to_string()),
30725 }],
30726 source_windows: vec![ExplorationSourceWindow {
30727 handle: "xwin-test".to_string(),
30728 file: "main.rs".to_string(),
30729 start: 1,
30730 end: 32,
30731 reason: "changed file".to_string(),
30732 expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
30733 }],
30734 worker_context: vec![ExplorationWorkerContext {
30735 handle: "xwrk-test".to_string(),
30736 target: "tasks/software/tsift.md".to_string(),
30737 summary: "do #kgnv".to_string(),
30738 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
30739 .to_string(),
30740 }],
30741 no_reread_guidance: "use windows".to_string(),
30742 };
30743
30744 let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
30745 assert_eq!(packet.source_windows[0].handle, "xwin-test");
30746
30747 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
30748 let source_handles = store.nodes_by_kind("source_handle").unwrap();
30749 assert_eq!(source_handles.len(), 1);
30750 assert_eq!(
30751 source_handles[0].properties.get("file"),
30752 Some(&"main.rs".to_string())
30753 );
30754 assert_eq!(
30755 store
30756 .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
30757 .unwrap()
30758 .len(),
30759 1
30760 );
30761 let worker_context = store.nodes_by_kind("worker_context").unwrap();
30762 assert_eq!(worker_context.len(), 1);
30763 assert_eq!(
30764 store
30765 .outgoing_edges("xwrk-test", Some("scopes_source"))
30766 .unwrap()
30767 .len(),
30768 1
30769 );
30770 }
30771
30772 #[test]
30773 fn context_pack_records_graph_orchestration_observability() {
30774 let dir = setup_traversal_project();
30775 init_git_repo(dir.path());
30776 let session = dir.path().join("tasks/software/tsift.md");
30777 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
30778
30779 let report = build_context_pack_report(
30780 &session,
30781 None,
30782 None,
30783 None,
30784 ResponseBudget::new(Some(4), Some(160)),
30785 )
30786 .unwrap();
30787
30788 assert_eq!(
30789 report.graph_orchestration.contract_version,
30790 CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
30791 );
30792 assert_eq!(
30793 report
30794 .graph_orchestration
30795 .projection_freshness
30796 .status
30797 .as_str(),
30798 "current"
30799 );
30800 assert!(!report.graph_orchestration.projection_hashes.is_empty());
30801 assert_eq!(report.graph_orchestration.readiness.status, "blocked");
30802 assert_eq!(
30803 report.graph_orchestration.readiness.reason,
30804 "summary_cache_empty"
30805 );
30806 assert!(report.graph_orchestration.readiness.fail_closed);
30807 assert!(
30808 report
30809 .graph_orchestration
30810 .readiness
30811 .next_commands
30812 .iter()
30813 .any(|command| command == "tsift summarize --extract ."),
30814 "{:?}",
30815 report.graph_orchestration.readiness.next_commands
30816 );
30817 assert!(
30818 report
30819 .graph_orchestration
30820 .evidence_packet_ids
30821 .iter()
30822 .all(|id| !id.starts_with("gevd-")),
30823 "evidence packet ids should be empty when readiness is blocked: {:?}",
30824 report.graph_orchestration.evidence_packet_ids
30825 );
30826 assert!(
30827 report
30828 .graph_orchestration
30829 .conflict_matrix_decisions
30830 .iter()
30831 .any(|decision| decision.contains("readiness blocked")),
30832 "conflict-matrix decisions should reference readiness block: {:?}",
30833 report.graph_orchestration.conflict_matrix_decisions
30834 );
30835 assert!(
30836 !report
30837 .graph_orchestration
30838 .follow_up_commands
30839 .iter()
30840 .any(|command| command.contains("conflict-matrix")),
30841 "conflict-matrix command should not appear when readiness is blocked: {:?}",
30842 report.graph_orchestration.follow_up_commands
30843 );
30844 assert!(
30845 report
30846 .graph_orchestration
30847 .follow_up_commands
30848 .iter()
30849 .any(|command| command == "tsift summarize --extract ."),
30850 "{:?}",
30851 report.graph_orchestration.follow_up_commands
30852 );
30853 assert!(
30854 !report
30855 .graph_orchestration
30856 .worker_ownership_blocks
30857 .is_empty()
30858 );
30859 }
30860
30861 #[test]
30862 fn convex_sync_report_chunks_upserts_and_tombstones() {
30863 let dir = setup_traversal_project();
30864 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
30865 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
30866 let mut snapshot = projection.to_convex_rows();
30867 snapshot.nodes.push(ConvexNodeRow {
30868 external_id: "stale-node".to_string(),
30869 kind: "backlog".to_string(),
30870 label: "stale".to_string(),
30871 properties: BTreeMap::new(),
30872 provenance: Vec::new(),
30873 freshness: None,
30874 });
30875 snapshot.edges.clear();
30876 snapshot.edges.push(ConvexEdgeRow {
30877 edge_key: "stale-edge".to_string(),
30878 from_external_id: "stale-node".to_string(),
30879 to_external_id: "stale-node".to_string(),
30880 kind: "mentions".to_string(),
30881 properties: BTreeMap::new(),
30882 provenance: Vec::new(),
30883 freshness: None,
30884 });
30885 let snapshot_path = dir.path().join("convex-snapshot.json");
30886 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30887
30888 let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
30889
30890 assert_eq!(report.freshness.status, "stale");
30891 assert!(report.freshness.fail_closed);
30892 assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
30893 assert!(
30894 report.edge_upserts.len() > 1,
30895 "snapshot without edges should upsert local edges"
30896 );
30897 assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
30898 assert_eq!(
30899 report.chunks.first().map(|chunk| chunk.operation.as_str()),
30900 Some("delete_edges"),
30901 "edge tombstones should be planned before node tombstones"
30902 );
30903 assert!(
30904 report
30905 .chunks
30906 .iter()
30907 .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
30908 "expected chunked edge upserts, got {:?}",
30909 report.chunks
30910 );
30911 }
30912
30913 #[test]
30914 fn convex_snapshot_validation_fails_closed_when_stale() {
30915 let dir = setup_traversal_project();
30916 build_traversal_graph(dir.path(), dir.path(), None).unwrap();
30917 let snapshot = ConvexProjectionRows::default();
30918 let snapshot_path = dir.path().join("empty-convex-snapshot.json");
30919 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30920
30921 let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
30922 assert!(
30923 err.to_string()
30924 .contains("Convex graph projection is not current"),
30925 "{err}"
30926 );
30927 }
30928
30929 #[test]
30930 fn convex_sync_report_marks_live_apply_mode_without_network() {
30931 let dir = setup_traversal_project();
30932 let report =
30933 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30934
30935 assert!(!report.dry_run);
30936 assert!(
30937 !report
30938 .diagnostics
30939 .iter()
30940 .any(|diagnostic| diagnostic.contains("dry-run only")),
30941 "apply-mode report should not claim dry-run diagnostics"
30942 );
30943 assert!(
30944 report
30945 .chunks
30946 .iter()
30947 .any(|chunk| chunk.operation == "upsert_nodes"),
30948 "live apply mode should still expose chunked idempotent operations"
30949 );
30950 }
30951
30952 #[test]
30953 fn convex_sync_apply_round_trips_with_http_backend() {
30954 use std::net::TcpListener;
30955 use std::sync::{Arc, Mutex};
30956
30957 let dir = setup_traversal_project();
30958 let report =
30959 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30960 let expected_chunks = report.chunks.len();
30961 assert!(expected_chunks > 0);
30962
30963 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
30964 let endpoint = format!("http://{}", listener.local_addr().unwrap());
30965 let operations = Arc::new(Mutex::new(Vec::<String>::new()));
30966 let server_operations = Arc::clone(&operations);
30967 let server = std::thread::spawn(move || {
30968 for _ in 0..expected_chunks {
30969 let (mut stream, _) = listener.accept().unwrap();
30970 let mut reader = BufReader::new(stream.try_clone().unwrap());
30971 let mut request_line = String::new();
30972 reader.read_line(&mut request_line).unwrap();
30973 assert!(request_line.starts_with("POST "));
30974
30975 let mut content_length = 0usize;
30976 loop {
30977 let mut line = String::new();
30978 reader.read_line(&mut line).unwrap();
30979 if line == "\r\n" {
30980 break;
30981 }
30982 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
30983 content_length = value.trim().parse().unwrap();
30984 }
30985 }
30986
30987 let mut body = vec![0u8; content_length];
30988 reader.read_exact(&mut body).unwrap();
30989 let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
30990 server_operations
30991 .lock()
30992 .unwrap()
30993 .push(request["operation"].as_str().unwrap().to_string());
30994
30995 let response = br#"{"status":"ok","message":"accepted"}"#;
30996 write!(
30997 stream,
30998 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
30999 response.len()
31000 )
31001 .unwrap();
31002 stream.write_all(response).unwrap();
31003 }
31004 });
31005
31006 cmd_convex_sync(
31007 ConvexSyncOptions {
31008 path: dir.path(),
31009 scope: None,
31010 snapshot: None,
31011 chunk_size: 100,
31012 remote_snapshot: false,
31013 apply: true,
31014 endpoint: Some(&endpoint),
31015 auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
31016 },
31017 OutputFormat {
31018 json_output: false,
31019 compact: true,
31020 pretty: false,
31021 terse: false,
31022 ultra_terse: false,
31023 schema: false,
31024 envelope: false,
31025 },
31026 )
31027 .unwrap();
31028 server.join().unwrap();
31029
31030 let operations = operations.lock().unwrap().clone();
31031 assert!(operations.contains(&"upsert_nodes".to_string()));
31032 assert!(operations.contains(&"upsert_edges".to_string()));
31033 }
31034
31035 #[test]
31036 fn context_pack_diff_preview_attaches_tag_ontology_refs() {
31037 let root = tempfile::tempdir().unwrap();
31038 fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
31039 fs::write(
31040 root.path().join(".naming/tags/alpha.md"),
31041 "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
31042 )
31043 .unwrap();
31044 let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
31045 let report = diff_digest::DiffDigestReport {
31046 root: root.path().display().to_string(),
31047 mode: diff_digest::DiffDigestMode::WorkingTree,
31048 revision: None,
31049 files_changed: 1,
31050 files_with_current_summaries: 1,
31051 symbols_touched: 1,
31052 call_edges_added: 0,
31053 call_edges_removed: 0,
31054 files: vec![diff_digest::DiffDigestFile {
31055 path: "src/lib.rs".to_string(),
31056 status: diff_digest::DiffDigestFileStatus::Modified,
31057 touched_symbols: vec!["alpha_helper".to_string()],
31058 summary_state: diff_digest::DiffDigestSummaryState::Current,
31059 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31060 symbol: "alpha_helper".to_string(),
31061 summary: "alpha helper summary".to_string(),
31062 }],
31063 added_call_edges: vec![],
31064 removed_call_edges: vec![],
31065 warnings: vec![],
31066 }],
31067 };
31068
31069 let preview = build_context_pack_diff_preview(
31070 &report,
31071 ResponseBudget::new(Some(1), Some(80)),
31072 Some(&ontology),
31073 );
31074
31075 let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
31076 assert!(symbol_ref.handle.starts_with("tont-"));
31077 assert_eq!(symbol_ref.tag, "alpha");
31078 assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
31079 assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
31080 assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
31081 assert_eq!(
31082 preview.files[0].summary_refs[0].ontology_refs[0].path,
31083 ".naming/tags/alpha.md"
31084 );
31085 }
31086
31087 #[test]
31088 fn context_pack_test_preview_limits_failure_groups() {
31089 let report = test_digest::TestDigestReport {
31090 root: "/repo".to_string(),
31091 runner: "cargo".to_string(),
31092 failures: 2,
31093 grouped_failures: 2,
31094 counts: test_digest::TestDigestCounts {
31095 passed: Some(8),
31096 failed: Some(2),
31097 skipped: Some(1),
31098 },
31099 failure_groups: vec![
31100 test_digest::TestDigestFailure {
31101 tests: vec!["suite::alpha_failure".to_string()],
31102 message: "assertion failed".to_string(),
31103 path: Some("src/lib.rs".to_string()),
31104 line: Some(42),
31105 column: None,
31106 occurrences: 1,
31107 summary_state: test_digest::TestDigestSummaryState::Current,
31108 current_summaries: vec![test_digest::TestDigestSummarySnippet {
31109 symbol: "alpha_failure".to_string(),
31110 summary: "failure summary for alpha test".to_string(),
31111 }],
31112 },
31113 test_digest::TestDigestFailure {
31114 tests: vec!["suite::beta_failure".to_string()],
31115 message: "panic".to_string(),
31116 path: Some("src/main.rs".to_string()),
31117 line: Some(7),
31118 column: None,
31119 occurrences: 1,
31120 summary_state: test_digest::TestDigestSummaryState::Missing,
31121 current_summaries: vec![],
31122 },
31123 ],
31124 warnings: vec!["warning text".to_string()],
31125 };
31126
31127 let preview =
31128 build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
31129
31130 assert!(preview.truncated);
31131 assert_eq!(preview.failure_groups.len(), 1);
31132 assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
31133 assert_eq!(preview.failure_groups[0].message, "assertion f...");
31134 assert!(
31135 preview.failure_groups[0].summary_refs[0]
31136 .handle
31137 .starts_with("ctsum-")
31138 );
31139 assert_eq!(
31140 preview.failure_groups[0].summary_refs[0].expand,
31141 "tsift summarize --file \"src/lib.rs\""
31142 );
31143 assert_eq!(preview.warnings, vec!["warning text"]);
31144 }
31145
31146 #[test]
31147 fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
31148 let dir = tempfile::tempdir().unwrap();
31149 let root = dir.path();
31150
31151 let small_input = "Compiling serde v1.0.130\n";
31153 let mut small = log_digest::compute(root, small_input).unwrap();
31154 maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
31155 assert!(small.raw_log_artifact.is_none());
31156 assert!(!root.join(".tsift/artifacts").exists());
31157
31158 let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
31160 let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
31161 maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
31162 let artifact = bulky.raw_log_artifact.expect("artifact attached for bulky log");
31163 assert!(artifact.handle.starts_with("logdg-"));
31164 assert_eq!(artifact.bytes, bulky_input.len());
31165 assert!(artifact.expand.contains("tsift log-digest"));
31166 assert!(artifact.expand.contains("--input"));
31167 let persisted = root.join(&artifact.path);
31168 assert!(persisted.exists(), "artifact file written to {persisted:?}");
31169 assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
31170 }
31171
31172 #[test]
31173 fn context_pack_log_preview_limits_signals_and_refs() {
31174 let report = log_digest::LogDigestReport {
31175 root: "/repo".to_string(),
31176 total_lines: 12,
31177 non_empty_lines: 10,
31178 signal_groups: 2,
31179 repeated_line_groups: 2,
31180 repeated_line_occurrences: 3,
31181 line_family_groups: 0,
31182 file_ref_groups: 2,
31183 symbol_ref_groups: 2,
31184 stack_groups: 1,
31185 signals: vec![
31186 log_digest::LogDigestSignal {
31187 severity: "error".to_string(),
31188 message: "src/lib.rs:42 boom".to_string(),
31189 path: Some("src/lib.rs".to_string()),
31190 line: Some(42),
31191 column: None,
31192 occurrences: 2,
31193 summary_state: log_digest::LogDigestSummaryState::Current,
31194 current_summaries: vec![log_digest::LogDigestSummarySnippet {
31195 symbol: "alpha_helper".to_string(),
31196 summary: "alpha helper cached log summary".to_string(),
31197 }],
31198 },
31199 log_digest::LogDigestSignal {
31200 severity: "warn".to_string(),
31201 message: "slow path".to_string(),
31202 path: None,
31203 line: None,
31204 column: None,
31205 occurrences: 1,
31206 summary_state: log_digest::LogDigestSummaryState::Unavailable,
31207 current_summaries: vec![],
31208 },
31209 ],
31210 repeated_lines: vec![
31211 log_digest::LogDigestRepeatedLine {
31212 line: "retrying work item alpha".to_string(),
31213 occurrences: 3,
31214 },
31215 log_digest::LogDigestRepeatedLine {
31216 line: "retrying work item beta".to_string(),
31217 occurrences: 2,
31218 },
31219 ],
31220 line_families: vec![],
31221 file_refs: vec![
31222 log_digest::LogDigestFileRef {
31223 path: "src/lib.rs".to_string(),
31224 line: Some(42),
31225 column: None,
31226 occurrences: 2,
31227 summary_state: log_digest::LogDigestSummaryState::Current,
31228 current_summaries: vec![log_digest::LogDigestSummarySnippet {
31229 symbol: "alpha_helper".to_string(),
31230 summary: "alpha helper cached file summary".to_string(),
31231 }],
31232 },
31233 log_digest::LogDigestFileRef {
31234 path: "src/main.rs".to_string(),
31235 line: Some(7),
31236 column: None,
31237 occurrences: 1,
31238 summary_state: log_digest::LogDigestSummaryState::Missing,
31239 current_summaries: vec![],
31240 },
31241 ],
31242 symbol_refs: vec![
31243 log_digest::LogDigestSymbolRef {
31244 symbol: "alpha_helper".to_string(),
31245 occurrences: 2,
31246 summary_state: log_digest::LogDigestSummaryState::Current,
31247 current_summaries: vec![log_digest::LogDigestSummarySnippet {
31248 symbol: "alpha_helper".to_string(),
31249 summary: "alpha helper cached symbol summary".to_string(),
31250 }],
31251 },
31252 log_digest::LogDigestSymbolRef {
31253 symbol: "beta_helper".to_string(),
31254 occurrences: 1,
31255 summary_state: log_digest::LogDigestSummaryState::Missing,
31256 current_summaries: vec![],
31257 },
31258 ],
31259 stack_traces: vec![log_digest::LogDigestStackGroup {
31260 frames: vec!["frame one".to_string()],
31261 occurrences: 1,
31262 }],
31263 raw_log_artifact: None,
31264 warnings: vec!["warning text".to_string()],
31265 };
31266
31267 let preview =
31268 build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
31269
31270 assert!(preview.truncated);
31271 assert_eq!(preview.signals.len(), 1);
31272 assert_eq!(preview.signals[0].message, "src/lib.rs:...");
31273 assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
31274 assert_eq!(preview.file_refs.len(), 1);
31275 assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
31276 assert!(
31277 preview.signals[0].summary_refs[0]
31278 .handle
31279 .starts_with("clsum-")
31280 );
31281 assert!(
31282 preview.file_refs[0].summary_refs[0]
31283 .handle
31284 .starts_with("clfsum-")
31285 );
31286 assert!(
31287 preview.symbol_refs[0].summary_refs[0]
31288 .handle
31289 .starts_with("clssum-")
31290 );
31291 assert_eq!(
31292 preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
31293 Some("alpha/helper")
31294 );
31295 assert_eq!(
31296 preview.symbol_refs[0].summary_refs[0].expand,
31297 "tsift summarize \"alpha_helper\""
31298 );
31299 assert_eq!(preview.warnings, vec!["warning text"]);
31300 }
31301
31302 #[test]
31303 fn cli_search_rejects_exact_with_strategy_flag() {
31304 let cli = try_parse_cli([
31305 "tsift",
31306 "search",
31307 "test",
31308 "--exact",
31309 "--strategy",
31310 "lexical",
31311 ]);
31312 assert!(cli.is_err());
31313 }
31314
31315 #[test]
31316 fn cli_search_autoindexes_by_default() {
31317 let cli = parse_cli(["tsift", "search", "test"]);
31318 match cli.command {
31319 Some(Commands::Search {
31320 autoindex,
31321 no_autoindex,
31322 ..
31323 }) => {
31324 assert!(!autoindex);
31325 assert!(!no_autoindex);
31326 assert!(autoindex || !no_autoindex);
31327 }
31328 _ => panic!("expected Search command"),
31329 }
31330 }
31331
31332 #[test]
31333 fn cli_search_accepts_no_autoindex_flag() {
31334 let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
31335 match cli.command {
31336 Some(Commands::Search {
31337 autoindex,
31338 no_autoindex,
31339 ..
31340 }) => {
31341 assert!(!autoindex);
31342 assert!(no_autoindex);
31343 }
31344 _ => panic!("expected Search command"),
31345 }
31346 }
31347
31348 #[test]
31349 fn cli_search_rejects_conflicting_autoindex_flags() {
31350 let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
31351 assert!(cli.is_err());
31352 }
31353
31354 #[test]
31357 fn cli_accepts_global_absolute_flag() {
31358 let cli = parse_cli(["tsift", "--absolute", "status"]);
31359 assert!(cli.absolute);
31360 assert!(matches!(cli.command, Some(Commands::Status { .. })));
31361 }
31362
31363 #[test]
31364 fn cli_accepts_global_tabular_flag() {
31365 let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
31366 assert!(cli.tabular);
31367 assert!(matches!(cli.command, Some(Commands::Search { .. })));
31368 }
31369
31370 #[test]
31371 fn cli_tabular_with_graph() {
31372 let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
31373 assert!(cli.tabular);
31374 assert!(matches!(cli.command, Some(Commands::Graph { .. })));
31375 }
31376
31377 #[test]
31378 fn cli_tabular_with_communities() {
31379 let cli = parse_cli(["tsift", "--tabular", "communities"]);
31380 assert!(cli.tabular);
31381 assert!(matches!(cli.command, Some(Commands::Communities { .. })));
31382 }
31383
31384 #[test]
31385 fn cli_tabular_with_explain() {
31386 let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
31387 assert!(cli.tabular);
31388 assert!(matches!(cli.command, Some(Commands::Explain { .. })));
31389 }
31390
31391 #[test]
31392 fn cli_traverse_accepts_path_target_and_html_format() {
31393 let cli = parse_cli([
31394 "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
31395 ]);
31396 match cli.command {
31397 Some(Commands::Traverse {
31398 node,
31399 to,
31400 path,
31401 format,
31402 ..
31403 }) => {
31404 assert_eq!(node.as_deref(), Some("#kgnv"));
31405 assert_eq!(to.as_deref(), Some("main"));
31406 assert_eq!(path, PathBuf::from("."));
31407 assert_eq!(format, TraverseFormat::Html);
31408 }
31409 _ => panic!("expected Traverse command"),
31410 }
31411 }
31412
31413 #[test]
31414 fn cli_parses_semantic_related_command() {
31415 let cli = parse_cli([
31416 "tsift",
31417 "semantic",
31418 "graph navigation",
31419 "--path",
31420 ".",
31421 "--kind",
31422 "all",
31423 "--limit",
31424 "3",
31425 "--json",
31426 ]);
31427 match cli.command {
31428 Some(Commands::Semantic {
31429 query,
31430 path,
31431 kind,
31432 limit,
31433 json,
31434 ..
31435 }) => {
31436 assert_eq!(query, "graph navigation");
31437 assert_eq!(path, PathBuf::from("."));
31438 assert_eq!(kind, SemanticRelatedKind::All);
31439 assert_eq!(limit, 3);
31440 assert!(json);
31441 }
31442 _ => panic!("expected Semantic command"),
31443 }
31444 }
31445
31446 #[test]
31447 fn cli_parses_convex_sync_command() {
31448 let cli = parse_cli([
31449 "tsift",
31450 "convex-sync",
31451 ".",
31452 "--snapshot",
31453 "rows.json",
31454 "--chunk-size",
31455 "25",
31456 "--json",
31457 ]);
31458 match cli.command {
31459 Some(Commands::ConvexSync {
31460 path,
31461 snapshot,
31462 chunk_size,
31463 json,
31464 ..
31465 }) => {
31466 assert_eq!(path, PathBuf::from("."));
31467 assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
31468 assert_eq!(chunk_size, 25);
31469 assert!(json);
31470 }
31471 _ => panic!("expected ConvexSync command"),
31472 }
31473 }
31474
31475 #[test]
31476 fn cli_parses_convex_sync_live_flags() {
31477 let cli = parse_cli([
31478 "tsift",
31479 "convex-sync",
31480 ".",
31481 "--remote-snapshot",
31482 "--apply",
31483 "--endpoint",
31484 "https://example.test/convex-graph",
31485 "--auth-token-env",
31486 "TSIFT_TEST_TOKEN",
31487 ]);
31488 match cli.command {
31489 Some(Commands::ConvexSync {
31490 remote_snapshot,
31491 apply,
31492 endpoint,
31493 auth_token_env,
31494 ..
31495 }) => {
31496 assert!(remote_snapshot);
31497 assert!(apply);
31498 assert_eq!(
31499 endpoint.as_deref(),
31500 Some("https://example.test/convex-graph")
31501 );
31502 assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
31503 }
31504 _ => panic!("expected ConvexSync command"),
31505 }
31506 }
31507
31508 #[test]
31509 fn cli_parses_graph_db_query() {
31510 let cli = parse_cli([
31511 "tsift",
31512 "graph-db",
31513 "--backend",
31514 "convex-snapshot",
31515 "--convex-snapshot",
31516 "rows.json",
31517 "--json",
31518 "neighborhood",
31519 "gbak-kgnv",
31520 "--depth",
31521 "2",
31522 "--edge-kind",
31523 "mentions",
31524 "--property",
31525 "path=tasks/software/tsift.md",
31526 "--cursor",
31527 "gbak-old",
31528 "--limit",
31529 "10",
31530 ]);
31531 match cli.command {
31532 Some(Commands::GraphDb {
31533 backend,
31534 convex_snapshot,
31535 json,
31536 query,
31537 ..
31538 }) => {
31539 assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
31540 assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
31541 assert!(json);
31542 match query {
31543 GraphDbQuery::Neighborhood {
31544 id,
31545 depth,
31546 edge_kind,
31547 cursor,
31548 limit,
31549 property_filters,
31550 } => {
31551 assert_eq!(id, "gbak-kgnv");
31552 assert_eq!(depth, 2);
31553 assert_eq!(edge_kind.as_deref(), Some("mentions"));
31554 assert_eq!(cursor.as_deref(), Some("gbak-old"));
31555 assert_eq!(limit, Some(10));
31556 assert_eq!(
31557 property_filters,
31558 vec!["path=tasks/software/tsift.md".to_string()]
31559 );
31560 }
31561 _ => panic!("expected graph-db neighborhood query"),
31562 }
31563 }
31564 _ => panic!("expected GraphDb command"),
31565 }
31566 }
31567
31568 #[test]
31569 fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
31570 let cli = parse_cli([
31571 "tsift",
31572 "graph-db",
31573 "--json",
31574 "backend-eval",
31575 "--candidate",
31576 "surrealdb",
31577 "--target",
31578 "gval",
31579 "--full-projection",
31580 ]);
31581 match cli.command {
31582 Some(Commands::GraphDb { json, query, .. }) => {
31583 assert!(json);
31584 match query {
31585 GraphDbQuery::BackendEval {
31586 candidates,
31587 targets,
31588 full_projection,
31589 } => {
31590 assert_eq!(candidates, vec!["surrealdb".to_string()]);
31591 assert_eq!(targets, vec!["gval".to_string()]);
31592 assert!(full_projection);
31593 }
31594 _ => panic!("expected graph-db backend-eval query"),
31595 }
31596 }
31597 _ => panic!("expected GraphDb command"),
31598 }
31599 }
31600
31601 #[test]
31602 fn cli_parses_graph_db_tokensave_backend() {
31603 let cli = parse_cli([
31604 "tsift",
31605 "graph-db",
31606 "--backend",
31607 "tokensave",
31608 "--json",
31609 "node",
31610 "fn:main",
31611 ]);
31612 match cli.command {
31613 Some(Commands::GraphDb {
31614 backend,
31615 json,
31616 query,
31617 ..
31618 }) => {
31619 assert_eq!(backend, GraphDbBackend::Tokensave);
31620 assert!(json);
31621 match query {
31622 GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
31623 _ => panic!("expected graph-db node query"),
31624 }
31625 }
31626 _ => panic!("expected GraphDb command"),
31627 }
31628 }
31629
31630 #[test]
31631 fn cli_parses_analyze_command() {
31632 let cli = parse_cli([
31633 "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
31634 "--limit", "7", "--json",
31635 ]);
31636 match cli.command {
31637 Some(Commands::Analyze {
31638 path,
31639 scope,
31640 entry_points,
31641 limit,
31642 json,
31643 }) => {
31644 assert_eq!(path, PathBuf::from("."));
31645 assert_eq!(scope.as_deref(), Some("core"));
31646 assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
31647 assert_eq!(limit, 7);
31648 assert!(json);
31649 }
31650 _ => panic!("expected Analyze command"),
31651 }
31652 }
31653
31654 #[test]
31655 fn cli_parses_graph_db_related_query() {
31656 let cli = parse_cli([
31657 "tsift",
31658 "graph-db",
31659 "--json",
31660 "related",
31661 "voice avatar memory retrieval",
31662 "--kind",
31663 "all",
31664 "--depth",
31665 "3",
31666 "--seed-limit",
31667 "4",
31668 "--limit",
31669 "12",
31670 ]);
31671 match cli.command {
31672 Some(Commands::GraphDb { json, query, .. }) => {
31673 assert!(json);
31674 match query {
31675 GraphDbQuery::Related {
31676 query,
31677 kind,
31678 depth,
31679 seed_limit,
31680 limit,
31681 } => {
31682 assert_eq!(query, "voice avatar memory retrieval");
31683 assert_eq!(kind, SemanticRelatedKind::All);
31684 assert_eq!(depth, 3);
31685 assert_eq!(seed_limit, 4);
31686 assert_eq!(limit, 12);
31687 }
31688 _ => panic!("expected graph-db related query"),
31689 }
31690 }
31691 _ => panic!("expected GraphDb command"),
31692 }
31693 }
31694
31695 #[test]
31696 fn cli_parses_graph_db_compact_query() {
31697 let cli = parse_cli([
31698 "tsift",
31699 "graph-db",
31700 "--path",
31701 ".",
31702 "compact",
31703 "--apply",
31704 "--prune-tombstones",
31705 "--confirmed-convex-reconciled",
31706 ]);
31707 match cli.command {
31708 Some(Commands::GraphDb { query, .. }) => match query {
31709 GraphDbQuery::Compact {
31710 apply,
31711 prune_tombstones,
31712 confirmed_convex_reconciled,
31713 } => {
31714 assert!(apply);
31715 assert!(prune_tombstones);
31716 assert!(confirmed_convex_reconciled);
31717 }
31718 _ => panic!("expected graph-db compact query"),
31719 },
31720 _ => panic!("expected GraphDb command"),
31721 }
31722 }
31723
31724 #[test]
31725 fn cli_parses_graph_db_snapshot_queries() {
31726 let export_cli = parse_cli([
31727 "tsift",
31728 "graph-db",
31729 "--json",
31730 "snapshot-export",
31731 "graph.db.gz",
31732 "--force",
31733 ]);
31734 match export_cli.command {
31735 Some(Commands::GraphDb { json, query, .. }) => {
31736 assert!(json);
31737 match query {
31738 GraphDbQuery::SnapshotExport { output, force } => {
31739 assert_eq!(output, PathBuf::from("graph.db.gz"));
31740 assert!(force);
31741 }
31742 _ => panic!("expected graph-db snapshot-export query"),
31743 }
31744 }
31745 _ => panic!("expected GraphDb command"),
31746 }
31747
31748 let import_cli = parse_cli([
31749 "tsift",
31750 "graph-db",
31751 "snapshot-import",
31752 "graph.db.gz",
31753 "--replace",
31754 ]);
31755 match import_cli.command {
31756 Some(Commands::GraphDb { query, .. }) => match query {
31757 GraphDbQuery::SnapshotImport { artifact, replace } => {
31758 assert_eq!(artifact, PathBuf::from("graph.db.gz"));
31759 assert!(replace);
31760 }
31761 _ => panic!("expected graph-db snapshot-import query"),
31762 },
31763 _ => panic!("expected GraphDb command"),
31764 }
31765 }
31766
31767 #[test]
31768 fn cli_parses_impact_command() {
31769 let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
31770 match cli.command {
31771 Some(Commands::Impact {
31772 path,
31773 cached,
31774 limit,
31775 ..
31776 }) => {
31777 assert_eq!(path, PathBuf::from("."));
31778 assert!(cached);
31779 assert_eq!(limit, 5);
31780 }
31781 _ => panic!("expected Impact command"),
31782 }
31783 }
31784
31785 #[test]
31786 fn cli_parses_conflict_matrix_command() {
31787 let cli = parse_cli([
31788 "tsift",
31789 "conflict-matrix",
31790 "--path",
31791 "tasks/software/tsift.md",
31792 "--depth",
31793 "4",
31794 "--limit",
31795 "12",
31796 "--impact-limit",
31797 "6",
31798 "--json",
31799 "pwcm",
31800 "#g6kf",
31801 ]);
31802 match cli.command {
31803 Some(Commands::ConflictMatrix {
31804 targets,
31805 path,
31806 depth,
31807 limit,
31808 impact_limit,
31809 json,
31810 ..
31811 }) => {
31812 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31813 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31814 assert_eq!(depth, 4);
31815 assert_eq!(limit, 12);
31816 assert_eq!(impact_limit, 6);
31817 assert!(json);
31818 }
31819 _ => panic!("expected ConflictMatrix command"),
31820 }
31821 }
31822
31823 #[test]
31824 fn cli_parses_dispatch_trace_command() {
31825 let cli = parse_cli([
31826 "tsift",
31827 "dispatch-trace",
31828 "--path",
31829 "tasks/software/tsift.md",
31830 "--format",
31831 "html",
31832 "--depth",
31833 "4",
31834 "pwcm",
31835 "#g6kf",
31836 ]);
31837 match cli.command {
31838 Some(Commands::DispatchTrace {
31839 targets,
31840 path,
31841 format,
31842 depth,
31843 ..
31844 }) => {
31845 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31846 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31847 assert_eq!(format, DispatchTraceFormat::Html);
31848 assert_eq!(depth, 4);
31849 }
31850 _ => panic!("expected DispatchTrace command"),
31851 }
31852 }
31853
31854 #[test]
31855 fn cli_parses_dependency_dag_command() {
31856 let cli = parse_cli([
31857 "tsift",
31858 "dependency-dag",
31859 "--path",
31860 "tasks/software/tsift.md",
31861 "--depth",
31862 "5",
31863 "--limit",
31864 "20",
31865 "--json",
31866 "alpha",
31867 "#beta",
31868 ]);
31869 match cli.command {
31870 Some(Commands::DependencyDag {
31871 targets,
31872 path,
31873 depth,
31874 limit,
31875 json,
31876 ..
31877 }) => {
31878 assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
31879 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31880 assert_eq!(depth, 5);
31881 assert_eq!(limit, 20);
31882 assert!(json);
31883 }
31884 _ => panic!("expected DependencyDag command"),
31885 }
31886 }
31887
31888 #[test]
31889 fn relativize_strips_root_prefix() {
31890 let root = std::path::Path::new("/home/user/project");
31891 assert_eq!(
31892 relativize("/home/user/project/src/main.rs", root),
31893 "src/main.rs"
31894 );
31895 }
31896
31897 #[test]
31898 fn relativize_leaves_non_matching_path() {
31899 let root = std::path::Path::new("/home/user/project");
31900 assert_eq!(
31901 relativize("/other/path/file.rs", root),
31902 "/other/path/file.rs"
31903 );
31904 }
31905
31906 #[test]
31907 fn relativize_leaves_already_relative() {
31908 let root = std::path::Path::new("/home/user/project");
31909 assert_eq!(relativize("src/main.rs", root), "src/main.rs");
31910 }
31911
31912 #[test]
31913 fn relativize_pathbuf_strips_prefix() {
31914 let root = std::path::Path::new("/home/user/project");
31915 let path = std::path::Path::new("/home/user/project/src/lib.rs");
31916 assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
31917 }
31918
31919 #[test]
31920 fn relativize_edges_strips_caller_file() {
31921 let root = std::path::Path::new("/tmp/proj");
31922 let mut edges = vec![index::StoredEdge {
31923 caller_file: "/tmp/proj/src/main.rs".to_string(),
31924 caller_name: "main".to_string(),
31925 caller_line: 1,
31926 callee_name: "helper".to_string(),
31927 call_site_line: 5,
31928 tagpath_handle: None,
31929 }];
31930 relativize_edges(&mut edges, root);
31931 assert_eq!(edges[0].caller_file, "src/main.rs");
31932 }
31933
31934 #[test]
31935 fn relativize_json_paths_strips_known_keys() {
31936 let root = std::path::Path::new("/tmp/proj");
31937 let mut val = serde_json::json!({
31938 "file": "/tmp/proj/src/main.rs",
31939 "path": "/tmp/proj/test.rs",
31940 "name": "/tmp/proj/not-a-path",
31941 "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
31942 });
31943 relativize_json_paths(&mut val, root);
31944 assert_eq!(val["file"], "src/main.rs");
31945 assert_eq!(val["path"], "test.rs");
31946 assert_eq!(val["name"], "/tmp/proj/not-a-path");
31947 assert_eq!(val["hits"][0]["path"], "nested.rs");
31948 }
31949
31950 #[test]
31953 fn cli_graph_accepts_limit_flag() {
31954 let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
31955 match cli.command {
31956 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
31957 _ => panic!("expected Graph command"),
31958 }
31959 }
31960
31961 #[test]
31962 fn cli_graph_default_limit_is_20() {
31963 let cli = parse_cli(["tsift", "graph", "main"]);
31964 match cli.command {
31965 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
31966 _ => panic!("expected Graph command"),
31967 }
31968 }
31969
31970 #[test]
31971 fn cli_communities_accepts_limit_flag() {
31972 let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
31973 match cli.command {
31974 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
31975 _ => panic!("expected Communities command"),
31976 }
31977 }
31978
31979 #[test]
31980 fn cli_communities_default_limit_is_10() {
31981 let cli = parse_cli(["tsift", "communities"]);
31982 match cli.command {
31983 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
31984 _ => panic!("expected Communities command"),
31985 }
31986 }
31987
31988 #[test]
31989 fn cli_explain_accepts_limit_flag() {
31990 let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
31991 match cli.command {
31992 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
31993 _ => panic!("expected Explain command"),
31994 }
31995 }
31996
31997 #[test]
31998 fn cli_explain_default_limit_is_15() {
31999 let cli = parse_cli(["tsift", "explain", "main"]);
32000 match cli.command {
32001 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
32002 _ => panic!("expected Explain command"),
32003 }
32004 }
32005
32006 #[test]
32007 fn cli_limit_zero_means_unlimited() {
32008 let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
32009 match cli.command {
32010 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
32011 _ => panic!("expected Graph command"),
32012 }
32013 }
32014
32015 #[test]
32016 fn graph_cmd_limit_runs_ok() {
32017 let dir = setup_graph_index();
32018 let result = cmd_graph(
32019 "main",
32020 dir.path(),
32021 false,
32022 false,
32023 None,
32024 1,
32025 false,
32026 false,
32027 false,
32028 false,
32029 false,
32030 false,
32031 false,
32032 TagpathSearchOpts::default(),
32033 );
32034 assert!(result.is_ok());
32035 }
32036
32037 #[test]
32038 fn graph_cmd_unlimited_runs_ok() {
32039 let dir = setup_graph_index();
32040 let result = cmd_graph(
32041 "main",
32042 dir.path(),
32043 false,
32044 false,
32045 None,
32046 0,
32047 false,
32048 false,
32049 false,
32050 false,
32051 false,
32052 false,
32053 false,
32054 TagpathSearchOpts::default(),
32055 );
32056 assert!(result.is_ok());
32057 }
32058
32059 #[test]
32060 fn graph_cmd_tabular_runs_ok() {
32061 let dir = setup_graph_index();
32062 let result = cmd_graph(
32063 "main",
32064 dir.path(),
32065 false,
32066 false,
32067 None,
32068 20,
32069 false,
32070 false,
32071 false,
32072 false,
32073 false,
32074 true,
32075 false,
32076 TagpathSearchOpts::default(),
32077 );
32078 assert!(result.is_ok());
32079 }
32080
32081 #[test]
32082 fn communities_cmd_tabular_runs_ok() {
32083 let dir = setup_graph_index();
32084 let result = cmd_communities(
32085 dir.path(),
32086 None,
32087 1,
32088 10,
32089 false,
32090 false,
32091 false,
32092 false,
32093 true,
32094 false,
32095 TagpathSearchOpts::default(),
32096 );
32097 assert!(result.is_ok());
32098 }
32099
32100 #[test]
32101 fn explain_cmd_tabular_runs_ok() {
32102 let dir = setup_graph_index();
32103 let result = cmd_explain(
32104 "main",
32105 dir.path(),
32106 None,
32107 15,
32108 false,
32109 false,
32110 false,
32111 false,
32112 false,
32113 true,
32114 false,
32115 false,
32116 );
32117 assert!(result.is_ok());
32118 }
32119
32120 #[test]
32121 fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
32122 let cases = [
32127 ".agent-doc",
32128 ".agent-doc/snapshots/abc.md",
32129 ".agent-doc/baselines/abc.md",
32130 ".agent-doc/archives/2026.md",
32131 ".agent-doc/runtime/run.jsonl",
32132 "src/foo/.agent-doc",
32133 "src/foo/.agent-doc/snapshots/x.md",
32134 "./.agent-doc/snapshots/x.md",
32135 ];
32136 for path in cases {
32137 assert!(
32138 traversal_relative_path_is_generated_artifact(path),
32139 "expected `{path}` to be excluded from source watermark"
32140 );
32141 }
32142 for path in [
32144 "src/main.rs",
32145 "tests/perf_gate.rs",
32146 "fixtures/x.json",
32147 "agent-doc/src/lib.rs", "src/.agent-doc-helper.rs",
32149 ] {
32150 assert!(
32151 !traversal_relative_path_is_generated_artifact(path),
32152 "expected `{path}` to be included in source watermark"
32153 );
32154 }
32155 }
32156
32157 #[test]
32158 fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
32159 let cases = [
32167 ".tsift",
32168 ".tsift/index.db",
32169 ".tsift/indexes/foo/index.db",
32170 ".tsift/conflict-matrix-cache/inputs/abc.json",
32171 ".tsift/summaries.db",
32172 "src/foo/.tsift",
32173 "src/foo/.tsift/graph.db",
32174 "./.tsift/index.db",
32175 "target",
32176 "target/debug/build/x",
32177 "target/release/tsift",
32178 "src/foo/target/debug/x",
32179 "./target/release/x",
32180 ];
32181 for path in cases {
32182 assert!(
32183 traversal_relative_path_is_generated_artifact(path),
32184 "expected `{path}` to be excluded from source watermark"
32185 );
32186 }
32187 for path in [
32189 "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
32190 "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
32191 "src/tsift-extras/lib.rs",
32192 "tsift/README.md",
32193 "src/targeting.rs",
32194 "src/.tsiftrc",
32195 "src/agent-doc-helper.rs",
32196 ] {
32197 assert!(
32198 !traversal_relative_path_is_generated_artifact(path),
32199 "expected `{path}` to be included in source watermark"
32200 );
32201 }
32202 }
32203
32204 #[test]
32205 fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
32206 let dir = tempfile::tempdir().unwrap();
32215 let root = dir.path();
32216 std::fs::create_dir_all(root.join("src")).unwrap();
32217 std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
32218 let hint = root.join("README.md");
32219 std::fs::write(&hint, "# stable\n").unwrap();
32220 std::fs::create_dir_all(root.join(".tsift")).unwrap();
32222 std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
32223 std::fs::create_dir_all(root.join("target/debug")).unwrap();
32224 std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
32225
32226 let first = traversal_source_watermark(root, &hint, None, true)
32227 .expect("first watermark call must succeed")
32228 .expect("first watermark must produce a hash for hinted markdown");
32229 let second = traversal_source_watermark(root, &hint, None, true)
32230 .expect("second watermark call must succeed")
32231 .expect("second watermark must produce a hash for hinted markdown");
32232 assert_eq!(
32233 first, second,
32234 "watermark must be identical across back-to-back invocations on a quiescent root"
32235 );
32236
32237 std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
32239 std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
32240 let third = traversal_source_watermark(root, &hint, None, true)
32241 .expect("third watermark call must succeed")
32242 .expect("third watermark must produce a hash for hinted markdown");
32243 assert_eq!(
32244 first, third,
32245 "watermark must ignore mutations under .tsift/ and target/"
32246 );
32247
32248 std::thread::sleep(std::time::Duration::from_millis(20));
32253 std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
32254 let fourth = traversal_source_watermark(root, &hint, None, true)
32255 .expect("fourth watermark call must succeed")
32256 .expect("fourth watermark must produce a hash for hinted markdown");
32257 assert_ne!(
32258 first, fourth,
32259 "watermark must invalidate when the hinted markdown file changes"
32260 );
32261 }
32262
32263 #[test]
32264 fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
32265 let dir = tempfile::tempdir().unwrap();
32269 let root = dir.path();
32270 std::fs::write(root.join("README.md"), "# stable\n").unwrap();
32271 let summaries_db_path = root.join(".tsift/summaries.db");
32272 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
32273 let mut summary = summarize::Summary {
32274 id: 0,
32275 symbol_name: "main".to_string(),
32276 file_path: "src/main.rs".to_string(),
32277 content_hash: "hash-main".to_string(),
32278 summary: "main wires the CLI".to_string(),
32279 entities: Some(vec![summarize::Entity {
32280 name: "Cli".to_string(),
32281 kind: "type".to_string(),
32282 description: "Command-line interface".to_string(),
32283 }]),
32284 relationships: None,
32285 concept_labels: Some(vec!["cli".to_string()]),
32286 extracted_at: "1700000000".to_string(),
32287 model: "test-model".to_string(),
32288 tokens_input: Some(10),
32289 tokens_output: Some(5),
32290 };
32291 summary_db.insert(&summary).unwrap();
32292 drop(summary_db);
32293
32294 let hint = root.join("README.md");
32295 let first = traversal_source_watermark(root, &hint, None, true)
32296 .expect("first watermark call must succeed")
32297 .expect("first watermark must produce a hash");
32298
32299 std::thread::sleep(std::time::Duration::from_millis(20));
32300 let conn = Connection::open(&summaries_db_path).unwrap();
32301 conn.pragma_update(None, "user_version", 1).unwrap();
32302 conn.pragma_update(None, "user_version", 0).unwrap();
32303 drop(conn);
32304
32305 let second = traversal_source_watermark(root, &hint, None, true)
32306 .expect("second watermark call must succeed")
32307 .expect("second watermark must produce a hash");
32308 assert_eq!(
32309 first, second,
32310 "metadata-only summaries.db churn must not invalidate the source watermark"
32311 );
32312
32313 summary.entities = Some(vec![summarize::Entity {
32314 name: "GraphCache".to_string(),
32315 kind: "type".to_string(),
32316 description: "Stable full-projection cache input".to_string(),
32317 }]);
32318 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
32319 summary_db.delete_by_file("src/main.rs").unwrap();
32320 summary_db.insert(&summary).unwrap();
32321 drop(summary_db);
32322
32323 let third = traversal_source_watermark(root, &hint, None, true)
32324 .expect("third watermark call must succeed")
32325 .expect("third watermark must produce a hash");
32326 assert_ne!(
32327 first, third,
32328 "semantic summary row changes must invalidate the source watermark"
32329 );
32330 }
32331
32332 #[test]
32333 fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
32334 let dir = tempfile::tempdir().unwrap();
32338 let root = dir.path();
32339 std::fs::create_dir_all(root.join("src")).unwrap();
32340 std::fs::create_dir_all(root.join(".tsift")).unwrap();
32341 let source = root.join("src/lib.rs");
32342 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
32343 std::fs::write(&source, source_body).unwrap();
32344 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32345 db.rebuild(root).unwrap();
32346 drop(db);
32347
32348 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
32349 .unwrap()
32350 .value;
32351 std::thread::sleep(std::time::Duration::from_millis(20));
32352 std::fs::write(&source, source_body).unwrap();
32353 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32354 db.apply_changes(root).unwrap();
32355 drop(db);
32356
32357 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
32358 .unwrap()
32359 .value;
32360 assert_eq!(
32361 first, second,
32362 "mtime-only source index churn must not invalidate the full-projection cache"
32363 );
32364 }
32365
32366 #[test]
32367 fn full_projection_source_watermark_ignores_session_markdown_churn() {
32368 let dir = tempfile::tempdir().unwrap();
32373 let root = dir.path();
32374 std::fs::create_dir_all(root.join("src")).unwrap();
32375 std::fs::create_dir_all(root.join("tasks/software")).unwrap();
32376 std::fs::create_dir_all(root.join(".tsift")).unwrap();
32377 std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
32378 let task_doc = root.join("tasks/software/tsift.md");
32379 std::fs::write(
32380 &task_doc,
32381 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
32382 )
32383 .unwrap();
32384 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32385 db.rebuild(root).unwrap();
32386 drop(db);
32387
32388 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
32389 .unwrap()
32390 .value;
32391 std::fs::write(
32392 &task_doc,
32393 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
32394 )
32395 .unwrap();
32396 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
32397 .unwrap()
32398 .value;
32399 assert_eq!(
32400 first, second,
32401 "session markdown churn must not invalidate the full-projection code/summary cache"
32402 );
32403 }
32404
32405 #[test]
32406 fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
32407 let dir = tempfile::tempdir().unwrap();
32411 let root = dir.path();
32412 std::fs::create_dir_all(root.join("src")).unwrap();
32413 std::fs::create_dir_all(root.join(".tsift")).unwrap();
32414 let source = root.join("src/lib.rs");
32415 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
32416 std::fs::write(&source, source_body).unwrap();
32417 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32418 db.rebuild(root).unwrap();
32419 drop(db);
32420
32421 let (_projection, _warnings, _phases, first_stats) =
32422 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
32423 assert!(
32424 !first_stats.hit,
32425 "the first full-projection run should populate the cache"
32426 );
32427
32428 std::thread::sleep(std::time::Duration::from_millis(20));
32429 std::fs::write(&source, source_body).unwrap();
32430 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32431 db.apply_changes(root).unwrap();
32432 drop(db);
32433
32434 let (_projection, _warnings, phases, second_stats) =
32435 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
32436 assert!(second_stats.hit, "mtime-only churn should still cache-hit");
32437 let source_graph_build = phases
32438 .iter()
32439 .find(|phase| phase.name == "full_projection.source_graph_build")
32440 .expect("cache hit must report source_graph_build");
32441 let projection_rows = phases
32442 .iter()
32443 .find(|phase| phase.name == "full_projection.projection_rows")
32444 .expect("cache hit must report projection_rows");
32445 assert_eq!(source_graph_build.duration_micros, 0);
32446 assert_eq!(projection_rows.duration_micros, 0);
32447 }
32448
32449 #[test]
32450 fn build_token_capped_preview_within_cap() {
32451 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 2", "}"];
32452 let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
32453 assert!(!capped.was_capped);
32454 assert_eq!(capped.preview.len(), 3);
32455 assert_eq!(capped.capped_end, 3);
32456 }
32457
32458 #[test]
32459 fn build_token_capped_preview_truncates_long_body() {
32460 let owned: Vec<String> = (0..200)
32461 .map(|i| format!(" let line_{i} = {i};"))
32462 .collect();
32463 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
32464 let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
32465 assert!(capped.was_capped);
32466 assert!(capped.preview.len() < 200);
32467 assert!(capped.capped_end < 200);
32468 assert!(!capped.preview.is_empty());
32469 }
32470
32471 #[test]
32472 fn build_token_capped_preview_respects_start_offset() {
32473 let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
32474 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
32475 let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
32476 assert!(capped.was_capped);
32477 assert!(capped.capped_end >= 50);
32478 assert!(capped.capped_end < 100);
32479 assert_eq!(capped.preview[0].line, 50);
32480 }
32481
32482 #[test]
32483 fn response_budget_body_token_cap_defaults() {
32484 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
32485 assert_eq!(budget.body_token_cap(), 1500);
32486
32487 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
32488 assert_eq!(budget.body_token_cap(), 500);
32489
32490 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
32491 assert_eq!(budget.body_token_cap(), 3000);
32492 }
32493
32494 #[test]
32495 fn build_token_capped_preview_empty_input() {
32496 let lines: Vec<&str> = vec![];
32497 let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
32498 assert!(!capped.was_capped);
32499 assert!(capped.preview.is_empty());
32500 }
32501
32502 #[test]
32503 fn build_token_capped_preview_single_long_line_fits() {
32504 let lines: Vec<&str> = vec!["short"];
32505 let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
32506 assert!(!capped.was_capped);
32507 assert_eq!(capped.preview.len(), 1);
32508 assert_eq!(capped.capped_end, 1);
32509 }
32510
32511 #[test]
32512 fn edge_index_replaces_from_id_to_id_with_positions() {
32513 let input = serde_json::json!({
32514 "nodes": [
32515 {"id": "symbol:src/lib.rs:foo"},
32516 {"id": "symbol:src/lib.rs:bar"},
32517 {"id": "symbol:src/lib.rs:baz"}
32518 ],
32519 "edges": [
32520 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
32521 {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
32522 ]
32523 });
32524 let result = edge_index_transform(input);
32525 let edges = result.get("edges").unwrap().as_array().unwrap();
32526 assert_eq!(edges.len(), 2);
32527 assert_eq!(edges[0]["from"], 0);
32528 assert_eq!(edges[0]["to"], 1);
32529 assert_eq!(edges[1]["from"], 1);
32530 assert_eq!(edges[1]["to"], 2);
32531 assert!(edges[0].get("from_id").is_none());
32532 assert!(edges[0].get("to_id").is_none());
32533 }
32534
32535 #[test]
32536 fn edge_index_preserves_unresolved_ids_as_strings() {
32537 let input = serde_json::json!({
32538 "nodes": [{"id": "symbol:src/lib.rs:foo"}],
32539 "edges": [
32540 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
32541 ]
32542 });
32543 let result = edge_index_transform(input);
32544 let edge = &result["edges"][0];
32545 assert_eq!(edge["from"], 0);
32546 assert_eq!(edge["to_id"], "symbol:other.rs:missing");
32547 }
32548
32549 #[test]
32550 fn edge_index_noop_without_nodes_and_edges() {
32551 let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
32552 let result = edge_index_transform(input);
32553 assert_eq!(result["report"]["entries"][0]["from_id"], "a");
32554 }
32555}
32556
32557#[derive(Serialize)]
32560struct TableInfo {
32561 name: String,
32562 columns: Vec<ColumnInfo>,
32563 row_count: i64,
32564}
32565
32566#[derive(Serialize)]
32567struct ColumnInfo {
32568 name: String,
32569 #[serde(rename = "type")]
32570 col_type: String,
32571 notnull: bool,
32572 pk: bool,
32573 #[serde(skip_serializing_if = "Option::is_none")]
32574 default_value: Option<String>,
32575}
32576
32577pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
32579 let conn = Connection::open_with_flags(
32580 path,
32581 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
32582 )
32583 .with_context(|| format!("opening database: {}", path.display()))?;
32584 Ok(conn)
32585}
32586
32587pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
32589 let mut stmt = conn.prepare(
32590 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
32591 )?;
32592 let table_names: Vec<String> = stmt
32593 .query_map([], |row| row.get(0))?
32594 .collect::<std::result::Result<Vec<_>, _>>()?;
32595
32596 let mut tables = Vec::new();
32597 for tbl in table_names {
32598 let columns = table_columns(conn, &tbl)?;
32599 let row_count: i64 =
32600 conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
32601 row.get(0)
32602 })?;
32603 tables.push(TableInfo {
32604 name: tbl,
32605 columns,
32606 row_count,
32607 });
32608 }
32609 Ok(tables)
32610}
32611
32612pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
32614 let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
32615 let cols = stmt
32616 .query_map([], |row| {
32617 Ok(ColumnInfo {
32618 name: row.get(1)?,
32619 col_type: row.get::<_, String>(2).unwrap_or_default(),
32620 notnull: row.get::<_, bool>(3).unwrap_or(false),
32621 pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
32622 default_value: row.get(4)?,
32623 })
32624 })?
32625 .collect::<std::result::Result<Vec<_>, _>>()?;
32626 Ok(cols)
32627}
32628
32629pub(crate) fn execute_query(
32631 conn: &Connection,
32632 sql: &str,
32633) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
32634 let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
32635 let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
32636 let col_count = col_names.len();
32637
32638 let mut rows = Vec::new();
32639 let mut query_rows = stmt.query([])?;
32640 while let Some(row) = query_rows.next()? {
32641 let mut vals = Vec::with_capacity(col_count);
32642 for i in 0..col_count {
32643 let val = match row.get_ref(i)? {
32644 rusqlite::types::ValueRef::Null => serde_json::Value::Null,
32645 rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
32646 rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
32647 rusqlite::types::ValueRef::Text(s) => {
32648 serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
32649 }
32650 rusqlite::types::ValueRef::Blob(b) => {
32651 serde_json::Value::String(format!("<blob {} bytes>", b.len()))
32652 }
32653 };
32654 vals.push(val);
32655 }
32656 rows.push(vals);
32657 }
32658 Ok((col_names, rows))
32659}
32660
32661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32662enum DigestRunnerKind {
32663 Test,
32664 Log,
32665}
32666
32667impl DigestRunnerKind {
32668 fn parse(raw: &str) -> Result<Self> {
32669 match raw.trim().to_ascii_lowercase().as_str() {
32670 "test" => Ok(Self::Test),
32671 "log" => Ok(Self::Log),
32672 other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
32673 }
32674 }
32675
32676 fn as_str(self) -> &'static str {
32677 match self {
32678 Self::Test => "test",
32679 Self::Log => "log",
32680 }
32681 }
32682}
32683
32684pub(crate) fn shell_split(s: &str) -> Vec<&str> {
32686 let mut parts = Vec::new();
32687 let mut i = 0;
32688 let bytes = s.as_bytes();
32689 while i < bytes.len() {
32690 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
32692 i += 1;
32693 }
32694 if i >= bytes.len() {
32695 break;
32696 }
32697 let start = i;
32698 if bytes[i] == b'"' || bytes[i] == b'\'' {
32699 let quote = bytes[i];
32700 i += 1;
32701 while i < bytes.len() && bytes[i] != quote {
32702 i += 1;
32703 }
32704 if i < bytes.len() {
32705 i += 1; }
32707 } else {
32708 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
32709 i += 1;
32710 }
32711 }
32712 parts.push(&s[start..i]);
32713 }
32714 parts
32715}
32716
32717pub(crate) fn shell_quote(s: &str) -> String {
32719 let unquoted =
32721 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
32722 &s[1..s.len() - 1]
32723 } else {
32724 s
32725 };
32726
32727 if unquoted
32728 .chars()
32729 .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
32730 {
32731 format!("\"{}\"", unquoted)
32732 } else {
32733 format!(
32734 "\"{}\"",
32735 unquoted.replace('\\', "\\\\").replace('"', "\\\"")
32736 )
32737 }
32738}
32739
32740fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
32741 sift::SearchCoverageSnapshot {
32742 mode: sift::SearchCoverageMode::Sealed,
32743 total_sector_count: 0,
32744 mounted_sector_count: 0,
32745 reused_sector_count: 0,
32746 dirty_sector_count: 0,
32747 completed_dirty_sector_count: 0,
32748 rebuilding_sector_count: 0,
32749 resumed_sector_count: 0,
32750 active_rebuild: None,
32751 }
32752}
32753
32754fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
32755 let total_sector_count = responses
32756 .iter()
32757 .map(|response| response.coverage.total_sector_count)
32758 .sum();
32759 let mounted_sector_count = responses
32760 .iter()
32761 .map(|response| response.coverage.mounted_sector_count)
32762 .sum();
32763 let reused_sector_count = responses
32764 .iter()
32765 .map(|response| response.coverage.reused_sector_count)
32766 .sum();
32767 let dirty_sector_count = responses
32768 .iter()
32769 .map(|response| response.coverage.dirty_sector_count)
32770 .sum();
32771 let completed_dirty_sector_count = responses
32772 .iter()
32773 .map(|response| response.coverage.completed_dirty_sector_count)
32774 .sum();
32775 let rebuilding_sector_count = responses
32776 .iter()
32777 .map(|response| response.coverage.rebuilding_sector_count)
32778 .sum();
32779 let resumed_sector_count = responses
32780 .iter()
32781 .map(|response| response.coverage.resumed_sector_count)
32782 .sum();
32783
32784 let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
32785 sift::SearchCoverageMode::Sealed
32786 } else if completed_dirty_sector_count > 0
32787 || rebuilding_sector_count > 0
32788 || resumed_sector_count > 0
32789 {
32790 sift::SearchCoverageMode::Converging
32791 } else {
32792 sift::SearchCoverageMode::Frontier
32793 };
32794
32795 sift::SearchCoverageSnapshot {
32796 mode,
32797 total_sector_count,
32798 mounted_sector_count,
32799 reused_sector_count,
32800 dirty_sector_count,
32801 completed_dirty_sector_count,
32802 rebuilding_sector_count,
32803 resumed_sector_count,
32804 active_rebuild: responses
32805 .iter()
32806 .find_map(|response| response.coverage.active_rebuild.clone()),
32807 }
32808}
32809
32810fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
32811 sift::SearchResponse {
32812 strategy: strategy.to_string(),
32813 root: root.display().to_string(),
32814 indexed_artifacts: 0,
32815 skipped_artifacts: 0,
32816 coverage: empty_search_coverage(),
32817 hits: Vec::new(),
32818 }
32819}
32820
32821fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
32822 for hit in &mut response.hits {
32823 let path = Path::new(&hit.path);
32824 if path.is_relative() {
32825 hit.path = search_root.join(path).display().to_string();
32826 }
32827 }
32828}
32829
32830fn merge_search_responses(
32831 root: &Path,
32832 strategy: &str,
32833 limit: usize,
32834 responses: Vec<sift::SearchResponse>,
32835) -> sift::SearchResponse {
32836 let indexed_artifacts = responses
32837 .iter()
32838 .map(|response| response.indexed_artifacts)
32839 .sum();
32840 let skipped_artifacts = responses
32841 .iter()
32842 .map(|response| response.skipped_artifacts)
32843 .sum();
32844 let coverage = if responses.is_empty() {
32845 empty_search_coverage()
32846 } else {
32847 aggregate_search_coverage(&responses)
32848 };
32849 let mut hits: Vec<sift::SearchHit> = responses
32850 .into_iter()
32851 .flat_map(|response| response.hits)
32852 .collect();
32853 hits.sort_by(|left, right| {
32854 right
32855 .score
32856 .partial_cmp(&left.score)
32857 .unwrap_or(Ordering::Equal)
32858 .then_with(|| left.path.cmp(&right.path))
32859 .then_with(|| left.location.cmp(&right.location))
32860 });
32861 hits.truncate(limit);
32862 for (rank, hit) in hits.iter_mut().enumerate() {
32863 hit.rank = rank + 1;
32864 }
32865
32866 sift::SearchResponse {
32867 strategy: strategy.to_string(),
32868 root: root.display().to_string(),
32869 indexed_artifacts,
32870 skipped_artifacts,
32871 coverage,
32872 hits,
32873 }
32874}
32875
32876pub(crate) fn federated_sift_search(
32877 root: &Path,
32878 cache_dir: &Path,
32879 query: &str,
32880 limit: usize,
32881 timeout_secs: u64,
32882 strategy: &str,
32883) -> Result<sift::SearchResponse> {
32884 let targets = resolve_search_index_targets(root, root, None, true)?;
32885 if targets.is_empty() {
32886 if config::Config::submodule_dirs(root)?.is_empty() {
32887 return run_search_with_timeout(
32888 root,
32889 cache_dir,
32890 query,
32891 limit,
32892 timeout_secs,
32893 strategy,
32894 &[],
32895 );
32896 }
32897 return Ok(empty_search_response(root, strategy));
32898 }
32899
32900 let mut responses = Vec::with_capacity(targets.len());
32901 for target in &targets {
32902 let mut response = run_search_with_timeout(
32903 &target.source_root,
32904 cache_dir,
32905 query,
32906 limit,
32907 timeout_secs,
32908 strategy,
32909 std::slice::from_ref(target),
32910 )?;
32911 absolutize_search_hit_paths(&mut response, &target.source_root);
32912 response.root = root.display().to_string();
32913 responses.push(response);
32914 }
32915
32916 Ok(merge_search_responses(root, strategy, limit, responses))
32917}
32918
32919pub(crate) fn federated_symbol_search(
32927 root: &std::path::Path,
32928 query: &str,
32929 limit: usize,
32930 tagpath_opts: &TagpathSearchOpts,
32931) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
32932 let cfg = config::Config::load(root)?;
32933 let submodules = config::Config::submodule_dirs(root)?;
32934 let mut all_hits: Vec<index::SymbolHit> = Vec::new();
32935 let mut combined = TagpathAnnotationDiagnostic::default();
32936 for scope in &submodules {
32937 if !cfg.federation_for_scope(scope) {
32938 continue;
32939 }
32940 let db_path = cfg.db_path_for(root, &scope.id);
32941 if !db_path.exists() {
32942 continue;
32943 }
32944 let db = index::IndexDb::open_read_only(&db_path)?;
32945 let mut hits = db.symbol_search(query, limit)?;
32946 let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
32947 combined.loaded |= diag.loaded;
32948 if diag.stale && !combined.stale {
32949 combined.stale = true;
32950 combined.reason = diag.reason;
32951 }
32952 all_hits.append(&mut hits);
32953 }
32954 all_hits.sort_by(|a, b| {
32955 b.score
32956 .partial_cmp(&a.score)
32957 .unwrap_or(std::cmp::Ordering::Equal)
32958 });
32959 all_hits.truncate(limit);
32960 Ok((all_hits, combined))
32961}
32962
32963#[derive(Debug, Deserialize)]
32964#[serde(tag = "type", rename_all = "lowercase")]
32965enum RipgrepJsonEvent {
32966 Match {
32967 data: RipgrepMatchData,
32968 },
32969 #[serde(other)]
32970 Other,
32971}
32972
32973#[derive(Debug, Deserialize)]
32974struct RipgrepMatchData {
32975 path: RipgrepTextField,
32976 lines: RipgrepTextField,
32977 line_number: Option<usize>,
32978}
32979
32980#[derive(Debug, Deserialize)]
32981struct RipgrepTextField {
32982 text: Option<String>,
32983}
32984
32985pub(crate) fn federated_exact_search(
32986 root: &Path,
32987 query: &str,
32988 limit: usize,
32989 timeout_secs: u64,
32990) -> Result<sift::SearchResponse> {
32991 let cfg = config::Config::load(root)?;
32992 let mut responses = Vec::new();
32993 for scope in config::Config::submodule_dirs(root)? {
32994 if !cfg.federation_for_scope(&scope) {
32995 continue;
32996 }
32997 let mut response =
32998 run_exact_search_with_timeout(&scope.source_root, query, limit, timeout_secs)?;
32999 absolutize_search_hit_paths(&mut response, &scope.source_root);
33000 response.root = root.display().to_string();
33001 responses.push(response);
33002 }
33003
33004 Ok(merge_search_responses(root, "exact", limit, responses))
33005}
33006
33007pub(crate) fn run_sift_search(
33008 search_path: &Path,
33009 cache_dir: &Path,
33010 query: &str,
33011 limit: usize,
33012 strategy: &str,
33013) -> Result<sift::SearchResponse> {
33014 let engine = Sift::builder().with_cache_dir(cache_dir).build();
33015 let options = SearchOptions::default()
33016 .with_limit(limit)
33017 .with_strategy(strategy.to_string());
33018 let input = SearchInput::new(search_path, query).with_options(options);
33019 engine.search(input).context("sift search failed")
33020}
33021
33022fn exact_search_timeout_message(timeout_secs: u64) -> String {
33023 format!(
33024 "tsift search timed out after {}s (strategy: exact). \
33025 Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
33026 timeout_secs
33027 )
33028}
33029
33030fn exact_search_command(search_path: &Path, query: &str) -> Command {
33031 let mut command = Command::new("rg");
33032 command
33033 .arg("--json")
33034 .arg("--fixed-strings")
33035 .arg("--line-number")
33036 .arg("--hidden")
33037 .arg("--")
33038 .arg(query)
33039 .arg(search_path);
33040 command
33041}
33042
33043fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
33044 let observed_unix_secs = SystemTime::now()
33045 .duration_since(UNIX_EPOCH)
33046 .unwrap_or_default()
33047 .as_secs() as i64;
33048 let modified_unix_secs = fs::metadata(path)
33049 .ok()
33050 .and_then(|metadata| metadata.modified().ok())
33051 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
33052 .map(|duration| duration.as_secs() as i64);
33053 sift::ArtifactFreshness {
33054 observed_unix_secs,
33055 modified_unix_secs,
33056 }
33057}
33058
33059fn parse_exact_search_output(
33060 search_path: &Path,
33061 limit: usize,
33062 raw: &str,
33063) -> Result<sift::SearchResponse> {
33064 if limit == 0 {
33065 return Ok(sift::SearchResponse {
33066 strategy: "exact".to_string(),
33067 root: search_path.display().to_string(),
33068 indexed_artifacts: 0,
33069 skipped_artifacts: 0,
33070 coverage: empty_search_coverage(),
33071 hits: Vec::new(),
33072 });
33073 }
33074
33075 let mut hits = Vec::new();
33076 for line in raw.lines() {
33077 let event: RipgrepJsonEvent =
33078 serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
33079 let RipgrepJsonEvent::Match { data } = event else {
33080 continue;
33081 };
33082 let Some(path_text) = data.path.text else {
33083 continue;
33084 };
33085 let Some(lines_text) = data.lines.text else {
33086 continue;
33087 };
33088 let path = PathBuf::from(path_text);
33089 let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
33090 let rank = hits.len() + 1;
33091 hits.push(sift::SearchHit {
33092 artifact_id: format!(
33093 "exact:{}:{}:{}",
33094 path.display(),
33095 data.line_number.unwrap_or(0),
33096 rank
33097 ),
33098 artifact_kind: sift::ContextArtifactKind::File,
33099 path: path.display().to_string(),
33100 rank,
33101 score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
33102 confidence: sift::ScoreConfidence::High,
33103 location: data.line_number.map(|line| format!("line {}", line)),
33104 snippet: snippet.clone(),
33105 provenance: sift::ArtifactProvenance {
33106 adapter: sift::AcquisitionAdapterKind::FileSystem,
33107 source: "ripgrep -F".to_string(),
33108 synthetic: false,
33109 },
33110 freshness: exact_search_file_timestamp(&path),
33111 budget: sift::ArtifactBudget::from_text(&snippet, 1),
33112 });
33113 if hits.len() >= limit {
33114 break;
33115 }
33116 }
33117
33118 Ok(sift::SearchResponse {
33119 strategy: "exact".to_string(),
33120 root: search_path.display().to_string(),
33121 indexed_artifacts: hits.len(),
33122 skipped_artifacts: 0,
33123 coverage: empty_search_coverage(),
33124 hits,
33125 })
33126}
33127
33128fn exact_search_response_from_process(
33129 search_path: &Path,
33130 limit: usize,
33131 status: std::process::ExitStatus,
33132 stdout: &[u8],
33133 stderr: &[u8],
33134) -> Result<sift::SearchResponse> {
33135 if !status.success() && status.code() != Some(1) {
33136 let message = String::from_utf8_lossy(stderr);
33137 let trimmed = message.trim();
33138 if trimmed.is_empty() {
33139 bail!("ripgrep exact search exited with status {}", status);
33140 }
33141 bail!("{}", trimmed);
33142 }
33143
33144 let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
33145 parse_exact_search_output(search_path, limit, &raw)
33146}
33147
33148fn run_exact_search(search_path: &Path, query: &str, limit: usize) -> Result<sift::SearchResponse> {
33149 let output = exact_search_command(search_path, query)
33150 .output()
33151 .context("running exact search with ripgrep")?;
33152 exact_search_response_from_process(
33153 search_path,
33154 limit,
33155 output.status,
33156 &output.stdout,
33157 &output.stderr,
33158 )
33159}
33160
33161pub(crate) fn run_exact_search_with_timeout(
33162 search_path: &Path,
33163 query: &str,
33164 limit: usize,
33165 timeout_secs: u64,
33166) -> Result<sift::SearchResponse> {
33167 if timeout_secs == 0 {
33168 return run_exact_search(search_path, query, limit);
33169 }
33170
33171 let mut child = exact_search_command(search_path, query)
33172 .stdin(Stdio::null())
33173 .stdout(Stdio::piped())
33174 .stderr(Stdio::piped())
33175 .spawn()
33176 .context("spawning timed exact search worker")?;
33177
33178 let timeout = Duration::from_secs(timeout_secs);
33179 let status = wait_for_child_exit(&mut child, timeout)
33180 .context("waiting for timed exact search worker")?;
33181 if status.is_none() {
33182 let _ = child.kill();
33183 let _ = child.wait();
33184 bail!("{}", exact_search_timeout_message(timeout_secs));
33185 }
33186
33187 let status = status.unwrap();
33188 let stdout = read_child_stdout(&mut child)?;
33189 let stderr = read_child_stderr(&mut child)?;
33190 exact_search_response_from_process(
33191 search_path,
33192 limit,
33193 status,
33194 stdout.as_bytes(),
33195 stderr.as_bytes(),
33196 )
33197}
33198
33199pub(crate) fn run_search_with_timeout(
33200 search_path: &Path,
33201 cache_dir: &Path,
33202 query: &str,
33203 limit: usize,
33204 timeout_secs: u64,
33205 strategy: &str,
33206 search_targets: &[SearchIndexTarget],
33207) -> Result<sift::SearchResponse> {
33208 if timeout_secs == 0 {
33209 return run_sift_search(search_path, cache_dir, query, limit, strategy);
33210 }
33211
33212 let output_path = next_search_worker_output_path();
33213 let mut child = Command::new(
33214 std::env::current_exe().context("resolving tsift executable for timed search")?,
33215 )
33216 .arg("__search-worker")
33217 .arg("--path")
33218 .arg(search_path)
33219 .arg("--cache-dir")
33220 .arg(cache_dir)
33221 .arg("--query")
33222 .arg(query)
33223 .arg("--limit")
33224 .arg(limit.to_string())
33225 .arg("--strategy")
33226 .arg(strategy)
33227 .arg("--output")
33228 .arg(&output_path)
33229 .stdin(Stdio::null())
33230 .stdout(Stdio::null())
33231 .stderr(Stdio::piped())
33232 .spawn()
33233 .context("spawning timed sift search worker")?;
33234
33235 let timeout = Duration::from_secs(timeout_secs);
33236 let status =
33237 wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
33238 if status.is_none() {
33239 let _ = child.kill();
33240 let _ = child.wait();
33241 let _ = fs::remove_file(&output_path);
33242 bail!(
33243 "{}",
33244 search_timeout_message(timeout_secs, strategy, search_targets)?
33245 );
33246 }
33247
33248 let status = status.unwrap();
33249 let stderr = read_child_stderr(&mut child)?;
33250 if !status.success() {
33251 let _ = fs::remove_file(&output_path);
33252 let message = stderr.trim();
33253 if message.is_empty() {
33254 bail!("sift search worker exited with status {}", status);
33255 }
33256 bail!("{}", message);
33257 }
33258
33259 let raw = fs::read_to_string(&output_path)
33260 .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
33261 let _ = fs::remove_file(&output_path);
33262 serde_json::from_str(&raw).context("parsing search worker output")
33263}
33264
33265fn next_search_worker_output_path() -> PathBuf {
33266 let stamp = SystemTime::now()
33267 .duration_since(UNIX_EPOCH)
33268 .unwrap_or_default()
33269 .as_nanos();
33270 std::env::temp_dir().join(format!(
33271 "tsift-search-{}-{}.json",
33272 std::process::id(),
33273 stamp
33274 ))
33275}
33276
33277fn wait_for_child_exit(
33278 child: &mut std::process::Child,
33279 timeout: Duration,
33280) -> Result<Option<std::process::ExitStatus>> {
33281 let started = Instant::now();
33282 loop {
33283 if let Some(status) = child.try_wait()? {
33284 return Ok(Some(status));
33285 }
33286 if started.elapsed() >= timeout {
33287 return Ok(None);
33288 }
33289 let remaining = timeout.saturating_sub(started.elapsed());
33290 std::thread::sleep(remaining.min(Duration::from_millis(10)));
33291 }
33292}
33293
33294fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
33295 let mut stderr = String::new();
33296 if let Some(mut pipe) = child.stderr.take() {
33297 pipe.read_to_string(&mut stderr)
33298 .context("reading search worker stderr")?;
33299 }
33300 Ok(stderr)
33301}
33302
33303fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
33304 let mut stdout = String::new();
33305 if let Some(mut pipe) = child.stdout.take() {
33306 pipe.read_to_string(&mut stdout)
33307 .context("reading search worker stdout")?;
33308 }
33309 Ok(stdout)
33310}
33311
33312pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
33313 if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
33314 fs::write(&path, std::process::id().to_string())
33315 .with_context(|| format!("writing search worker pid file: {path}"))?;
33316 }
33317 if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
33318 let delay_ms = ms
33319 .parse::<u64>()
33320 .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
33321 std::thread::sleep(Duration::from_millis(delay_ms));
33322 }
33323 Ok(())
33324}
33325
33326#[cfg(test)]
33327thread_local! {
33328 static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
33329}
33330
33331#[cfg(test)]
33332enum SearchPostPrecheckLockMode {
33333 RollbackJournal,
33334 Wal,
33335}
33336
33337#[cfg(test)]
33338struct SearchPostPrecheckLockHook {
33339 db_path: PathBuf,
33340 mode: SearchPostPrecheckLockMode,
33341}
33342
33343#[cfg(test)]
33344struct SearchPostPrecheckLockGuard;
33345
33346#[cfg(test)]
33347impl Drop for SearchPostPrecheckLockGuard {
33348 fn drop(&mut self) {
33349 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
33350 hook.borrow_mut().take();
33351 });
33352 }
33353}
33354
33355#[cfg(test)]
33356fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
33357 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
33358}
33359
33360#[cfg(test)]
33361fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
33362 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
33363}
33364
33365#[cfg(test)]
33366fn install_search_post_precheck_lock_hook(
33367 db_path: PathBuf,
33368 mode: SearchPostPrecheckLockMode,
33369) -> SearchPostPrecheckLockGuard {
33370 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
33371 assert!(
33372 hook.borrow().is_none(),
33373 "search post-precheck lock hook already installed"
33374 );
33375 *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
33376 });
33377 SearchPostPrecheckLockGuard
33378}
33379
33380#[cfg(test)]
33381pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
33382 let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
33383 return Ok(());
33384 };
33385 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
33386 std::thread::spawn(move || {
33387 let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
33388 match hook.mode {
33389 SearchPostPrecheckLockMode::RollbackJournal => {
33390 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
33391 .expect("acquiring rollback-journal hook lock");
33392 fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
33393 .expect("writing rollback journal marker");
33394 }
33395 SearchPostPrecheckLockMode::Wal => {
33396 conn.execute_batch(
33397 "PRAGMA journal_mode=WAL;
33398 PRAGMA wal_autocheckpoint=0;
33399 CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
33400 INSERT INTO search_wal_lock_probe DEFAULT VALUES;
33401 PRAGMA locking_mode=EXCLUSIVE;
33402 BEGIN EXCLUSIVE;",
33403 )
33404 .expect("acquiring WAL hook lock");
33405 assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
33406 }
33407 }
33408 ready_tx.send(()).expect("signaling search lock hook");
33409 std::thread::sleep(Duration::from_millis(200));
33410 drop(conn);
33411 let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
33412 });
33413 ready_rx
33414 .recv_timeout(Duration::from_secs(1))
33415 .context("waiting for search post-precheck lock hook")?;
33416 Ok(())
33417}
33418
33419#[cfg(not(test))]
33420pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
33421 Ok(())
33422}