1mod cli;
2mod commands;
3mod community_detection;
4mod conflict_matrix;
5mod context_pack;
6mod output;
7mod rewrite;
8mod search_budget;
9mod semantic_edit;
10mod session_review_budget;
11mod token_savings;
12mod workflow;
13
14pub(crate) use community_detection::{
15 CommunityDetectionReport, annotate_community_members_with_context,
16 community_tagpath_cache_part, community_tagpath_cache_part_for_loaded,
17 detect_communities_cached, file_communities_from_callers, graph_effectiveness_blocked,
18 graph_effectiveness_ready, resolve_tagpath_handle_for_callee_edge,
19 update_community_annotation_diagnostics,
20};
21#[allow(unused_imports)]
22pub(crate) use conflict_matrix::{
23 ConflictMatrixCandidate, ConflictMatrixGraphPreparedInputs, ConflictMatrixPreparedInputs,
24 ConflictMatrixReport, ConflictMatrixSemanticRef, ConflictMatrixSharedPreparationSummary,
25 ConflictMatrixWorkerFeedback, ConflictMatrixWorkerPromptPacket, build_conflict_matrix_report,
26 build_conflict_matrix_report_from_prepared_graph, cmd_conflict_matrix,
27 collect_conflict_matrix_evidence_packets, conflict_matrix_candidate_from_evidence,
28 conflict_matrix_graph_index, conflict_matrix_semantic_ref,
29 conflict_matrix_shared_preparation_summary, conflict_matrix_source_handle,
30 conflict_matrix_target_scoped_graph_snapshot, conflict_matrix_worker_feedback,
31 conflict_risk_label, extract_conflict_target_refs, hash_bytes_hex, is_planner_config_path,
32 normalize_conflict_target, prepare_conflict_matrix_graph_orchestration,
33 prepare_conflict_matrix_inputs, resolve_conflict_matrix_targets, sorted_intersection,
34 sorted_set,
35};
36#[allow(unused_imports)]
37pub(crate) use context_pack::{
38 ContextPackReport, ContextPackSummaryRefPreview, build_context_pack_diff_preview,
39 build_context_pack_log_preview, build_context_pack_report,
40 build_context_pack_report_with_profile, build_context_pack_test_preview,
41 context_pack_status_reminders, exploration_ref_id, materialize_context_pack_exploration_packet,
42 print_context_pack_human,
43};
44pub use rewrite::rewrite_command;
45pub(crate) use rewrite::{
46 apply_rewrite_output_format, execute_rewritten_command, no_rewrite_message,
47};
48#[cfg(test)]
49use search_budget::{SearchBudgetReport, search_facet_filters_summary};
50pub(crate) use search_budget::{
51 SearchBudgetReportInput, apply_search_facet_filters, build_search_budget_follow_up,
52 build_search_budget_report, print_search_budget_human,
53};
54pub(crate) use semantic_edit::{
55 AstSpanPreview, EditBatch, EditResult, EditStatus, MarkdownEmbeddedSymbol,
56 MarkdownSpanMetadata, MetricDigestOptions, SemanticEditVerifyOptions,
57 apply_edit_plan_atomically, build_edit_plan, cmd_edit_intents,
58};
59#[allow(unused_imports)]
60pub(crate) use session_review_budget::{
61 SessionReviewBudgetFailurePreview, SessionReviewBudgetReport,
62 SessionReviewNextContextBudgetReport, SessionReviewNextTokenAction,
63 build_session_review_budget_report, build_session_review_next_context_budget_report,
64 print_session_review_budget_human, print_session_review_next_context_budget_human,
65};
66
67#[cfg(test)]
68use rewrite::{
69 OutputCap, apply_output_cap, effective_rewrite_run_command, resolve_digest_context_path,
70 rewrite_output_cap,
71};
72#[cfg(test)]
73use std::io::{BufRead as _, BufReader};
74#[cfg(test)]
75use token_savings::{
76 TokenSavingsFamily, TokenSavingsFixture, TokenSavingsFixtureCase,
77 TokenSavingsMarkdownProjectionInput, TokenSavingsMarkdownProjectionInputs,
78 TokenSavingsRawSymbol, TokenSavingsSourceReadInput, TokenSavingsSourceReadInputs,
79 build_token_savings_report,
80};
81
82use anyhow::{Context, Result, bail};
83use clap::Parser;
84use cli::{
85 Cli, Commands, DispatchTraceFormat, GraphDbQuery, KgCommand, LeaseCommand, LocalModelCommand,
86 SemanticRelatedKind, SourceReadStyle,
87};
88#[cfg(test)]
89use cli::{GraphDbBackend, TraverseFormat};
90use commands::digests::{
91 cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
92 cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
93};
94#[cfg(test)]
95use commands::graph::cmd_explain;
96use commands::graph::{
97 cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
98};
99#[cfg(test)]
100use commands::index_search::cmd_search;
101use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
102use commands::infra::{
103 StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
104 cmd_rewrite, cmd_route, cmd_sql, cmd_status,
105};
106use commands::memory::cmd_memory;
107use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
108use commands::summarize::cmd_summarize;
109use flate2::{Compression, read::GzDecoder, write::GzEncoder};
110#[cfg(test)]
111use output::ResponseBudgetPreset;
112use output::tagpath::{
113 TagpathAnnotationDiagnostic, TagpathSearchOpts, annotate_communities_with_tagpath,
114 annotate_hits_with_tagpath, annotate_path_nodes_with_tagpath,
115 annotate_stored_edges_with_tagpath, annotate_stored_symbols_with_tagpath,
116};
117use output::{
118 OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric, ToolEnvelopeSummary,
119 TranscriptArtifactRef,
120};
121use rusqlite::{Connection, OptionalExtension, Row};
122use serde::{Deserialize, Serialize};
123use sift::{SearchInput, SearchOptions, Sift};
124#[cfg(test)]
125use std::cell::RefCell;
126use std::cmp::Ordering;
127use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
128use std::env;
129use std::fs;
130use std::io::{Read as _, Write as _};
131use std::path::{Path, PathBuf};
132use std::process::{Command, Stdio};
133use std::sync::{Mutex, OnceLock};
134use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
135use substrate::{
136 ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
137 GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
138 GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
139 SqliteGraphStore, SqliteProjectionRefresh, TerseGraphEdge as SubstrateTerseGraphEdge,
140 TerseGraphNode as SubstrateTerseGraphNode,
141};
142use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
143#[cfg(test)]
144use tsift_agent_doc::session_cost;
145use tsift_agent_doc::session_markdown::{self, AgentDocQueueItem, AgentDocSessionDocument};
146#[cfg(test)]
147use tsift_agent_doc::session_review;
148use tsift_cache::cycle_packet_cache;
149use tsift_core::{
150 NeighborhoodScoring, RankedNeighborhoodOptions, SemanticSeededNeighborhoodOptions,
151};
152use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
153use tsift_graph as graph;
154use tsift_index::{config, index, init, multiplicity, walk};
155use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
156#[cfg(test)]
157use tsift_memory::MemoryEvent;
158use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
159use tsift_resolution as resolution;
160use tsift_search::{impact, sift};
161use tsift_sqlite as substrate;
162use tsift_status::status;
163use tsift_summarize::summarize;
164#[cfg(feature = "backend-surrealdb")]
165use tsift_surrealdb::SurrealdbGraphStore;
166use tsift_tokensave::TokensaveDb;
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
169pub(crate) enum GraphDbExperimentalBackend {
170 DuckdbDuckpgq,
171 Falkordb,
172 Ladybug,
173 Kuzu,
174 Surrealdb,
175}
176
177#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
178pub(crate) struct SearchFacetFilters {
179 #[serde(skip_serializing_if = "Vec::is_empty", default)]
180 pub(crate) languages: Vec<String>,
181 #[serde(skip_serializing_if = "Vec::is_empty", default)]
182 pub(crate) kinds: Vec<String>,
183 #[serde(skip_serializing_if = "Vec::is_empty", default)]
184 pub(crate) node_kinds: Vec<String>,
185 #[serde(skip_serializing_if = "Vec::is_empty", default)]
186 pub(crate) sections: Vec<String>,
187 #[serde(skip_serializing_if = "Vec::is_empty", default)]
188 pub(crate) parents: Vec<String>,
189 #[serde(skip_serializing_if = "Vec::is_empty", default)]
190 pub(crate) children: Vec<String>,
191 #[serde(skip_serializing_if = "Vec::is_empty", default)]
192 pub(crate) fence_languages: Vec<String>,
193 #[serde(skip_serializing_if = "Vec::is_empty", default)]
194 pub(crate) list_depths: Vec<usize>,
195 #[serde(skip_serializing_if = "Vec::is_empty", default)]
196 pub(crate) heading_levels: Vec<usize>,
197}
198
199impl SearchFacetFilters {
200 pub(crate) fn is_empty(&self) -> bool {
201 self.languages.is_empty()
202 && self.kinds.is_empty()
203 && self.node_kinds.is_empty()
204 && self.sections.is_empty()
205 && self.parents.is_empty()
206 && self.children.is_empty()
207 && self.fence_languages.is_empty()
208 && self.list_depths.is_empty()
209 && self.heading_levels.is_empty()
210 }
211
212 fn needs_ast_context(&self) -> bool {
213 !self.sections.is_empty()
214 || !self.parents.is_empty()
215 || !self.children.is_empty()
216 || !self.fence_languages.is_empty()
217 || !self.list_depths.is_empty()
218 || !self.heading_levels.is_empty()
219 }
220}
221
222#[derive(Serialize)]
223struct GraphDbBackendPromotionGate {
224 status: String,
225 native_adapter_required: bool,
226 required_checks: Vec<String>,
227}
228
229impl GraphDbExperimentalBackend {
230 fn name(self) -> &'static str {
231 match self {
232 Self::DuckdbDuckpgq => "duckdb-duckpgq",
233 Self::Falkordb => "falkordb",
234 Self::Ladybug => "ladybug",
235 Self::Kuzu => "kuzu",
236 Self::Surrealdb => "surrealdb",
237 }
238 }
239
240 fn adapter_label(self) -> &'static str {
241 match self {
242 Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
243 Self::Falkordb => "FalkorDB read-only prototype",
244 Self::Ladybug => "Ladybug read-only prototype",
245 Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
246 Self::Surrealdb => "SurrealDB read-only prototype",
247 }
248 }
249
250 fn projection_load(self) -> &'static str {
251 match self {
252 Self::Falkordb => {
253 "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"
254 }
255 Self::Kuzu => {
256 "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"
257 }
258 Self::Surrealdb => {
259 "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"
260 }
261 _ => {
262 "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
263 }
264 }
265 }
266
267 fn lock_behavior(self) -> &'static str {
268 match self {
269 Self::Falkordb => {
270 "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
271 }
272 Self::Kuzu => {
273 "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"
274 }
275 Self::Surrealdb => {
276 "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
277 }
278 _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
279 }
280 }
281
282 fn install_portability(self) -> &'static str {
283 match self {
284 Self::Falkordb => {
285 "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
286 }
287 Self::Kuzu => {
288 "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"
289 }
290 Self::Surrealdb => {
291 "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"
292 }
293 _ => {
294 "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
295 }
296 }
297 }
298
299 fn prototype_hold_reason(self) -> Option<&'static str> {
300 match self {
301 Self::DuckdbDuckpgq => Some(
302 "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
303 ),
304 Self::Falkordb => Some(
305 "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",
306 ),
307 Self::Ladybug => Some(
308 "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
309 ),
310 Self::Kuzu => Some(
311 "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
312 ),
313 Self::Surrealdb => Some(
314 "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",
315 ),
316 }
317 }
318
319 fn promotion_gate(self) -> GraphDbBackendPromotionGate {
320 match self {
321 Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
322 status: "hold_native_adapter_required".to_string(),
323 native_adapter_required: true,
324 required_checks: vec![
325 "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
326 .to_string(),
327 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
328 .to_string(),
329 "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
330 "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
331 .to_string(),
332 ],
333 },
334 Self::Falkordb => GraphDbBackendPromotionGate {
335 status: "hold_native_adapter_required".to_string(),
336 native_adapter_required: true,
337 required_checks: vec![
338 "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
339 .to_string(),
340 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
341 .to_string(),
342 "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
343 .to_string(),
344 "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
345 .to_string(),
346 ],
347 },
348 Self::Ladybug => GraphDbBackendPromotionGate {
349 status: "hold_native_adapter_required".to_string(),
350 native_adapter_required: true,
351 required_checks: vec![
352 "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
353 .to_string(),
354 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
355 .to_string(),
356 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
357 .to_string(),
358 "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
359 .to_string(),
360 ],
361 },
362 Self::Kuzu => GraphDbBackendPromotionGate {
363 status: "hold_native_adapter_required".to_string(),
364 native_adapter_required: true,
365 required_checks: vec![
366 "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
367 .to_string(),
368 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
369 .to_string(),
370 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
371 .to_string(),
372 "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
373 .to_string(),
374 ],
375 },
376 Self::Surrealdb => GraphDbBackendPromotionGate {
377 status: "hold_native_adapter_required".to_string(),
378 native_adapter_required: true,
379 required_checks: vec![
380 "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
381 .to_string(),
382 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
383 .to_string(),
384 "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
385 .to_string(),
386 "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
387 .to_string(),
388 ],
389 },
390 }
391 }
392
393 fn parse(raw: &str) -> Result<Self> {
394 match raw {
395 "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
396 "falkordb" | "falkor" => Ok(Self::Falkordb),
397 "ladybug" => Ok(Self::Ladybug),
398 "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
399 "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
400 _ => {
401 bail!(
402 "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
403 )
404 }
405 }
406 }
407}
408
409pub fn run() -> Result<()> {
410 let cli = Cli::parse();
411 let compact = cli.compact;
412 let pretty = cli.pretty;
413 let terse = cli.terse || cli.ultra_terse;
414 let ultra_terse = cli.ultra_terse;
415 let absolute = cli.absolute;
416 let tabular = cli.tabular;
417 let schema = cli.schema;
418 let envelope = cli.envelope;
419 match cli.command {
420 Some(Commands::Search {
421 query,
422 path,
423 limit,
424 strategy,
425 exact,
426 scope,
427 federated,
428 lang,
429 kind,
430 node_kind,
431 section,
432 parent,
433 child,
434 fence_language,
435 list_depth,
436 heading_level,
437 json,
438 autoindex,
439 no_autoindex,
440 timeout,
441 max_items,
442 max_bytes,
443 budget,
444 no_tagpath,
445 tagpath_strict,
446 }) => cmd_search_with_budget(
447 query,
448 path,
449 limit,
450 if exact {
451 Some("exact".to_string())
452 } else {
453 strategy
454 },
455 scope,
456 federated,
457 json || terse || schema || envelope,
458 autoindex || !no_autoindex,
459 timeout,
460 compact,
461 pretty,
462 terse,
463 ultra_terse,
464 absolute,
465 tabular,
466 schema,
467 envelope,
468 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
469 TagpathSearchOpts {
470 no_tagpath,
471 strict: tagpath_strict,
472 },
473 SearchFacetFilters {
474 languages: lang,
475 kinds: kind,
476 node_kinds: node_kind,
477 sections: section,
478 parents: parent,
479 children: child,
480 fence_languages: fence_language,
481 list_depths: list_depth,
482 heading_levels: heading_level,
483 },
484 ),
485 Some(Commands::SearchWorker {
486 path,
487 cache_dir,
488 query,
489 limit,
490 strategy,
491 output,
492 fts_index_fresh,
493 }) => cmd_search_worker(
494 &path,
495 &cache_dir,
496 &query,
497 limit,
498 &strategy,
499 &output,
500 fts_index_fresh,
501 ),
502 Some(Commands::DigestRunner {
503 kind,
504 path,
505 runner,
506 shell_command,
507 json,
508 }) => cmd_digest_runner(
509 &kind,
510 &path,
511 runner.as_deref(),
512 &shell_command,
513 OutputFormat {
514 json_output: json || terse || schema || envelope,
515 compact,
516 pretty,
517 terse,
518 ultra_terse,
519 schema,
520 envelope,
521 },
522 ),
523 Some(Commands::Edit { dry_run, file }) => {
524 cmd_edit(dry_run, file, compact, pretty, terse, schema)
525 }
526 Some(Commands::EditIntents {
527 path,
528 scope,
529 file,
530 json,
531 apply,
532 verify,
533 verify_command,
534 max_items,
535 max_bytes,
536 budget,
537 }) => cmd_edit_intents(
538 &path,
539 scope.as_deref(),
540 file,
541 apply,
542 SemanticEditVerifyOptions {
543 enabled: verify,
544 command: verify_command.as_deref(),
545 },
546 OutputFormat {
547 json_output: json || terse || schema || envelope,
548 compact,
549 pretty,
550 terse,
551 ultra_terse,
552 schema,
553 envelope,
554 },
555 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
556 ),
557 Some(Commands::Index {
558 path,
559 rebuild,
560 check,
561 exit_code,
562 prune,
563 quiet,
564 workspace,
565 submodule,
566 json,
567 }) => cmd_index(
568 &path,
569 rebuild,
570 check,
571 exit_code,
572 prune,
573 quiet,
574 workspace,
575 submodule.as_deref(),
576 json || terse || schema || envelope,
577 compact,
578 pretty,
579 terse,
580 absolute,
581 schema,
582 ),
583 Some(Commands::Rewrite { command, run }) => cmd_rewrite(
584 &command,
585 run,
586 OutputFormat {
587 json_output: terse || schema || envelope,
588 compact,
589 pretty,
590 terse,
591 ultra_terse,
592 schema,
593 envelope,
594 },
595 ),
596 Some(Commands::Route { task, id }) => cmd_route(&task, id),
597 Some(Commands::Memory { command }) => {
598 let json = command.json_output();
599 cmd_memory(
600 command,
601 OutputFormat {
602 json_output: json || terse || schema || envelope,
603 compact,
604 pretty,
605 terse,
606 ultra_terse,
607 schema,
608 envelope,
609 },
610 )
611 }
612 Some(Commands::LocalModel { command }) => {
613 let json = command.json_output();
614 cmd_local_model(
615 command,
616 OutputFormat {
617 json_output: json || terse || schema || envelope,
618 compact,
619 pretty,
620 terse,
621 ultra_terse,
622 schema,
623 envelope,
624 },
625 )
626 }
627 Some(Commands::Kg { command }) => match command {
628 KgCommand::Extract {
629 profile,
630 model,
631 host,
632 input,
633 source_ref,
634 graph_db,
635 no_lease,
636 idle_ttl_seconds,
637 keep_loaded,
638 lease_file,
639 no_context,
640 json,
641 } => commands::kg::cmd_kg_extract(commands::kg::KgExtractArgs {
642 profile,
643 model,
644 host,
645 input,
646 source_ref,
647 graph_db,
648 no_lease,
649 idle_ttl_seconds,
650 keep_loaded,
651 lease_file,
652 no_context,
653 json: json || terse || schema || envelope,
654 }),
655 KgCommand::Status { graph_db, json } => {
656 commands::kg::cmd_kg_status(graph_db, json || terse || schema || envelope)
657 }
658 KgCommand::Refresh {
659 graph_db,
660 apply,
661 profile,
662 model,
663 host,
664 no_lease,
665 idle_ttl_seconds,
666 keep_loaded,
667 lease_file,
668 no_context,
669 json,
670 } => commands::kg::cmd_kg_refresh(commands::kg::KgRefreshArgs {
671 graph_db,
672 json: json || terse || schema || envelope,
673 apply,
674 profile,
675 model,
676 host,
677 no_lease,
678 idle_ttl_seconds,
679 keep_loaded,
680 lease_file,
681 no_context,
682 }),
683 KgCommand::Evidence {
684 symbol,
685 kind,
686 limit,
687 graph_db,
688 json,
689 } => commands::kg::cmd_kg_evidence(
690 symbol,
691 kind,
692 limit,
693 graph_db,
694 json || terse || schema || envelope,
695 ),
696 KgCommand::Unload {
697 profile,
698 model,
699 host,
700 json,
701 } => commands::kg::cmd_kg_unload(
702 profile,
703 model,
704 host,
705 json || terse || schema || envelope,
706 ),
707 KgCommand::Smoke {
708 profile,
709 model,
710 host,
711 unload,
712 json,
713 } => commands::kg::cmd_kg_smoke(
714 profile,
715 model,
716 host,
717 unload,
718 json || terse || schema || envelope,
719 ),
720 },
721 Some(Commands::Finding { command }) => match command {
722 cli::FindingCommand::Add {
723 path,
724 kind,
725 title,
726 body,
727 about,
728 confidence,
729 status,
730 relates,
731 scope,
732 json,
733 } => commands::finding::cmd_finding_add(
734 &path,
735 &kind,
736 &title,
737 &body,
738 &about,
739 confidence,
740 &status,
741 relates.as_deref(),
742 scope.as_deref(),
743 json || terse || schema || envelope,
744 pretty,
745 ),
746 cli::FindingCommand::List {
747 path,
748 about,
749 kind,
750 status,
751 include_stale,
752 scope,
753 json,
754 } => commands::finding::cmd_finding_list(
755 &path,
756 about.as_deref(),
757 kind.as_deref(),
758 status.as_deref(),
759 include_stale,
760 scope.as_deref(),
761 json || terse || schema || envelope,
762 pretty,
763 ),
764 cli::FindingCommand::Harvest { path, scope, json } => {
765 commands::finding::cmd_finding_harvest(
766 &path,
767 scope.as_deref(),
768 json || terse || schema || envelope,
769 pretty,
770 )
771 }
772 cli::FindingCommand::Promote { id, path, json } => {
773 commands::finding::cmd_finding_promote(
774 &path,
775 &id,
776 json || terse || schema || envelope,
777 pretty,
778 )
779 }
780 },
781 Some(Commands::Graph {
782 symbol,
783 path,
784 callers,
785 callees,
786 scope,
787 limit,
788 json,
789 no_tagpath,
790 tagpath_strict,
791 }) => cmd_graph(
792 &symbol,
793 &path,
794 callers,
795 callees,
796 scope.as_deref(),
797 limit,
798 json || terse || schema || envelope,
799 compact,
800 pretty,
801 terse,
802 absolute,
803 tabular,
804 schema,
805 TagpathSearchOpts {
806 no_tagpath,
807 strict: tagpath_strict,
808 },
809 ),
810 Some(Commands::Sql {
811 db,
812 query,
813 table,
814 json,
815 }) => cmd_sql(
816 &db,
817 query,
818 table,
819 json || terse || schema || envelope,
820 compact,
821 pretty,
822 terse,
823 schema,
824 ),
825 Some(Commands::Communities {
826 path,
827 scope,
828 min_size,
829 limit,
830 json,
831 no_tagpath,
832 tagpath_strict,
833 }) => cmd_communities(
834 &path,
835 scope.as_deref(),
836 min_size,
837 limit,
838 json || terse || schema || envelope,
839 compact,
840 pretty,
841 terse,
842 tabular,
843 schema,
844 TagpathSearchOpts {
845 no_tagpath,
846 strict: tagpath_strict,
847 },
848 ),
849 Some(Commands::Analyze {
850 path,
851 scope,
852 entry_points,
853 limit,
854 json,
855 }) => cmd_analyze(
856 &path,
857 scope.as_deref(),
858 &entry_points,
859 limit,
860 OutputFormat {
861 json_output: json || terse || schema || envelope,
862 compact,
863 pretty,
864 terse,
865 ultra_terse,
866 schema,
867 envelope,
868 },
869 ),
870 Some(Commands::Path {
871 from,
872 to,
873 path,
874 scope,
875 json,
876 no_tagpath,
877 tagpath_strict,
878 }) => cmd_path(
879 &from,
880 &to,
881 &path,
882 scope.as_deref(),
883 json || terse || schema || envelope,
884 compact,
885 pretty,
886 terse,
887 schema,
888 TagpathSearchOpts {
889 no_tagpath,
890 strict: tagpath_strict,
891 },
892 ),
893 Some(Commands::Explain {
894 symbol,
895 path,
896 scope,
897 limit,
898 json,
899 max_items,
900 max_bytes,
901 budget,
902 no_tagpath,
903 tagpath_strict,
904 }) => cmd_explain_with_budget(
905 &symbol,
906 &path,
907 scope.as_deref(),
908 limit,
909 json || terse || schema || envelope,
910 compact,
911 pretty,
912 terse,
913 ultra_terse,
914 absolute,
915 tabular,
916 schema,
917 envelope,
918 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
919 TagpathSearchOpts {
920 no_tagpath,
921 strict: tagpath_strict,
922 },
923 ),
924 Some(Commands::Traverse {
925 node,
926 to,
927 path,
928 scope,
929 depth,
930 limit,
931 format,
932 convex_snapshot,
933 }) => cmd_traverse(
934 node.as_deref(),
935 to.as_deref(),
936 &path,
937 scope.as_deref(),
938 depth,
939 limit,
940 format,
941 pretty,
942 terse,
943 schema,
944 convex_snapshot.as_deref(),
945 ),
946 Some(Commands::ConvexSync {
947 path,
948 scope,
949 snapshot,
950 chunk_size,
951 remote_snapshot,
952 apply,
953 endpoint,
954 auth_token_env,
955 json,
956 }) => cmd_convex_sync(
957 ConvexSyncOptions {
958 path: &path,
959 scope: scope.as_deref(),
960 snapshot: snapshot.as_deref(),
961 chunk_size,
962 remote_snapshot,
963 apply,
964 endpoint: endpoint.as_deref(),
965 auth_token_env: &auth_token_env,
966 },
967 OutputFormat {
968 json_output: json || terse || schema || envelope,
969 compact,
970 pretty,
971 terse,
972 ultra_terse,
973 schema,
974 envelope,
975 },
976 ),
977 Some(Commands::GraphDb {
978 path,
979 scope,
980 backend,
981 convex_snapshot,
982 json,
983 query,
984 }) => cmd_graph_db(
985 &path,
986 scope.as_deref(),
987 backend,
988 convex_snapshot.as_deref(),
989 query,
990 OutputFormat {
991 json_output: json || terse || schema || envelope,
992 compact,
993 pretty,
994 terse,
995 ultra_terse,
996 schema,
997 envelope,
998 },
999 ),
1000 Some(Commands::SourceRead {
1001 file,
1002 path,
1003 style,
1004 start,
1005 lines,
1006 end,
1007 scope,
1008 json,
1009 max_items,
1010 max_bytes,
1011 budget,
1012 }) => cmd_source_read(
1013 &file,
1014 &path,
1015 style,
1016 start,
1017 lines,
1018 end,
1019 scope.as_deref(),
1020 OutputFormat {
1021 json_output: json || terse || schema || envelope,
1022 compact,
1023 pretty,
1024 terse,
1025 ultra_terse,
1026 schema,
1027 envelope,
1028 },
1029 absolute,
1030 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1031 ),
1032 Some(Commands::MarkdownAst {
1033 file,
1034 path,
1035 node,
1036 json,
1037 max_items,
1038 max_bytes,
1039 budget,
1040 }) => cmd_markdown_ast(
1041 &file,
1042 &path,
1043 node.as_deref(),
1044 OutputFormat {
1045 json_output: json || terse || schema || envelope,
1046 compact,
1047 pretty,
1048 terse,
1049 ultra_terse,
1050 schema,
1051 envelope,
1052 },
1053 absolute,
1054 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1055 ),
1056 Some(Commands::SymbolRead {
1057 symbol,
1058 file,
1059 path,
1060 scope,
1061 json,
1062 max_items,
1063 max_bytes,
1064 budget,
1065 }) => cmd_symbol_read(
1066 &symbol,
1067 file.as_deref(),
1068 &path,
1069 scope.as_deref(),
1070 OutputFormat {
1071 json_output: json || terse || schema || envelope,
1072 compact,
1073 pretty,
1074 terse,
1075 ultra_terse,
1076 schema,
1077 envelope,
1078 },
1079 absolute,
1080 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1081 ),
1082 Some(Commands::Audit {
1083 skills_dir,
1084 manifest,
1085 usage,
1086 cleanup,
1087 report,
1088 json,
1089 }) => cmd_audit(
1090 &skills_dir,
1091 manifest,
1092 usage,
1093 cleanup,
1094 report,
1095 json || terse || schema || envelope,
1096 compact,
1097 pretty,
1098 terse,
1099 schema,
1100 ),
1101 Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
1102 &path,
1103 scope.as_deref(),
1104 json || terse || schema || envelope,
1105 pretty,
1106 terse,
1107 schema,
1108 ),
1109 Some(Commands::Init {
1110 path,
1111 codex,
1112 opencode,
1113 workspace,
1114 }) => cmd_init(&path, codex, opencode, workspace),
1115 Some(Commands::Lint {
1116 file,
1117 index,
1118 entities_from,
1119 json,
1120 }) => cmd_lint(
1121 &file,
1122 index,
1123 entities_from,
1124 json || terse || schema || envelope,
1125 compact,
1126 pretty,
1127 terse,
1128 schema,
1129 ),
1130 Some(Commands::Summarize {
1131 symbol,
1132 file,
1133 extract,
1134 diff,
1135 stats,
1136 path,
1137 profile,
1138 json,
1139 }) => cmd_summarize(
1140 symbol,
1141 file,
1142 extract,
1143 diff,
1144 stats,
1145 &path,
1146 json || terse || schema || envelope,
1147 compact,
1148 pretty,
1149 terse,
1150 schema,
1151 profile,
1152 ),
1153 Some(Commands::Semantic {
1154 query,
1155 path,
1156 scope,
1157 limit,
1158 kind,
1159 profile,
1160 json,
1161 }) => cmd_semantic_related(
1162 &query,
1163 &path,
1164 scope.as_deref(),
1165 limit,
1166 kind,
1167 json || terse || schema || envelope,
1168 compact,
1169 pretty,
1170 terse,
1171 schema,
1172 profile,
1173 ),
1174 Some(Commands::DiffDigest {
1175 path,
1176 cached,
1177 revision,
1178 max_parsed_files,
1179 json,
1180 }) => cmd_diff_digest(
1181 &path,
1182 cached,
1183 revision.as_deref(),
1184 max_parsed_files,
1185 OutputFormat {
1186 json_output: json || terse || schema || envelope,
1187 compact,
1188 pretty,
1189 terse,
1190 ultra_terse,
1191 schema,
1192 envelope,
1193 },
1194 ),
1195 Some(Commands::Impact {
1196 path,
1197 cached,
1198 revision,
1199 scope,
1200 limit,
1201 json,
1202 }) => cmd_impact(
1203 &path,
1204 cached,
1205 revision.as_deref(),
1206 scope.as_deref(),
1207 limit,
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::TestDigest {
1219 path,
1220 input,
1221 runner,
1222 json,
1223 }) => cmd_test_digest(
1224 &path,
1225 input.as_deref(),
1226 runner.as_deref(),
1227 OutputFormat {
1228 json_output: json || terse || schema || envelope,
1229 compact,
1230 pretty,
1231 terse,
1232 ultra_terse,
1233 schema,
1234 envelope,
1235 },
1236 ),
1237 Some(Commands::LogDigest {
1238 path,
1239 input,
1240 fixture,
1241 fail_under,
1242 json,
1243 }) => cmd_log_digest(
1244 &path,
1245 input.as_deref(),
1246 fixture.as_deref(),
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::ContextPack {
1259 path,
1260 test_input,
1261 runner,
1262 log_input,
1263 json,
1264 max_items,
1265 max_bytes,
1266 budget,
1267 convex_snapshot,
1268 }) => cmd_context_pack(
1269 &path,
1270 test_input.as_deref(),
1271 runner.as_deref(),
1272 log_input.as_deref(),
1273 OutputFormat {
1274 json_output: json || terse || schema || envelope,
1275 compact,
1276 pretty,
1277 terse,
1278 ultra_terse,
1279 schema,
1280 envelope,
1281 },
1282 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1283 convex_snapshot.as_deref(),
1284 ),
1285 Some(Commands::ConflictMatrix {
1286 targets,
1287 path,
1288 scope,
1289 depth,
1290 limit,
1291 impact_limit,
1292 json,
1293 }) => cmd_conflict_matrix(
1294 &path,
1295 scope.as_deref(),
1296 &targets,
1297 depth,
1298 limit,
1299 impact_limit,
1300 OutputFormat {
1301 json_output: json || terse || schema || envelope,
1302 compact,
1303 pretty,
1304 terse,
1305 ultra_terse,
1306 schema,
1307 envelope,
1308 },
1309 ),
1310 Some(Commands::DispatchTrace {
1311 targets,
1312 path,
1313 scope,
1314 depth,
1315 limit,
1316 impact_limit,
1317 format,
1318 json,
1319 }) => cmd_dispatch_trace(
1320 DispatchTraceOptions {
1321 path: &path,
1322 scope: scope.as_deref(),
1323 raw_targets: &targets,
1324 depth,
1325 limit,
1326 impact_limit,
1327 trace_format: if json {
1328 DispatchTraceFormat::Json
1329 } else {
1330 format
1331 },
1332 },
1333 OutputFormat {
1334 json_output: json || terse || schema || envelope,
1335 compact,
1336 pretty,
1337 terse,
1338 ultra_terse,
1339 schema,
1340 envelope,
1341 },
1342 ),
1343 Some(Commands::DependencyDag {
1344 targets,
1345 path,
1346 scope,
1347 depth,
1348 limit,
1349 json,
1350 }) => cmd_dependency_dag(
1351 &path,
1352 scope.as_deref(),
1353 &targets,
1354 depth,
1355 limit,
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::TokenSavings {
1367 fixture,
1368 fail_under,
1369 json,
1370 }) => token_savings::cmd_token_savings(
1371 &fixture,
1372 fail_under,
1373 OutputFormat {
1374 json_output: json || terse || schema || envelope,
1375 compact,
1376 pretty,
1377 terse,
1378 ultra_terse,
1379 schema,
1380 envelope,
1381 },
1382 ),
1383 Some(Commands::MetricDigest {
1384 input,
1385 baseline,
1386 metrics,
1387 lower_is_better,
1388 higher_is_better,
1389 history,
1390 top,
1391 json,
1392 }) => cmd_metric_digest(
1393 MetricDigestOptions {
1394 input_path: input.as_deref(),
1395 baseline_path: baseline.as_deref(),
1396 metrics: &metrics,
1397 lower_is_better: &lower_is_better,
1398 higher_is_better: &higher_is_better,
1399 history,
1400 top,
1401 },
1402 OutputFormat {
1403 json_output: json || terse || schema || envelope,
1404 compact,
1405 pretty,
1406 terse,
1407 ultra_terse,
1408 schema,
1409 envelope,
1410 },
1411 ),
1412 Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1413 &fixture,
1414 OutputFormat {
1415 json_output: json || terse || schema || envelope,
1416 compact,
1417 pretty,
1418 terse,
1419 ultra_terse,
1420 schema,
1421 envelope,
1422 },
1423 ),
1424 Some(Commands::TokenGate { command }) => {
1425 cmd_token_gate(
1426 command,
1427 OutputFormat {
1428 json_output: true,
1429 compact,
1430 pretty,
1431 terse,
1432 ultra_terse,
1433 schema,
1434 envelope,
1435 },
1436 )?;
1437 Ok(())
1438 }
1439 Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1440 &topic,
1441 OutputFormat {
1442 json_output: json || terse || schema || envelope,
1443 compact,
1444 pretty,
1445 terse,
1446 ultra_terse,
1447 schema,
1448 envelope,
1449 },
1450 ),
1451 Some(Commands::SessionDigest {
1452 path,
1453 input,
1454 source,
1455 json,
1456 }) => cmd_session_digest(
1457 &path,
1458 input.as_deref(),
1459 source.as_deref(),
1460 OutputFormat {
1461 json_output: json || terse || schema || envelope,
1462 compact,
1463 pretty,
1464 terse,
1465 ultra_terse,
1466 schema,
1467 envelope,
1468 },
1469 ),
1470 Some(Commands::SessionCost {
1471 input,
1472 fixture,
1473 fail_under,
1474 source,
1475 json,
1476 }) => cmd_session_cost(
1477 input.as_deref(),
1478 fixture.as_deref(),
1479 fail_under,
1480 source.as_deref(),
1481 OutputFormat {
1482 json_output: json || terse || schema || envelope,
1483 compact,
1484 pretty,
1485 terse,
1486 ultra_terse,
1487 schema,
1488 envelope,
1489 },
1490 ),
1491 Some(Commands::SessionReview {
1492 path,
1493 next_context,
1494 json,
1495 max_items,
1496 max_bytes,
1497 budget,
1498 }) => cmd_session_review_with_budget(
1499 &path,
1500 next_context,
1501 OutputFormat {
1502 json_output: json || terse || schema || envelope,
1503 compact,
1504 pretty,
1505 terse,
1506 ultra_terse,
1507 schema,
1508 envelope,
1509 },
1510 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1511 ),
1512 Some(Commands::Status {
1513 path,
1514 fix,
1515 no_fix,
1516 json,
1517 }) => cmd_status(
1518 &path,
1519 StatusCommandOptions {
1520 fix,
1521 no_fix,
1522 json_output: json || terse || schema || envelope,
1523 compact,
1524 pretty,
1525 terse,
1526 schema,
1527 },
1528 ),
1529 Some(Commands::Locks { path, scope, json }) => cmd_locks(
1530 &path,
1531 scope.as_deref(),
1532 json || terse || schema || envelope,
1533 compact,
1534 pretty,
1535 terse,
1536 schema,
1537 ),
1538 None => {
1539 println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1540 println!("Run `tsift --help` for usage.");
1541 Ok(())
1542 }
1543 }
1544}
1545
1546fn cmd_local_model(command: LocalModelCommand, output: OutputFormat) -> Result<()> {
1547 match command {
1548 LocalModelCommand::Status { no_probe, .. } => {
1549 let report = tsift_local_model::build_status_report(!no_probe);
1550 if output.json_output {
1551 if output.pretty {
1552 println!("{}", serde_json::to_string_pretty(&report)?);
1553 } else {
1554 println!("{}", serde_json::to_string(&report)?);
1555 }
1556 } else {
1557 print!("{}", tsift_local_model::format_status_human(&report));
1558 }
1559 Ok(())
1560 }
1561 LocalModelCommand::Unload {
1562 profile,
1563 provider_endpoint,
1564 provider_pid,
1565 idle_ttl_seconds,
1566 no_probe,
1567 pre_used_mib,
1568 post_used_mib,
1569 tolerance_mib,
1570 strict,
1571 ..
1572 } => {
1573 let profile = tsift_local_model::profile_by_id(&profile)
1574 .with_context(|| format!("unknown local model profile {profile:?}"))?;
1575 let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1576 let post_probe =
1577 lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1578 let report = tsift_local_model::build_lifecycle_report(
1579 profile,
1580 pre_probe,
1581 post_probe,
1582 provider_endpoint,
1583 provider_pid,
1584 idle_ttl_seconds,
1585 tolerance_mib,
1586 );
1587 if output.json_output {
1588 if output.pretty {
1589 println!("{}", serde_json::to_string_pretty(&report)?);
1590 } else {
1591 println!("{}", serde_json::to_string(&report)?);
1592 }
1593 } else {
1594 print!("{}", tsift_local_model::format_lifecycle_human(&report));
1595 }
1596 if strict && !report.cleanup.cleanup_proven {
1597 bail!(
1598 "local model VRAM cleanup was not proven: {}",
1599 report.cleanup.reason
1600 );
1601 }
1602 Ok(())
1603 }
1604 LocalModelCommand::Lease { command } => cmd_local_model_lease(command, output),
1605 LocalModelCommand::Resolve {
1606 profile,
1607 role,
1608 no_probe,
1609 ..
1610 } => {
1611 let preference_value = profile.as_deref();
1612 let preference = tsift_local_model::ProfilePreference::from_cli(preference_value);
1613 let probe = if no_probe {
1614 tsift_local_model::GpuProbe::unavailable("gpu probe skipped")
1615 } else {
1616 tsift_local_model::probe_nvidia_smi()
1617 };
1618 let resolution = tsift_local_model::resolve_profile_preference(
1619 &preference,
1620 role.to_model_role(),
1621 &probe,
1622 );
1623 if output.json_output {
1624 if output.pretty {
1625 println!("{}", serde_json::to_string_pretty(&resolution)?);
1626 } else {
1627 println!("{}", serde_json::to_string(&resolution)?);
1628 }
1629 } else {
1630 println!(
1631 "preference: {} | role: {:?}",
1632 preference.describe(),
1633 role.to_model_role()
1634 );
1635 println!(
1636 "selected: {} ({})",
1637 resolution.profile.id, resolution.profile.label
1638 );
1639 println!("selectable: {}", resolution.selectable);
1640 println!("source: {:?}", resolution.source);
1641 println!("reason: {}", resolution.reason);
1642 }
1643 Ok(())
1644 }
1645 LocalModelCommand::Swap {
1646 from,
1647 to,
1648 provider_endpoint,
1649 provider_pid,
1650 idle_ttl_seconds,
1651 no_probe,
1652 pre_used_mib,
1653 post_used_mib,
1654 tolerance_mib,
1655 strict,
1656 ..
1657 } => {
1658 let from_profile = tsift_local_model::profile_by_id(&from)
1659 .with_context(|| format!("unknown source local model profile {from:?}"))?;
1660 let to_profile = tsift_local_model::profile_by_id(&to)
1661 .with_context(|| format!("unknown target local model profile {to:?}"))?;
1662 let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1663 let post_probe =
1664 lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1665 let report = tsift_local_model::build_swap_report(
1666 from_profile,
1667 to_profile,
1668 pre_probe,
1669 post_probe,
1670 provider_endpoint,
1671 provider_pid,
1672 idle_ttl_seconds,
1673 tolerance_mib,
1674 );
1675 if output.json_output {
1676 if output.pretty {
1677 println!("{}", serde_json::to_string_pretty(&report)?);
1678 } else {
1679 println!("{}", serde_json::to_string(&report)?);
1680 }
1681 } else {
1682 println!(
1683 "swap: {} -> {} | status: {:?}",
1684 report.from_profile_id, report.to_profile_id, report.swap_status
1685 );
1686 println!(
1687 "unload cleanup: {:?} ({})",
1688 report.unload.cleanup.status, report.unload.cleanup.reason
1689 );
1690 println!(
1691 "target resolution: {:?} -> {} (selectable: {})",
1692 report.target_resolution.source,
1693 report.target_resolution.profile.id,
1694 report.target_resolution.selectable
1695 );
1696 for note in &report.notes {
1697 println!("note: {note}");
1698 }
1699 }
1700 if strict {
1701 match report.swap_status {
1702 tsift_local_model::SwapStatus::UnloadNotProven => {
1703 bail!(
1704 "swap blocked: source unload cleanup was not proven ({})",
1705 report.unload.cleanup.reason
1706 );
1707 }
1708 tsift_local_model::SwapStatus::UnloadProvenTargetUnselectable => {
1709 bail!(
1710 "swap blocked: target {} is not selectable on the post-unload probe",
1711 report.to_profile_id
1712 );
1713 }
1714 _ => {}
1715 }
1716 }
1717 Ok(())
1718 }
1719 }
1720}
1721
1722fn unload_profile_model(
1729 profile_id: &str,
1730 host: Option<&str>,
1731) -> tsift_local_model::UnloadActionResult {
1732 let endpoint = tsift_local_model::resolve_provider_endpoint(
1733 &tsift_local_model::UnloadStrategy::OllamaKeepAliveZero,
1734 host,
1735 );
1736 match tsift_local_model::profile_by_id(profile_id) {
1737 Some(profile) => tsift_local_model::unload_model_at(&endpoint, profile.model_ref),
1738 None => tsift_local_model::unload_model_at(&endpoint, profile_id),
1739 }
1740}
1741
1742fn cmd_local_model_lease(command: LeaseCommand, output: OutputFormat) -> Result<()> {
1743 use tsift_local_model::{
1744 acquire_lease, current_unix_seconds, format_lease_show_human, lease_mode_for_profile,
1745 profile_by_id, reap_leases, release_lease, renew_lease, resolve_lease_file, show_registry,
1746 };
1747 let now = current_unix_seconds();
1748 match command {
1749 LeaseCommand::Acquire {
1750 profile,
1751 holder_pid,
1752 holder_command,
1753 idle_ttl_seconds,
1754 vram_baseline_mib,
1755 no_probe,
1756 lease_file,
1757 strict,
1758 ..
1759 } => {
1760 let profile_lookup = profile_by_id(&profile)
1761 .with_context(|| format!("unknown local model profile {profile:?}"))?;
1762 let _ = lease_mode_for_profile(&profile_lookup);
1764 let pid = holder_pid.unwrap_or_else(std::process::id);
1765 let baseline = match vram_baseline_mib {
1766 Some(value) => value,
1767 None => {
1768 if no_probe {
1769 0
1770 } else {
1771 let probe = tsift_local_model::probe_nvidia_smi();
1772 probe.used_vram_mib.unwrap_or(0)
1773 }
1774 }
1775 };
1776 let path = resolve_lease_file(lease_file.as_deref());
1777 let acquisition = acquire_lease(
1778 &profile,
1779 pid,
1780 &holder_command,
1781 baseline,
1782 idle_ttl_seconds,
1783 now,
1784 &path,
1785 )?;
1786 if output.json_output {
1787 if output.pretty {
1788 println!("{}", serde_json::to_string_pretty(&acquisition)?);
1789 } else {
1790 println!("{}", serde_json::to_string(&acquisition)?);
1791 }
1792 } else {
1793 println!(
1794 "lease {} for {} (pid={}): {:?}",
1795 match acquisition.status {
1796 tsift_local_model::GpuLeaseAcquisitionStatus::Acquired => "acquired",
1797 tsift_local_model::GpuLeaseAcquisitionStatus::Refreshed => "refreshed",
1798 tsift_local_model::GpuLeaseAcquisitionStatus::ReclaimedStale => {
1799 "reclaimed-stale"
1800 }
1801 tsift_local_model::GpuLeaseAcquisitionStatus::CpuOrHashBypass => {
1802 "bypass-cpu-or-hash"
1803 }
1804 tsift_local_model::GpuLeaseAcquisitionStatus::Conflict => "conflicted",
1805 },
1806 acquisition.profile_id,
1807 acquisition.holder_pid,
1808 acquisition.status
1809 );
1810 if let Some(conflict) = &acquisition.conflict {
1811 println!(
1812 "held by pid={} cmd={} acquired {}s ago",
1813 conflict.holder_pid,
1814 conflict.holder_command,
1815 now.saturating_sub(conflict.acquired_at_unix_seconds)
1816 );
1817 }
1818 println!("registry: {}", path.display());
1819 }
1820 if strict
1821 && acquisition.status == tsift_local_model::GpuLeaseAcquisitionStatus::Conflict
1822 {
1823 bail!(
1824 "gpu lease for {profile:?} is held by pid={}",
1825 acquisition
1826 .conflict
1827 .map(|conflict| conflict.holder_pid.to_string())
1828 .unwrap_or_else(|| "unknown".to_string())
1829 );
1830 }
1831 Ok(())
1832 }
1833 LeaseCommand::Release {
1834 profile,
1835 holder_pid,
1836 lease_file,
1837 unload_on_last_release,
1838 host,
1839 ..
1840 } => {
1841 let pid = holder_pid.unwrap_or_else(std::process::id);
1842 let path = resolve_lease_file(lease_file.as_deref());
1843 let release = release_lease(&profile, pid, now, &path)?;
1844 let unloaded = if unload_on_last_release
1847 && release.outcome == tsift_local_model::GpuLeaseReleaseOutcome::Released
1848 && release.remaining_holders == 0
1849 {
1850 Some(unload_profile_model(&profile, host.as_deref()))
1851 } else {
1852 None
1853 };
1854 if output.json_output {
1855 let payload = serde_json::json!({
1856 "release": release,
1857 "unloaded": unloaded,
1858 });
1859 if output.pretty {
1860 println!("{}", serde_json::to_string_pretty(&payload)?);
1861 } else {
1862 println!("{}", serde_json::to_string(&payload)?);
1863 }
1864 } else {
1865 println!(
1866 "release {} for {} (pid={}): {:?} (remaining holders: {})",
1867 match release.outcome {
1868 tsift_local_model::GpuLeaseReleaseOutcome::Released => "ok",
1869 tsift_local_model::GpuLeaseReleaseOutcome::NotHeld => "not-held",
1870 tsift_local_model::GpuLeaseReleaseOutcome::ProfileAbsent => "absent",
1871 },
1872 release.profile_id,
1873 release.holder_pid,
1874 release.outcome,
1875 release.remaining_holders
1876 );
1877 if let Some(result) = &unloaded {
1878 println!("unloaded {} (last reference released): {}", profile, result.outcome);
1879 }
1880 println!("registry: {}", path.display());
1881 }
1882 Ok(())
1883 }
1884 LeaseCommand::Renew {
1885 profile,
1886 holder_pid,
1887 lease_file,
1888 ..
1889 } => {
1890 let pid = holder_pid.unwrap_or_else(std::process::id);
1891 let path = resolve_lease_file(lease_file.as_deref());
1892 let renew = renew_lease(&profile, pid, now, &path)?;
1893 if output.json_output {
1894 if output.pretty {
1895 println!("{}", serde_json::to_string_pretty(&renew)?);
1896 } else {
1897 println!("{}", serde_json::to_string(&renew)?);
1898 }
1899 } else {
1900 println!(
1901 "renew {} (pid={}): {:?}",
1902 renew.profile_id, renew.holder_pid, renew.outcome
1903 );
1904 println!("registry: {}", path.display());
1905 }
1906 Ok(())
1907 }
1908 LeaseCommand::Reap {
1909 lease_file,
1910 unload_empty,
1911 host,
1912 ..
1913 } => {
1914 let path = resolve_lease_file(lease_file.as_deref());
1915 let reap = reap_leases(now, &path)?;
1916 let unloaded: Vec<_> = if unload_empty {
1919 reap.emptied_profiles
1920 .iter()
1921 .filter_map(|profile_id| {
1922 profile_by_id(profile_id).map(|profile| {
1923 serde_json::json!({
1924 "profile": profile_id,
1925 "outcome": unload_profile_model(profile_id, host.as_deref()).outcome,
1926 "model": profile.model_ref,
1927 })
1928 })
1929 })
1930 .collect()
1931 } else {
1932 Vec::new()
1933 };
1934 if output.json_output {
1935 let payload = serde_json::json!({
1936 "reap": reap,
1937 "unloaded": unloaded,
1938 });
1939 if output.pretty {
1940 println!("{}", serde_json::to_string_pretty(&payload)?);
1941 } else {
1942 println!("{}", serde_json::to_string(&payload)?);
1943 }
1944 } else {
1945 println!(
1946 "reaped {} stale holder(s); {} profile(s) dropped to zero references",
1947 reap.reclaimed.len(),
1948 reap.emptied_profiles.len()
1949 );
1950 for profile_id in &reap.emptied_profiles {
1951 println!(" emptied: {profile_id}");
1952 }
1953 if !unloaded.is_empty() {
1954 println!("unloaded {} unreferenced model(s)", unloaded.len());
1955 }
1956 println!("registry: {}", path.display());
1957 }
1958 Ok(())
1959 }
1960 LeaseCommand::Show {
1961 lease_file,
1962 include_stale,
1963 ..
1964 } => {
1965 let path = resolve_lease_file(lease_file.as_deref());
1966 let registry = show_registry(&path, now, include_stale)?;
1967 if output.json_output {
1968 if output.pretty {
1969 println!("{}", serde_json::to_string_pretty(®istry)?);
1970 } else {
1971 println!("{}", serde_json::to_string(®istry)?);
1972 }
1973 } else {
1974 print!("{}", format_lease_show_human(®istry, now));
1975 }
1976 println!("registry: {}", path.display());
1977 Ok(())
1978 }
1979 }
1980}
1981
1982fn lifecycle_probe(
1983 no_probe: bool,
1984 synthetic_used_mib: Option<u64>,
1985 skipped_reason: &str,
1986) -> tsift_local_model::GpuProbe {
1987 if let Some(used_mib) = synthetic_used_mib {
1988 return tsift_local_model::GpuProbe::synthetic_vram(used_mib);
1989 }
1990 if no_probe {
1991 return tsift_local_model::GpuProbe::unavailable(skipped_reason);
1992 }
1993 tsift_local_model::probe_nvidia_smi()
1994}
1995
1996pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1999 let lower = task.to_lowercase();
2000 for signal in &[
2002 "architect",
2003 "architecture",
2004 "design",
2005 "plan",
2006 "strateg",
2007 "analy",
2008 "review",
2009 "evaluate",
2010 "assess",
2011 ] {
2012 if lower.contains(signal) {
2013 return ("opus", "claude-opus-4-6");
2014 }
2015 }
2016 for signal in &[
2018 "edit",
2019 "write",
2020 "fix",
2021 "change",
2022 "update",
2023 "create",
2024 "add ",
2025 "remove",
2026 "delete",
2027 "modify",
2028 "refactor",
2029 "implement",
2030 "build",
2031 ] {
2032 if lower.contains(signal) {
2033 return ("sonnet", "claude-sonnet-4-6");
2034 }
2035 }
2036 ("haiku", "claude-haiku-4-5-20251001")
2038}
2039
2040#[cfg(test)]
2041fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
2042 to_json_schema(val, pretty, terse, false, false)
2043}
2044
2045pub(crate) fn inject_tagpath_stale_into_json(
2052 value: &mut serde_json::Value,
2053 stale: bool,
2054 reason: Option<&str>,
2055) {
2056 if !stale {
2057 return;
2058 }
2059 if let Some(obj) = value.as_object_mut() {
2060 obj.insert(
2061 "tagpath_index_stale".to_string(),
2062 serde_json::Value::Bool(true),
2063 );
2064 if let Some(reason) = reason {
2065 obj.insert(
2066 "tagpath_stale_reason".to_string(),
2067 serde_json::Value::String(reason.to_string()),
2068 );
2069 }
2070 }
2071}
2072
2073pub(crate) fn to_json_schema<T: serde::Serialize>(
2074 val: &T,
2075 pretty: bool,
2076 terse: bool,
2077 ultra_terse: bool,
2078 schema: bool,
2079) -> anyhow::Result<String> {
2080 if terse || schema {
2081 let value = serde_json::to_value(val)?;
2082 let mut transformed = if terse { terse_transform(value) } else { value };
2083 if ultra_terse {
2084 transformed = ultra_terse_transform(transformed);
2085 transformed = edge_index_transform(transformed);
2086 }
2087 if schema {
2088 transformed = schema_transform(transformed);
2089 }
2090 if terse {
2091 let terse_schema = terse_schema_for(&transformed);
2092 let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
2093 if pretty {
2094 Ok(serde_json::to_string_pretty(&wrapped)?)
2095 } else {
2096 Ok(serde_json::to_string(&wrapped)?)
2097 }
2098 } else if pretty {
2099 Ok(serde_json::to_string_pretty(&transformed)?)
2100 } else {
2101 Ok(serde_json::to_string(&transformed)?)
2102 }
2103 } else if pretty {
2104 Ok(serde_json::to_string_pretty(val)?)
2105 } else {
2106 Ok(serde_json::to_string(val)?)
2107 }
2108}
2109
2110pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
2111 ToolEnvelopeMetric {
2112 label: label.to_string(),
2113 value: value.to_string(),
2114 }
2115}
2116
2117pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
2118 let mut seen = HashSet::new();
2119 let mut deduped = Vec::new();
2120 for value in values {
2121 if seen.insert(value.clone()) {
2122 deduped.push(value);
2123 }
2124 }
2125 deduped
2126}
2127
2128pub(crate) fn print_json_or_envelope<T: Serialize>(
2129 report: &T,
2130 format: &OutputFormat,
2131 tool: &str,
2132 view: &str,
2133 summary: ToolEnvelopeSummary,
2134 truncated: bool,
2135 follow_up: Vec<String>,
2136) -> Result<()> {
2137 if format.envelope {
2138 let schema = format.schema || tool == "source-read";
2139 let envelope = ToolEnvelope {
2140 tool,
2141 view,
2142 summary,
2143 truncated,
2144 follow_up: dedupe_preserve_order(follow_up),
2145 report,
2146 };
2147 println!(
2148 "{}",
2149 to_json_schema(
2150 &envelope,
2151 format.pretty,
2152 format.terse,
2153 format.ultra_terse,
2154 schema
2155 )?
2156 );
2157 } else {
2158 println!(
2159 "{}",
2160 to_json_schema(
2161 report,
2162 format.pretty,
2163 format.terse,
2164 format.ultra_terse,
2165 format.schema
2166 )?
2167 );
2168 }
2169 Ok(())
2170}
2171
2172pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
2173 bytes.div_ceil(4)
2174}
2175
2176fn cmd_token_gate(command: cli::TokenGateCommand, format: OutputFormat) -> Result<()> {
2177 match command {
2178 cli::TokenGateCommand::Sample {
2179 surface,
2180 path,
2181 scope,
2182 target,
2183 depth,
2184 sample_index,
2185 json: _,
2186 } => cmd_token_gate_sample(
2187 &surface,
2188 &path,
2189 scope.as_deref(),
2190 target.as_deref(),
2191 depth,
2192 sample_index,
2193 ),
2194 cli::TokenGateCommand::Evaluate {
2195 history,
2196 allowed_regression_percent,
2197 json: _,
2198 } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
2199 }
2200}
2201
2202fn cmd_token_gate_sample(
2203 surface: &str,
2204 path: &Path,
2205 scope: Option<&str>,
2206 target: Option<&str>,
2207 depth: usize,
2208 sample_index: usize,
2209) -> Result<()> {
2210 if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
2211 bail!(
2212 "unknown surface `{}`; expected one of: {}",
2213 surface,
2214 token_gate::TOKEN_GATE_SURFACES.join(", ")
2215 );
2216 }
2217
2218 let path_str = path.to_string_lossy().to_string();
2219 let tsift_bin = std::env::current_exe()?;
2220
2221 let args: Vec<String> = match surface {
2222 "context_pack" => vec!["context-pack".to_string(), "--json".to_string(), path_str],
2223 "session_review_next_context" => vec![
2224 "session-review".to_string(),
2225 "--json".to_string(),
2226 "--next-context".to_string(),
2227 path_str,
2228 ],
2229 "graph_db_evidence" => {
2230 let tgt = target.unwrap_or("default").to_string();
2231 vec![
2232 "graph-db".to_string(),
2233 "--json".to_string(),
2234 "--path".to_string(),
2235 path_str,
2236 "evidence".to_string(),
2237 tgt,
2238 "--depth".to_string(),
2239 depth.to_string(),
2240 ]
2241 }
2242 "conflict_matrix" => {
2243 let tgt = target.unwrap_or("default").to_string();
2244 let mut a = vec![
2245 "conflict-matrix".to_string(),
2246 "--json".to_string(),
2247 "--path".to_string(),
2248 path_str,
2249 "--depth".to_string(),
2250 depth.to_string(),
2251 ];
2252 if let Some(s) = scope {
2253 a.push("--scope".to_string());
2254 a.push(s.to_string());
2255 }
2256 a.push(tgt);
2257 a
2258 }
2259 "dispatch_trace" => {
2260 let tgt = target.unwrap_or("default").to_string();
2261 vec![
2262 "dispatch-trace".to_string(),
2263 "--json".to_string(),
2264 "--path".to_string(),
2265 path_str,
2266 tgt,
2267 ]
2268 }
2269 _ => bail!("unhandled surface: {}", surface),
2270 };
2271
2272 let start = Instant::now();
2273 let child = Command::new(&tsift_bin)
2274 .args(&args)
2275 .stdout(Stdio::piped())
2276 .stderr(Stdio::piped())
2277 .env("TSIFT_QUIET", "1")
2278 .spawn();
2279 let output = match child {
2280 Ok(c) => c.wait_with_output()?,
2281 Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
2282 };
2283 let runtime_micros = start.elapsed().as_micros() as f64;
2284
2285 let stdout = String::from_utf8_lossy(&output.stdout);
2286 let envelope_bytes = stdout.trim().len() as f64;
2287 let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
2288
2289 let cache_hit_rate_percent = 0.0;
2290 let raw_read_avoidance = 0.0;
2291 let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
2292
2293 let timestamp = iso_timestamp_now();
2294 let id = format!(
2295 "{surface}-baseline-{}-sample-{sample_index}",
2296 ×tamp[..10]
2297 );
2298 let label = format!(
2299 "token-gate baseline {surface} sample {sample_index} for {}",
2300 path.display()
2301 );
2302
2303 let mut metrics = BTreeMap::new();
2304 metrics.insert("prompt_tokens".to_string(), prompt_tokens);
2305 metrics.insert("envelope_bytes".to_string(), envelope_bytes);
2306 metrics.insert("runtime_micros".to_string(), runtime_micros);
2307 metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
2308 metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
2309 metrics.insert("useful_hit_density".to_string(), useful_hit_density);
2310
2311 let sample = token_gate::TokenGateSample {
2312 label,
2313 id,
2314 timestamp: Some(timestamp),
2315 surface: surface.to_string(),
2316 metrics,
2317 };
2318
2319 println!("{}", serde_json::to_string_pretty(&sample)?);
2320 Ok(())
2321}
2322
2323fn cmd_token_gate_evaluate(
2324 history_path: Option<&Path>,
2325 allowed_regression_percent: f64,
2326 format: &OutputFormat,
2327) -> Result<()> {
2328 let history_path = history_path.map(PathBuf::from).unwrap_or_else(|| {
2329 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2330 p.push("../../fixtures/token-gate-history.json");
2331 p
2332 });
2333
2334 let raw = std::fs::read_to_string(&history_path).with_context(|| {
2335 format!(
2336 "failed to read token gate history: {}",
2337 history_path.display()
2338 )
2339 })?;
2340 let samples = token_gate::parse_token_history(&raw)?;
2341 let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
2342
2343 if format.json_output {
2344 println!(
2345 "{}",
2346 to_json_schema(&report, format.pretty, format.terse, false, format.schema)?
2347 );
2348 } else {
2349 println!("Token Gate Report");
2350 println!(" min_samples: {}", report.min_samples);
2351 println!(
2352 " allowed_regression: {:.1}%",
2353 report.allowed_regression_percent
2354 );
2355 println!(" decision: {:?}", report.decision);
2356 for eval in &report.surface_evaluations {
2357 println!(
2358 " {} ({} samples): {:?}",
2359 eval.display_name, eval.sample_count, eval.verdict
2360 );
2361 for me in &eval.metric_evaluations {
2362 println!(" {} ({:?}): {}", me.metric, me.direction, me.diagnostic);
2363 }
2364 }
2365 for d in &report.diagnostics {
2366 println!(" ! {}", d);
2367 }
2368 }
2369 Ok(())
2370}
2371
2372fn iso_timestamp_now() -> String {
2373 let dur = SystemTime::now()
2374 .duration_since(UNIX_EPOCH)
2375 .unwrap_or_default();
2376 let total_secs = dur.as_secs();
2377 let days_since_epoch = total_secs / 86400;
2378 let (year, month, day) = days_to_ymd(days_since_epoch);
2379 let time_of_day = total_secs % 86400;
2380 let hour = (time_of_day / 3600) as u8;
2381 let minute = ((time_of_day % 3600) / 60) as u8;
2382 let second = (time_of_day % 60) as u8;
2383 format!(
2384 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
2385 year, month, day, hour, minute, second
2386 )
2387}
2388
2389fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
2390 let mut year = 1970u64;
2391 loop {
2392 let days_in_year = if is_leap(year) { 366 } else { 365 };
2393 if days < days_in_year {
2394 break;
2395 }
2396 days -= days_in_year;
2397 year += 1;
2398 }
2399 let leap = is_leap(year);
2400 let month_days: [u8; 12] = if leap {
2401 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2402 } else {
2403 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2404 };
2405 let mut month: u8 = 1;
2406 for &md in &month_days {
2407 if days < md as u64 {
2408 break;
2409 }
2410 days -= md as u64;
2411 month += 1;
2412 }
2413 let day = days as u8 + 1;
2414 (year, month, day)
2415}
2416
2417fn is_leap(year: u64) -> bool {
2418 year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
2419}
2420
2421fn persist_transcript_artifact(
2422 root: &Path,
2423 prefix: &str,
2424 suffix: &str,
2425 key: &str,
2426 body: &str,
2427 expand: String,
2428) -> Result<TranscriptArtifactRef> {
2429 let handle = stable_handle(prefix, key);
2430 let artifacts_dir = root.join(".tsift/artifacts");
2431 fs::create_dir_all(&artifacts_dir).with_context(|| {
2432 format!(
2433 "creating transcript artifacts dir: {}",
2434 artifacts_dir.display()
2435 )
2436 })?;
2437 let file_name = format!("{handle}.{suffix}");
2438 let artifact_path = artifacts_dir.join(file_name);
2439 fs::write(&artifact_path, body)
2440 .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
2441 let rel_path = relativize_pathbuf(&artifact_path, root);
2442 Ok(TranscriptArtifactRef {
2443 handle,
2444 path: rel_path.display().to_string(),
2445 bytes: body.len(),
2446 lines: body.lines().count(),
2447 expand,
2448 })
2449}
2450
2451fn terse_key(key: &str) -> &str {
2452 match key {
2453 "name" => "n",
2454 "kind" => "k",
2455 "file" => "f",
2456 "line" => "l",
2457 "path" => "p",
2458 "from" => "fr",
2459 "type" => "ty",
2460 "text" => "tx",
2461 "new" => "nw",
2462 "run" => "r",
2463 "use" => "u",
2464 "score" => "sc",
2465 "language" => "la",
2466 "status" => "st",
2467 "state" => "stt",
2468 "error" => "err",
2469 "errors" => "ers",
2470 "hops" => "hp",
2471 "tags" => "tg",
2472 "model" => "ml",
2473 "skill" => "sk",
2474 "count" => "ct",
2475 "total" => "tot",
2476 "column" => "col",
2477 "description" => "dsc",
2478 "end_line" => "el",
2479 "signature" => "sig",
2480 "parent_module" => "pm",
2481 "visibility" => "vis",
2482 "match_type" => "mt",
2483 "caller_file" => "cf",
2484 "caller_name" => "cn",
2485 "caller_line" => "cl",
2486 "callee_name" => "en",
2487 "call_site_line" => "csl",
2488 "members" => "m",
2489 "refs" => "refs",
2490 "role" => "rl",
2491 "peer" => "pr",
2492 "modularity" => "q",
2493 "modularity_contribution" => "mc",
2494 "iterations" => "it",
2495 "node_count" => "nc",
2496 "edge_count" => "ec",
2497 "community_count" => "cc",
2498 "communities" => "cms",
2499 "community" => "cm",
2500 "community_diagnostics" => "cd",
2501 "cache_hit" => "cah",
2502 "tagpath_state" => "tps",
2503 "tagpath_stale_reason" => "tsr",
2504 "annotated_community_count" => "acc",
2505 "annotated_member_count" => "amc",
2506 "ambiguous_member_count" => "ambc",
2507 "ambiguous_members" => "amb",
2508 "candidate_count" => "cand",
2509 "tagpath_candidate_count" => "tcand",
2510 "evidence" => "ev",
2511 "chosen_file" => "chf",
2512 "symbol" => "s",
2513 "symbols" => "sy",
2514 "definitions" => "df",
2515 "callers" => "crs",
2516 "callees" => "ces",
2517 "total_tracked" => "tt",
2518 "modified" => "md",
2519 "deleted" => "dl",
2520 "unchanged" => "uc",
2521 "changes" => "ch",
2522 "prune_stats" => "ps",
2523 "hits" => "h",
2524 "rank" => "rk",
2525 "snippet" => "sn",
2526 "confidence" => "co",
2527 "index" => "ix",
2528 "summaries" => "sms",
2529 "recommendations" => "rec",
2530 "total_files" => "tf",
2531 "stale_files" => "sf",
2532 "last_indexed_secs_ago" => "age",
2533 "cached_files" => "caf",
2534 "total_indexed_files" => "tif",
2535 "coverage_pct" => "cov",
2536 "symbol_name" => "syn",
2537 "file_path" => "fp",
2538 "content_hash" => "hsh",
2539 "summary" => "sum",
2540 "tool" => "tl",
2541 "view" => "vw",
2542 "truncated" => "tr",
2543 "follow_up" => "fu",
2544 "report" => "rp",
2545 "metrics" => "ms",
2546 "label" => "lb",
2547 "value" => "v",
2548 "command" => "cmd",
2549 "exit_code" => "xc",
2550 "success" => "ok",
2551 "artifact" => "art",
2552 "digest" => "dg",
2553 "bytes" => "bt",
2554 "lines" => "lns",
2555 "expand" => "xp",
2556 "entities" => "ent",
2557 "relationships" => "rel",
2558 "concept_labels" => "cls",
2559 "extracted_at" => "at",
2560 "tokens_input" => "ti",
2561 "tokens_output" => "tout",
2562 "total_summaries" => "ts",
2563 "stale_count" => "stc",
2564 "total_tokens_input" => "tti",
2565 "total_tokens_output" => "tto",
2566 "estimated_tokens_saved" => "ets",
2567 "files_processed" => "fps",
2568 "symbols_extracted" => "se",
2569 "skills_dir" => "sd",
2570 "healthy" => "ok",
2571 "broken" => "brk",
2572 "skills" => "sks",
2573 "manifest_diffs" => "mdf",
2574 "similar_pairs" => "sim",
2575 "usage" => "usg",
2576 "cleanup" => "cln",
2577 "has_skill_md" => "hsm",
2578 "is_symlink" => "isl",
2579 "issues" => "iss",
2580 "invocation_count" => "inv",
2581 "reasons" => "rsn",
2582 "token_estimate" => "te",
2583 "skill_a" => "sa",
2584 "skill_b" => "sb",
2585 "desc_a" => "da",
2586 "desc_b" => "db",
2587 "annotations" => "ann",
2588 "entity" => "ety",
2589 "suggestion" => "sug",
2590 "columns" => "cols",
2591 "row_count" => "rc",
2592 "notnull" => "nn",
2593 "default_value" => "dv",
2594 "replace_all" => "ra",
2595 other => other,
2596 }
2597}
2598
2599fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2600 match val {
2601 serde_json::Value::Object(map) => {
2602 let mut new_map = serde_json::Map::new();
2603 for (k, v) in map {
2604 new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2605 }
2606 serde_json::Value::Object(new_map)
2607 }
2608 serde_json::Value::Array(arr) => {
2609 serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2610 }
2611 other => other,
2612 }
2613}
2614
2615fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2616 match val {
2617 serde_json::Value::Object(mut map) => {
2618 let is_graph_node =
2619 map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2620 let is_graph_edge =
2621 map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2622 if is_graph_node || is_graph_edge {
2623 map.remove("properties");
2624 map.remove("provenance");
2625 map.remove("freshness");
2626 }
2627 if is_graph_edge && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2628 *s = abbreviate_edge_kind(s).to_string();
2629 }
2630 let is_coverage = map.contains_key("mode")
2631 && (map.contains_key("total_sector_count")
2632 || map.contains_key("dirty_sector_count"));
2633 if is_coverage {
2634 map.remove("active_rebuild");
2635 map.remove("completed_dirty_sector_count");
2636 map.remove("mounted_sector_count");
2637 map.remove("rebuilding_sector_count");
2638 map.remove("resumed_sector_count");
2639 map.remove("reused_sector_count");
2640 }
2641 if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2642 *s = truncate_for_ultra_terse(s, 80);
2643 }
2644 if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2645 *s = truncate_for_ultra_terse(s, 80);
2646 }
2647 let new_map: serde_json::Map<String, serde_json::Value> = map
2648 .into_iter()
2649 .map(|(k, v)| (k, ultra_terse_transform(v)))
2650 .collect();
2651 serde_json::Value::Object(new_map)
2652 }
2653 serde_json::Value::Array(arr) => {
2654 serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2655 }
2656 other => other,
2657 }
2658}
2659
2660fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2661 match val {
2662 serde_json::Value::Object(mut map) => {
2663 let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2664 nodes.as_array().map(|arr| {
2665 arr.iter()
2666 .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2667 .collect()
2668 })
2669 });
2670 if let Some(ref ids) = node_ids {
2671 let id_map: std::collections::HashMap<&str, usize> = ids
2672 .iter()
2673 .enumerate()
2674 .map(|(i, id)| (id.as_str(), i))
2675 .collect();
2676 if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2677 for edge in edges.iter_mut() {
2678 if let serde_json::Value::Object(edge_map) = edge {
2679 if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id")
2680 {
2681 if let Some(&idx) = id_map.get(fid.as_str()) {
2682 edge_map.insert(
2683 "from".to_string(),
2684 serde_json::Value::Number(idx.into()),
2685 );
2686 } else {
2687 edge_map.insert(
2688 "from_id".to_string(),
2689 serde_json::Value::String(fid),
2690 );
2691 }
2692 }
2693 if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2694 if let Some(&idx) = id_map.get(tid.as_str()) {
2695 edge_map.insert(
2696 "to".to_string(),
2697 serde_json::Value::Number(idx.into()),
2698 );
2699 } else {
2700 edge_map.insert(
2701 "to_id".to_string(),
2702 serde_json::Value::String(tid),
2703 );
2704 }
2705 }
2706 }
2707 }
2708 }
2709 }
2710 let new_map: serde_json::Map<String, serde_json::Value> = map
2711 .into_iter()
2712 .map(|(k, v)| (k, edge_index_transform(v)))
2713 .collect();
2714 serde_json::Value::Object(new_map)
2715 }
2716 serde_json::Value::Array(arr) => {
2717 serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2718 }
2719 other => other,
2720 }
2721}
2722
2723fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2724 if s.len() <= max_len {
2725 s.to_string()
2726 } else {
2727 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2728 format!("{truncated}...")
2729 }
2730}
2731
2732fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2733 let mut keys = HashSet::new();
2734 collect_terse_keys(val, &mut keys);
2735 let mut schema = serde_json::Map::new();
2736 for (long, short) in TERSE_PAIRS {
2737 if keys.contains(*short) {
2738 schema.insert(
2739 short.to_string(),
2740 serde_json::Value::String(long.to_string()),
2741 );
2742 }
2743 }
2744 serde_json::Value::Object(schema)
2745}
2746
2747fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2748 match val {
2749 serde_json::Value::Object(map) => {
2750 for (k, v) in map {
2751 keys.insert(k.clone());
2752 collect_terse_keys(v, keys);
2753 }
2754 }
2755 serde_json::Value::Array(arr) => {
2756 for v in arr {
2757 collect_terse_keys(v, keys);
2758 }
2759 }
2760 _ => {}
2761 }
2762}
2763
2764fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2765 match val {
2766 serde_json::Value::Array(arr) if arr.len() >= 2 => {
2767 if let Some(cols) = homogeneous_keys(&arr) {
2768 let rows: Vec<serde_json::Value> = arr
2769 .into_iter()
2770 .map(|item| {
2771 if let serde_json::Value::Object(map) = item {
2772 let vals: Vec<serde_json::Value> = cols
2773 .iter()
2774 .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2775 .collect();
2776 serde_json::Value::Array(vals)
2777 } else {
2778 item
2779 }
2780 })
2781 .collect();
2782 let col_vals: Vec<serde_json::Value> =
2783 cols.into_iter().map(serde_json::Value::String).collect();
2784 serde_json::json!({"_c": col_vals, "_r": rows})
2785 } else {
2786 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2787 }
2788 }
2789 serde_json::Value::Array(arr) => {
2790 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2791 }
2792 serde_json::Value::Object(map) => {
2793 let new_map: serde_json::Map<String, serde_json::Value> = map
2794 .into_iter()
2795 .map(|(k, v)| (k, schema_transform(v)))
2796 .collect();
2797 serde_json::Value::Object(new_map)
2798 }
2799 other => other,
2800 }
2801}
2802
2803fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2804 let first = arr.first()?.as_object()?;
2805 let keys: Vec<String> = first.keys().cloned().collect();
2806 for item in &arr[1..] {
2807 let obj = item.as_object()?;
2808 if obj.len() != keys.len() {
2809 return None;
2810 }
2811 for k in &keys {
2812 if !obj.contains_key(k) {
2813 return None;
2814 }
2815 }
2816 }
2817 Some(keys)
2818}
2819
2820const TERSE_PAIRS: &[(&str, &str)] = &[
2821 ("name", "n"),
2822 ("kind", "k"),
2823 ("file", "f"),
2824 ("line", "l"),
2825 ("path", "p"),
2826 ("from", "fr"),
2827 ("type", "ty"),
2828 ("text", "tx"),
2829 ("new", "nw"),
2830 ("run", "r"),
2831 ("use", "u"),
2832 ("score", "sc"),
2833 ("language", "la"),
2834 ("status", "st"),
2835 ("state", "stt"),
2836 ("error", "err"),
2837 ("errors", "ers"),
2838 ("hops", "hp"),
2839 ("tags", "tg"),
2840 ("model", "ml"),
2841 ("skill", "sk"),
2842 ("count", "ct"),
2843 ("total", "tot"),
2844 ("column", "col"),
2845 ("description", "dsc"),
2846 ("end_line", "el"),
2847 ("signature", "sig"),
2848 ("parent_module", "pm"),
2849 ("visibility", "vis"),
2850 ("match_type", "mt"),
2851 ("caller_file", "cf"),
2852 ("caller_name", "cn"),
2853 ("caller_line", "cl"),
2854 ("callee_name", "en"),
2855 ("call_site_line", "csl"),
2856 ("members", "m"),
2857 ("refs", "refs"),
2858 ("role", "rl"),
2859 ("peer", "pr"),
2860 ("modularity", "q"),
2861 ("modularity_contribution", "mc"),
2862 ("iterations", "it"),
2863 ("node_count", "nc"),
2864 ("edge_count", "ec"),
2865 ("community_count", "cc"),
2866 ("communities", "cms"),
2867 ("community", "cm"),
2868 ("community_diagnostics", "cd"),
2869 ("cache_hit", "cah"),
2870 ("tagpath_state", "tps"),
2871 ("tagpath_stale_reason", "tsr"),
2872 ("annotated_community_count", "acc"),
2873 ("annotated_member_count", "amc"),
2874 ("ambiguous_member_count", "ambc"),
2875 ("ambiguous_members", "amb"),
2876 ("candidate_count", "cand"),
2877 ("tagpath_candidate_count", "tcand"),
2878 ("evidence", "ev"),
2879 ("chosen_file", "chf"),
2880 ("symbol", "s"),
2881 ("symbols", "sy"),
2882 ("definitions", "df"),
2883 ("callers", "crs"),
2884 ("callees", "ces"),
2885 ("total_tracked", "tt"),
2886 ("modified", "md"),
2887 ("deleted", "dl"),
2888 ("unchanged", "uc"),
2889 ("changes", "ch"),
2890 ("prune_stats", "ps"),
2891 ("hits", "h"),
2892 ("rank", "rk"),
2893 ("snippet", "sn"),
2894 ("confidence", "co"),
2895 ("index", "ix"),
2896 ("summaries", "sms"),
2897 ("recommendations", "rec"),
2898 ("total_files", "tf"),
2899 ("stale_files", "sf"),
2900 ("last_indexed_secs_ago", "age"),
2901 ("cached_files", "caf"),
2902 ("total_indexed_files", "tif"),
2903 ("coverage_pct", "cov"),
2904 ("symbol_name", "syn"),
2905 ("file_path", "fp"),
2906 ("content_hash", "hsh"),
2907 ("summary", "sum"),
2908 ("tool", "tl"),
2909 ("view", "vw"),
2910 ("truncated", "tr"),
2911 ("follow_up", "fu"),
2912 ("report", "rp"),
2913 ("metrics", "ms"),
2914 ("label", "lb"),
2915 ("value", "v"),
2916 ("command", "cmd"),
2917 ("exit_code", "xc"),
2918 ("success", "ok"),
2919 ("artifact", "art"),
2920 ("digest", "dg"),
2921 ("bytes", "bt"),
2922 ("lines", "lns"),
2923 ("expand", "xp"),
2924 ("entities", "ent"),
2925 ("relationships", "rel"),
2926 ("concept_labels", "cls"),
2927 ("extracted_at", "at"),
2928 ("tokens_input", "ti"),
2929 ("tokens_output", "tout"),
2930 ("total_summaries", "ts"),
2931 ("stale_count", "stc"),
2932 ("total_tokens_input", "tti"),
2933 ("total_tokens_output", "tto"),
2934 ("estimated_tokens_saved", "ets"),
2935 ("files_processed", "fps"),
2936 ("symbols_extracted", "se"),
2937 ("skills_dir", "sd"),
2938 ("healthy", "ok"),
2939 ("broken", "brk"),
2940 ("skills", "sks"),
2941 ("manifest_diffs", "mdf"),
2942 ("similar_pairs", "sim"),
2943 ("usage", "usg"),
2944 ("cleanup", "cln"),
2945 ("has_skill_md", "hsm"),
2946 ("is_symlink", "isl"),
2947 ("issues", "iss"),
2948 ("invocation_count", "inv"),
2949 ("reasons", "rsn"),
2950 ("token_estimate", "te"),
2951 ("skill_a", "sa"),
2952 ("skill_b", "sb"),
2953 ("desc_a", "da"),
2954 ("desc_b", "db"),
2955 ("annotations", "ann"),
2956 ("entity", "ety"),
2957 ("suggestion", "sug"),
2958 ("columns", "cols"),
2959 ("row_count", "rc"),
2960 ("notnull", "nn"),
2961 ("default_value", "dv"),
2962 ("replace_all", "ra"),
2963];
2964
2965pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2966 let root_str = root.to_string_lossy();
2967 let prefix = format!("{}/", root_str.trim_end_matches('/'));
2968 path.strip_prefix(&prefix).unwrap_or(path).to_string()
2969}
2970
2971fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2972 let canonical = path
2973 .canonicalize()
2974 .with_context(|| format!("canonicalizing {}", path.display()))?;
2975 let start = if canonical.is_dir() {
2976 canonical.clone()
2977 } else {
2978 canonical
2979 .parent()
2980 .map(Path::to_path_buf)
2981 .unwrap_or_else(|| canonical.clone())
2982 };
2983
2984 for ancestor in start.ancestors() {
2985 if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2986 return Ok(ancestor.to_path_buf());
2987 }
2988 }
2989
2990 Ok(start)
2991}
2992
2993pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2994 path.strip_prefix(root)
2995 .map(|p| p.to_path_buf())
2996 .unwrap_or_else(|_| path.to_path_buf())
2997}
2998
2999pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
3000 for edge in edges {
3001 edge.caller_file = relativize(&edge.caller_file, root);
3002 }
3003}
3004
3005pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
3006 for sym in symbols {
3007 sym.file = relativize(&sym.file, root);
3008 }
3009}
3010
3011pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
3012 for hit in hits {
3013 hit.file = relativize(&hit.file, root);
3014 }
3015}
3016
3017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3020pub enum EdgeSide {
3021 Caller,
3022 Callee,
3023}
3024
3025const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
3026
3027pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
3028 let root_str = root.to_string_lossy();
3029 let prefix = format!("{}/", root_str.trim_end_matches('/'));
3030 relativize_json_inner(val, &prefix);
3031}
3032
3033fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
3034 match val {
3035 serde_json::Value::Array(arr) => {
3036 for v in arr {
3037 relativize_json_inner(v, prefix);
3038 }
3039 }
3040 serde_json::Value::Object(map) => {
3041 for (k, v) in map.iter_mut() {
3042 if JSON_PATH_KEYS.contains(&k.as_str())
3043 && let serde_json::Value::String(s) = v
3044 && let Some(rest) = s.strip_prefix(prefix)
3045 {
3046 *s = rest.to_string();
3047 }
3048 relativize_json_inner(v, prefix);
3049 }
3050 }
3051 _ => {}
3052 }
3053}
3054
3055pub(crate) fn format_score(score: f64, compact: bool) -> String {
3056 if compact {
3057 format!("{score:.2}")
3058 } else {
3059 format!("{score:.4}")
3060 }
3061}
3062
3063pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
3064 let trimmed = input.trim();
3065 let count = trimmed.chars().count();
3066 if count <= max_chars {
3067 return trimmed.to_string();
3068 }
3069 let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
3070 format!("{prefix}...")
3071}
3072
3073pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
3074 snippet
3075 .lines()
3076 .find(|line| !line.trim().is_empty())
3077 .map(|line| truncate_for_compact(line, 100))
3078}
3079
3080pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
3081 let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
3082 if names.len() <= limit {
3083 return names.join(", ");
3084 }
3085 format!(
3086 "{} (+{} more)",
3087 names[..limit].join(", "),
3088 names.len() - limit
3089 )
3090}
3091
3092pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
3093 let mut hasher = blake3::Hasher::new();
3094 hasher.update(prefix.as_bytes());
3095 hasher.update(&[0]);
3096 hasher.update(key.as_bytes());
3097 let hex = hasher.finalize().to_hex();
3098 format!("{prefix}-{}", &hex[..10])
3099}
3100
3101#[derive(Clone, Debug, PartialEq, Eq)]
3102struct CanonicalTagFamily {
3103 canonical: String,
3104 tag_alias: String,
3105}
3106
3107fn canonical_family_from_tagpath_family(
3108 family: tagpath_family::TagFamily,
3109) -> Option<CanonicalTagFamily> {
3110 let tag_alias = if family.dimensions.is_empty() {
3111 family.tags.join("/")
3112 } else {
3113 family
3114 .dimensions
3115 .iter()
3116 .filter(|dimension| !dimension.tags.is_empty())
3117 .map(|dimension| dimension.tags.join("."))
3118 .collect::<Vec<_>>()
3119 .join("/")
3120 };
3121
3122 if tag_alias.is_empty() {
3123 None
3124 } else {
3125 Some(CanonicalTagFamily {
3126 canonical: family.canonical,
3127 tag_alias,
3128 })
3129 }
3130}
3131
3132fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
3133 let trimmed = name.trim();
3134 if trimmed.is_empty() {
3135 return None;
3136 }
3137
3138 canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
3139}
3140
3141fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
3142 let canonical = tags
3143 .split(',')
3144 .map(str::trim)
3145 .filter(|tag| !tag.is_empty())
3146 .collect::<Vec<_>>()
3147 .join("_");
3148 if canonical.is_empty() {
3149 None
3150 } else {
3151 canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
3152 }
3153}
3154
3155pub(crate) fn canonical_tag_family_from_symbol(
3156 name: &str,
3157 tags: Option<&str>,
3158) -> Option<CanonicalTagFamily> {
3159 tags.and_then(canonical_tag_family_from_tags)
3160 .or_else(|| canonical_tag_family_from_name(name))
3161}
3162
3163fn tag_alias_from_name(name: &str) -> Option<String> {
3164 canonical_tag_family_from_name(name).map(|family| family.tag_alias)
3165}
3166
3167fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
3168 canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
3169}
3170
3171pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
3172 let query = tag_alias
3173 .split(['/', '.'])
3174 .map(str::trim)
3175 .filter(|part| !part.is_empty())
3176 .collect::<Vec<_>>()
3177 .join(" ");
3178 if query.is_empty() { None } else { Some(query) }
3179}
3180
3181#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3182struct CompactOntologyRefPreview {
3183 handle: String,
3184 tag: String,
3185 path: String,
3186 #[serde(skip_serializing_if = "Option::is_none")]
3187 title: Option<String>,
3188 #[serde(skip_serializing_if = "Option::is_none")]
3189 domain: Option<String>,
3190}
3191
3192#[derive(Clone, Debug)]
3193struct TagOntologyPreviewContext {
3194 project_root: PathBuf,
3195 tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
3196}
3197
3198#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3199struct CompactSymbolRefPreview {
3200 handle: String,
3201 name: String,
3202 #[serde(skip_serializing_if = "Option::is_none")]
3203 tag_alias: Option<String>,
3204 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3205 ontology_refs: Vec<CompactOntologyRefPreview>,
3206}
3207
3208fn build_compact_symbol_ref(
3209 prefix: &str,
3210 key: &str,
3211 name: &str,
3212 tags: Option<&str>,
3213 max_bytes: usize,
3214) -> CompactSymbolRefPreview {
3215 build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
3216}
3217
3218fn build_compact_symbol_ref_with_ontology(
3219 prefix: &str,
3220 key: &str,
3221 name: &str,
3222 tags: Option<&str>,
3223 max_bytes: usize,
3224 ontology: Option<&TagOntologyPreviewContext>,
3225) -> CompactSymbolRefPreview {
3226 let tag_alias = tag_alias_from_tags(name, tags);
3227 let ontology_refs = tag_alias
3228 .as_deref()
3229 .map(|alias| ontology_refs_for_alias(ontology, alias))
3230 .unwrap_or_default();
3231 CompactSymbolRefPreview {
3232 handle: stable_handle(prefix, key),
3233 name: truncate_for_budget(name, max_bytes),
3234 tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
3235 ontology_refs,
3236 }
3237}
3238
3239fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
3240 let report = tagpath_ontology::load_project(root).ok()?;
3241 if report.tags.is_empty() {
3242 return None;
3243 }
3244 Some(TagOntologyPreviewContext {
3245 project_root: report.project_path,
3246 tags: report
3247 .tags
3248 .into_iter()
3249 .map(|tag| (tag.tag.clone(), tag))
3250 .collect(),
3251 })
3252}
3253
3254fn ontology_refs_for_alias(
3255 ontology: Option<&TagOntologyPreviewContext>,
3256 alias: &str,
3257) -> Vec<CompactOntologyRefPreview> {
3258 let Some(ontology) = ontology else {
3259 return Vec::new();
3260 };
3261 let mut seen = BTreeSet::new();
3262 alias
3263 .split('/')
3264 .flat_map(|part| part.split('.'))
3265 .map(str::trim)
3266 .filter(|tag| !tag.is_empty())
3267 .filter_map(|tag| {
3268 let key = tag.to_ascii_lowercase();
3269 if !seen.insert(key.clone()) {
3270 return None;
3271 }
3272 let ontology_tag = ontology.tags.get(&key)?;
3273 let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
3274 Some(CompactOntologyRefPreview {
3275 handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
3276 tag: ontology_tag.tag.clone(),
3277 path,
3278 title: ontology_tag.title.clone(),
3279 domain: ontology_tag.domain.clone(),
3280 })
3281 })
3282 .collect()
3283}
3284
3285fn relativize_ontology_path(path: &Path, root: &Path) -> String {
3286 path.strip_prefix(root)
3287 .unwrap_or(path)
3288 .to_string_lossy()
3289 .replace('\\', "/")
3290}
3291
3292fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
3293 match tag_alias {
3294 Some(alias) => format!("{handle} {name} tag:{alias}"),
3295 None => format!("{handle} {name}"),
3296 }
3297}
3298
3299fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
3300 match summary.tag_alias.as_deref() {
3301 Some(alias) => format!(
3302 "{} {} tag:{} expand:{}",
3303 summary.handle, summary.symbol, alias, summary.expand
3304 ),
3305 None => format!(
3306 "{} {} expand:{}",
3307 summary.handle, summary.symbol, summary.expand
3308 ),
3309 }
3310}
3311
3312fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
3313 match symbol.tag_alias.as_deref() {
3314 Some(alias) => format!("{}@{}", symbol.handle, alias),
3315 None => format!("{}@{}", symbol.handle, symbol.name),
3316 }
3317}
3318
3319pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
3320 let trimmed = input.trim();
3321 if trimmed.len() <= max_bytes {
3322 return trimmed.to_string();
3323 }
3324 if max_bytes <= 3 {
3325 return ".".repeat(max_bytes);
3326 }
3327
3328 let mut end = 0usize;
3329 for (idx, ch) in trimmed.char_indices() {
3330 let next = idx + ch.len_utf8();
3331 if next > max_bytes.saturating_sub(3) {
3332 break;
3333 }
3334 end = next;
3335 }
3336
3337 if end == 0 {
3338 "...".to_string()
3339 } else {
3340 format!("{}...", &trimmed[..end])
3341 }
3342}
3343
3344struct TokenCappedPreview {
3345 preview: Vec<SourceLinePreview>,
3346 capped_end: usize,
3347 was_capped: bool,
3348}
3349
3350fn build_token_capped_preview(
3351 all_lines: &[&str],
3352 start: usize,
3353 end: usize,
3354 max_bytes: usize,
3355 token_cap: usize,
3356) -> TokenCappedPreview {
3357 let mut preview = Vec::new();
3358 let mut accumulated_tokens = 0usize;
3359 let mut capped_end = end;
3360 let mut was_capped = false;
3361
3362 for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
3363 let truncated = truncate_for_budget(line, max_bytes);
3364 let line_tokens = estimated_tokens_from_bytes(truncated.len());
3365 if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
3366 capped_end = start + idx - 1;
3367 was_capped = true;
3368 break;
3369 }
3370 accumulated_tokens += line_tokens;
3371 preview.push(SourceLinePreview {
3372 line: start + idx,
3373 text: truncated,
3374 });
3375 }
3376
3377 TokenCappedPreview {
3378 preview,
3379 capped_end,
3380 was_capped,
3381 }
3382}
3383
3384pub(crate) fn abbreviate_kind(kind: &str) -> &str {
3385 match kind {
3386 "function" => "fn",
3387 "method" => "meth",
3388 "module" | "mod" => "mod",
3389 "struct" => "struct",
3390 "trait" => "trait",
3391 "impl" => "impl",
3392 "class" => "cls",
3393 "interface" => "iface",
3394 "type_alias" => "type",
3395 "data_class" => "data_cls",
3396 "sealed_class" => "sealed_cls",
3397 "enum_class" => "enum_cls",
3398 "companion_object" => "comp_obj",
3399 "object" => "obj",
3400 "heading" => "h",
3401 "code_block" => "code",
3402 "alias" => "alias",
3403 other => other,
3404 }
3405}
3406
3407pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
3408 match kind {
3409 "calls" => "c",
3410 "defines" => "d",
3411 "contains" => "ct",
3412 "imports" => "i",
3413 "mentions" => "m",
3414 "mentions_concept" => "mc",
3415 "mentions_entity" => "me",
3416 "semantic_relation" => "sr",
3417 "belongs_to" => "bt",
3418 "scopes_context" => "sctx",
3419 "scopes_source" => "ssrc",
3420 "requests_context" => "rctx",
3421 "explains_result" => "er",
3422 "tagged_concept" => "tc",
3423 "tagged_entity" => "te",
3424 "related_concept" => "relc",
3425 "handled_by" => "hb",
3426 "defines_route" => "dr",
3427 "handles_route" => "hr",
3428 "targets" => "tgt",
3429 "has_vector_handle" => "hv",
3430 "parent" => "p",
3431 "child" => "ch",
3432 "uses" => "u",
3433 "projects_source" => "psrc",
3434 "records_memory_source" => "rms",
3435 "records_memory_event" => "rme",
3436 "has_ast_span" => "ha",
3437 "represents_symbol" => "rs",
3438 "contains_embedded_symbol" => "ces",
3439 "embedded_in_fence" => "ef",
3440 "contains_markdown_block" => "cmb",
3441 "contains_embedded_code" => "cec",
3442 "enclosing_module" => "em",
3443 "enclosing_section" => "es",
3444 "previous_sibling" => "psib",
3445 "next_sibling" => "nsib",
3446 "explicit_depends_on" => "edo",
3447 "worker_result_follow_up" => "wrf",
3448 "shared_resource" => "shr",
3449 "community_member" => "cm",
3450 other => other,
3451 }
3452}
3453
3454pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
3455 match mt {
3456 "exact_name" => "exact",
3457 "all_tags" => "all_tags",
3458 "partial_tags" => "partial",
3459 other => other,
3460 }
3461}
3462
3463pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
3464 path.iter()
3465 .map(|n| n.name.as_str())
3466 .collect::<Vec<_>>()
3467 .join(" -> ")
3468}
3469
3470const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
3471
3472struct SearchHitGroup {
3473 path: String,
3474 first_rank: usize,
3475 top_score: f64,
3476 confidence: String,
3477 hits: usize,
3478 samples: Vec<String>,
3479}
3480
3481fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
3482 let snippet = compact_snippet(&hit.snippet)?;
3483 Some(match hit.location.as_deref() {
3484 Some(location) => format!("{location}: {snippet}"),
3485 None => snippet,
3486 })
3487}
3488
3489pub(crate) fn group_search_hits(
3490 hits: &[sift::SearchHit],
3491 root: &Path,
3492 absolute: bool,
3493) -> Vec<SearchHitGroup> {
3494 let mut positions = BTreeMap::new();
3495 let mut groups = Vec::new();
3496 for hit in hits {
3497 let path = if absolute {
3498 hit.path.clone()
3499 } else {
3500 relativize(&hit.path, root)
3501 };
3502 let entry = positions.entry(path.clone()).or_insert_with(|| {
3503 groups.push(SearchHitGroup {
3504 path: path.clone(),
3505 first_rank: hit.rank,
3506 top_score: hit.score,
3507 confidence: format!("{:?}", hit.confidence),
3508 hits: 0,
3509 samples: Vec::new(),
3510 });
3511 groups.len() - 1
3512 });
3513 let group = &mut groups[*entry];
3514 group.hits += 1;
3515 if hit.rank < group.first_rank {
3516 group.first_rank = hit.rank;
3517 }
3518 if hit.score > group.top_score {
3519 group.top_score = hit.score;
3520 }
3521 if let Some(sample) = format_search_sample(hit)
3522 && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
3523 && !group.samples.contains(&sample)
3524 {
3525 group.samples.push(sample);
3526 }
3527 }
3528 groups.sort_by_key(|group| group.first_rank);
3529 groups
3530}
3531
3532pub(crate) fn should_collapse_search_hits(
3533 hits: &[sift::SearchHit],
3534 root: &Path,
3535 absolute: bool,
3536) -> bool {
3537 let groups = group_search_hits(hits, root, absolute);
3538 let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
3539 max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
3540}
3541
3542pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
3543 let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
3544 for edge in edges {
3545 let key = edge.caller_file.as_str();
3546 let name = if use_callers {
3547 edge.caller_name.as_str()
3548 } else {
3549 edge.callee_name.as_str()
3550 };
3551 let names = grouped.entry(key).or_default();
3552 if !names.contains(&name) {
3553 names.push(name);
3554 }
3555 }
3556
3557 grouped
3558 .into_iter()
3559 .map(|(file, names)| format!(" {} ({}): {}", file, names.len(), names.join(", ")))
3560 .collect()
3561}
3562
3563pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
3564 let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
3565 for edge in edges {
3566 *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
3567 }
3568 let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
3569 max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
3570}
3571
3572fn resolve_query_index_target(
3573 root: &Path,
3574 path_hint: &Path,
3575 scope: Option<&str>,
3576) -> Result<SearchIndexTarget> {
3577 let cfg = config::Config::load(root)?;
3578 if let Some(scope_name) = scope {
3579 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3580 return Ok(SearchIndexTarget {
3581 label: format!("submodule `{}` index", scope.id),
3582 db_path: cfg.db_path_for(root, &scope.id),
3583 source_root: scope.source_root.clone(),
3584 scope_name: Some(scope.id.clone()),
3585 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3586 });
3587 }
3588 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3589 return Ok(cargo_package_index_target(root, package));
3590 }
3591 config::Config::resolve_submodule(root, scope_name)?;
3592 }
3593
3594 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3595 return Ok(SearchIndexTarget {
3596 label: format!("submodule `{}` index", scope.id),
3597 db_path: cfg.db_path_for(root, &scope.id),
3598 source_root: scope.source_root.clone(),
3599 scope_name: Some(scope.id.clone()),
3600 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3601 });
3602 }
3603
3604 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3605 return Ok(cargo_package_index_target(root, package));
3606 }
3607
3608 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3609 return Ok(SearchIndexTarget {
3610 label: format!("submodule `{}` index", scope.id),
3611 db_path: cfg.db_path_for(root, &scope.id),
3612 source_root: scope.source_root.clone(),
3613 scope_name: Some(scope.id.clone()),
3614 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3615 });
3616 }
3617
3618 let db_path = root.join(".tsift/index.db");
3619 if db_path.exists() {
3620 return Ok(SearchIndexTarget {
3621 label: "index".to_string(),
3622 db_path,
3623 source_root: root.to_path_buf(),
3624 scope_name: None,
3625 reindex_cmd: format!("tsift index {}", root.display()),
3626 });
3627 }
3628
3629 let scopes = config::Config::submodule_dirs(root)?;
3630 if scopes.is_empty() {
3631 return Ok(SearchIndexTarget {
3632 label: "index".to_string(),
3633 db_path,
3634 source_root: root.to_path_buf(),
3635 scope_name: None,
3636 reindex_cmd: format!("tsift index {}", root.display()),
3637 });
3638 }
3639
3640 let available_scopes = scopes
3641 .iter()
3642 .map(|scope| scope.id.as_str())
3643 .collect::<Vec<_>>()
3644 .join(", ");
3645 let indexed_scopes = scopes
3646 .iter()
3647 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3648 .map(|scope| scope.id.as_str())
3649 .collect::<Vec<_>>();
3650 let indexed_label = if indexed_scopes.is_empty() {
3651 "none".to_string()
3652 } else {
3653 indexed_scopes.join(", ")
3654 };
3655
3656 bail!(
3657 "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: {}.",
3658 root.display(),
3659 db_path.display(),
3660 available_scopes,
3661 indexed_label
3662 );
3663}
3664
3665pub(crate) fn resolve_query_db_path(
3666 root: &Path,
3667 path_hint: &Path,
3668 scope: Option<&str>,
3669) -> Result<PathBuf> {
3670 Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3671}
3672
3673fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3674 let state = inspect_search_index(target)?;
3675 let Some(reason) = index_reason_for_state(state) else {
3676 return Ok(());
3677 };
3678
3679 match apply_search_index_update(root, target) {
3680 Ok(_) => {
3681 index::inspect_scope_invalidate_all();
3682 Ok(())
3683 }
3684 Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3685 eprintln!(
3686 "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3687 Continuing with the current read-only index snapshot; graph results may lag. \
3688 Retry `{}` after the active writer finishes for fresh graph results.",
3689 index_reason_detail(target, reason),
3690 target.reindex_cmd
3691 );
3692 Ok(())
3693 }
3694 Err(err) => Err(err),
3695 }
3696}
3697
3698pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3699 let root = lint::resolve_project_root_or_canonical_path(path)?;
3700 let target = resolve_query_index_target(&root, path, scope)?;
3701 ensure_query_index_current(&root, &target)?;
3702 let db_path = target.db_path;
3703 if !db_path.exists() {
3704 bail!(
3705 "no index found at {}. Run `tsift index` first.",
3706 db_path.display()
3707 );
3708 }
3709 index::IndexDb::open_read_only_resilient(&db_path)
3710}
3711
3712pub(crate) fn query_tagpath_root(
3713 root: &std::path::Path,
3714 path_hint: &std::path::Path,
3715 scope: Option<&str>,
3716) -> Result<PathBuf> {
3717 if let Some(scope_name) = scope {
3718 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3719 return Ok(scope.source_root);
3720 }
3721 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3722 return Ok(package.package_root);
3723 }
3724 config::Config::resolve_submodule(root, scope_name)?;
3725 }
3726 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3727 return Ok(scope.source_root);
3728 }
3729 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3730 return Ok(package.package_root);
3731 }
3732 Ok(root.to_path_buf())
3733}
3734
3735#[derive(Clone, Debug, Serialize, PartialEq)]
3736struct TraversalNode {
3737 handle: String,
3738 kind: String,
3739 label: String,
3740 #[serde(skip_serializing_if = "Option::is_none")]
3741 ref_id: Option<String>,
3742 #[serde(skip_serializing_if = "Option::is_none")]
3743 path: Option<String>,
3744 #[serde(skip_serializing_if = "Option::is_none")]
3745 line: Option<i64>,
3746 #[serde(skip_serializing_if = "Option::is_none")]
3747 detail: Option<String>,
3748 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3749 properties: BTreeMap<String, String>,
3750 expand: String,
3751}
3752
3753#[derive(Clone, Debug, Serialize, PartialEq)]
3754struct TraversalEdge {
3755 from: String,
3756 to: String,
3757 relation: String,
3758 #[serde(skip_serializing_if = "Option::is_none")]
3759 label: Option<String>,
3760 weight: usize,
3761}
3762
3763#[derive(Clone, Debug, Default)]
3764struct TraversalGraphBuild {
3765 nodes: BTreeMap<String, TraversalNode>,
3766 edges: Vec<TraversalEdge>,
3767 edge_keys: BTreeSet<(String, String, String)>,
3768 warnings: Vec<String>,
3769}
3770
3771pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3772const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3773const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3774const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3775const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3776 "context-pack-graph-orchestration-v1";
3777const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3778const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3779const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3780const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3781const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3782const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3783const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3784
3785#[derive(Debug, Serialize, PartialEq)]
3786struct TraversalTotals {
3787 nodes: usize,
3788 edges: usize,
3789}
3790
3791#[derive(Debug, Serialize, PartialEq)]
3792struct TraversalPathReport {
3793 from: TraversalNode,
3794 to: TraversalNode,
3795 hops: usize,
3796 nodes: Vec<TraversalNode>,
3797 edges: Vec<TraversalEdge>,
3798}
3799
3800#[derive(Debug, Serialize, PartialEq)]
3801struct TraversalRecommendation {
3802 handle: String,
3803 kind: String,
3804 label: String,
3805 reason: String,
3806 score: usize,
3807 expand: String,
3808}
3809
3810#[derive(Debug, Serialize, PartialEq)]
3811struct TraversalReport {
3812 root: String,
3813 #[serde(skip_serializing_if = "Option::is_none")]
3814 scope: Option<String>,
3815 mode: String,
3816 totals: TraversalTotals,
3817 #[serde(skip_serializing_if = "Option::is_none")]
3818 query: Option<String>,
3819 #[serde(skip_serializing_if = "Option::is_none")]
3820 target: Option<String>,
3821 nodes: Vec<TraversalNode>,
3822 edges: Vec<TraversalEdge>,
3823 #[serde(skip_serializing_if = "Option::is_none")]
3824 shortest_path: Option<TraversalPathReport>,
3825 recommendations: Vec<TraversalRecommendation>,
3826 exploration: ExplorationPacket,
3827 truncated: bool,
3828 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3829 warnings: Vec<String>,
3830}
3831
3832#[derive(Debug, Serialize, PartialEq)]
3833struct SemanticRelatedReport {
3834 root: String,
3835 #[serde(skip_serializing_if = "Option::is_none")]
3836 scope: Option<String>,
3837 query: String,
3838 embedding_model: String,
3839 count: usize,
3840 items: Vec<SemanticRelatedItem>,
3841 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3842 warnings: Vec<String>,
3843}
3844
3845#[derive(Clone, Debug, Serialize, PartialEq)]
3846struct SemanticRelatedItem {
3847 handle: String,
3848 kind: String,
3849 label: String,
3850 score: f64,
3851 #[serde(skip_serializing_if = "Option::is_none")]
3852 file_path: Option<String>,
3853 #[serde(skip_serializing_if = "Option::is_none")]
3854 source_symbol: Option<String>,
3855 #[serde(skip_serializing_if = "Option::is_none")]
3856 detail: Option<String>,
3857 expand: String,
3858}
3859
3860#[derive(Clone)]
3861struct TraversalSymbolIndexEntry {
3862 handle: String,
3863 node: TraversalNode,
3864 tokens: BTreeSet<String>,
3865}
3866
3867#[derive(Clone)]
3868struct TraversalFileIndexEntry {
3869 handle: String,
3870 node: TraversalNode,
3871 tokens: BTreeSet<String>,
3872}
3873
3874#[derive(Clone)]
3875struct TraversalRouteIndexEntry {
3876 handle: String,
3877 node: TraversalNode,
3878 tokens: BTreeSet<String>,
3879}
3880
3881#[derive(Clone)]
3882struct TraversalAstSpanIndexEntry {
3883 handle: String,
3884 symbol_handle: String,
3885 file_handle: Option<String>,
3886 file: String,
3887 name: String,
3888 kind: String,
3889 language: String,
3890 node_kind: String,
3891 start_byte: usize,
3892 end_byte: usize,
3893 parent_module: Option<String>,
3894 markdown: Option<MarkdownSpanMetadata>,
3895}
3896
3897#[derive(Clone)]
3898struct TraversalMultiplicityIndexEntry {
3899 handle: String,
3900 node: TraversalNode,
3901 tokens: BTreeSet<String>,
3902}
3903
3904struct TraversalCodeLookup<'a> {
3905 symbols: &'a [TraversalSymbolIndexEntry],
3906 files: &'a [TraversalFileIndexEntry],
3907 routes: &'a [TraversalRouteIndexEntry],
3908 multiplicities: &'a [TraversalMultiplicityIndexEntry],
3909 symbol_index: HashMap<String, Vec<usize>>,
3910 file_index: HashMap<String, Vec<usize>>,
3911 route_index: HashMap<String, Vec<usize>>,
3912 multiplicity_index: HashMap<String, Vec<usize>>,
3913 file_path_index: HashMap<String, String>,
3914}
3915
3916#[derive(Clone, Debug, Serialize, PartialEq)]
3917struct ExplorationBudget {
3918 project_size: String,
3919 max_source_windows: usize,
3920 lines_per_window: usize,
3921 relationship_limit: usize,
3922}
3923
3924#[derive(Clone, Debug, Serialize, PartialEq)]
3925struct ExplorationRelation {
3926 from: String,
3927 relation: String,
3928 to: String,
3929 #[serde(skip_serializing_if = "Option::is_none")]
3930 label: Option<String>,
3931}
3932
3933#[derive(Clone, Debug, Serialize, PartialEq)]
3934struct ExplorationSourceWindow {
3935 handle: String,
3936 file: String,
3937 start: usize,
3938 end: usize,
3939 reason: String,
3940 expand: String,
3941}
3942
3943#[derive(Clone, Debug, Serialize, PartialEq)]
3944struct ExplorationWorkerContext {
3945 handle: String,
3946 target: String,
3947 summary: String,
3948 expand: String,
3949}
3950
3951#[derive(Clone, Debug, Serialize, PartialEq)]
3952struct ExplorationPacket {
3953 budget: ExplorationBudget,
3954 relationship_map: Vec<ExplorationRelation>,
3955 source_windows: Vec<ExplorationSourceWindow>,
3956 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3957 worker_context: Vec<ExplorationWorkerContext>,
3958 no_reread_guidance: String,
3959}
3960
3961impl TraversalGraphBuild {
3962 fn add_node(&mut self, node: TraversalNode) {
3963 self.nodes.entry(node.handle.clone()).or_insert(node);
3964 }
3965
3966 fn add_edge(
3967 &mut self,
3968 from: &str,
3969 to: &str,
3970 relation: &str,
3971 label: Option<String>,
3972 weight: usize,
3973 ) {
3974 if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3975 return;
3976 }
3977 let key = (from.to_string(), to.to_string(), relation.to_string());
3978 if self.edge_keys.insert(key) {
3979 self.edges.push(TraversalEdge {
3980 from: from.to_string(),
3981 to: to.to_string(),
3982 relation: relation.to_string(),
3983 label,
3984 weight,
3985 });
3986 }
3987 }
3988}
3989
3990pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3991 match scope {
3992 Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3993 None => root.join(".tsift/graph.db"),
3994 }
3995}
3996
3997fn graph_projection_meta_id(scope: Option<&str>) -> String {
3998 format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3999}
4000
4001pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
4002 let bytes = serde_json::to_vec(value)?;
4003 Ok(blake3::hash(&bytes).to_hex().to_string())
4004}
4005
4006fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
4007 let mut hashable = node.clone();
4008 hashable.freshness = None;
4009 node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4010 Ok(node)
4011}
4012
4013fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
4014 let mut hashable = edge.clone();
4015 hashable.freshness = None;
4016 edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4017 Ok(edge)
4018}
4019
4020const SEMANTIC_EMBEDDING_DIM: usize = 32;
4021const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
4022
4023fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
4024 match kind {
4025 SemanticRelatedKind::Concept => "concept",
4026 SemanticRelatedKind::Entity => "entity",
4027 SemanticRelatedKind::All => "all",
4028 }
4029}
4030
4031fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
4032 format!(
4033 "tsift semantic {} --path {} --kind {} --limit 10",
4034 shell_quote(query),
4035 shell_quote(root.to_string_lossy().as_ref()),
4036 semantic_related_kind_name(kind)
4037 )
4038}
4039
4040fn semantic_embedding(input: &str) -> Vec<f64> {
4041 let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
4042 let mut tokens = traversal_tokens(input);
4043 if tokens.is_empty() {
4044 let trimmed = input.trim().to_ascii_lowercase();
4045 if !trimmed.is_empty() {
4046 tokens.insert(trimmed);
4047 }
4048 }
4049
4050 for token in tokens {
4051 let hash = blake3::hash(token.as_bytes());
4052 let bytes = hash.as_bytes();
4053 let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
4054 let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
4055 vector[idx] += sign;
4056 }
4057
4058 let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
4059 if norm > 0.0 {
4060 for value in &mut vector {
4061 *value /= norm;
4062 }
4063 }
4064 vector
4065}
4066
4067fn semantic_embedding_property(input: &str) -> String {
4068 semantic_embedding(input)
4069 .iter()
4070 .map(|value| format!("{value:.6}"))
4071 .collect::<Vec<_>>()
4072 .join(",")
4073}
4074
4075fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
4076 let parsed = value
4077 .split(',')
4078 .map(str::trim)
4079 .map(str::parse::<f64>)
4080 .collect::<std::result::Result<Vec<_>, _>>()
4081 .ok()?;
4082 (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
4083}
4084
4085fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
4086 if left.len() != right.len() {
4087 return 0.0;
4088 }
4089 left.iter()
4090 .zip(right.iter())
4091 .map(|(left, right)| left * right)
4092 .sum::<f64>()
4093}
4094
4095fn semantic_entity_handle(name: &str, kind: &str) -> String {
4096 stable_handle(
4097 "gent",
4098 &format!(
4099 "entity:{}:{}",
4100 kind.trim().to_ascii_lowercase(),
4101 name.trim().to_ascii_lowercase()
4102 ),
4103 )
4104}
4105
4106fn semantic_concept_handle(label: &str) -> String {
4107 stable_handle(
4108 "gcon",
4109 &format!("concept:{}", label.trim().to_ascii_lowercase()),
4110 )
4111}
4112
4113fn summary_source_handles(
4114 summary: &summarize::Summary,
4115 file_node_by_path: &BTreeMap<String, String>,
4116 symbol_node_by_file_label: &BTreeMap<(String, String), String>,
4117) -> Vec<String> {
4118 let mut handles = Vec::new();
4119 if let Some(handle) = file_node_by_path.get(&summary.file_path) {
4120 handles.push(handle.clone());
4121 }
4122 if let Some(handle) =
4123 symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
4124 && !handles.iter().any(|existing| existing == handle)
4125 {
4126 handles.push(handle.clone());
4127 }
4128 handles
4129}
4130
4131fn semantic_entity_node(
4132 root: &Path,
4133 summary: &summarize::Summary,
4134 name: &str,
4135 kind: &str,
4136 description: &str,
4137 provenance: &GraphProvenance,
4138) -> SubstrateGraphNode {
4139 let handle = semantic_entity_handle(name, kind);
4140 let detail = if description.trim().is_empty() {
4141 format!("{kind} entity from cached summaries")
4142 } else {
4143 format!("{kind}: {description}")
4144 };
4145 SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
4146 .with_property("handle", handle)
4147 .with_property("ref_id", name.to_string())
4148 .with_property("detail", detail)
4149 .with_property("entity_kind", kind.to_string())
4150 .with_property("description", description.to_string())
4151 .with_property("source_file", summary.file_path.clone())
4152 .with_property("source_symbol", summary.symbol_name.clone())
4153 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4154 .with_property(
4155 "embedding",
4156 semantic_embedding_property(&format!("{name} {kind} {description}")),
4157 )
4158 .with_property(
4159 "expand",
4160 semantic_related_command(root, name, SemanticRelatedKind::Entity),
4161 )
4162 .with_provenance(provenance.clone())
4163}
4164
4165fn semantic_concept_node(
4166 root: &Path,
4167 summary: &summarize::Summary,
4168 label: &str,
4169 provenance: &GraphProvenance,
4170) -> SubstrateGraphNode {
4171 let handle = semantic_concept_handle(label);
4172 SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
4173 .with_property("handle", handle)
4174 .with_property("ref_id", label.to_string())
4175 .with_property("detail", "concept label from cached summaries".to_string())
4176 .with_property("source_file", summary.file_path.clone())
4177 .with_property("source_symbol", summary.symbol_name.clone())
4178 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4179 .with_property("embedding", semantic_embedding_property(label))
4180 .with_property(
4181 "expand",
4182 semantic_related_command(root, label, SemanticRelatedKind::Concept),
4183 )
4184 .with_provenance(provenance.clone())
4185}
4186
4187fn insert_semantic_edge(
4188 edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
4189 edge: SubstrateGraphEdge,
4190) {
4191 edge_map
4192 .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
4193 .or_insert(edge);
4194}
4195
4196fn append_summary_semantic_projection_rows(
4197 root: &Path,
4198 graph: &TraversalGraphBuild,
4199 provenance: &GraphProvenance,
4200 nodes: &mut Vec<SubstrateGraphNode>,
4201 edges: &mut Vec<SubstrateGraphEdge>,
4202) -> Result<()> {
4203 let summaries_db = root.join(".tsift/summaries.db");
4204 if !summaries_db.exists() {
4205 return Ok(());
4206 }
4207
4208 let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
4209 let summaries = summary_db.all()?;
4210 if summaries.is_empty() {
4211 return Ok(());
4212 }
4213
4214 let file_node_by_path = graph
4215 .nodes
4216 .values()
4217 .filter(|node| node.kind == "file")
4218 .filter_map(|node| {
4219 node.path
4220 .as_ref()
4221 .map(|path| (path.clone(), node.handle.clone()))
4222 })
4223 .collect::<BTreeMap<_, _>>();
4224 let symbol_node_by_file_label = graph
4225 .nodes
4226 .values()
4227 .filter(|node| node.kind == "symbol")
4228 .filter_map(|node| {
4229 Some((
4230 (node.path.clone()?, node.label.clone()),
4231 node.handle.clone(),
4232 ))
4233 })
4234 .collect::<BTreeMap<_, _>>();
4235
4236 let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
4237 let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
4238
4239 for summary in &summaries {
4240 let source_handles =
4241 summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
4242 let mut entity_ids_by_name = BTreeMap::<String, String>::new();
4243
4244 if let Some(entities) = &summary.entities {
4245 for entity in entities {
4246 let node = semantic_entity_node(
4247 root,
4248 summary,
4249 &entity.name,
4250 &entity.kind,
4251 &entity.description,
4252 provenance,
4253 );
4254 let entity_id = node.id.clone();
4255 entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
4256 semantic_nodes.entry(entity_id.clone()).or_insert(node);
4257
4258 for source_handle in &source_handles {
4259 insert_semantic_edge(
4260 &mut semantic_edges,
4261 SubstrateGraphEdge::new(
4262 source_handle.clone(),
4263 entity_id.clone(),
4264 "mentions_entity",
4265 )
4266 .with_property("label", format!("summary entity: {}", entity.name))
4267 .with_property("source_file", summary.file_path.clone())
4268 .with_provenance(provenance.clone()),
4269 );
4270 }
4271 }
4272 }
4273
4274 let mut concept_ids = Vec::new();
4275 if let Some(labels) = &summary.concept_labels {
4276 for label in labels
4277 .iter()
4278 .map(|label| label.trim())
4279 .filter(|label| !label.is_empty())
4280 {
4281 let node = semantic_concept_node(root, summary, label, provenance);
4282 let concept_id = node.id.clone();
4283 semantic_nodes.entry(concept_id.clone()).or_insert(node);
4284 concept_ids.push(concept_id.clone());
4285
4286 for source_handle in &source_handles {
4287 insert_semantic_edge(
4288 &mut semantic_edges,
4289 SubstrateGraphEdge::new(
4290 source_handle.clone(),
4291 concept_id.clone(),
4292 "mentions_concept",
4293 )
4294 .with_property("label", format!("summary concept: {label}"))
4295 .with_property("source_file", summary.file_path.clone())
4296 .with_provenance(provenance.clone()),
4297 );
4298 }
4299 }
4300 }
4301
4302 for entity_id in entity_ids_by_name.values() {
4303 for concept_id in &concept_ids {
4304 insert_semantic_edge(
4305 &mut semantic_edges,
4306 SubstrateGraphEdge::new(
4307 entity_id.clone(),
4308 concept_id.clone(),
4309 "tagged_concept",
4310 )
4311 .with_property("label", "entity concept label".to_string())
4312 .with_property("source_file", summary.file_path.clone())
4313 .with_provenance(provenance.clone()),
4314 );
4315 }
4316 }
4317
4318 for idx in 0..concept_ids.len() {
4319 for next_idx in (idx + 1)..concept_ids.len() {
4320 insert_semantic_edge(
4321 &mut semantic_edges,
4322 SubstrateGraphEdge::new(
4323 concept_ids[idx].clone(),
4324 concept_ids[next_idx].clone(),
4325 "related_concept",
4326 )
4327 .with_property("label", format!("co-occurs in {}", summary.symbol_name))
4328 .with_property("source_file", summary.file_path.clone())
4329 .with_provenance(provenance.clone()),
4330 );
4331 }
4332 }
4333
4334 if let Some(relationships) = &summary.relationships {
4335 for relationship in relationships {
4336 let from_id = entity_ids_by_name
4337 .get(&relationship.from.to_ascii_lowercase())
4338 .cloned()
4339 .unwrap_or_else(|| {
4340 let node = semantic_entity_node(
4341 root,
4342 summary,
4343 &relationship.from,
4344 "unknown",
4345 "",
4346 provenance,
4347 );
4348 let id = node.id.clone();
4349 semantic_nodes.entry(id.clone()).or_insert(node);
4350 id
4351 });
4352 let to_id = entity_ids_by_name
4353 .get(&relationship.to.to_ascii_lowercase())
4354 .cloned()
4355 .unwrap_or_else(|| {
4356 let node = semantic_entity_node(
4357 root,
4358 summary,
4359 &relationship.to,
4360 "unknown",
4361 "",
4362 provenance,
4363 );
4364 let id = node.id.clone();
4365 semantic_nodes.entry(id.clone()).or_insert(node);
4366 id
4367 });
4368 insert_semantic_edge(
4369 &mut semantic_edges,
4370 SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
4371 .with_property("relationship_kind", relationship.kind.clone())
4372 .with_property("label", relationship.kind.clone())
4373 .with_property("source_file", summary.file_path.clone())
4374 .with_property("source_symbol", summary.symbol_name.clone())
4375 .with_provenance(provenance.clone()),
4376 );
4377 }
4378 }
4379 }
4380
4381 for node in semantic_nodes.into_values() {
4382 nodes.push(node_with_content_freshness(node)?);
4383 }
4384 for edge in semantic_edges.into_values() {
4385 edges.push(edge_with_content_freshness(edge)?);
4386 }
4387
4388 Ok(())
4389}
4390
4391fn projection_content_hash(
4392 nodes: &[SubstrateGraphNode],
4393 edges: &[SubstrateGraphEdge],
4394) -> Result<String> {
4395 #[derive(Serialize)]
4396 struct Payload<'a> {
4397 version: &'static str,
4398 nodes: &'a [SubstrateGraphNode],
4399 edges: &'a [SubstrateGraphEdge],
4400 }
4401
4402 content_hash(&Payload {
4403 version: GRAPH_PROJECTION_VERSION,
4404 nodes,
4405 edges,
4406 })
4407}
4408
4409pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
4410 projection
4411 .nodes
4412 .iter()
4413 .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
4414 .and_then(|node| node.properties.get("content_hash").cloned())
4415}
4416
4417fn traversal_projection_from_graph(
4418 root: &Path,
4419 scope: Option<&str>,
4420 graph: &TraversalGraphBuild,
4421) -> Result<GraphProjection> {
4422 let provenance = GraphProvenance::new(
4423 "tsift.traverse",
4424 format!("{}:{}", root.display(), scope.unwrap_or("root")),
4425 );
4426 let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
4427 for node in graph.nodes.values() {
4428 let mut projected =
4429 SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
4430 .with_property("handle", node.handle.clone())
4431 .with_property("expand", node.expand.clone())
4432 .with_provenance(provenance.clone());
4433 if let Some(ref_id) = &node.ref_id {
4434 projected = projected.with_property("ref_id", ref_id.clone());
4435 }
4436 if let Some(path) = &node.path {
4437 projected = projected.with_property("path", path.clone());
4438 }
4439 if let Some(line) = node.line {
4440 projected = projected.with_property("line", line.to_string());
4441 }
4442 if let Some(detail) = &node.detail {
4443 projected = projected.with_property("detail", detail.clone());
4444 }
4445 for (key, value) in &node.properties {
4446 projected = projected.with_property(key.clone(), value.clone());
4447 }
4448 nodes.push(node_with_content_freshness(projected)?);
4449 }
4450
4451 let mut edges = Vec::with_capacity(graph.edges.len());
4452 for edge in &graph.edges {
4453 let mut projected =
4454 SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
4455 .with_property("weight", edge.weight.to_string())
4456 .with_provenance(provenance.clone());
4457 if let Some(label) = &edge.label {
4458 projected = projected.with_property("label", label.clone());
4459 }
4460 edges.push(edge_with_content_freshness(projected)?);
4461 }
4462
4463 append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4464 append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4465 append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
4466
4467 let projection_hash = projection_content_hash(&nodes, &edges)?;
4468 let meta = SubstrateGraphNode::new(
4469 graph_projection_meta_id(scope),
4470 GRAPH_PROJECTION_META_KIND,
4471 "tsift traversal projection",
4472 )
4473 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
4474 .with_property("content_hash", projection_hash.clone())
4475 .with_property("root", root.to_string_lossy().to_string())
4476 .with_property("scope", scope.unwrap_or("root"))
4477 .with_property("node_count", graph.nodes.len().to_string())
4478 .with_property("edge_count", graph.edges.len().to_string())
4479 .with_provenance(provenance)
4480 .with_freshness(GraphFreshness::content_hash(projection_hash));
4481 nodes.push(meta);
4482
4483 Ok(GraphProjection { nodes, edges })
4484}
4485
4486#[allow(clippy::too_many_arguments)]
4487fn ensure_traversal_source_handle(
4488 root: &Path,
4489 provenance: &GraphProvenance,
4490 file_node_by_path: &BTreeMap<String, String>,
4491 node: &TraversalNode,
4492 budget: &ExplorationBudget,
4493 source_handle_by_node: &mut BTreeMap<String, String>,
4494 seen_windows: &mut BTreeMap<(String, usize, usize), String>,
4495 nodes: &mut Vec<SubstrateGraphNode>,
4496 edges: &mut Vec<SubstrateGraphEdge>,
4497) -> Result<Option<String>> {
4498 if let Some(handle) = source_handle_by_node.get(&node.handle) {
4499 return Ok(Some(handle.clone()));
4500 }
4501 let Some(window) = exploration_source_window_for_node(root, node, budget) else {
4502 return Ok(None);
4503 };
4504 let window_key = (window.file.clone(), window.start, window.end);
4505 let handle = if let Some(handle) = seen_windows.get(&window_key) {
4506 handle.clone()
4507 } else {
4508 let label = format!("{}:{}-{}", window.file, window.start, window.end);
4509 let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
4510 .with_property("handle", window.handle.clone())
4511 .with_property("file", window.file.clone())
4512 .with_property("start", window.start.to_string())
4513 .with_property("end", window.end.to_string())
4514 .with_property("reason", window.reason.clone())
4515 .with_property("expand", window.expand.clone())
4516 .with_provenance(provenance.clone());
4517 nodes.push(node_with_content_freshness(projected)?);
4518
4519 if let Some(file_handle) = file_node_by_path.get(&window.file) {
4520 let edge = SubstrateGraphEdge::new(
4521 window.handle.clone(),
4522 file_handle.clone(),
4523 "expands_source",
4524 )
4525 .with_property("label", window.reason.clone())
4526 .with_provenance(provenance.clone());
4527 edges.push(edge_with_content_freshness(edge)?);
4528 }
4529 if node.kind != "file" {
4530 let edge = SubstrateGraphEdge::new(
4531 window.handle.clone(),
4532 node.handle.clone(),
4533 "anchors_source",
4534 )
4535 .with_property("label", window.reason.clone())
4536 .with_provenance(provenance.clone());
4537 edges.push(edge_with_content_freshness(edge)?);
4538 }
4539 seen_windows.insert(window_key, window.handle.clone());
4540 window.handle
4541 };
4542 source_handle_by_node.insert(node.handle.clone(), handle.clone());
4543 Ok(Some(handle))
4544}
4545
4546fn push_traversal_backlog_target_handles<'a>(
4547 backlog: &TraversalNode,
4548 edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
4549 node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
4550 max_handles: usize,
4551 seen_target_nodes: &mut BTreeSet<String>,
4552 target_node_handles: &mut Vec<String>,
4553) {
4554 for edge in edges_by_from
4555 .get(backlog.handle.as_str())
4556 .into_iter()
4557 .flatten()
4558 .filter(|edge| edge.relation == "mentions")
4559 {
4560 let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
4561 continue;
4562 };
4563 if !matches!(
4564 target_node.kind.as_str(),
4565 "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
4566 ) {
4567 continue;
4568 }
4569 if target_node
4570 .path
4571 .as_deref()
4572 .zip(backlog.path.as_deref())
4573 .is_some_and(|(target_path, backlog_path)| {
4574 target_path == backlog_path && target_path.ends_with(".md")
4575 })
4576 {
4577 continue;
4578 }
4579 if seen_target_nodes.insert(target_node.handle.clone()) {
4580 target_node_handles.push(target_node.handle.clone());
4581 }
4582 if target_node_handles.len() >= max_handles {
4583 break;
4584 }
4585 }
4586}
4587
4588fn append_traversal_context_projection_rows(
4589 root: &Path,
4590 graph: &TraversalGraphBuild,
4591 provenance: &GraphProvenance,
4592 nodes: &mut Vec<SubstrateGraphNode>,
4593 edges: &mut Vec<SubstrateGraphEdge>,
4594) -> Result<()> {
4595 let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4596 let file_node_by_path = graph
4597 .nodes
4598 .values()
4599 .filter(|node| node.kind == "file")
4600 .filter_map(|node| {
4601 node.path
4602 .as_ref()
4603 .map(|path| (path.clone(), node.handle.clone()))
4604 })
4605 .collect::<BTreeMap<_, _>>();
4606
4607 let node_by_handle = graph
4608 .nodes
4609 .values()
4610 .map(|node| (node.handle.as_str(), node))
4611 .collect::<BTreeMap<_, _>>();
4612 let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4613 for edge in &graph.edges {
4614 edges_by_from
4615 .entry(edge.from.as_str())
4616 .or_default()
4617 .push(edge);
4618 }
4619 for rows in edges_by_from.values_mut() {
4620 rows.sort_by(|left, right| {
4621 right
4622 .weight
4623 .cmp(&left.weight)
4624 .then(left.relation.cmp(&right.relation))
4625 .then(left.to.cmp(&right.to))
4626 });
4627 }
4628
4629 let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4630 let mut source_handle_by_node = BTreeMap::<String, String>::new();
4631
4632 let mut code_context_count = 0usize;
4633 let code_context_limit = budget.relationship_limit.min(8);
4634 for node in graph.nodes.values() {
4635 if !matches!(
4636 node.kind.as_str(),
4637 "backlog" | "job_packet" | "worker_result"
4638 ) {
4639 continue;
4640 }
4641 let mut target_node_handles = Vec::new();
4642 let mut fallback_target_handles = Vec::new();
4643 let mut seen_target_nodes = BTreeSet::new();
4644 if node.kind == "backlog" || node.kind == "worker_result" {
4645 push_traversal_backlog_target_handles(
4646 node,
4647 &edges_by_from,
4648 &node_by_handle,
4649 budget.max_source_windows,
4650 &mut seen_target_nodes,
4651 &mut target_node_handles,
4652 );
4653 fallback_target_handles.push(node.handle.clone());
4654 } else {
4655 for edge in edges_by_from
4656 .get(node.handle.as_str())
4657 .into_iter()
4658 .flatten()
4659 .filter(|edge| edge.relation == "targets")
4660 {
4661 let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4662 continue;
4663 };
4664 fallback_target_handles.push(backlog.handle.clone());
4665 push_traversal_backlog_target_handles(
4666 backlog,
4667 &edges_by_from,
4668 &node_by_handle,
4669 budget.max_source_windows,
4670 &mut seen_target_nodes,
4671 &mut target_node_handles,
4672 );
4673 if target_node_handles.len() >= budget.max_source_windows {
4674 break;
4675 }
4676 }
4677 if fallback_target_handles.is_empty() {
4678 continue;
4679 }
4680 }
4681 let code_context = !target_node_handles.is_empty();
4682 if target_node_handles.is_empty() {
4683 target_node_handles = dedupe_preserve_order(fallback_target_handles);
4684 } else if code_context_count >= code_context_limit {
4685 continue;
4686 }
4687
4688 let mut worker_source_handles = Vec::new();
4689 let mut seen_worker_handles = BTreeSet::new();
4690 for target_handle in target_node_handles {
4691 if worker_source_handles.len() >= budget.max_source_windows {
4692 break;
4693 }
4694 let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4695 continue;
4696 };
4697 let Some(handle) = ensure_traversal_source_handle(
4698 root,
4699 provenance,
4700 &file_node_by_path,
4701 target_node,
4702 &budget,
4703 &mut source_handle_by_node,
4704 &mut seen_windows,
4705 nodes,
4706 edges,
4707 )?
4708 else {
4709 continue;
4710 };
4711 if seen_worker_handles.insert(handle.clone()) {
4712 worker_source_handles.push(handle);
4713 }
4714 }
4715 if worker_source_handles.is_empty() {
4716 continue;
4717 }
4718 let target = node
4719 .path
4720 .clone()
4721 .unwrap_or_else(|| root.to_string_lossy().to_string());
4722 let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4723 let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4724 let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4725 .with_property("handle", handle.clone())
4726 .with_property("target", target.clone())
4727 .with_property("summary", summary)
4728 .with_property(
4729 "source_handle_count",
4730 worker_source_handles.len().to_string(),
4731 )
4732 .with_property(
4733 "expand",
4734 format!(
4735 "tsift --envelope context-pack {} --budget normal",
4736 shell_quote(&target)
4737 ),
4738 )
4739 .with_provenance(provenance.clone());
4740 nodes.push(node_with_content_freshness(projected)?);
4741
4742 let request_edge =
4743 SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4744 .with_property("label", "bounded worker context".to_string())
4745 .with_provenance(provenance.clone());
4746 edges.push(edge_with_content_freshness(request_edge)?);
4747
4748 for source_handle in &worker_source_handles {
4749 let scope_edge =
4750 SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4751 .with_property("label", "bounded worker source window".to_string())
4752 .with_provenance(provenance.clone());
4753 edges.push(edge_with_content_freshness(scope_edge)?);
4754 }
4755 if code_context {
4756 code_context_count += 1;
4757 }
4758 }
4759
4760 Ok(())
4761}
4762
4763fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4764 let handle = node
4765 .properties
4766 .get("handle")
4767 .cloned()
4768 .unwrap_or_else(|| node.id.clone());
4769 TraversalNode {
4770 expand: node
4771 .properties
4772 .get("expand")
4773 .cloned()
4774 .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4775 handle,
4776 kind: node.kind,
4777 label: node.label,
4778 ref_id: node.properties.get("ref_id").cloned(),
4779 path: node.properties.get("path").cloned(),
4780 line: node
4781 .properties
4782 .get("line")
4783 .and_then(|value| value.parse::<i64>().ok()),
4784 detail: node.properties.get("detail").cloned(),
4785 properties: node.properties,
4786 }
4787}
4788
4789fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4790 let mut graph = TraversalGraphBuild::default();
4791 for node in store.all_nodes()? {
4792 if node.kind == GRAPH_PROJECTION_META_KIND {
4793 continue;
4794 }
4795 graph.add_node(traversal_node_from_graph_node(root, node));
4796 }
4797 for edge in store.all_edges()? {
4798 graph.add_edge(
4799 &edge.from_id,
4800 &edge.to_id,
4801 &edge.kind,
4802 edge.properties.get("label").cloned(),
4803 edge.properties
4804 .get("weight")
4805 .and_then(|value| value.parse::<usize>().ok())
4806 .unwrap_or(1),
4807 );
4808 }
4809 Ok(graph)
4810}
4811
4812pub(crate) fn convex_rows_from_graph_store(
4813 store: &impl GraphStore,
4814) -> Result<ConvexProjectionRows> {
4815 Ok(GraphProjection {
4816 nodes: store.all_nodes()?,
4817 edges: store.all_edges()?,
4818 }
4819 .to_convex_rows())
4820}
4821
4822#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4823struct ConvexRequiredIndex {
4824 table: String,
4825 name: String,
4826 fields: Vec<String>,
4827}
4828
4829#[derive(Clone, Debug, Serialize, PartialEq)]
4830struct ConvexSyncChunk {
4831 operation: String,
4832 chunk: usize,
4833 count: usize,
4834 keys: Vec<String>,
4835 max_attempts: usize,
4836 retry_policy: String,
4837}
4838
4839#[derive(Clone, Debug, Serialize, PartialEq)]
4840struct ConvexTransportSummary {
4841 endpoint_env: String,
4842 endpoint_configured: bool,
4843 auth_token_env: String,
4844 auth_configured: bool,
4845 remote_snapshot: bool,
4846 applied_chunks: usize,
4847}
4848
4849#[derive(Clone, Debug, Serialize, PartialEq)]
4850struct ConvexTransportReceipt {
4851 operation: String,
4852 chunk: usize,
4853 attempt: usize,
4854 status: String,
4855 message: Option<String>,
4856}
4857
4858#[derive(Serialize)]
4859#[serde(rename_all = "camelCase")]
4860struct ConvexTransportRequest<'a> {
4861 operation: &'a str,
4862 chunk: usize,
4863 projection_version: &'a str,
4864 projection_hash: Option<&'a str>,
4865 #[serde(skip_serializing_if = "Option::is_none")]
4866 projection_meta_id: Option<&'a str>,
4867 node_rows: Vec<ConvexNodeRow>,
4868 edge_rows: Vec<ConvexEdgeRow>,
4869 keys: Vec<String>,
4870 #[serde(skip_serializing_if = "Option::is_none")]
4871 cursor: Option<String>,
4872 #[serde(skip_serializing_if = "Option::is_none")]
4873 limit: Option<usize>,
4874}
4875
4876#[derive(Deserialize)]
4877#[serde(rename_all = "camelCase")]
4878struct ConvexTransportResponse {
4879 status: Option<String>,
4880 message: Option<String>,
4881 rows: Option<ConvexProjectionRows>,
4882 #[serde(default)]
4883 meta: Option<ConvexSnapshotMeta>,
4884 #[serde(default)]
4885 page: Option<ConvexSnapshotPage>,
4886}
4887
4888#[derive(Deserialize, Debug, Clone)]
4889#[serde(rename_all = "camelCase")]
4890struct ConvexSnapshotMeta {
4891 #[serde(default)]
4895 #[allow(dead_code)]
4896 indexes: Vec<ConvexRequiredIndex>,
4897 #[serde(default)]
4898 #[allow(dead_code)]
4899 node_count: Option<usize>,
4900 #[serde(default)]
4901 #[allow(dead_code)]
4902 edge_count: Option<usize>,
4903 #[serde(default)]
4904 projection_hash: Option<String>,
4905 #[serde(default)]
4906 #[allow(dead_code)]
4907 page_size: Option<usize>,
4908}
4909
4910#[derive(Deserialize, Debug, Clone)]
4915#[serde(rename_all = "camelCase")]
4916struct ConvexSnapshotPage {
4917 rows: Vec<serde_json::Value>,
4918 #[serde(default)]
4919 next_cursor: Option<String>,
4920}
4921
4922#[derive(Clone, Debug, Serialize, PartialEq)]
4923struct ConvexProjectionFreshness {
4924 status: String,
4925 fail_closed: bool,
4926 local_hash: Option<String>,
4927 snapshot_hash: Option<String>,
4928 missing_nodes: Vec<String>,
4929 stale_nodes: Vec<String>,
4930 missing_edges: Vec<String>,
4931 stale_edges: Vec<String>,
4932 diagnostics: Vec<String>,
4933}
4934
4935const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4936
4937impl ConvexProjectionFreshness {
4938 fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4939 Self {
4940 status: "current".to_string(),
4941 fail_closed: false,
4942 local_hash,
4943 snapshot_hash,
4944 missing_nodes: Vec::new(),
4945 stale_nodes: Vec::new(),
4946 missing_edges: Vec::new(),
4947 stale_edges: Vec::new(),
4948 diagnostics: Vec::new(),
4949 }
4950 }
4951}
4952
4953#[derive(Clone, Debug, Serialize, PartialEq)]
4954struct ConvexSyncReport {
4955 root: String,
4956 #[serde(skip_serializing_if = "Option::is_none")]
4957 scope: Option<String>,
4958 graph_db: String,
4959 dry_run: bool,
4960 projection_version: String,
4961 projection_hash: Option<String>,
4962 required_indexes: Vec<ConvexRequiredIndex>,
4963 node_upserts: Vec<ConvexNodeRow>,
4964 edge_upserts: Vec<ConvexEdgeRow>,
4965 node_tombstones: Vec<String>,
4966 edge_tombstones: Vec<String>,
4967 chunks: Vec<ConvexSyncChunk>,
4968 freshness: ConvexProjectionFreshness,
4969 transport: Option<ConvexTransportSummary>,
4970 receipts: Vec<ConvexTransportReceipt>,
4971 diagnostics: Vec<String>,
4972 warnings: Vec<String>,
4973}
4974
4975fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4976 vec![
4977 ConvexRequiredIndex {
4978 table: "nodes".to_string(),
4979 name: "by_external_id".to_string(),
4980 fields: vec!["externalId".to_string()],
4981 },
4982 ConvexRequiredIndex {
4983 table: "nodes".to_string(),
4984 name: "by_kind".to_string(),
4985 fields: vec!["kind".to_string()],
4986 },
4987 ConvexRequiredIndex {
4988 table: "edges".to_string(),
4989 name: "by_edge_key".to_string(),
4990 fields: vec!["edgeKey".to_string()],
4991 },
4992 ConvexRequiredIndex {
4993 table: "edges".to_string(),
4994 name: "by_from_kind".to_string(),
4995 fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4996 },
4997 ConvexRequiredIndex {
4998 table: "edges".to_string(),
4999 name: "by_to_kind".to_string(),
5000 fields: vec!["toExternalId".to_string(), "kind".to_string()],
5001 },
5002 ]
5003}
5004
5005pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
5006 let content = fs::read_to_string(path)
5007 .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
5008 serde_json::from_str(&content)
5009 .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
5010}
5011
5012fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
5013 let mut diagnostics = Vec::new();
5014 let mut node_counts = BTreeMap::<&str, usize>::new();
5015 for row in &rows.nodes {
5016 *node_counts.entry(row.external_id.as_str()).or_default() += 1;
5017 }
5018 for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
5019 diagnostics.push(format!(
5020 "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
5021 ));
5022 }
5023
5024 let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
5025 let mut edge_counts = BTreeMap::<&str, usize>::new();
5026 for edge in &rows.edges {
5027 *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
5028 if !node_ids.contains(edge.from_external_id.as_str()) {
5029 diagnostics.push(format!(
5030 "Convex snapshot edge {} references missing from node {}",
5031 edge.edge_key, edge.from_external_id
5032 ));
5033 }
5034 if !node_ids.contains(edge.to_external_id.as_str()) {
5035 diagnostics.push(format!(
5036 "Convex snapshot edge {} references missing to node {}",
5037 edge.edge_key, edge.to_external_id
5038 ));
5039 }
5040 let expected_key =
5041 ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
5042 if edge.edge_key != expected_key {
5043 diagnostics.push(format!(
5044 "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
5045 edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
5046 ));
5047 }
5048 }
5049 for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
5050 diagnostics.push(format!(
5051 "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
5052 ));
5053 }
5054 diagnostics
5055}
5056
5057pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
5058 let diagnostics = convex_projection_row_diagnostics(rows);
5059 if diagnostics.is_empty() {
5060 Ok(())
5061 } else {
5062 bail!("{}", diagnostics.join("; "))
5063 }
5064}
5065
5066pub(crate) struct ConvexHttpTransport {
5067 endpoint: String,
5068 auth_token_env: String,
5069 auth_token: Option<String>,
5070}
5071
5072impl ConvexHttpTransport {
5073 fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
5074 let endpoint = endpoint
5075 .map(str::to_string)
5076 .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
5077 .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
5078 let auth_token = env::var(auth_token_env)
5079 .ok()
5080 .filter(|value| !value.trim().is_empty());
5081 Ok(Self {
5082 endpoint,
5083 auth_token_env: auth_token_env.to_string(),
5084 auth_token,
5085 })
5086 }
5087
5088 fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
5089 ConvexTransportSummary {
5090 endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
5091 endpoint_configured: true,
5092 auth_token_env: self.auth_token_env.clone(),
5093 auth_configured: self.auth_token.is_some(),
5094 remote_snapshot,
5095 applied_chunks,
5096 }
5097 }
5098
5099 fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
5100 let mut builder = ureq::post(&self.endpoint);
5101 if let Some(token) = &self.auth_token {
5102 builder = builder.header("Authorization", &format!("Bearer {token}"));
5103 }
5104 builder
5105 .send_json(request)
5106 .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
5107 .body_mut()
5108 .read_json::<ConvexTransportResponse>()
5109 .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
5110 }
5111
5112 fn fetch_snapshot(
5123 &self,
5124 projection_version: &str,
5125 scope: Option<&str>,
5126 local_hash: Option<&str>,
5127 local_rows: Option<&ConvexProjectionRows>,
5128 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5129 match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
5130 Ok(rows) => Ok(rows),
5131 Err(err) => {
5132 let msg = format!("{err:#}");
5137 let is_unknown_op = msg.contains("unknown operation")
5138 || msg.contains("snapshot_meta")
5139 || msg.contains("404");
5140 if !is_unknown_op {
5141 return Err(err);
5142 }
5143 self.fetch_snapshot_legacy(projection_version)
5144 .map(|rows| (rows, Vec::new()))
5145 }
5146 }
5147 }
5148
5149 fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
5150 let response = self.post(&ConvexTransportRequest {
5151 operation: "snapshot",
5152 chunk: 0,
5153 projection_version,
5154 projection_hash: None,
5155 projection_meta_id: None,
5156 node_rows: Vec::new(),
5157 edge_rows: Vec::new(),
5158 keys: Vec::new(),
5159 cursor: None,
5160 limit: None,
5161 })?;
5162 response
5163 .rows
5164 .context("Convex snapshot response did not include rows")
5165 }
5166
5167 fn fetch_snapshot_paginated(
5168 &self,
5169 projection_version: &str,
5170 scope: Option<&str>,
5171 local_hash: Option<&str>,
5172 local_rows: Option<&ConvexProjectionRows>,
5173 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5174 let projection_meta_id = graph_projection_meta_id(scope);
5175 let meta_response = self.post(&ConvexTransportRequest {
5176 operation: "snapshot_meta",
5177 chunk: 0,
5178 projection_version,
5179 projection_hash: None,
5180 projection_meta_id: Some(&projection_meta_id),
5181 node_rows: Vec::new(),
5182 edge_rows: Vec::new(),
5183 keys: Vec::new(),
5184 cursor: None,
5185 limit: None,
5186 })?;
5187 if matches!(meta_response.status.as_deref(), Some("error")) {
5188 anyhow::bail!(
5189 "Convex snapshot_meta returned error: {}",
5190 meta_response.message.unwrap_or_default()
5191 );
5192 }
5193 let meta = meta_response
5194 .meta
5195 .context("Convex snapshot_meta response did not include meta")?;
5196 if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
5197 (meta.projection_hash.as_deref(), local_hash, local_rows)
5198 && remote_hash == local_hash
5199 {
5200 return Ok((
5201 local_rows.clone(),
5202 vec![
5203 "remote projection hash matched local graph; skipped full row-page snapshot diff"
5204 .to_string(),
5205 ],
5206 ));
5207 }
5208
5209 let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
5210 let mut node_cursor: Option<String> = None;
5211 loop {
5212 let response = self.post(&ConvexTransportRequest {
5213 operation: "snapshot_nodes_page",
5214 chunk: 0,
5215 projection_version,
5216 projection_hash: None,
5217 projection_meta_id: None,
5218 node_rows: Vec::new(),
5219 edge_rows: Vec::new(),
5220 keys: Vec::new(),
5221 cursor: node_cursor.clone(),
5222 limit: None,
5223 })?;
5224 let page = response
5225 .page
5226 .context("Convex snapshot_nodes_page response did not include page")?;
5227 for raw in page.rows {
5228 let row: ConvexNodeRow =
5229 serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
5230 nodes.push(row);
5231 }
5232 match page.next_cursor {
5233 Some(next) => node_cursor = Some(next),
5234 None => break,
5235 }
5236 }
5237
5238 let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
5239 let mut edge_cursor: Option<String> = None;
5240 loop {
5241 let response = self.post(&ConvexTransportRequest {
5242 operation: "snapshot_edges_page",
5243 chunk: 0,
5244 projection_version,
5245 projection_hash: None,
5246 projection_meta_id: None,
5247 node_rows: Vec::new(),
5248 edge_rows: Vec::new(),
5249 keys: Vec::new(),
5250 cursor: edge_cursor.clone(),
5251 limit: None,
5252 })?;
5253 let page = response
5254 .page
5255 .context("Convex snapshot_edges_page response did not include page")?;
5256 for raw in page.rows {
5257 let row: ConvexEdgeRow =
5258 serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
5259 edges.push(row);
5260 }
5261 match page.next_cursor {
5262 Some(next) => edge_cursor = Some(next),
5263 None => break,
5264 }
5265 }
5266
5267 Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
5268 }
5269
5270 fn apply_chunk(
5271 &self,
5272 report: &ConvexSyncReport,
5273 chunk: &ConvexSyncChunk,
5274 ) -> Result<ConvexTransportReceipt> {
5275 let node_rows = if chunk.operation == "upsert_nodes" {
5276 report
5277 .node_upserts
5278 .iter()
5279 .filter(|row| chunk.keys.contains(&row.external_id))
5280 .cloned()
5281 .collect()
5282 } else {
5283 Vec::new()
5284 };
5285 let edge_rows = if chunk.operation == "upsert_edges" {
5286 report
5287 .edge_upserts
5288 .iter()
5289 .filter(|row| chunk.keys.contains(&row.edge_key))
5290 .cloned()
5291 .collect()
5292 } else {
5293 Vec::new()
5294 };
5295 let request = ConvexTransportRequest {
5296 operation: &chunk.operation,
5297 chunk: chunk.chunk,
5298 projection_version: &report.projection_version,
5299 projection_hash: report.projection_hash.as_deref(),
5300 projection_meta_id: None,
5301 node_rows,
5302 edge_rows,
5303 keys: chunk.keys.clone(),
5304 cursor: None,
5305 limit: None,
5306 };
5307 let mut last_error = None;
5308 for attempt in 1..=chunk.max_attempts {
5309 match self.post(&request) {
5310 Ok(response) => {
5311 return Ok(ConvexTransportReceipt {
5312 operation: chunk.operation.clone(),
5313 chunk: chunk.chunk,
5314 attempt,
5315 status: response.status.unwrap_or_else(|| "ok".to_string()),
5316 message: response.message,
5317 });
5318 }
5319 Err(err) => {
5320 last_error = Some(err);
5321 if attempt < chunk.max_attempts {
5322 std::thread::sleep(Duration::from_millis(100 * attempt as u64));
5323 }
5324 }
5325 }
5326 }
5327 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
5328 .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
5329 }
5330}
5331
5332fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
5333 let meta_id = graph_projection_meta_id(scope);
5334 rows.nodes
5335 .iter()
5336 .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
5337 .and_then(|row| row.properties.get("content_hash").cloned())
5338}
5339
5340fn convex_projection_freshness(
5341 local: &ConvexProjectionRows,
5342 snapshot: Option<&ConvexProjectionRows>,
5343 scope: Option<&str>,
5344) -> ConvexProjectionFreshness {
5345 let local_hash = convex_projection_hash(local, scope);
5346 let Some(snapshot) = snapshot else {
5347 return ConvexProjectionFreshness {
5348 status: "unchecked".to_string(),
5349 fail_closed: false,
5350 local_hash,
5351 snapshot_hash: None,
5352 missing_nodes: Vec::new(),
5353 stale_nodes: Vec::new(),
5354 missing_edges: Vec::new(),
5355 stale_edges: Vec::new(),
5356 diagnostics: vec![
5357 "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
5358 ],
5359 };
5360 };
5361
5362 let snapshot_hash = convex_projection_hash(snapshot, scope);
5363 let snapshot_nodes = snapshot
5364 .nodes
5365 .iter()
5366 .map(|row| (row.external_id.as_str(), row))
5367 .collect::<BTreeMap<_, _>>();
5368 let snapshot_edges = snapshot
5369 .edges
5370 .iter()
5371 .map(|row| (row.edge_key.as_str(), row))
5372 .collect::<BTreeMap<_, _>>();
5373
5374 let mut missing_nodes = Vec::new();
5375 let mut stale_nodes = Vec::new();
5376 for row in &local.nodes {
5377 match snapshot_nodes.get(row.external_id.as_str()) {
5378 Some(snapshot_row) if *snapshot_row == row => {}
5379 Some(_) => stale_nodes.push(row.external_id.clone()),
5380 None => missing_nodes.push(row.external_id.clone()),
5381 }
5382 }
5383
5384 let mut missing_edges = Vec::new();
5385 let mut stale_edges = Vec::new();
5386 for row in &local.edges {
5387 match snapshot_edges.get(row.edge_key.as_str()) {
5388 Some(snapshot_row) if *snapshot_row == row => {}
5389 Some(_) => stale_edges.push(row.edge_key.clone()),
5390 None => missing_edges.push(row.edge_key.clone()),
5391 }
5392 }
5393
5394 let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
5395 let rows_current = missing_nodes.is_empty()
5396 && stale_nodes.is_empty()
5397 && missing_edges.is_empty()
5398 && stale_edges.is_empty();
5399 if hash_current && rows_current {
5400 return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
5401 }
5402
5403 let mut diagnostics = Vec::new();
5404 if local_hash != snapshot_hash {
5405 diagnostics.push(format!(
5406 "projection hash mismatch: local={} snapshot={}",
5407 local_hash.as_deref().unwrap_or("missing"),
5408 snapshot_hash.as_deref().unwrap_or("missing")
5409 ));
5410 }
5411 if !missing_nodes.is_empty() || !missing_edges.is_empty() {
5412 diagnostics.push(format!(
5413 "Convex snapshot is missing {} node(s) and {} edge(s)",
5414 missing_nodes.len(),
5415 missing_edges.len()
5416 ));
5417 }
5418 if !stale_nodes.is_empty() || !stale_edges.is_empty() {
5419 diagnostics.push(format!(
5420 "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
5421 stale_nodes.len(),
5422 stale_edges.len()
5423 ));
5424 }
5425
5426 ConvexProjectionFreshness {
5427 status: "stale".to_string(),
5428 fail_closed: true,
5429 local_hash,
5430 snapshot_hash,
5431 missing_nodes,
5432 stale_nodes,
5433 missing_edges,
5434 stale_edges,
5435 diagnostics,
5436 }
5437}
5438
5439pub(crate) fn verify_convex_projection_snapshot(
5440 root: &Path,
5441 scope: Option<&str>,
5442 snapshot_path: &Path,
5443) -> Result<()> {
5444 let graph_db = graph_substrate_db_path(root, scope);
5445 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5446 let local = convex_rows_from_graph_store(&store)?;
5447 let snapshot = load_convex_projection_rows(snapshot_path)?;
5448 validate_convex_projection_rows(&snapshot)?;
5449 let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
5450 if freshness.fail_closed {
5451 bail!(
5452 "Convex graph projection is not current for {}: {}",
5453 root.display(),
5454 freshness.diagnostics.join("; ")
5455 );
5456 }
5457 Ok(())
5458}
5459
5460fn convex_rows_diff(
5461 local: &ConvexProjectionRows,
5462 snapshot: Option<&ConvexProjectionRows>,
5463) -> (
5464 Vec<ConvexNodeRow>,
5465 Vec<ConvexEdgeRow>,
5466 Vec<String>,
5467 Vec<String>,
5468) {
5469 let Some(snapshot) = snapshot else {
5470 return (
5471 local.nodes.clone(),
5472 local.edges.clone(),
5473 Vec::new(),
5474 Vec::new(),
5475 );
5476 };
5477 let local_nodes = local
5478 .nodes
5479 .iter()
5480 .map(|row| (row.external_id.as_str(), row))
5481 .collect::<BTreeMap<_, _>>();
5482 let local_edges = local
5483 .edges
5484 .iter()
5485 .map(|row| (row.edge_key.as_str(), row))
5486 .collect::<BTreeMap<_, _>>();
5487 let snapshot_nodes = snapshot
5488 .nodes
5489 .iter()
5490 .map(|row| (row.external_id.as_str(), row))
5491 .collect::<BTreeMap<_, _>>();
5492 let snapshot_edges = snapshot
5493 .edges
5494 .iter()
5495 .map(|row| (row.edge_key.as_str(), row))
5496 .collect::<BTreeMap<_, _>>();
5497
5498 let node_upserts = local
5499 .nodes
5500 .iter()
5501 .filter(|row| {
5502 snapshot_nodes
5503 .get(row.external_id.as_str())
5504 .is_none_or(|snapshot_row| *snapshot_row != *row)
5505 })
5506 .cloned()
5507 .collect::<Vec<_>>();
5508 let edge_upserts = local
5509 .edges
5510 .iter()
5511 .filter(|row| {
5512 snapshot_edges
5513 .get(row.edge_key.as_str())
5514 .is_none_or(|snapshot_row| *snapshot_row != *row)
5515 })
5516 .cloned()
5517 .collect::<Vec<_>>();
5518 let node_tombstones = snapshot
5519 .nodes
5520 .iter()
5521 .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
5522 .map(|row| row.external_id.clone())
5523 .collect::<Vec<_>>();
5524 let edge_tombstones = snapshot
5525 .edges
5526 .iter()
5527 .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
5528 .map(|row| row.edge_key.clone())
5529 .collect::<Vec<_>>();
5530
5531 (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
5532}
5533
5534fn push_sync_chunks(
5535 chunks: &mut Vec<ConvexSyncChunk>,
5536 operation: &str,
5537 keys: Vec<String>,
5538 size: usize,
5539) {
5540 if keys.is_empty() {
5541 return;
5542 }
5543 for (idx, chunk) in keys.chunks(size).enumerate() {
5544 chunks.push(ConvexSyncChunk {
5545 operation: operation.to_string(),
5546 chunk: idx + 1,
5547 count: chunk.len(),
5548 keys: chunk.to_vec(),
5549 max_attempts: 3,
5550 retry_policy:
5551 "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
5552 .to_string(),
5553 });
5554 }
5555}
5556
5557pub(crate) fn build_convex_sync_report_with_snapshot(
5558 path: &Path,
5559 scope: Option<&str>,
5560 snapshot: Option<ConvexProjectionRows>,
5561 chunk_size: usize,
5562 dry_run: bool,
5563) -> Result<ConvexSyncReport> {
5564 if chunk_size == 0 {
5565 bail!("--chunk-size must be greater than zero");
5566 }
5567 let root = lint::resolve_project_root_or_canonical_path(path)?;
5568 let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
5569 let graph_db = graph_substrate_db_path(&root, scope);
5570 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5571 let local = convex_rows_from_graph_store(&store)?;
5572 let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
5573 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
5574 convex_rows_diff(&local, snapshot.as_ref());
5575
5576 let mut chunks = Vec::new();
5577 push_sync_chunks(
5578 &mut chunks,
5579 "delete_edges",
5580 edge_tombstones.clone(),
5581 chunk_size,
5582 );
5583 push_sync_chunks(
5584 &mut chunks,
5585 "upsert_nodes",
5586 node_upserts
5587 .iter()
5588 .map(|row| row.external_id.clone())
5589 .collect(),
5590 chunk_size,
5591 );
5592 push_sync_chunks(
5593 &mut chunks,
5594 "upsert_edges",
5595 edge_upserts
5596 .iter()
5597 .map(|row| row.edge_key.clone())
5598 .collect(),
5599 chunk_size,
5600 );
5601 push_sync_chunks(
5602 &mut chunks,
5603 "delete_nodes",
5604 node_tombstones.clone(),
5605 chunk_size,
5606 );
5607
5608 let mut diagnostics = vec![
5609 "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5610 .to_string(),
5611 ];
5612 if dry_run {
5613 diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5614 }
5615 if freshness.fail_closed {
5616 diagnostics.push(
5617 "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5618 .to_string(),
5619 );
5620 }
5621
5622 Ok(ConvexSyncReport {
5623 root: root.to_string_lossy().to_string(),
5624 scope: scope.map(str::to_string),
5625 graph_db: graph_db.to_string_lossy().to_string(),
5626 dry_run,
5627 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5628 projection_hash: convex_projection_hash(&local, scope),
5629 required_indexes: convex_required_indexes(),
5630 node_upserts,
5631 edge_upserts,
5632 node_tombstones,
5633 edge_tombstones,
5634 chunks,
5635 freshness,
5636 transport: None,
5637 receipts: Vec::new(),
5638 diagnostics,
5639 warnings: graph.warnings,
5640 })
5641}
5642
5643#[cfg(test)]
5644fn build_convex_sync_report(
5645 path: &Path,
5646 scope: Option<&str>,
5647 snapshot_path: Option<&Path>,
5648 chunk_size: usize,
5649) -> Result<ConvexSyncReport> {
5650 let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5651 build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5652}
5653
5654pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5655 if compact {
5656 println!(
5657 "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5658 report.node_upserts.len(),
5659 report.node_tombstones.len(),
5660 report.edge_upserts.len(),
5661 report.edge_tombstones.len(),
5662 report.chunks.len(),
5663 report.freshness.status
5664 );
5665 return;
5666 }
5667
5668 println!(
5669 "Convex graph sync {}",
5670 if report.dry_run { "dry-run" } else { "apply" }
5671 );
5672 println!("root: {}", report.root);
5673 println!("graph_db: {}", report.graph_db);
5674 println!(
5675 "upserts: {} node(s), {} edge(s)",
5676 report.node_upserts.len(),
5677 report.edge_upserts.len()
5678 );
5679 println!(
5680 "tombstones: {} node(s), {} edge(s)",
5681 report.node_tombstones.len(),
5682 report.edge_tombstones.len()
5683 );
5684 println!("chunks: {}", report.chunks.len());
5685 println!("freshness: {}", report.freshness.status);
5686 if let Some(transport) = &report.transport {
5687 println!(
5688 "transport: endpoint_env={} auth_env={} applied_chunks={}",
5689 transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5690 );
5691 }
5692 for receipt in &report.receipts {
5693 println!(
5694 "receipt: {} chunk {} attempt {} {}",
5695 receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5696 );
5697 }
5698 for diagnostic in report
5699 .diagnostics
5700 .iter()
5701 .chain(report.freshness.diagnostics.iter())
5702 {
5703 println!("- {}", diagnostic);
5704 }
5705}
5706
5707pub(crate) struct ConvexSyncOptions<'a> {
5708 path: &'a Path,
5709 scope: Option<&'a str>,
5710 snapshot: Option<&'a Path>,
5711 chunk_size: usize,
5712 remote_snapshot: bool,
5713 apply: bool,
5714 endpoint: Option<&'a str>,
5715 auth_token_env: &'a str,
5716}
5717
5718#[derive(Serialize)]
5719struct GraphDbSchemaField {
5720 name: &'static str,
5721 value_type: &'static str,
5722 description: &'static str,
5723}
5724
5725#[derive(Serialize)]
5726struct GraphDbSchemaOperation {
5727 command: &'static str,
5728 description: &'static str,
5729}
5730
5731#[derive(Serialize)]
5732struct GraphDbSchemaContract {
5733 name: &'static str,
5734 version: &'static str,
5735 description: &'static str,
5736}
5737
5738#[derive(Serialize)]
5739struct GraphDbSchema {
5740 contract_versions: Vec<GraphDbSchemaContract>,
5741 node_fields: Vec<GraphDbSchemaField>,
5742 edge_fields: Vec<GraphDbSchemaField>,
5743 operations: Vec<GraphDbSchemaOperation>,
5744}
5745
5746#[derive(Clone, Serialize, Deserialize)]
5747struct GraphDbFreshnessReport {
5748 status: String,
5749 fail_closed: bool,
5750 projection_version: Option<String>,
5751 content_hash: Option<String>,
5752 source_watermark: Option<String>,
5753 diagnostics: Vec<String>,
5754}
5755
5756#[derive(Clone, Debug, Serialize)]
5757pub(crate) struct GraphEffectivenessReadiness {
5758 pub(crate) status: String,
5759 pub(crate) fail_closed: bool,
5760 pub(crate) reason: String,
5761 pub(crate) diagnostics: Vec<String>,
5762 pub(crate) next_commands: Vec<String>,
5763}
5764
5765#[derive(Clone, Debug, Serialize, PartialEq)]
5766struct GraphDbPropertyFilter {
5767 key: String,
5768 value: String,
5769}
5770
5771#[derive(Clone, Debug, Default)]
5772struct GraphDbQueryOptions {
5773 cursor: Option<String>,
5774 limit: Option<usize>,
5775 property_filters: Vec<GraphDbPropertyFilter>,
5776}
5777
5778#[derive(Clone, Debug, Serialize, PartialEq)]
5779struct GraphDbPageReport {
5780 #[serde(skip_serializing_if = "Option::is_none")]
5781 cursor: Option<String>,
5782 #[serde(skip_serializing_if = "Option::is_none")]
5783 limit: Option<usize>,
5784 #[serde(skip_serializing_if = "Option::is_none")]
5785 next_cursor: Option<String>,
5786 returned_nodes: usize,
5787 returned_edges: usize,
5788 truncated: bool,
5789 property_filters: Vec<GraphDbPropertyFilter>,
5790 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5791 diagnostics: Vec<String>,
5792}
5793
5794type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5795
5796#[derive(Clone, Debug, Serialize)]
5797struct CommunityTruncationSummary {
5798 total_communities: usize,
5799 fully_kept: usize,
5800 partially_pruned: usize,
5801 fully_pruned: usize,
5802 pruned_community_kinds: Vec<String>,
5803 pruned_community_top_labels: Vec<String>,
5804}
5805
5806#[derive(Clone, Debug, Serialize)]
5807struct GraphDbRankedNeighborhoodComparison {
5808 traversal_nodes: usize,
5809 traversal_edges: usize,
5810 pruned_count: usize,
5811 total_discovered: usize,
5812 latency_micros: u128,
5813 overlap_with_unranked_pct: f64,
5814 useful_hit_density_ranked: f64,
5815 useful_hit_density_unranked: f64,
5816 duplicate_name_count_ranked: usize,
5817 duplicate_name_count_unranked: usize,
5818 handle_coverage_ranked_pct: f64,
5819 handle_coverage_unranked_pct: f64,
5820 #[serde(skip_serializing_if = "Option::is_none")]
5821 community_truncation_summary: Option<CommunityTruncationSummary>,
5822 diagnostics: Vec<String>,
5823}
5824
5825#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5826struct GraphDbDroppedByBudget {
5827 item: String,
5828 kind: String,
5829 dropped: usize,
5830 reason: String,
5831}
5832
5833#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5834struct GraphDbOutputBudgetReport {
5835 max_tokens: usize,
5836 estimated_tokens: usize,
5837 selected_nodes: usize,
5838 selected_edges: usize,
5839 candidate_nodes: usize,
5840 candidate_edges: usize,
5841 dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5842 diagnostics: Vec<String>,
5843}
5844
5845#[derive(Clone, Debug, Serialize, PartialEq)]
5846struct GraphDbKnowledgeRetrieval {
5847 mode: String,
5848 query: String,
5849 seed_kind: String,
5850 seed_limit: usize,
5851 seed_count: usize,
5852 depth: usize,
5853 limit: usize,
5854 node_count: usize,
5855 edge_count: usize,
5856 truncated: bool,
5857 traversal: String,
5858 freshness_boundary: String,
5859 privacy_boundary: String,
5860 diagnostics: Vec<String>,
5861}
5862
5863struct GraphDbSemanticSeededSubgraph {
5864 nodes: Vec<SubstrateGraphNode>,
5865 edges: Vec<SubstrateGraphEdge>,
5866 truncated: bool,
5867 diagnostics: Vec<String>,
5868}
5869
5870type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5871
5872#[derive(Serialize)]
5873struct GraphDbReport {
5874 root: String,
5875 #[serde(skip_serializing_if = "Option::is_none")]
5876 scope: Option<String>,
5877 backend: String,
5878 query: String,
5879 freshness: GraphDbFreshnessReport,
5880 #[serde(skip_serializing_if = "Option::is_none")]
5881 readiness: Option<GraphEffectivenessReadiness>,
5882 #[serde(skip_serializing_if = "Option::is_none")]
5883 schema: Option<GraphDbSchema>,
5884 #[serde(skip_serializing_if = "Option::is_none")]
5885 node: Option<SubstrateTerseGraphNode>,
5886 #[serde(skip_serializing_if = "Option::is_none")]
5887 edge: Option<SubstrateTerseGraphEdge>,
5888 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5889 nodes: Vec<SubstrateTerseGraphNode>,
5890 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5891 edges: Vec<SubstrateTerseGraphEdge>,
5892 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5893 ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5894 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5895 semantic_related: Vec<SemanticRelatedItem>,
5896 #[serde(skip_serializing_if = "Option::is_none")]
5897 neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5898 #[serde(skip_serializing_if = "Option::is_none")]
5899 ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5900 #[serde(skip_serializing_if = "Option::is_none")]
5901 knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5902 #[serde(skip_serializing_if = "Option::is_none")]
5903 output_budget: Option<GraphDbOutputBudgetReport>,
5904 #[serde(skip_serializing_if = "Option::is_none")]
5905 path: Option<substrate::GraphPath>,
5906 #[serde(skip_serializing_if = "Option::is_none")]
5907 page: Option<GraphDbPageReport>,
5908 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5909 warnings: Vec<String>,
5910}
5911
5912struct ExperimentalReadOnlyGraphStore {
5913 backend: GraphDbExperimentalBackend,
5914 nodes: BTreeMap<String, SubstrateGraphNode>,
5915 edges: BTreeMap<String, SubstrateGraphEdge>,
5916 node_ids_by_kind: BTreeMap<String, Vec<String>>,
5917 outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5918}
5919
5920impl ExperimentalReadOnlyGraphStore {
5921 fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5922 validate_convex_projection_rows(rows)?;
5923 let nodes = rows
5924 .nodes
5925 .iter()
5926 .map(|row| {
5927 let node = SubstrateGraphNode {
5928 id: row.external_id.clone(),
5929 kind: row.kind.clone(),
5930 label: row.label.clone(),
5931 properties: row.properties.clone(),
5932 provenance: row.provenance.clone(),
5933 freshness: row.freshness.clone(),
5934 };
5935 (node.id.clone(), node)
5936 })
5937 .collect::<BTreeMap<_, _>>();
5938 let edges = rows
5939 .edges
5940 .iter()
5941 .map(|row| {
5942 let edge = SubstrateGraphEdge {
5943 id: row.edge_key.clone(),
5944 from_id: row.from_external_id.clone(),
5945 to_id: row.to_external_id.clone(),
5946 kind: row.kind.clone(),
5947 properties: row.properties.clone(),
5948 provenance: row.provenance.clone(),
5949 freshness: row.freshness.clone(),
5950 };
5951 (graph_db_edge_key(&edge), edge)
5952 })
5953 .collect::<BTreeMap<_, _>>();
5954 let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5955 for node in nodes.values() {
5956 node_ids_by_kind
5957 .entry(node.kind.clone())
5958 .or_default()
5959 .push(node.id.clone());
5960 }
5961 for ids in node_ids_by_kind.values_mut() {
5962 ids.sort();
5963 }
5964 let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5965 for edge in edges.values() {
5966 outgoing_edge_keys_by_from
5967 .entry(edge.from_id.clone())
5968 .or_default()
5969 .push(graph_db_edge_key(edge));
5970 }
5971 for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5972 edge_keys.sort_by(|left_key, right_key| {
5973 let left = &edges[left_key];
5974 let right = &edges[right_key];
5975 left.to_id
5976 .cmp(&right.to_id)
5977 .then(left.kind.cmp(&right.kind))
5978 .then(left_key.cmp(right_key))
5979 });
5980 }
5981 Ok(Self {
5982 backend,
5983 nodes,
5984 edges,
5985 node_ids_by_kind,
5986 outgoing_edge_keys_by_from,
5987 })
5988 }
5989}
5990
5991impl GraphStore for ExperimentalReadOnlyGraphStore {
5992 fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5993 bail!("{} backend-eval adapter is read-only", self.backend.name())
5994 }
5995
5996 fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5997 bail!("{} backend-eval adapter is read-only", self.backend.name())
5998 }
5999
6000 fn delete_node(&self, _id: &str) -> Result<usize> {
6001 bail!("{} backend-eval adapter is read-only", self.backend.name())
6002 }
6003
6004 fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
6005 bail!("{} backend-eval adapter is read-only", self.backend.name())
6006 }
6007
6008 fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
6009 Ok(self.nodes.get(id).cloned())
6010 }
6011
6012 fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
6013 Ok(self.nodes.values().cloned().collect())
6014 }
6015
6016 fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
6017 let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
6018 edges.sort_by(|left, right| {
6019 left.from_id
6020 .cmp(&right.from_id)
6021 .then(left.kind.cmp(&right.kind))
6022 .then(left.to_id.cmp(&right.to_id))
6023 });
6024 Ok(edges)
6025 }
6026
6027 fn graph_counts(&self) -> Result<(usize, usize)> {
6028 Ok((self.nodes.len(), self.edges.len()))
6029 }
6030
6031 fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
6032 let mut edges = self
6033 .edges
6034 .values()
6035 .filter(|edge| edge.from_id != edge.to_id)
6036 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6037 .cloned()
6038 .collect::<Vec<_>>();
6039 edges.sort_by(|left, right| {
6040 left.from_id
6041 .cmp(&right.from_id)
6042 .then(left.kind.cmp(&right.kind))
6043 .then(left.to_id.cmp(&right.to_id))
6044 });
6045 Ok(edges.into_iter().next())
6046 }
6047
6048 fn sample_edge_with_property(
6049 &self,
6050 ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
6051 Ok(self
6052 .edges
6053 .values()
6054 .filter(|edge| edge.from_id != edge.to_id)
6055 .filter_map(|edge| {
6056 edge.properties.iter().next().map(|(key, value)| {
6057 (
6058 edge,
6059 GraphPropertyFilter {
6060 key: key.clone(),
6061 value: value.clone(),
6062 },
6063 )
6064 })
6065 })
6066 .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
6067 left_filter
6068 .key
6069 .cmp(&right_filter.key)
6070 .then(left_filter.value.cmp(&right_filter.value))
6071 .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
6072 })
6073 .map(|(edge, filter)| (edge.clone(), filter)))
6074 }
6075
6076 fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
6077 Ok(self
6078 .node_ids_by_kind
6079 .get(kind)
6080 .into_iter()
6081 .flatten()
6082 .filter_map(|id| self.nodes.get(id).cloned())
6083 .collect())
6084 }
6085
6086 fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
6087 Ok(self
6088 .outgoing_edge_keys_by_from
6089 .get(from_id)
6090 .into_iter()
6091 .flatten()
6092 .filter_map(|key| self.edges.get(key))
6093 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6094 .cloned()
6095 .collect())
6096 }
6097
6098 fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
6099 Ok(self
6100 .edges
6101 .values()
6102 .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
6103 .cloned()
6104 .collect())
6105 }
6106
6107 fn shortest_path(
6108 &self,
6109 from_id: &str,
6110 to_id: &str,
6111 kind: Option<&str>,
6112 ) -> Result<Option<substrate::GraphPath>> {
6113 if from_id == to_id {
6114 return Ok(Some(substrate::GraphPath {
6115 nodes: vec![from_id.to_string()],
6116 hops: 0,
6117 }));
6118 }
6119
6120 let mut queue = VecDeque::new();
6121 let mut parent = BTreeMap::<String, String>::new();
6122 parent.insert(from_id.to_string(), String::new());
6123 queue.push_back(from_id.to_string());
6124
6125 while let Some(current) = queue.pop_front() {
6126 for edge in self.outgoing_edges(¤t, kind)? {
6127 if parent.contains_key(&edge.to_id) {
6128 continue;
6129 }
6130 parent.insert(edge.to_id.clone(), current.clone());
6131 if edge.to_id == to_id {
6132 let mut nodes = vec![to_id.to_string()];
6133 let mut cursor = to_id;
6134 while let Some(previous) = parent.get(cursor) {
6135 if previous.is_empty() {
6136 break;
6137 }
6138 nodes.push(previous.clone());
6139 cursor = previous;
6140 }
6141 nodes.reverse();
6142 return Ok(Some(substrate::GraphPath {
6143 hops: nodes.len().saturating_sub(1),
6144 nodes,
6145 }));
6146 }
6147 queue.push_back(edge.to_id);
6148 }
6149 }
6150
6151 Ok(None)
6152 }
6153
6154 fn reachable_nodes_by_kinds(
6155 &self,
6156 from_id: &str,
6157 kinds: &[&str],
6158 depth: usize,
6159 limit: usize,
6160 ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
6161 let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
6162 let mut rows = requested
6163 .iter()
6164 .map(|kind| {
6165 (
6166 (*kind).to_string(),
6167 BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
6168 )
6169 })
6170 .collect::<BTreeMap<_, _>>();
6171 if requested.is_empty() {
6172 return Ok(BTreeMap::new());
6173 }
6174
6175 let mut seen = BTreeSet::from([from_id.to_string()]);
6176 let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
6177 while let Some((current, path)) = queue.pop_front() {
6178 let current_depth = path.len().saturating_sub(1);
6179 if current_depth >= depth {
6180 continue;
6181 }
6182 for edge in self.outgoing_edges(¤t, None)? {
6183 if !seen.insert(edge.to_id.clone()) {
6184 continue;
6185 }
6186 let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
6187 continue;
6188 };
6189 let mut next_path = path.clone();
6190 next_path.push(edge.to_id.clone());
6191 let graph_path = substrate::GraphPath {
6192 hops: next_path.len().saturating_sub(1),
6193 nodes: next_path.clone(),
6194 };
6195 if requested.contains(node.kind.as_str()) {
6196 rows.entry(node.kind.clone())
6197 .or_default()
6198 .entry(node.id.clone())
6199 .or_insert((node.clone(), graph_path));
6200 }
6201 queue.push_back((edge.to_id, next_path));
6202 }
6203 }
6204
6205 Ok(rows
6206 .into_iter()
6207 .map(|(kind, values)| {
6208 let mut values = values.into_values().collect::<Vec<_>>();
6209 values.sort_by(|(left_node, left_path), (right_node, right_path)| {
6210 left_path
6211 .hops
6212 .cmp(&right_path.hops)
6213 .then(left_node.label.cmp(&right_node.label))
6214 .then(left_node.id.cmp(&right_node.id))
6215 });
6216 if limit > 0 && values.len() > limit {
6217 values.truncate(limit);
6218 }
6219 (kind, values)
6220 })
6221 .collect())
6222 }
6223}
6224
6225pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
6226pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
6227pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
6228const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
6229pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
6230const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
6231const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
6232const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
6233const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
6234
6235#[derive(Clone, Serialize, Deserialize)]
6236pub(crate) struct GraphDbBackendEvalPhaseTiming {
6237 name: String,
6238 duration_micros: u128,
6239 detail: String,
6240}
6241
6242#[derive(Serialize, Deserialize)]
6243struct GraphDbBackendEvalFullProjectionCache {
6244 version: String,
6245 key: String,
6246 source_watermark: String,
6247 projection: GraphProjection,
6248 warnings: Vec<String>,
6249}
6250
6251#[derive(Clone, Default)]
6252struct GraphDbBackendEvalFullProjectionCacheStats {
6253 hit: bool,
6254 disk_bytes: u64,
6255 json_bytes: u64,
6256 pruned_files: usize,
6257 pruned_bytes: u64,
6258}
6259
6260#[derive(Serialize)]
6261struct GraphDbBackendEvalRawSourceWatermarkRow {
6262 path: String,
6263 bytes: u64,
6264 content_hash: String,
6265}
6266
6267#[derive(Clone)]
6268struct GraphDbBackendEvalFullProjectionSourceWatermark {
6269 value: String,
6270 detail: String,
6271}
6272
6273#[derive(Serialize)]
6274pub(crate) struct GraphDbBackendEvalConfig {
6275 high_degree_nodes: usize,
6276 high_degree_fanout: usize,
6277 deep_chain_nodes: usize,
6278 deep_chain_fanout: usize,
6279 depth: usize,
6280 limit: usize,
6281 impact_limit: usize,
6282 path_max_hops: usize,
6283 path_direct_hop_budget: usize,
6284 path_deep_chain_hop_budget: usize,
6285 path_extended_hop_budgets: Vec<usize>,
6286 path_hop_policy: String,
6287 path_probe_strategy: String,
6288 path_query_plan_checks: Vec<String>,
6289 full_projection_enabled: bool,
6290 full_projection_profile: String,
6291 normalization_row_unit: usize,
6292}
6293
6294#[derive(Clone)]
6295struct GraphDbBackendEvalSignature {
6296 operation: String,
6297 value: serde_json::Value,
6298}
6299
6300#[derive(Serialize)]
6301struct GraphDbBackendEvalOperation {
6302 name: String,
6303 supported: bool,
6304 status: String,
6305 duration_micros: u128,
6306 #[serde(skip_serializing_if = "Option::is_none")]
6307 rows: Option<usize>,
6308 #[serde(skip_serializing_if = "Option::is_none")]
6309 error: Option<String>,
6310}
6311
6312#[derive(Serialize)]
6313struct GraphDbBackendEvalParity {
6314 matches_sqlite: bool,
6315 diagnostics: Vec<String>,
6316}
6317
6318#[derive(Serialize)]
6319struct GraphDbBackendEvalBackendReport {
6320 backend: String,
6321 adapter: String,
6322 read_only: bool,
6323 projection_load: String,
6324 operations: Vec<GraphDbBackendEvalOperation>,
6325 total_micros: u128,
6326 parity: GraphDbBackendEvalParity,
6327 lock_behavior: String,
6328 install_portability: String,
6329}
6330
6331#[derive(Serialize)]
6332struct GraphDbBackendEvalDataset {
6333 name: String,
6334 target_count: usize,
6335 nodes: usize,
6336 edges: usize,
6337 backends: Vec<GraphDbBackendEvalBackendReport>,
6338}
6339
6340#[derive(Serialize)]
6341struct GraphDbBackendPromotionDecision {
6342 backend: String,
6343 decision: String,
6344 reasons: Vec<String>,
6345 gate: GraphDbBackendPromotionGate,
6346}
6347
6348#[derive(Serialize)]
6349struct GraphDbBackendEvalPerformanceGate {
6350 baseline_fixture: String,
6351 ci_profile: String,
6352 opt_in_real_profile: String,
6353 full_projection_cache_hit_gate: String,
6354 allowed_regression_percent: f64,
6355 minimum_sample_runs: usize,
6356 normalized_metric_unit: String,
6357 required_metrics: Vec<String>,
6358 digest_command: String,
6359 repeated_sample_command: String,
6360 hop_cap_promotion: GraphDbHopCapPromotionGate,
6361 backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
6362}
6363
6364#[derive(Serialize)]
6365struct GraphDbHopCapPromotionGate {
6366 status: String,
6367 current_default_hops: usize,
6368 candidate_hop_tiers: Vec<usize>,
6369 required_backend: String,
6370 required_workloads: Vec<String>,
6371 required_metrics: Vec<String>,
6372 allowed_regression_percent: f64,
6373 minimum_sample_runs: usize,
6374 decision_rule: String,
6375}
6376
6377#[derive(Serialize)]
6378struct GraphDbBackendAdapterSpikeGate {
6379 status: String,
6380 candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
6381 required_workloads: Vec<String>,
6382 required_checks: Vec<String>,
6383 decision_rule: String,
6384 evidence_plan: String,
6385}
6386
6387#[derive(Serialize)]
6388struct GraphDbBackendAdapterSpikeCandidate {
6389 backend: String,
6390 adapter_label: String,
6391 projection_load: String,
6392 lock_behavior: String,
6393 install_portability: String,
6394}
6395
6396#[derive(Serialize)]
6397pub(crate) struct GraphDbBackendEvalReport {
6398 root: String,
6399 #[serde(skip_serializing_if = "Option::is_none")]
6400 scope: Option<String>,
6401 label: String,
6402 baseline_backend: String,
6403 candidates: Vec<String>,
6404 targets: Vec<String>,
6405 config: GraphDbBackendEvalConfig,
6406 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6407 datasets: Vec<GraphDbBackendEvalDataset>,
6408 promotion: Vec<GraphDbBackendPromotionDecision>,
6409 performance_gate: GraphDbBackendEvalPerformanceGate,
6410 metrics: BTreeMap<String, f64>,
6411 metric_digest_command: String,
6412 warnings: Vec<String>,
6413}
6414
6415#[derive(Clone, Debug, Serialize)]
6416struct GraphDbDoctorCheck {
6417 name: String,
6418 status: String,
6419 fail_closed: bool,
6420 diagnostics: Vec<String>,
6421 repair_commands: Vec<String>,
6422}
6423
6424#[derive(Serialize)]
6425pub(crate) struct GraphDbDoctorReport {
6426 root: String,
6427 #[serde(skip_serializing_if = "Option::is_none")]
6428 scope: Option<String>,
6429 backend: String,
6430 graph_db: String,
6431 #[serde(skip_serializing_if = "Option::is_none")]
6432 convex_snapshot: Option<String>,
6433 status: String,
6434 fail_closed: bool,
6435 checks: Vec<GraphDbDoctorCheck>,
6436 repair_commands: Vec<String>,
6437 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6438 required_indexes: Vec<ConvexRequiredIndex>,
6439}
6440
6441#[derive(Serialize)]
6442struct GraphDbDriftSummary {
6443 node_upserts: usize,
6444 edge_upserts: usize,
6445 node_tombstones: usize,
6446 edge_tombstones: usize,
6447 stale_nodes: usize,
6448 stale_edges: usize,
6449 stale_projection_metadata: usize,
6450 duplicate_failures: usize,
6451 orphan_failures: usize,
6452 missing_required_indexes: usize,
6453}
6454
6455#[derive(Serialize)]
6456struct GraphDbDriftReport {
6457 root: String,
6458 #[serde(skip_serializing_if = "Option::is_none")]
6459 scope: Option<String>,
6460 graph_db: String,
6461 convex_snapshot: String,
6462 status: String,
6463 graph_reads_allowed: bool,
6464 projection_version: String,
6465 local_hash: Option<String>,
6466 snapshot_hash: Option<String>,
6467 summary: GraphDbDriftSummary,
6468 node_upserts: Vec<String>,
6469 edge_upserts: Vec<String>,
6470 node_tombstones: Vec<String>,
6471 edge_tombstones: Vec<String>,
6472 stale_nodes: Vec<String>,
6473 stale_edges: Vec<String>,
6474 diagnostics: Vec<String>,
6475 next_commands: Vec<String>,
6476 required_indexes: Vec<ConvexRequiredIndex>,
6477 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6478 warnings: Vec<String>,
6479}
6480
6481#[derive(Clone, Serialize)]
6482struct GraphDbTombstoneCounts {
6483 nodes: usize,
6484 edges: usize,
6485 total: usize,
6486}
6487
6488#[derive(Clone, Serialize)]
6489struct GraphDbOperatorCounts {
6490 nodes: usize,
6491 edges: usize,
6492 tombstones: GraphDbTombstoneCounts,
6493 #[serde(skip_serializing_if = "Option::is_none")]
6494 file_size_bytes: Option<u64>,
6495 #[serde(skip_serializing_if = "Option::is_none")]
6496 freelist_bytes: Option<u64>,
6497}
6498
6499#[derive(Clone, Serialize)]
6500struct GraphDbCompactionPolicy {
6501 status: String,
6502 tombstone_scan_rows: usize,
6503 live_rows: usize,
6504 file_size_bytes: Option<u64>,
6505 freelist_bytes: Option<u64>,
6506 safe_to_prune_tombstones: bool,
6507 requires_convex_reconciliation: bool,
6508 recommendations: Vec<String>,
6509 proof: Vec<String>,
6510}
6511
6512#[derive(Serialize)]
6513pub(crate) struct GraphDbRefreshSummary {
6514 scope: String,
6515 projection_version: String,
6516 mode: String,
6517 #[serde(skip_serializing_if = "Option::is_none")]
6518 source_watermark: Option<String>,
6519 tombstoned_nodes: usize,
6520 tombstoned_edges: usize,
6521 upserted_nodes: usize,
6522 upserted_edges: usize,
6523 unchanged_nodes: usize,
6524 unchanged_edges: usize,
6525 upserted_properties: usize,
6526 unchanged_properties: usize,
6527 deleted_properties: usize,
6528 deleted_nodes: usize,
6529 deleted_edges: usize,
6530 pruned_tombstones: usize,
6531 #[serde(skip_serializing_if = "Option::is_none")]
6532 file_size_bytes_before: Option<u64>,
6533 #[serde(skip_serializing_if = "Option::is_none")]
6534 file_size_bytes_after: Option<u64>,
6535 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6536 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6537}
6538
6539#[derive(Serialize)]
6540struct GraphDbOperatorReport {
6541 root: String,
6542 #[serde(skip_serializing_if = "Option::is_none")]
6543 scope: Option<String>,
6544 graph_db: String,
6545 operation: String,
6546 status: String,
6547 materialized: bool,
6548 freshness: GraphDbFreshnessReport,
6549 readiness: GraphEffectivenessReadiness,
6550 counts: GraphDbOperatorCounts,
6551 #[serde(skip_serializing_if = "Option::is_none")]
6552 refresh: Option<GraphDbRefreshSummary>,
6553 compaction: GraphDbCompactionPolicy,
6554 #[serde(skip_serializing_if = "Option::is_none")]
6555 recovery: Option<index::ReadOnlyRecovery>,
6556 next_commands: Vec<String>,
6557 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6558 warnings: Vec<String>,
6559}
6560
6561#[derive(Serialize)]
6562pub(crate) struct GraphDbCompactionReport {
6563 root: String,
6564 #[serde(skip_serializing_if = "Option::is_none")]
6565 scope: Option<String>,
6566 graph_db: String,
6567 applied: bool,
6568 pruned_tombstones: usize,
6569 counts_before: GraphDbOperatorCounts,
6570 counts_after: GraphDbOperatorCounts,
6571 compaction_before: GraphDbCompactionPolicy,
6572 compaction_after: GraphDbCompactionPolicy,
6573 reclaimed_bytes: i64,
6574 next_commands: Vec<String>,
6575 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6576 warnings: Vec<String>,
6577}
6578
6579#[derive(Clone, Serialize, Deserialize)]
6580struct GraphDbEvidencePath {
6581 to: String,
6582 kind: String,
6583 label: String,
6584 #[serde(skip_serializing_if = "Option::is_none")]
6585 path: Option<substrate::GraphPath>,
6586 #[serde(skip_serializing_if = "Option::is_none")]
6587 expand: Option<String>,
6588}
6589
6590#[derive(Clone, Serialize, Deserialize)]
6591struct GraphDbFixtureCoverage {
6592 test: String,
6593 fixture: String,
6594 assertions: Vec<String>,
6595}
6596
6597#[derive(Clone, Serialize, Deserialize)]
6598struct GraphDbEvidenceReport {
6599 root: String,
6600 #[serde(skip_serializing_if = "Option::is_none")]
6601 scope: Option<String>,
6602 backend: String,
6603 contract_version: String,
6604 target: String,
6605 packet_id: String,
6606 #[serde(skip_serializing_if = "Option::is_none")]
6607 projection_hash: Option<String>,
6608 freshness: GraphDbFreshnessReport,
6609 target_node: SubstrateTerseGraphNode,
6610 worker_context: Vec<SubstrateTerseGraphNode>,
6611 source_handles: Vec<SubstrateTerseGraphNode>,
6612 worker_results: Vec<SubstrateTerseGraphNode>,
6613 semantic_related: Vec<SubstrateTerseGraphNode>,
6614 shortest_paths: Vec<GraphDbEvidencePath>,
6615 #[serde(skip_serializing_if = "Option::is_none")]
6616 output_budget: Option<GraphDbOutputBudgetReport>,
6617 #[serde(default)]
6618 truncated: bool,
6619 #[serde(skip_serializing_if = "Option::is_none")]
6620 next_cursor: Option<String>,
6621 next_commands: Vec<String>,
6622 replay_commands: Vec<String>,
6623 repair_commands: Vec<String>,
6624 fixture_coverage: GraphDbFixtureCoverage,
6625 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6626 warnings: Vec<String>,
6627}
6628
6629pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6630 root: &'a Path,
6631 scope: Option<&'a str>,
6632 backend: &'a str,
6633 target: &'a str,
6634 preferred_path: Option<&'a str>,
6635 depth: usize,
6636 limit: usize,
6637 cursor: Option<&'a str>,
6638 store: &'a S,
6639 freshness: GraphDbFreshnessReport,
6640 warnings: Vec<String>,
6641}
6642
6643impl GraphDbDoctorReport {
6644 fn new(
6645 root: &Path,
6646 scope: Option<&str>,
6647 backend: &str,
6648 graph_db: &Path,
6649 convex_snapshot: Option<&Path>,
6650 ) -> Self {
6651 Self {
6652 root: root.to_string_lossy().to_string(),
6653 scope: scope.map(str::to_string),
6654 backend: backend.to_string(),
6655 graph_db: graph_db.to_string_lossy().to_string(),
6656 convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6657 status: "ok".to_string(),
6658 fail_closed: false,
6659 checks: Vec::new(),
6660 repair_commands: Vec::new(),
6661 required_indexes: Vec::new(),
6662 }
6663 }
6664
6665 fn push_check(&mut self, check: GraphDbDoctorCheck) {
6666 self.checks.push(check);
6667 }
6668
6669 fn finalize(&mut self) {
6670 self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6671 self.status = if self.fail_closed {
6672 "fail_closed"
6673 } else {
6674 "ok"
6675 }
6676 .to_string();
6677 let mut commands = BTreeSet::new();
6678 for check in &self.checks {
6679 commands.extend(check.repair_commands.iter().cloned());
6680 }
6681 self.repair_commands = commands.into_iter().collect();
6682 }
6683
6684 fn summary(&self) -> String {
6685 self.checks
6686 .iter()
6687 .filter(|check| check.fail_closed)
6688 .flat_map(|check| check.diagnostics.iter())
6689 .take(3)
6690 .cloned()
6691 .collect::<Vec<_>>()
6692 .join("; ")
6693 }
6694}
6695
6696fn graph_db_doctor_check(
6697 name: impl Into<String>,
6698 diagnostics: Vec<String>,
6699 repair_commands: Vec<String>,
6700) -> GraphDbDoctorCheck {
6701 let fail_closed = !diagnostics.is_empty();
6702 GraphDbDoctorCheck {
6703 name: name.into(),
6704 status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6705 fail_closed,
6706 diagnostics,
6707 repair_commands: if fail_closed {
6708 repair_commands
6709 } else {
6710 Vec::new()
6711 },
6712 }
6713}
6714
6715pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6716 scope
6717 .map(|scope| format!(" --scope {}", shell_quote(scope)))
6718 .unwrap_or_default()
6719}
6720
6721fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6722 format!(
6723 "tsift graph-db --path {}{} refresh --json",
6724 shell_quote(root.to_string_lossy().as_ref()),
6725 graph_db_scope_arg(scope)
6726 )
6727}
6728
6729fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6730 graph_db_refresh_command(root, scope)
6731}
6732
6733fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6734 let backup = format!("{}.bak", graph_db.to_string_lossy());
6735 format!(
6736 "mv {} {} && {}",
6737 shell_quote(graph_db.to_string_lossy().as_ref()),
6738 shell_quote(&backup),
6739 graph_db_rebuild_command(root, scope)
6740 )
6741}
6742
6743fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6744 format!(
6745 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6746 shell_quote(root.to_string_lossy().as_ref()),
6747 graph_db_scope_arg(scope)
6748 )
6749}
6750
6751fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6752 substrate::open_graph_read_only_connection_resilient(graph_db)
6753}
6754
6755fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6756 conn.query_row(
6757 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6758 [table],
6759 |row| row.get::<_, bool>(0),
6760 )
6761 .map_err(Into::into)
6762}
6763
6764fn row_usize(row: &Row<'_>, idx: usize) -> rusqlite::Result<usize> {
6765 let value: i64 = row.get(idx)?;
6766 usize::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6767}
6768
6769fn row_u64(row: &Row<'_>, idx: usize) -> rusqlite::Result<u64> {
6770 let value: i64 = row.get(idx)?;
6771 u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6772}
6773
6774fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6775 let sql = match table {
6776 "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6777 "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6778 "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6779 other => bail!("unsupported graph count table {other}"),
6780 };
6781 conn.query_row(sql, [], |row| row_usize(row, 0))
6782 .map_err(Into::into)
6783}
6784
6785fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6786 if !sqlite_table_exists(conn, "graph_tombstones")? {
6787 return Ok(GraphDbTombstoneCounts {
6788 nodes: 0,
6789 edges: 0,
6790 total: 0,
6791 });
6792 }
6793 let mut stmt =
6794 conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6795 let mut rows = stmt.query([])?;
6796 let mut nodes = 0usize;
6797 let mut edges = 0usize;
6798 while let Some(row) = rows.next()? {
6799 let row_kind: String = row.get(0)?;
6800 let count = row_usize(row, 1)?;
6801 match row_kind.as_str() {
6802 "node" => nodes = count,
6803 "edge" => edges = count,
6804 _ => {}
6805 }
6806 }
6807 Ok(GraphDbTombstoneCounts {
6808 nodes,
6809 edges,
6810 total: nodes + edges,
6811 })
6812}
6813
6814fn sqlite_graph_counts_from_cache(
6815 conn: &Connection,
6816 scope: &str,
6817) -> Result<Option<GraphDbOperatorCounts>> {
6818 if !sqlite_table_exists(conn, "graph_operator_stats")? {
6819 return Ok(None);
6820 }
6821 let row = conn
6822 .query_row(
6823 r#"
6824 SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6825 FROM graph_operator_stats
6826 WHERE scope = ?1
6827 "#,
6828 [scope],
6829 |row| {
6830 Ok((
6831 row_usize(row, 0)?,
6832 row_usize(row, 1)?,
6833 row_usize(row, 2)?,
6834 row_usize(row, 3)?,
6835 row.get::<_, Option<i64>>(4)?,
6836 row.get::<_, Option<i64>>(5)?,
6837 ))
6838 },
6839 )
6840 .optional()?;
6841 Ok(row.map(
6842 |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6843 GraphDbOperatorCounts {
6844 nodes,
6845 edges,
6846 tombstones: GraphDbTombstoneCounts {
6847 nodes: tombstone_nodes,
6848 edges: tombstone_edges,
6849 total: tombstone_nodes + tombstone_edges,
6850 },
6851 file_size_bytes: file_size_bytes
6852 .and_then(|value| u64::try_from(value).ok())
6853 .or_else(|| sqlite_database_size_bytes(conn).ok()),
6854 freelist_bytes: freelist_bytes
6855 .and_then(|value| u64::try_from(value).ok())
6856 .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6857 }
6858 },
6859 ))
6860}
6861
6862fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6863 if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6864 return Ok(counts);
6865 }
6866 let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6867 sqlite_known_table_count(conn, "graph_nodes")?
6868 } else {
6869 0
6870 };
6871 let edges = if sqlite_table_exists(conn, "graph_edges")? {
6872 sqlite_known_table_count(conn, "graph_edges")?
6873 } else {
6874 0
6875 };
6876 Ok(GraphDbOperatorCounts {
6877 nodes,
6878 edges,
6879 tombstones: sqlite_tombstone_counts(conn)?,
6880 file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6881 freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6882 })
6883}
6884
6885fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6886 if !sqlite_table_exists(conn, "graph_nodes")? {
6887 return Ok(0);
6888 }
6889 let count: i64 = conn.query_row(
6890 "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6891 [],
6892 |row| row.get(0),
6893 )?;
6894 Ok(count as usize)
6895}
6896
6897pub(crate) fn graph_db_compaction_policy(
6898 root: &Path,
6899 scope: Option<&str>,
6900 counts: &GraphDbOperatorCounts,
6901 prune_confirmed: bool,
6902) -> GraphDbCompactionPolicy {
6903 let live_rows = counts.nodes + counts.edges;
6904 let tombstone_scan_rows = counts.tombstones.total;
6905 let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6906 let freelist_heavy = counts
6907 .file_size_bytes
6908 .zip(counts.freelist_bytes)
6909 .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6910 let status = if tombstone_heavy || freelist_heavy {
6911 "recommended"
6912 } else {
6913 "not_needed"
6914 }
6915 .to_string();
6916 let mut recommendations = vec![
6917 convex_refresh_command(root, scope),
6918 graph_db_refresh_command(root, scope),
6919 format!(
6920 "tsift graph-db --path {}{} compact --apply --json",
6921 shell_quote(root.to_string_lossy().as_ref()),
6922 graph_db_scope_arg(scope)
6923 ),
6924 ];
6925 if prune_confirmed {
6926 recommendations.push(format!(
6927 "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6928 shell_quote(root.to_string_lossy().as_ref()),
6929 graph_db_scope_arg(scope)
6930 ));
6931 }
6932 let proof = vec![
6933 format!("{live_rows} live graph row(s)"),
6934 format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6935 format!(
6936 "graph.db file_size={} byte(s), freelist={} byte(s)",
6937 counts.file_size_bytes.unwrap_or(0),
6938 counts.freelist_bytes.unwrap_or(0)
6939 ),
6940 ];
6941 GraphDbCompactionPolicy {
6942 status,
6943 tombstone_scan_rows,
6944 live_rows,
6945 file_size_bytes: counts.file_size_bytes,
6946 freelist_bytes: counts.freelist_bytes,
6947 safe_to_prune_tombstones: prune_confirmed,
6948 requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6949 recommendations,
6950 proof,
6951 }
6952}
6953
6954fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6955 let page_count = conn.query_row("PRAGMA page_count", [], |row| row_u64(row, 0))?;
6956 let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
6957 Ok(page_count.saturating_mul(page_size))
6958}
6959
6960fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6961 let freelist_count = conn.query_row("PRAGMA freelist_count", [], |row| row_u64(row, 0))?;
6962 let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
6963 Ok(freelist_count.saturating_mul(page_size))
6964}
6965
6966fn sqlite_graph_tombstone_retention_diagnostics(
6967 conn: &Connection,
6968 scope: &str,
6969) -> Result<Vec<String>> {
6970 if !sqlite_table_exists(conn, "graph_tombstones")? {
6971 return Ok(Vec::new());
6972 }
6973 let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6974 let counts = match cached.clone() {
6975 Some(counts) => counts,
6976 None => sqlite_graph_counts(conn, scope)?,
6977 };
6978 let live_rows = counts.nodes + counts.edges;
6979 let file_size = counts.file_size_bytes.unwrap_or(0);
6980 let freelist = counts.freelist_bytes.unwrap_or(0);
6981 let stale_live_tombstones = if cached.is_some() {
6982 0
6983 } else {
6984 let mut live_keys = BTreeSet::new();
6985 if sqlite_table_exists(conn, "graph_nodes")? {
6986 let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6987 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6988 live_keys.insert(format!("node:{}", row?));
6989 }
6990 }
6991 if sqlite_table_exists(conn, "graph_edges")? {
6992 let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6993 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6994 live_keys.insert(format!("edge:{}", row?));
6995 }
6996 }
6997 let mut stale_live_tombstones = 0usize;
6998 let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6999 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7000 if live_keys.contains(&row?) {
7001 stale_live_tombstones += 1;
7002 }
7003 }
7004 stale_live_tombstones
7005 };
7006
7007 let mut diagnostics = Vec::new();
7008 if stale_live_tombstones > 0 {
7009 diagnostics.push(format!(
7010 "{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"
7011 ));
7012 }
7013 if counts.tombstones.total > live_rows.max(1) {
7014 let source = if cached.is_some() {
7015 "cached refresh stats"
7016 } else {
7017 "live row scan"
7018 };
7019 diagnostics.push(format!(
7020 "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.",
7021 counts.tombstones.total,
7022 live_rows,
7023 source,
7024 file_size,
7025 freelist,
7026 counts.tombstones.total
7027 ));
7028 }
7029 Ok(diagnostics)
7030}
7031
7032fn sqlite_graph_freshness_from_conn(
7033 conn: &Connection,
7034 scope: &str,
7035) -> Result<GraphDbFreshnessReport> {
7036 if !sqlite_table_exists(conn, "graph_projection_versions")? {
7037 return Ok(GraphDbFreshnessReport {
7038 status: "missing".to_string(),
7039 fail_closed: true,
7040 projection_version: None,
7041 content_hash: None,
7042 source_watermark: None,
7043 diagnostics: vec![
7044 "graph projection metadata table is missing; refresh graph.db before trusting reads"
7045 .to_string(),
7046 ],
7047 });
7048 }
7049 let version = conn
7050 .query_row(
7051 r#"
7052 SELECT projection_version, content_hash, source_watermark
7053 FROM graph_projection_versions
7054 WHERE scope = ?1
7055 "#,
7056 [scope],
7057 |row| {
7058 Ok((
7059 row.get::<_, String>(0)?,
7060 row.get::<_, Option<String>>(1)?,
7061 row.get::<_, Option<String>>(2)?,
7062 ))
7063 },
7064 )
7065 .optional()?;
7066 let Some((projection_version, content_hash, source_watermark)) = version else {
7067 return Ok(GraphDbFreshnessReport {
7068 status: "missing".to_string(),
7069 fail_closed: true,
7070 projection_version: None,
7071 content_hash: None,
7072 source_watermark: None,
7073 diagnostics: vec![
7074 "graph projection metadata is missing; refresh graph.db before trusting reads"
7075 .to_string(),
7076 ],
7077 });
7078 };
7079
7080 let mut diagnostics = Vec::new();
7081 if projection_version != GRAPH_PROJECTION_VERSION {
7082 diagnostics.push(format!(
7083 "projection version mismatch: expected {} got {}",
7084 GRAPH_PROJECTION_VERSION, projection_version
7085 ));
7086 }
7087 if content_hash.is_none() {
7088 diagnostics.push("projection content hash is missing".to_string());
7089 }
7090 let fail_closed = !diagnostics.is_empty();
7091 Ok(GraphDbFreshnessReport {
7092 status: if fail_closed { "stale" } else { "current" }.to_string(),
7093 fail_closed,
7094 projection_version: Some(projection_version),
7095 content_hash,
7096 source_watermark,
7097 diagnostics,
7098 })
7099}
7100
7101fn graph_db_operator_next_commands(
7102 root: &Path,
7103 scope: Option<&str>,
7104 include_refresh: bool,
7105) -> Vec<String> {
7106 let mut commands = Vec::new();
7107 if include_refresh {
7108 commands.push(graph_db_refresh_command(root, scope));
7109 }
7110 commands.push(format!(
7111 "tsift graph-db --path {}{} doctor --json",
7112 shell_quote(root.to_string_lossy().as_ref()),
7113 graph_db_scope_arg(scope)
7114 ));
7115 commands.push(format!(
7116 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
7117 shell_quote(root.to_string_lossy().as_ref()),
7118 graph_db_scope_arg(scope)
7119 ));
7120 commands.push(format!(
7121 "tsift convex-sync {}{} --remote-snapshot --apply --json",
7122 shell_quote(root.to_string_lossy().as_ref()),
7123 graph_db_scope_arg(scope)
7124 ));
7125 commands
7126}
7127
7128pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
7129 match recovery {
7130 index::ReadOnlyRecovery::SnapshotFallback => {
7131 "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
7132 }
7133 index::ReadOnlyRecovery::SnapshotFallbackWal => {
7134 "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
7135 }
7136 }
7137}
7138
7139fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
7140 let mut stmt = conn.prepare(sql)?;
7141 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7142 let mut values = BTreeSet::new();
7143 for row in rows {
7144 values.insert(row?);
7145 }
7146 Ok(values)
7147}
7148
7149fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
7150 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
7151 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
7152 let mut columns = BTreeSet::new();
7153 for row in rows {
7154 columns.insert(row?);
7155 }
7156 Ok(columns)
7157}
7158
7159fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7160 let mut diagnostics = Vec::new();
7161 let user_version: i64 =
7162 conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
7163 if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
7164 diagnostics.push(format!(
7165 "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7166 ));
7167 } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
7168 diagnostics.push(format!(
7169 "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7170 ));
7171 }
7172
7173 let tables = sqlite_string_set(
7174 conn,
7175 "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
7176 )?;
7177 let required_tables = [
7178 (
7179 "graph_nodes",
7180 vec![
7181 "id",
7182 "kind",
7183 "label",
7184 "properties_json",
7185 "provenance_json",
7186 "freshness_json",
7187 "row_hash",
7188 "source_watermark",
7189 ],
7190 ),
7191 (
7192 "graph_edges",
7193 vec![
7194 "edge_key",
7195 "from_id",
7196 "to_id",
7197 "kind",
7198 "properties_json",
7199 "provenance_json",
7200 "freshness_json",
7201 "row_hash",
7202 "source_watermark",
7203 ],
7204 ),
7205 (
7206 "graph_projection_versions",
7207 vec![
7208 "scope",
7209 "projection_version",
7210 "content_hash",
7211 "source_watermark",
7212 "observed_at_unix",
7213 ],
7214 ),
7215 (
7216 "graph_tombstones",
7217 vec!["row_key", "row_kind", "deleted_at_unix"],
7218 ),
7219 ("graph_node_properties", vec!["node_id", "key", "value"]),
7220 ("graph_edge_properties", vec!["edge_key", "key", "value"]),
7221 ];
7222 for (table, required_columns) in required_tables {
7223 if !tables.contains(table) {
7224 diagnostics.push(format!("graph.db schema drift: missing table {table}"));
7225 continue;
7226 }
7227 let columns = sqlite_column_names(conn, table)?;
7228 for column in required_columns {
7229 if !columns.contains(column) {
7230 diagnostics.push(format!(
7231 "graph.db schema drift: missing column {table}.{column}"
7232 ));
7233 }
7234 }
7235 }
7236
7237 let indexes = sqlite_string_set(
7238 conn,
7239 "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
7240 )?;
7241 for index in [
7242 "idx_graph_nodes_kind",
7243 "idx_graph_edges_from_kind",
7244 "idx_graph_edges_to_kind",
7245 "idx_graph_edges_edge_key",
7246 "idx_graph_node_properties_key_value_node",
7247 "idx_graph_edge_properties_key_value_edge",
7248 ] {
7249 if !indexes.contains(index) {
7250 diagnostics.push(format!("graph.db schema drift: missing index {index}"));
7251 }
7252 }
7253
7254 if tables.contains("graph_edges") {
7255 let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
7256 let rows = stmt.query_map([], |row| {
7257 Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
7258 })?;
7259 let mut fks = BTreeSet::new();
7260 for row in rows {
7261 fks.insert(row?);
7262 }
7263 for expected in [
7264 ("from_id".to_string(), "id".to_string()),
7265 ("to_id".to_string(), "id".to_string()),
7266 ] {
7267 if !fks.contains(&expected) {
7268 diagnostics.push(format!(
7269 "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
7270 expected.0, expected.1
7271 ));
7272 }
7273 }
7274 }
7275
7276 Ok(diagnostics)
7277}
7278
7279fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
7280 let mut stmt = conn.prepare(sql)?;
7281 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7282 let mut diagnostics = Vec::new();
7283 for row in rows {
7284 diagnostics.push(row?);
7285 }
7286 Ok(diagnostics)
7287}
7288
7289fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7290 let mut diagnostics = sqlite_query_diagnostics(
7291 conn,
7292 r#"
7293 SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
7294 FROM graph_nodes
7295 GROUP BY id
7296 HAVING COUNT(*) > 1
7297 ORDER BY id
7298 "#,
7299 )?;
7300 diagnostics.extend(sqlite_query_diagnostics(
7301 conn,
7302 r#"
7303 SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
7304 FROM graph_edges
7305 GROUP BY from_id, to_id, kind
7306 HAVING COUNT(*) > 1
7307 ORDER BY from_id, kind, to_id
7308 "#,
7309 )?);
7310 diagnostics.extend(sqlite_query_diagnostics(
7311 conn,
7312 r#"
7313 SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
7314 FROM graph_edges
7315 GROUP BY edge_key
7316 HAVING COUNT(*) > 1
7317 ORDER BY edge_key
7318 "#,
7319 )?);
7320 Ok(diagnostics)
7321}
7322
7323fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7324 sqlite_query_diagnostics(
7325 conn,
7326 r#"
7327 SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7328 FROM graph_edges e
7329 LEFT JOIN graph_nodes n ON n.id = e.from_id
7330 WHERE n.id IS NULL
7331 UNION ALL
7332 SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7333 FROM graph_edges e
7334 LEFT JOIN graph_nodes n ON n.id = e.to_id
7335 WHERE n.id IS NULL
7336 ORDER BY 1
7337 "#,
7338 )
7339}
7340
7341fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7342 let mut diagnostics = Vec::new();
7343 let mut node_stmt = conn.prepare(
7344 "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7345 )?;
7346 let node_rows = node_stmt.query_map([], |row| {
7347 Ok((
7348 row.get::<_, String>(0)?,
7349 row.get::<_, String>(1)?,
7350 row.get::<_, String>(2)?,
7351 row.get::<_, Option<String>>(3)?,
7352 ))
7353 })?;
7354 for row in node_rows {
7355 let (id, properties_json, provenance_json, freshness_json) = row?;
7356 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7357 diagnostics.push(format!(
7358 "graph_nodes {id} properties_json is invalid: {err}"
7359 ));
7360 }
7361 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7362 diagnostics.push(format!(
7363 "graph_nodes {id} provenance_json is invalid: {err}"
7364 ));
7365 }
7366 if let Some(freshness_json) = freshness_json
7367 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7368 {
7369 diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
7370 }
7371 }
7372
7373 let mut edge_stmt = conn.prepare(
7374 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7375 )?;
7376 let edge_rows = edge_stmt.query_map([], |row| {
7377 Ok((
7378 row.get::<_, String>(0)?,
7379 row.get::<_, String>(1)?,
7380 row.get::<_, String>(2)?,
7381 row.get::<_, String>(3)?,
7382 row.get::<_, String>(4)?,
7383 row.get::<_, String>(5)?,
7384 row.get::<_, Option<String>>(6)?,
7385 ))
7386 })?;
7387 for row in edge_rows {
7388 let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
7389 row?;
7390 let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
7391 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7392 diagnostics.push(format!(
7393 "graph_edges {edge} properties_json is invalid: {err}"
7394 ));
7395 }
7396 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7397 diagnostics.push(format!(
7398 "graph_edges {edge} provenance_json is invalid: {err}"
7399 ));
7400 }
7401 if let Some(freshness_json) = freshness_json
7402 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7403 {
7404 diagnostics.push(format!(
7405 "graph_edges {edge} freshness_json is invalid: {err}"
7406 ));
7407 }
7408 }
7409 Ok(diagnostics)
7410}
7411
7412fn sqlite_graph_projection_metadata_diagnostics(
7413 conn: &Connection,
7414 scope: Option<&str>,
7415) -> Result<Vec<String>> {
7416 let mut diagnostics = Vec::new();
7417 let scope_key = scope.unwrap_or("root");
7418 let version = conn
7419 .query_row(
7420 r#"
7421 SELECT projection_version, content_hash, source_watermark
7422 FROM graph_projection_versions
7423 WHERE scope = ?1
7424 "#,
7425 [scope_key],
7426 |row| {
7427 Ok((
7428 row.get::<_, String>(0)?,
7429 row.get::<_, Option<String>>(1)?,
7430 row.get::<_, Option<String>>(2)?,
7431 ))
7432 },
7433 )
7434 .optional()?;
7435 let Some((projection_version, content_hash, _source_watermark)) = version else {
7436 diagnostics.push(format!(
7437 "graph projection metadata is missing for scope {scope_key}"
7438 ));
7439 return Ok(diagnostics);
7440 };
7441 if projection_version != GRAPH_PROJECTION_VERSION {
7442 diagnostics.push(format!(
7443 "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
7444 ));
7445 }
7446 if content_hash.is_none() {
7447 diagnostics.push("projection content hash is missing".to_string());
7448 }
7449
7450 let meta_id = graph_projection_meta_id(scope);
7451 let meta_properties = conn
7452 .query_row(
7453 "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
7454 (&meta_id, GRAPH_PROJECTION_META_KIND),
7455 |row| row.get::<_, String>(0),
7456 )
7457 .optional()?;
7458 let Some(meta_properties) = meta_properties else {
7459 diagnostics.push(format!("projection_meta node {meta_id} is missing"));
7460 return Ok(diagnostics);
7461 };
7462 let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
7463 .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
7464 if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
7465 diagnostics.push(format!(
7466 "projection_meta node {meta_id} has stale projection_version"
7467 ));
7468 }
7469 if properties.get("content_hash") != content_hash.as_ref() {
7470 diagnostics.push(format!(
7471 "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
7472 ));
7473 }
7474 Ok(diagnostics)
7475}
7476
7477pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
7478 let mut node_stmt = conn.prepare(
7479 "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7480 )?;
7481 let node_rows = node_stmt.query_map([], |row| {
7482 let properties_json: String = row.get(3)?;
7483 let provenance_json: String = row.get(4)?;
7484 let freshness_json: Option<String> = row.get(5)?;
7485 Ok((
7486 row.get::<_, String>(0)?,
7487 row.get::<_, String>(1)?,
7488 row.get::<_, String>(2)?,
7489 properties_json,
7490 provenance_json,
7491 freshness_json,
7492 ))
7493 })?;
7494 let mut nodes = Vec::new();
7495 for row in node_rows {
7496 let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
7497 nodes.push(ConvexNodeRow {
7498 external_id,
7499 kind,
7500 label,
7501 properties: serde_json::from_str(&properties_json)?,
7502 provenance: serde_json::from_str(&provenance_json)?,
7503 freshness: freshness_json
7504 .map(|value| serde_json::from_str(&value))
7505 .transpose()?,
7506 });
7507 }
7508
7509 let mut edge_stmt = conn.prepare(
7510 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7511 )?;
7512 let edge_rows = edge_stmt.query_map([], |row| {
7513 let properties_json: String = row.get(4)?;
7514 let provenance_json: String = row.get(5)?;
7515 let freshness_json: Option<String> = row.get(6)?;
7516 Ok((
7517 row.get::<_, String>(0)?,
7518 row.get::<_, String>(1)?,
7519 row.get::<_, String>(2)?,
7520 row.get::<_, String>(3)?,
7521 properties_json,
7522 provenance_json,
7523 freshness_json,
7524 ))
7525 })?;
7526 let mut edges = Vec::new();
7527 for row in edge_rows {
7528 let (
7529 edge_key,
7530 from_external_id,
7531 to_external_id,
7532 kind,
7533 properties_json,
7534 provenance_json,
7535 freshness_json,
7536 ) = row?;
7537 edges.push(ConvexEdgeRow {
7538 edge_key,
7539 from_external_id,
7540 to_external_id,
7541 kind,
7542 properties: serde_json::from_str(&properties_json)?,
7543 provenance: serde_json::from_str(&provenance_json)?,
7544 freshness: freshness_json
7545 .map(|value| serde_json::from_str(&value))
7546 .transpose()?,
7547 });
7548 }
7549 Ok(ConvexProjectionRows { nodes, edges })
7550}
7551
7552fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
7553 format!("{}.{}({})", index.table, index.name, index.fields.join(","))
7554}
7555
7556fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
7557 value
7558 .get("indexes")
7559 .or_else(|| value.get("requiredIndexes"))
7560 .or_else(|| {
7561 value
7562 .get("metadata")
7563 .and_then(|metadata| metadata.get("indexes"))
7564 })
7565}
7566
7567fn convex_snapshot_declared_indexes(
7568 value: &serde_json::Value,
7569) -> Result<Option<Vec<ConvexRequiredIndex>>> {
7570 convex_snapshot_index_value(value)
7571 .map(|indexes| {
7572 serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
7573 .context("parsing Convex snapshot index metadata")
7574 })
7575 .transpose()
7576}
7577
7578fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
7579 let required = convex_required_indexes();
7580 let Some(declared) = convex_snapshot_declared_indexes(value)? else {
7581 return Ok(vec![format!(
7582 "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
7583 required
7584 .iter()
7585 .map(convex_required_index_label)
7586 .collect::<Vec<_>>()
7587 .join(", ")
7588 )]);
7589 };
7590 let declared = declared.into_iter().collect::<BTreeSet<_>>();
7591 let missing = required
7592 .iter()
7593 .filter(|index| !declared.contains(*index))
7594 .map(convex_required_index_label)
7595 .collect::<Vec<_>>();
7596 if missing.is_empty() {
7597 Ok(Vec::new())
7598 } else {
7599 Ok(vec![format!(
7600 "Convex snapshot is missing required index metadata: {}",
7601 missing.join(", ")
7602 )])
7603 }
7604}
7605
7606pub(crate) fn load_convex_projection_snapshot_value(
7607 snapshot_path: &Path,
7608) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7609 let content = fs::read_to_string(snapshot_path).with_context(|| {
7610 format!(
7611 "reading Convex projection snapshot {}",
7612 snapshot_path.display()
7613 )
7614 })?;
7615 let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7616 format!(
7617 "parsing Convex projection snapshot {}",
7618 snapshot_path.display()
7619 )
7620 })?;
7621 let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7622 .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7623 Ok((rows, value))
7624}
7625
7626pub(crate) fn append_sqlite_graph_doctor_checks(
7627 report: &mut GraphDbDoctorReport,
7628 root: &Path,
7629 scope: Option<&str>,
7630 graph_db: &Path,
7631) -> Option<substrate::SqliteReadOnlyConnection> {
7632 let rebuild = graph_db_rebuild_command(root, scope);
7633 let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7634 if !graph_db.exists() {
7635 report.push_check(graph_db_doctor_check(
7636 "sqlite_graph_db_exists",
7637 vec![format!("graph.db is missing at {}", graph_db.display())],
7638 vec![rebuild],
7639 ));
7640 return None;
7641 }
7642 report.push_check(graph_db_doctor_check(
7643 "sqlite_graph_db_exists",
7644 Vec::new(),
7645 vec![rebuild.clone()],
7646 ));
7647
7648 let conn = match open_sqlite_graph_db_readonly(graph_db) {
7649 Ok(conn) => conn,
7650 Err(err) => {
7651 report.push_check(graph_db_doctor_check(
7652 "sqlite_graph_db_open",
7653 vec![err.to_string()],
7654 vec![backup_rebuild],
7655 ));
7656 return None;
7657 }
7658 };
7659 report.push_check(graph_db_doctor_check(
7660 "sqlite_graph_db_open",
7661 Vec::new(),
7662 vec![rebuild.clone()],
7663 ));
7664 if let Some(recovery) = conn.recovery() {
7665 report.push_check(GraphDbDoctorCheck {
7666 name: "sqlite_graph_db_read_recovery".to_string(),
7667 status: "recovered".to_string(),
7668 fail_closed: false,
7669 diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7670 repair_commands: Vec::new(),
7671 });
7672 }
7673
7674 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7675 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7676 report.push_check(graph_db_doctor_check(
7677 "sqlite_schema",
7678 schema_diagnostics,
7679 vec![backup_rebuild.clone()],
7680 ));
7681
7682 let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7683 .unwrap_or_else(|err| {
7684 vec![format!(
7685 "graph projection metadata inspection failed: {err}"
7686 )]
7687 });
7688 report.push_check(graph_db_doctor_check(
7689 "sqlite_projection_metadata",
7690 metadata_diagnostics,
7691 vec![rebuild.clone()],
7692 ));
7693
7694 let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7695 .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7696 report.push_check(graph_db_doctor_check(
7697 "sqlite_duplicate_ids",
7698 duplicate_diagnostics,
7699 vec![backup_rebuild.clone()],
7700 ));
7701
7702 let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7703 .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7704 report.push_check(graph_db_doctor_check(
7705 "sqlite_orphan_edges",
7706 orphan_diagnostics,
7707 vec![rebuild.clone()],
7708 ));
7709
7710 let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7711 .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7712 report.push_check(graph_db_doctor_check(
7713 "sqlite_row_json",
7714 json_diagnostics,
7715 vec![backup_rebuild],
7716 ));
7717
7718 let tombstone_diagnostics =
7719 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7720 .unwrap_or_else(|err| {
7721 vec![format!(
7722 "graph tombstone retention inspection failed: {err}"
7723 )]
7724 });
7725 report.push_check(GraphDbDoctorCheck {
7726 name: "sqlite_tombstone_retention".to_string(),
7727 status: if tombstone_diagnostics.is_empty() {
7728 "ok".to_string()
7729 } else {
7730 "warning".to_string()
7731 },
7732 fail_closed: false,
7733 diagnostics: tombstone_diagnostics,
7734 repair_commands: Vec::new(),
7735 });
7736 let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7737 Ok(counts) => {
7738 let policy = graph_db_compaction_policy(root, scope, &counts, false);
7739 GraphDbDoctorCheck {
7740 name: "sqlite_compaction_policy".to_string(),
7741 status: policy.status.clone(),
7742 fail_closed: false,
7743 diagnostics: policy.proof,
7744 repair_commands: if policy.status == "recommended" {
7745 policy.recommendations
7746 } else {
7747 Vec::new()
7748 },
7749 }
7750 }
7751 Err(err) => GraphDbDoctorCheck {
7752 name: "sqlite_compaction_policy".to_string(),
7753 status: "warning".to_string(),
7754 fail_closed: false,
7755 diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7756 repair_commands: Vec::new(),
7757 },
7758 };
7759 report.push_check(compaction_check);
7760
7761 Some(conn)
7762}
7763
7764pub(crate) fn append_convex_snapshot_doctor_checks(
7765 report: &mut GraphDbDoctorReport,
7766 root: &Path,
7767 scope: Option<&str>,
7768 local_rows: Option<&ConvexProjectionRows>,
7769 snapshot_path: Option<&Path>,
7770) {
7771 let repair = convex_refresh_command(root, scope);
7772 let Some(snapshot_path) = snapshot_path else {
7773 report.push_check(graph_db_doctor_check(
7774 "convex_snapshot_present",
7775 vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7776 vec![format!(
7777 "tsift convex-sync {}{} --json > convex-rows.json",
7778 shell_quote(root.to_string_lossy().as_ref()),
7779 graph_db_scope_arg(scope)
7780 )],
7781 ));
7782 return;
7783 };
7784 report.push_check(graph_db_doctor_check(
7785 "convex_snapshot_present",
7786 Vec::new(),
7787 vec![repair.clone()],
7788 ));
7789
7790 let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7791 Ok(snapshot) => snapshot,
7792 Err(err) => {
7793 report.push_check(graph_db_doctor_check(
7794 "convex_snapshot_parse",
7795 vec![err.to_string()],
7796 vec![repair],
7797 ));
7798 return;
7799 }
7800 };
7801 report.push_check(graph_db_doctor_check(
7802 "convex_snapshot_parse",
7803 Vec::new(),
7804 vec![repair.clone()],
7805 ));
7806
7807 let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7808 report.push_check(graph_db_doctor_check(
7809 "convex_snapshot_rows",
7810 row_diagnostics,
7811 vec![repair.clone()],
7812 ));
7813
7814 let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7815 .unwrap_or_else(|err| vec![err.to_string()]);
7816 report.required_indexes = convex_required_indexes();
7817 report.push_check(graph_db_doctor_check(
7818 "convex_required_indexes",
7819 index_diagnostics,
7820 vec![
7821 "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7822 .to_string(),
7823 ],
7824 ));
7825
7826 if let Some(local_rows) = local_rows {
7827 let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7828 report.push_check(graph_db_doctor_check(
7829 "convex_projection_freshness",
7830 freshness.diagnostics,
7831 vec![repair],
7832 ));
7833 } else {
7834 report.push_check(graph_db_doctor_check(
7835 "convex_projection_freshness",
7836 vec![
7837 "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7838 .to_string(),
7839 ],
7840 vec![graph_db_rebuild_command(root, scope)],
7841 ));
7842 }
7843}
7844
7845fn graph_db_convex_snapshot_doctor_command(
7846 root: &Path,
7847 scope: Option<&str>,
7848 snapshot_path: &Path,
7849) -> String {
7850 format!(
7851 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7852 shell_quote(root.to_string_lossy().as_ref()),
7853 graph_db_scope_arg(scope),
7854 shell_quote(snapshot_path.to_string_lossy().as_ref())
7855 )
7856}
7857
7858fn graph_db_convex_snapshot_read_command(
7859 root: &Path,
7860 scope: Option<&str>,
7861 snapshot_path: &Path,
7862) -> String {
7863 format!(
7864 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7865 shell_quote(root.to_string_lossy().as_ref()),
7866 graph_db_scope_arg(scope),
7867 shell_quote(snapshot_path.to_string_lossy().as_ref())
7868 )
7869}
7870
7871fn convex_sync_snapshot_diff_command(
7872 root: &Path,
7873 scope: Option<&str>,
7874 snapshot_path: &Path,
7875) -> String {
7876 format!(
7877 "tsift convex-sync {}{} --snapshot {} --json",
7878 shell_quote(root.to_string_lossy().as_ref()),
7879 graph_db_scope_arg(scope),
7880 shell_quote(snapshot_path.to_string_lossy().as_ref())
7881 )
7882}
7883
7884pub(crate) struct GraphDbDriftInput<'a> {
7885 root: &'a Path,
7886 scope: Option<&'a str>,
7887 graph_db: &'a Path,
7888 snapshot_path: &'a Path,
7889 local: &'a ConvexProjectionRows,
7890 snapshot: &'a ConvexProjectionRows,
7891 snapshot_value: &'a serde_json::Value,
7892 warnings: Vec<String>,
7893}
7894
7895pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7896 let GraphDbDriftInput {
7897 root,
7898 scope,
7899 graph_db,
7900 snapshot_path,
7901 local,
7902 snapshot,
7903 snapshot_value,
7904 warnings,
7905 } = input;
7906 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7907 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7908 convex_rows_diff(local, Some(snapshot));
7909 let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7910 let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7911 .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7912 let local_hash = freshness.local_hash.clone();
7913 let snapshot_hash = freshness.snapshot_hash.clone();
7914 let stale_nodes = freshness.stale_nodes.clone();
7915 let stale_edges = freshness.stale_edges.clone();
7916
7917 let duplicate_failures = row_diagnostics
7918 .iter()
7919 .filter(|diagnostic| diagnostic.contains("duplicate"))
7920 .count();
7921 let orphan_failures = row_diagnostics
7922 .iter()
7923 .filter(|diagnostic| diagnostic.contains("references missing"))
7924 .count();
7925 let missing_required_indexes = index_diagnostics.len();
7926 let stale_projection_metadata =
7927 usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7928 let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7929 let has_drift = freshness.fail_closed
7930 || !node_upserts.is_empty()
7931 || !edge_upserts.is_empty()
7932 || !node_tombstones.is_empty()
7933 || !edge_tombstones.is_empty();
7934 let status = if hard_failures > 0 {
7935 "fail_closed"
7936 } else if has_drift {
7937 "drift"
7938 } else {
7939 "current"
7940 }
7941 .to_string();
7942
7943 let mut diagnostics = Vec::new();
7944 diagnostics.extend(row_diagnostics);
7945 diagnostics.extend(index_diagnostics);
7946 diagnostics.extend(freshness.diagnostics.clone());
7947 if has_drift {
7948 diagnostics.push(format!(
7949 "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7950 node_upserts.len(),
7951 edge_upserts.len(),
7952 node_tombstones.len(),
7953 edge_tombstones.len()
7954 ));
7955 }
7956
7957 let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7958 root,
7959 scope,
7960 snapshot_path,
7961 )];
7962 if status == "current" {
7963 next_commands.push(graph_db_convex_snapshot_read_command(
7964 root,
7965 scope,
7966 snapshot_path,
7967 ));
7968 } else {
7969 next_commands.push(convex_sync_snapshot_diff_command(
7970 root,
7971 scope,
7972 snapshot_path,
7973 ));
7974 next_commands.push(convex_refresh_command(root, scope));
7975 }
7976
7977 GraphDbDriftReport {
7978 root: root.to_string_lossy().to_string(),
7979 scope: scope.map(str::to_string),
7980 graph_db: graph_db.to_string_lossy().to_string(),
7981 convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7982 status: status.clone(),
7983 graph_reads_allowed: status == "current",
7984 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7985 local_hash,
7986 snapshot_hash,
7987 summary: GraphDbDriftSummary {
7988 node_upserts: node_upserts.len(),
7989 edge_upserts: edge_upserts.len(),
7990 node_tombstones: node_tombstones.len(),
7991 edge_tombstones: edge_tombstones.len(),
7992 stale_nodes: stale_nodes.len(),
7993 stale_edges: stale_edges.len(),
7994 stale_projection_metadata,
7995 duplicate_failures,
7996 orphan_failures,
7997 missing_required_indexes,
7998 },
7999 node_upserts: node_upserts
8000 .into_iter()
8001 .map(|row| row.external_id)
8002 .collect(),
8003 edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
8004 node_tombstones,
8005 edge_tombstones,
8006 stale_nodes,
8007 stale_edges,
8008 diagnostics,
8009 next_commands,
8010 required_indexes: convex_required_indexes(),
8011 warnings,
8012 }
8013}
8014
8015pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
8016 println!(
8017 "graph-db drift status: {} reads_allowed: {}",
8018 report.status, report.graph_reads_allowed
8019 );
8020 println!("graph_db: {}", report.graph_db);
8021 println!("convex_snapshot: {}", report.convex_snapshot);
8022 println!(
8023 "upserts: {} node(s), {} edge(s)",
8024 report.summary.node_upserts, report.summary.edge_upserts
8025 );
8026 println!(
8027 "tombstones: {} node(s), {} edge(s)",
8028 report.summary.node_tombstones, report.summary.edge_tombstones
8029 );
8030 for diagnostic in &report.diagnostics {
8031 println!("diagnostic: {diagnostic}");
8032 }
8033 for command in &report.next_commands {
8034 println!("next: {command}");
8035 }
8036}
8037
8038pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
8039 println!(
8040 "graph-db doctor backend: {} status: {}",
8041 report.backend, report.status
8042 );
8043 println!("graph_db: {}", report.graph_db);
8044 if let Some(snapshot) = &report.convex_snapshot {
8045 println!("convex_snapshot: {snapshot}");
8046 }
8047 for check in &report.checks {
8048 println!("check: {} {}", check.name, check.status);
8049 for diagnostic in &check.diagnostics {
8050 println!(" diagnostic: {diagnostic}");
8051 }
8052 }
8053 for command in &report.repair_commands {
8054 println!("repair: {command}");
8055 }
8056}
8057
8058pub(crate) fn graph_db_operator_report_from_disk(
8059 root: &Path,
8060 scope: Option<&str>,
8061 graph_db: &Path,
8062 operation: &str,
8063 refresh: Option<GraphDbRefreshSummary>,
8064 warnings: Vec<String>,
8065) -> Result<GraphDbOperatorReport> {
8066 if !graph_db.exists() {
8067 let next_commands = graph_db_operator_next_commands(root, scope, true);
8068 let counts = GraphDbOperatorCounts {
8069 nodes: 0,
8070 edges: 0,
8071 tombstones: GraphDbTombstoneCounts {
8072 nodes: 0,
8073 edges: 0,
8074 total: 0,
8075 },
8076 file_size_bytes: None,
8077 freelist_bytes: None,
8078 };
8079 return Ok(GraphDbOperatorReport {
8080 root: root.to_string_lossy().to_string(),
8081 scope: scope.map(str::to_string),
8082 graph_db: graph_db.to_string_lossy().to_string(),
8083 operation: operation.to_string(),
8084 status: "missing".to_string(),
8085 materialized: false,
8086 freshness: GraphDbFreshnessReport {
8087 status: "missing".to_string(),
8088 fail_closed: true,
8089 projection_version: None,
8090 content_hash: None,
8091 source_watermark: None,
8092 diagnostics: vec![
8093 "graph.db is missing; run graph-db refresh before trusting graph reads"
8094 .to_string(),
8095 ],
8096 },
8097 readiness: graph_effectiveness_blocked(
8098 "graph_db_missing",
8099 vec![
8100 "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
8101 ],
8102 next_commands.clone(),
8103 ),
8104 counts: counts.clone(),
8105 refresh,
8106 compaction: graph_db_compaction_policy(root, scope, &counts, false),
8107 recovery: None,
8108 next_commands,
8109 warnings,
8110 });
8111 }
8112
8113 let conn = open_sqlite_graph_db_readonly(graph_db)?;
8114 let recovery = conn.recovery();
8115 let mut warnings = warnings;
8116 if let Some(recovery) = recovery {
8117 warnings.push(graph_db_read_recovery_diagnostic(recovery));
8118 }
8119 let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
8120 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
8121 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
8122 if !schema_diagnostics.is_empty() {
8123 freshness.diagnostics.extend(schema_diagnostics);
8124 freshness.fail_closed = true;
8125 freshness.status = "stale".to_string();
8126 }
8127 let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
8128 let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
8129 warnings.extend(
8130 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
8131 .unwrap_or_else(|err| {
8132 vec![format!(
8133 "graph tombstone retention inspection failed: {err}"
8134 )]
8135 }),
8136 );
8137 let status = if freshness.fail_closed {
8138 "stale"
8139 } else {
8140 "current"
8141 }
8142 .to_string();
8143
8144 Ok(GraphDbOperatorReport {
8145 root: root.to_string_lossy().to_string(),
8146 scope: scope.map(str::to_string),
8147 graph_db: graph_db.to_string_lossy().to_string(),
8148 operation: operation.to_string(),
8149 status,
8150 materialized: true,
8151 freshness,
8152 readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
8153 compaction: graph_db_compaction_policy(root, scope, &counts, false),
8154 counts,
8155 refresh,
8156 recovery,
8157 next_commands: graph_db_operator_next_commands(root, scope, false),
8158 warnings,
8159 })
8160}
8161
8162fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
8163 println!(
8164 "graph-db {} status: {} materialized: {}",
8165 report.operation, report.status, report.materialized
8166 );
8167 println!("graph_db: {}", report.graph_db);
8168 println!(
8169 "projection: version={} hash={} watermark={}",
8170 report
8171 .freshness
8172 .projection_version
8173 .as_deref()
8174 .unwrap_or("<missing>"),
8175 report
8176 .freshness
8177 .content_hash
8178 .as_deref()
8179 .unwrap_or("<missing>"),
8180 report
8181 .freshness
8182 .source_watermark
8183 .as_deref()
8184 .unwrap_or("<missing>")
8185 );
8186 println!(
8187 "rows: {} node(s), {} edge(s), {} tombstone(s)",
8188 report.counts.nodes, report.counts.edges, report.counts.tombstones.total
8189 );
8190 println!(
8191 "readiness: {} reason: {} fail_closed: {}",
8192 report.readiness.status, report.readiness.reason, report.readiness.fail_closed
8193 );
8194 if let Some(file_size) = report.counts.file_size_bytes {
8195 println!(
8196 "storage: {} byte(s), {} free byte(s)",
8197 file_size,
8198 report.counts.freelist_bytes.unwrap_or(0)
8199 );
8200 }
8201 if let Some(refresh) = &report.refresh {
8202 println!(
8203 "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
8204 refresh.tombstoned_nodes, refresh.tombstoned_edges
8205 );
8206 println!(
8207 "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)",
8208 refresh.upserted_nodes,
8209 refresh.upserted_edges,
8210 refresh.upserted_properties,
8211 refresh.unchanged_nodes,
8212 refresh.unchanged_edges,
8213 refresh.unchanged_properties,
8214 refresh.deleted_properties,
8215 refresh.pruned_tombstones
8216 );
8217 }
8218 println!(
8219 "compaction: {} tombstone_scan_rows={} live_rows={}",
8220 report.compaction.status,
8221 report.compaction.tombstone_scan_rows,
8222 report.compaction.live_rows
8223 );
8224 for proof in &report.compaction.proof {
8225 println!("compaction proof: {proof}");
8226 }
8227 if let Some(recovery) = report.recovery {
8228 println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
8229 }
8230 for diagnostic in &report.freshness.diagnostics {
8231 println!("diagnostic: {diagnostic}");
8232 }
8233 for diagnostic in &report.readiness.diagnostics {
8234 println!("readiness diagnostic: {diagnostic}");
8235 }
8236 for warning in &report.warnings {
8237 println!("warning: {warning}");
8238 }
8239 for command in &report.readiness.next_commands {
8240 println!("readiness next: {command}");
8241 }
8242 for command in &report.next_commands {
8243 println!("next: {command}");
8244 }
8245}
8246
8247pub(crate) fn print_graph_db_operator_report(
8248 report: &GraphDbOperatorReport,
8249 format: OutputFormat,
8250) -> Result<()> {
8251 if format.json_output {
8252 print_json_or_envelope(
8253 report,
8254 &format,
8255 "graph-db",
8256 &report.operation,
8257 ToolEnvelopeSummary {
8258 text: format!(
8259 "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
8260 report.operation,
8261 report.status,
8262 report.counts.nodes,
8263 report.counts.edges,
8264 report.counts.tombstones.total
8265 ),
8266 metrics: vec![
8267 envelope_metric("operation", &report.operation),
8268 envelope_metric("status", &report.status),
8269 envelope_metric("nodes", report.counts.nodes),
8270 envelope_metric("edges", report.counts.edges),
8271 envelope_metric("tombstones", report.counts.tombstones.total),
8272 envelope_metric("compaction", &report.compaction.status),
8273 envelope_metric("readiness", &report.readiness.status),
8274 ],
8275 },
8276 false,
8277 report.next_commands.clone(),
8278 )
8279 } else {
8280 print_graph_db_operator_human(report);
8281 Ok(())
8282 }
8283}
8284
8285fn status_run_command_without_notes(run: &str) -> &str {
8286 run.split_once(" (")
8287 .map(|(command, _)| command)
8288 .unwrap_or(run)
8289}
8290
8291fn status_summarize_extract_command(run: &str) -> &str {
8292 let run = status_run_command_without_notes(run);
8293 run.split(" && ")
8294 .find(|command| command.contains("summarize --extract"))
8295 .unwrap_or(run)
8296}
8297
8298fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
8299 report
8300 .recommendations
8301 .run
8302 .as_deref()
8303 .filter(|command| command.contains("summarize --extract"))
8304 .map(status_summarize_extract_command)
8305 .unwrap_or("tsift summarize --extract .")
8306 .to_string()
8307}
8308
8309fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
8310 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8311 readiness.diagnostics.push(format!(
8312 "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
8313 ));
8314 readiness
8315}
8316
8317fn graph_db_semantic_readiness(
8318 root: &Path,
8319 scope: Option<&str>,
8320 semantic_row_count: Option<usize>,
8321) -> GraphEffectivenessReadiness {
8322 if let Some(row_count) = semantic_row_count
8323 && row_count > 0
8324 {
8325 return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
8326 }
8327
8328 let report = match status::check_status(root) {
8329 Ok(report) => report,
8330 Err(err) => {
8331 return graph_effectiveness_blocked(
8332 "status_check_unavailable",
8333 vec![format!(
8334 "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
8335 )],
8336 vec![graph_db_refresh_command(root, scope)],
8337 );
8338 }
8339 };
8340
8341 match &report.summaries {
8342 status::SummaryStatus::Available {
8343 cached_files,
8344 total_indexed_files,
8345 coverage_pct,
8346 ..
8347 } => {
8348 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8349 readiness.diagnostics.push(format!(
8350 "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
8351 ));
8352 readiness
8353 }
8354 status::SummaryStatus::None { .. } => {
8355 let summarize = graph_db_status_summarize_command(&report);
8356 let index_command = report
8357 .recommendations
8358 .run
8359 .as_deref()
8360 .filter(|cmd| cmd.contains("index"))
8361 .map(str::to_string);
8362 let mut repair = Vec::new();
8363 if let Some(cmd) = index_command {
8364 repair.push(cmd);
8365 }
8366 repair.push(summarize.clone());
8367 repair.push(graph_db_refresh_command(root, scope));
8368 graph_effectiveness_blocked(
8369 "summary_cache_empty",
8370 vec![format!(
8371 "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8372 summarize,
8373 root.display(),
8374 graph_db_refresh_command(root, scope)
8375 )],
8376 repair,
8377 )
8378 }
8379 status::SummaryStatus::Unavailable => {
8380 let mut repair: Vec<String> = report.recommendations.run.clone().into_iter().collect();
8381 let summarize = "tsift summarize --extract .".to_string();
8382 repair.push(summarize);
8383 repair.push(graph_db_refresh_command(root, scope));
8384 graph_effectiveness_blocked(
8385 "summary_cache_unavailable",
8386 vec![
8387 "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(),
8388 ],
8389 repair,
8390 )
8391 }
8392 }
8393}
8394
8395pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
8396 let report = match status::check_status(root) {
8397 Ok(report) => report,
8398 Err(err) => {
8399 return vec![format!(
8400 "status check unavailable after graph-db refresh: {err:#}"
8401 )];
8402 }
8403 };
8404
8405 let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8406 Some(graph_db_status_summarize_command(&report))
8407 } else {
8408 None
8409 };
8410 let mut warnings = report.reminders;
8411 if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8412 let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
8413 warnings.push(format!(
8414 "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8415 run,
8416 root.display(),
8417 graph_db_refresh_command(root, scope)
8418 ));
8419 }
8420 dedupe_preserve_order(warnings)
8421}
8422
8423pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
8424 println!(
8425 "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
8426 report.applied, report.pruned_tombstones, report.reclaimed_bytes
8427 );
8428 println!("graph_db: {}", report.graph_db);
8429 println!(
8430 "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8431 report.counts_before.nodes,
8432 report.counts_before.edges,
8433 report.counts_before.tombstones.total,
8434 report.counts_before.file_size_bytes.unwrap_or(0),
8435 report.counts_before.freelist_bytes.unwrap_or(0)
8436 );
8437 println!(
8438 "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8439 report.counts_after.nodes,
8440 report.counts_after.edges,
8441 report.counts_after.tombstones.total,
8442 report.counts_after.file_size_bytes.unwrap_or(0),
8443 report.counts_after.freelist_bytes.unwrap_or(0)
8444 );
8445 for proof in &report.compaction_after.proof {
8446 println!("proof: {proof}");
8447 }
8448 for warning in &report.warnings {
8449 println!("warning: {warning}");
8450 }
8451 for command in &report.next_commands {
8452 println!("next: {command}");
8453 }
8454}
8455
8456fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
8457 raw.iter()
8458 .map(|value| {
8459 let (key, filter_value) = value
8460 .split_once('=')
8461 .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
8462 let key = key.trim();
8463 let filter_value = filter_value.trim();
8464 if key.is_empty() || filter_value.is_empty() {
8465 bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
8466 }
8467 Ok(GraphDbPropertyFilter {
8468 key: key.to_string(),
8469 value: filter_value.to_string(),
8470 })
8471 })
8472 .collect()
8473}
8474
8475fn graph_db_query_options(
8476 cursor: Option<String>,
8477 limit: Option<usize>,
8478 property_filters: &[String],
8479) -> Result<GraphDbQueryOptions> {
8480 Ok(GraphDbQueryOptions {
8481 cursor,
8482 limit: limit.filter(|limit| *limit > 0),
8483 property_filters: parse_graph_db_property_filters(property_filters)?,
8484 })
8485}
8486
8487fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
8488 GraphQueryOptions {
8489 cursor: options.cursor.clone(),
8490 limit: options.limit,
8491 property_filters: options
8492 .property_filters
8493 .iter()
8494 .map(|filter| GraphPropertyFilter {
8495 key: filter.key.clone(),
8496 value: filter.value.clone(),
8497 })
8498 .collect(),
8499 }
8500}
8501
8502fn graph_db_page_report_from_store(
8503 page: GraphQueryPage,
8504 property_filters: Vec<GraphDbPropertyFilter>,
8505) -> GraphDbPageReport {
8506 GraphDbPageReport {
8507 cursor: page.cursor,
8508 limit: page.limit,
8509 next_cursor: page.next_cursor,
8510 returned_nodes: page.returned_nodes,
8511 returned_edges: page.returned_edges,
8512 truncated: page.truncated,
8513 property_filters,
8514 diagnostics: page.diagnostics,
8515 }
8516}
8517
8518fn graph_db_neighborhood_ranking_gate(
8519 ranked_neighbor_cap: usize,
8520) -> GraphDbNeighborhoodRankingGate {
8521 GraphDbNeighborhoodRankingGate {
8522 status: "held_default_order_unchanged".to_string(),
8523 ranked_output_default: false,
8524 default_order: "stable_node_id".to_string(),
8525 default_change_gate: "community_search_quality_metrics".to_string(),
8526 required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
8527 .iter()
8528 .map(|workload| (*workload).to_string())
8529 .collect(),
8530 required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
8531 .iter()
8532 .map(|metric| (*metric).to_string())
8533 .collect(),
8534 max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
8535 min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
8536 min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
8537 min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
8538 diagnostics: vec![
8539 "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
8540 format!(
8541 "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
8542 ),
8543 "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
8544 ],
8545 }
8546}
8547
8548fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
8549 match limit {
8550 Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
8551 Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
8552 }
8553}
8554
8555fn graph_db_ranked_neighbors(
8556 center_id: &str,
8557 nodes: &[SubstrateGraphNode],
8558 edges: &[SubstrateGraphEdge],
8559 cap: usize,
8560) -> Vec<GraphDbRankedNeighbor> {
8561 resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
8562}
8563
8564fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
8565 center_id: &str,
8566 depth: usize,
8567 edge_kind: Option<&str>,
8568 limit: Option<usize>,
8569 unranked_nodes: &[SubstrateGraphNode],
8570 unranked_edges: &[SubstrateGraphEdge],
8571 store: &S,
8572) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
8573 use std::time::Instant;
8574 let max_nodes = match limit {
8575 Some(0) | None => 200,
8576 Some(n) => n.clamp(10, 500),
8577 };
8578 let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
8579 .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
8580 if let Some(kind) = edge_kind {
8581 options = options.with_edge_kind(kind);
8582 }
8583 let start = Instant::now();
8584 let result = store.ranked_neighborhood(center_id, &options)?;
8585 let latency = start.elapsed().as_micros();
8586 let Some(ranked) = result else {
8587 return Ok(None);
8588 };
8589 let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
8590 let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
8591 let overlap_count = ranked_ids.intersection(&unranked_ids).count();
8592 let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
8593 0.0
8594 } else {
8595 (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8596 };
8597 let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8598 let mut name_count = BTreeMap::<&str, usize>::new();
8599 for n in nodes {
8600 *name_count.entry(&n.label).or_default() += 1;
8601 }
8602 name_count.values().filter(|&&c| c > 1).count()
8603 };
8604 let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8605 if nodes.is_empty() {
8606 return 100.0;
8607 }
8608 let with_handle = nodes
8609 .iter()
8610 .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8611 .count();
8612 (with_handle as f64 / nodes.len() as f64) * 100.0
8613 };
8614 let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8615 if nodes.is_empty() {
8616 return 0.0;
8617 }
8618 let semantic_kinds = [
8619 "semantic_concept",
8620 "semantic_entity",
8621 "symbol",
8622 "file",
8623 "source_handle",
8624 ];
8625 let useful = nodes
8626 .iter()
8627 .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8628 .count();
8629 let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8630 let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8631 (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8632 / nodes.len() as f64
8633 };
8634 let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8635 let edge_pairs: Vec<(String, String)> = ranked
8636 .edges
8637 .iter()
8638 .map(|e| (e.from_id.clone(), e.to_id.clone()))
8639 .collect();
8640 let cr = tsift_graph::detect_communities(&edge_pairs);
8641 let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8642 let mut fully_kept = 0usize;
8643 let mut partially_pruned = 0usize;
8644 let mut fully_pruned = 0usize;
8645 let mut pruned_kinds = BTreeSet::new();
8646 let mut pruned_labels = Vec::new();
8647 for comm in &cr.communities {
8648 let kept_in_comm: Vec<&str> = comm
8649 .members
8650 .iter()
8651 .filter(|m| kept_labels.contains(m.name.as_str()))
8652 .map(|m| m.name.as_str())
8653 .collect();
8654 if kept_in_comm.len() == comm.members.len() {
8655 fully_kept += 1;
8656 } else if kept_in_comm.is_empty() {
8657 fully_pruned += 1;
8658 for m in &comm.members {
8659 if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8660 pruned_kinds.insert(n.kind.clone());
8661 }
8662 pruned_labels.push(m.name.clone());
8663 }
8664 } else {
8665 partially_pruned += 1;
8666 }
8667 }
8668 pruned_labels.truncate(5);
8669 Some(CommunityTruncationSummary {
8670 total_communities: cr.communities.len(),
8671 fully_kept,
8672 partially_pruned,
8673 fully_pruned,
8674 pruned_community_kinds: pruned_kinds.into_iter().collect(),
8675 pruned_community_top_labels: pruned_labels,
8676 })
8677 } else {
8678 None
8679 };
8680 Ok(Some(GraphDbRankedNeighborhoodComparison {
8681 traversal_nodes: ranked.nodes.len(),
8682 traversal_edges: ranked.edges.len(),
8683 pruned_count: ranked.pruned_count,
8684 total_discovered: ranked.total_discovered,
8685 latency_micros: latency,
8686 overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8687 useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8688 / 1000.0,
8689 useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8690 .round()
8691 / 1000.0,
8692 duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8693 duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8694 handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8695 handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8696 / 100.0,
8697 community_truncation_summary,
8698 diagnostics: vec![
8699 format!(
8700 "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8701 ranked.nodes.len(),
8702 ranked.edges.len(),
8703 ranked.pruned_count,
8704 ranked.total_discovered,
8705 latency
8706 ),
8707 format!(
8708 "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8709 overlap_pct,
8710 overlap_count,
8711 unranked_ids.len(),
8712 ranked_ids.len()
8713 ),
8714 "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8715 ],
8716 }))
8717}
8718
8719struct GraphDbBudgetedSubgraph {
8720 nodes: Vec<SubstrateGraphNode>,
8721 edges: Vec<SubstrateGraphEdge>,
8722 report: GraphDbOutputBudgetReport,
8723 truncated: bool,
8724 next_cursor: Option<String>,
8725}
8726
8727const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8728const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8729const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8730
8731fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8732 match limit {
8733 Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8734 Some(limit) => limit
8735 .saturating_mul(320)
8736 .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8737 }
8738}
8739
8740fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8741 if matches!(limit, Some(0) | None) {
8742 return match kind {
8743 "source_handle" => 10,
8744 "worker_context" | "worker_result" => 8,
8745 "semantic_concept" | "semantic_entity" => 10,
8746 "file" | "symbol" | "route" => 12,
8747 _ => 8,
8748 };
8749 }
8750 let base = limit.unwrap_or(0).max(1);
8751 match kind {
8752 "source_handle" => base.saturating_add(4),
8753 "worker_context" | "worker_result" => base.saturating_add(2),
8754 "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8755 "file" | "symbol" | "route" => base.saturating_add(4),
8756 _ => base.saturating_add(1),
8757 }
8758}
8759
8760fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8761 if matches!(limit, Some(0) | None) {
8762 return match kind {
8763 "mentions" | "mentions_concept" | "mentions_entity" => 24,
8764 "semantic_relation" | "calls" | "defines" => 20,
8765 _ => 16,
8766 };
8767 }
8768 let base = limit.unwrap_or(0).max(1);
8769 match kind {
8770 "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8771 "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8772 _ => base.saturating_add(2),
8773 }
8774}
8775
8776fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8777 serde_json::to_vec(value)
8778 .map(|bytes| bytes.len().div_ceil(4).max(1))
8779 .unwrap_or(1)
8780}
8781
8782fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8783 let mut parts = vec![node.kind.clone(), node.label.clone()];
8784 for key in [
8785 "detail",
8786 "description",
8787 "source_ref",
8788 "path",
8789 "source_file",
8790 "source_symbol",
8791 "text_preview",
8792 ] {
8793 if let Some(value) = node.properties.get(key) {
8794 parts.push(value.clone());
8795 }
8796 }
8797 parts.join(" ")
8798}
8799
8800fn graph_db_semantic_scores_for_query(
8801 query: Option<&str>,
8802 nodes: &[SubstrateGraphNode],
8803) -> BTreeMap<String, f64> {
8804 let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8805 return BTreeMap::new();
8806 };
8807 let query_embedding = semantic_embedding(query);
8808 nodes
8809 .iter()
8810 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8811 .filter_map(|node| {
8812 let embedding = node
8813 .properties
8814 .get("embedding")
8815 .and_then(|value| parse_semantic_embedding_property(value))?;
8816 Some((
8817 node.id.clone(),
8818 semantic_cosine(&query_embedding, &embedding),
8819 ))
8820 })
8821 .collect()
8822}
8823
8824fn graph_db_depth_by_id(
8825 origin_ids: &[String],
8826 edges: &[SubstrateGraphEdge],
8827) -> BTreeMap<String, usize> {
8828 let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8829 for edge in edges {
8830 adjacency
8831 .entry(edge.from_id.clone())
8832 .or_default()
8833 .push(edge.to_id.clone());
8834 adjacency
8835 .entry(edge.to_id.clone())
8836 .or_default()
8837 .push(edge.from_id.clone());
8838 }
8839
8840 let mut depth_by_id = BTreeMap::<String, usize>::new();
8841 let mut queue = VecDeque::<String>::new();
8842 for origin in origin_ids {
8843 if depth_by_id.insert(origin.clone(), 0).is_none() {
8844 queue.push_back(origin.clone());
8845 }
8846 }
8847 while let Some(current) = queue.pop_front() {
8848 let depth = depth_by_id.get(¤t).copied().unwrap_or(0);
8849 for next in adjacency.get(¤t).into_iter().flatten() {
8850 if depth_by_id.contains_key(next) {
8851 continue;
8852 }
8853 depth_by_id.insert(next.clone(), depth.saturating_add(1));
8854 queue.push_back(next.clone());
8855 }
8856 }
8857 depth_by_id
8858}
8859
8860fn graph_db_source_covered_ids(
8861 nodes: &[SubstrateGraphNode],
8862 edges: &[SubstrateGraphEdge],
8863) -> BTreeSet<String> {
8864 let source_ids = nodes
8865 .iter()
8866 .filter(|node| node.kind == "source_handle")
8867 .map(|node| node.id.as_str())
8868 .collect::<BTreeSet<_>>();
8869 let mut covered = source_ids
8870 .iter()
8871 .map(|id| (*id).to_string())
8872 .collect::<BTreeSet<_>>();
8873 for edge in edges {
8874 if source_ids.contains(edge.from_id.as_str()) {
8875 covered.insert(edge.to_id.clone());
8876 }
8877 if source_ids.contains(edge.to_id.as_str()) {
8878 covered.insert(edge.from_id.clone());
8879 }
8880 }
8881 covered
8882}
8883
8884fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8885 for key in [
8886 "observed_at_unix",
8887 "completed_at_unix",
8888 "created_at_unix",
8889 "started_at_unix",
8890 ] {
8891 if let Some(value) = node.properties.get(key)
8892 && let Ok(epoch) = value.parse::<i64>()
8893 {
8894 return epoch.div_euclid(86_400).clamp(0, 40_000);
8895 }
8896 }
8897 0
8898}
8899
8900fn graph_db_node_kind_score(kind: &str) -> i64 {
8901 match kind {
8902 "source_handle" => 180,
8903 "worker_context" => 170,
8904 "worker_result" => 160,
8905 "semantic_concept" | "semantic_entity" => 150,
8906 "backlog" | "job_packet" => 130,
8907 "symbol" => 120,
8908 "file" => 110,
8909 "route" => 105,
8910 "session" => 90,
8911 _ => 40,
8912 }
8913}
8914
8915fn graph_db_edge_kind_score(kind: &str) -> i64 {
8916 match kind {
8917 "mentions_concept" | "mentions_entity" => 180,
8918 "semantic_relation" => 170,
8919 "mentions" => 165,
8920 "requests_context" | "scopes_context" | "scopes_source" => 155,
8921 "explains_result" => 150,
8922 "calls" => 145,
8923 "defines" | "handled_by" | "defines_route" => 130,
8924 "contains" | "targets" => 120,
8925 "records_memory_source" | "has_vector_handle" => 115,
8926 _ => 40,
8927 }
8928}
8929
8930fn graph_db_node_usefulness_score(
8931 node: &SubstrateGraphNode,
8932 depth_by_id: &BTreeMap<String, usize>,
8933 semantic_scores: &BTreeMap<String, f64>,
8934 source_covered_ids: &BTreeSet<String>,
8935 origin_ids: &[String],
8936) -> i64 {
8937 if origin_ids.iter().any(|origin| origin == &node.id) {
8938 return 1_000_000;
8939 }
8940 let semantic = semantic_scores
8941 .get(&node.id)
8942 .map(|score| (score.max(0.0) * 1_000.0) as i64)
8943 .unwrap_or(0);
8944 let depth_penalty = depth_by_id
8945 .get(&node.id)
8946 .map(|depth| (*depth as i64).saturating_mul(55))
8947 .unwrap_or(180);
8948 let source_coverage = if source_covered_ids.contains(&node.id)
8949 || node.properties.contains_key("source_ref")
8950 || node.properties.contains_key("path")
8951 {
8952 120
8953 } else {
8954 0
8955 };
8956 graph_db_node_kind_score(&node.kind)
8957 + semantic
8958 + source_coverage
8959 + graph_db_recency_score(node).min(80)
8960 - depth_penalty
8961}
8962
8963fn graph_db_edge_usefulness_score(
8964 edge: &SubstrateGraphEdge,
8965 node_score_by_id: &BTreeMap<String, i64>,
8966 depth_by_id: &BTreeMap<String, usize>,
8967) -> i64 {
8968 let endpoint_score = node_score_by_id
8969 .get(&edge.from_id)
8970 .copied()
8971 .unwrap_or_default()
8972 .max(
8973 node_score_by_id
8974 .get(&edge.to_id)
8975 .copied()
8976 .unwrap_or_default(),
8977 );
8978 let depth_penalty = depth_by_id
8979 .get(&edge.from_id)
8980 .into_iter()
8981 .chain(depth_by_id.get(&edge.to_id))
8982 .min()
8983 .map(|depth| (*depth as i64).saturating_mul(35))
8984 .unwrap_or(140);
8985 graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8986}
8987
8988fn graph_db_push_drop(
8989 drops: &mut BTreeMap<(String, String, String), usize>,
8990 item: &str,
8991 kind: &str,
8992 reason: &str,
8993) {
8994 *drops
8995 .entry((item.to_string(), kind.to_string(), reason.to_string()))
8996 .or_default() += 1;
8997}
8998
8999fn graph_db_budget_drop_report(
9000 drops: BTreeMap<(String, String, String), usize>,
9001) -> Vec<GraphDbDroppedByBudget> {
9002 drops
9003 .into_iter()
9004 .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
9005 item,
9006 kind,
9007 reason,
9008 dropped,
9009 })
9010 .collect()
9011}
9012
9013fn graph_db_apply_output_budget(
9014 origin_ids: &[String],
9015 semantic_scores: &BTreeMap<String, f64>,
9016 nodes: Vec<SubstrateGraphNode>,
9017 edges: Vec<SubstrateGraphEdge>,
9018 limit: Option<usize>,
9019) -> GraphDbBudgetedSubgraph {
9020 graph_db_apply_output_budget_with_depths_and_cursor(
9021 origin_ids,
9022 semantic_scores,
9023 nodes,
9024 edges,
9025 limit,
9026 None,
9027 None,
9028 )
9029}
9030
9031fn graph_db_apply_output_budget_with_depths_and_cursor(
9032 origin_ids: &[String],
9033 semantic_scores: &BTreeMap<String, f64>,
9034 nodes: Vec<SubstrateGraphNode>,
9035 edges: Vec<SubstrateGraphEdge>,
9036 limit: Option<usize>,
9037 depth_overrides: Option<&BTreeMap<String, usize>>,
9038 cursor: Option<&str>,
9039) -> GraphDbBudgetedSubgraph {
9040 let max_tokens = graph_db_output_token_cap(limit);
9041 let candidate_nodes = nodes.len();
9042 let candidate_edges = edges.len();
9043 let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
9044 if let Some(depth_overrides) = depth_overrides {
9045 for (id, depth) in depth_overrides {
9046 depth_by_id
9047 .entry(id.clone())
9048 .and_modify(|current| *current = (*current).min(*depth))
9049 .or_insert(*depth);
9050 }
9051 }
9052 let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
9053 let node_score_by_id = nodes
9054 .iter()
9055 .map(|node| {
9056 (
9057 node.id.clone(),
9058 graph_db_node_usefulness_score(
9059 node,
9060 &depth_by_id,
9061 semantic_scores,
9062 &source_covered_ids,
9063 origin_ids,
9064 ),
9065 )
9066 })
9067 .collect::<BTreeMap<_, _>>();
9068
9069 let mut node_candidates = nodes.iter().collect::<Vec<_>>();
9070 node_candidates.sort_by(|left, right| {
9071 node_score_by_id
9072 .get(&right.id)
9073 .cmp(&node_score_by_id.get(&left.id))
9074 .then_with(|| left.kind.cmp(&right.kind))
9075 .then_with(|| left.label.cmp(&right.label))
9076 .then_with(|| left.id.cmp(&right.id))
9077 });
9078
9079 let cursor_skip = if let Some(cursor) = cursor {
9080 node_candidates
9081 .iter()
9082 .position(|node| node.id == cursor)
9083 .map(|pos| pos.saturating_add(1))
9084 .unwrap_or(0)
9085 } else {
9086 0
9087 };
9088 if cursor_skip > 0 {
9089 node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
9090 }
9091
9092 let mut selected_node_ids = BTreeSet::new();
9093 let mut selected_node_counts = BTreeMap::<String, usize>::new();
9094 let mut estimated_tokens = 0usize;
9095 let mut drops = BTreeMap::<(String, String, String), usize>::new();
9096 for node in &node_candidates {
9097 let kind_count = selected_node_counts
9098 .get(&node.kind)
9099 .copied()
9100 .unwrap_or_default();
9101 if !origin_ids.iter().any(|origin| origin == &node.id)
9102 && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
9103 {
9104 graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
9105 continue;
9106 }
9107 let tokens = graph_db_estimated_tokens(node);
9108 if !origin_ids.iter().any(|origin| origin == &node.id)
9109 && estimated_tokens.saturating_add(tokens) > max_tokens
9110 {
9111 graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
9112 continue;
9113 }
9114 selected_node_ids.insert(node.id.clone());
9115 *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
9116 estimated_tokens = estimated_tokens.saturating_add(tokens);
9117 }
9118
9119 let has_remaining_candidates = node_candidates
9120 .iter()
9121 .any(|node| !selected_node_ids.contains(&node.id));
9122
9123 let mut selected_nodes = nodes
9124 .into_iter()
9125 .filter(|node| selected_node_ids.contains(&node.id))
9126 .collect::<Vec<_>>();
9127
9128 let mut edge_candidates = edges
9129 .iter()
9130 .filter(|edge| {
9131 selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
9132 })
9133 .collect::<Vec<_>>();
9134 let edge_score_by_key = edge_candidates
9135 .iter()
9136 .map(|edge| {
9137 (
9138 graph_db_edge_key(edge),
9139 graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
9140 )
9141 })
9142 .collect::<BTreeMap<_, _>>();
9143 edge_candidates.sort_by(|left, right| {
9144 edge_score_by_key
9145 .get(&graph_db_edge_key(right))
9146 .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
9147 .then_with(|| left.kind.cmp(&right.kind))
9148 .then_with(|| left.from_id.cmp(&right.from_id))
9149 .then_with(|| left.to_id.cmp(&right.to_id))
9150 });
9151
9152 let endpoint_dropped_edges = edges
9153 .iter()
9154 .filter(|edge| {
9155 !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
9156 })
9157 .count();
9158 if endpoint_dropped_edges > 0 {
9159 drops.insert(
9160 (
9161 "edge".to_string(),
9162 "*".to_string(),
9163 "endpoint_node_dropped".to_string(),
9164 ),
9165 endpoint_dropped_edges,
9166 );
9167 }
9168
9169 let mut selected_edge_ids = BTreeSet::new();
9170 let mut selected_edge_counts = BTreeMap::<String, usize>::new();
9171 for edge in edge_candidates {
9172 let kind_count = selected_edge_counts
9173 .get(&edge.kind)
9174 .copied()
9175 .unwrap_or_default();
9176 if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
9177 graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
9178 continue;
9179 }
9180 let tokens = graph_db_estimated_tokens(edge);
9181 if estimated_tokens.saturating_add(tokens) > max_tokens {
9182 graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
9183 continue;
9184 }
9185 selected_edge_ids.insert(graph_db_edge_key(edge));
9186 *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
9187 estimated_tokens = estimated_tokens.saturating_add(tokens);
9188 }
9189
9190 let selected_edges = edges
9191 .into_iter()
9192 .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
9193 .collect::<Vec<_>>();
9194 let dropped_by_budget = graph_db_budget_drop_report(drops);
9195 let truncated = has_remaining_candidates;
9196 let next_cursor = if truncated {
9197 selected_nodes.last().map(|node| node.id.clone())
9198 } else {
9199 None
9200 };
9201 let mut diagnostics = vec![
9202 "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
9203 .to_string(),
9204 format!(
9205 "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
9206 selected_nodes.len(),
9207 candidate_nodes,
9208 selected_edges.len(),
9209 candidate_edges,
9210 max_tokens
9211 ),
9212 ];
9213 if cursor.is_some() {
9214 diagnostics.push(format!(
9215 "cursor skipped {} previously returned candidate(s)",
9216 cursor_skip
9217 ));
9218 }
9219 if next_cursor.is_some() {
9220 diagnostics.push(
9221 "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
9222 );
9223 }
9224 selected_nodes.shrink_to_fit();
9225
9226 GraphDbBudgetedSubgraph {
9227 nodes: selected_nodes,
9228 edges: selected_edges,
9229 report: GraphDbOutputBudgetReport {
9230 max_tokens,
9231 estimated_tokens,
9232 selected_nodes: selected_node_ids.len(),
9233 selected_edges: selected_edge_ids.len(),
9234 candidate_nodes,
9235 candidate_edges,
9236 dropped_by_budget,
9237 diagnostics,
9238 },
9239 truncated,
9240 next_cursor,
9241 }
9242}
9243
9244fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
9245 if edge.id.is_empty() {
9246 substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
9247 } else {
9248 edge.id.clone()
9249 }
9250}
9251
9252fn graph_db_schema() -> GraphDbSchema {
9253 GraphDbSchema {
9254 contract_versions: vec![
9255 GraphDbSchemaContract {
9256 name: "graph_db_evidence",
9257 version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9258 description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
9259 },
9260 GraphDbSchemaContract {
9261 name: "worker_prompt_packet",
9262 version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
9263 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",
9264 },
9265 GraphDbSchemaContract {
9266 name: "conflict_matrix",
9267 version: CONFLICT_MATRIX_CONTRACT_VERSION,
9268 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",
9269 },
9270 GraphDbSchemaContract {
9271 name: "context_pack_graph_orchestration",
9272 version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
9273 description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
9274 },
9275 GraphDbSchemaContract {
9276 name: "session_review_follow_up",
9277 version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
9278 description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
9279 },
9280 GraphDbSchemaContract {
9281 name: "dispatch_trace",
9282 version: DISPATCH_TRACE_CONTRACT_VERSION,
9283 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",
9284 },
9285 GraphDbSchemaContract {
9286 name: "dependency_dag",
9287 version: DEPENDENCY_DAG_CONTRACT_VERSION,
9288 description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
9289 },
9290 ],
9291 node_fields: vec![
9292 GraphDbSchemaField {
9293 name: "id",
9294 value_type: "string",
9295 description: "Stable provider-neutral node id",
9296 },
9297 GraphDbSchemaField {
9298 name: "kind",
9299 value_type: "string",
9300 description: "Application-defined node family such as file, symbol, or backlog",
9301 },
9302 GraphDbSchemaField {
9303 name: "label",
9304 value_type: "string",
9305 description: "Human-readable label",
9306 },
9307 GraphDbSchemaField {
9308 name: "properties",
9309 value_type: "object<string,string>",
9310 description: "Adapter-specific string properties",
9311 },
9312 GraphDbSchemaField {
9313 name: "provenance",
9314 value_type: "array",
9315 description: "Source system and source reference metadata",
9316 },
9317 GraphDbSchemaField {
9318 name: "freshness",
9319 value_type: "object|null",
9320 description: "Optional content hash and observed timestamp",
9321 },
9322 ],
9323 edge_fields: vec![
9324 GraphDbSchemaField {
9325 name: "id",
9326 value_type: "string",
9327 description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
9328 },
9329 GraphDbSchemaField {
9330 name: "from_id",
9331 value_type: "string",
9332 description: "Source node id",
9333 },
9334 GraphDbSchemaField {
9335 name: "to_id",
9336 value_type: "string",
9337 description: "Target node id",
9338 },
9339 GraphDbSchemaField {
9340 name: "kind",
9341 value_type: "string",
9342 description: "Application-defined edge relation",
9343 },
9344 GraphDbSchemaField {
9345 name: "properties",
9346 value_type: "object<string,string>",
9347 description: "Adapter-specific string properties",
9348 },
9349 GraphDbSchemaField {
9350 name: "provenance",
9351 value_type: "array",
9352 description: "Source system and source reference metadata",
9353 },
9354 GraphDbSchemaField {
9355 name: "freshness",
9356 value_type: "object|null",
9357 description: "Optional content hash and observed timestamp",
9358 },
9359 ],
9360 operations: vec![
9361 GraphDbSchemaOperation {
9362 command: "refresh",
9363 description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
9364 },
9365 GraphDbSchemaOperation {
9366 command: "status",
9367 description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
9368 },
9369 GraphDbSchemaOperation {
9370 command: "doctor",
9371 description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
9372 },
9373 GraphDbSchemaOperation {
9374 command: "drift",
9375 description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
9376 },
9377 GraphDbSchemaOperation {
9378 command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
9379 description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
9380 },
9381 GraphDbSchemaOperation {
9382 command: "snapshot-export <output.db.gz> [--force]",
9383 description: "Export the current SQLite graph.db as a gzip-compressed shareable artifact only after freshness, doctor, WAL, and sidecar checks pass",
9384 },
9385 GraphDbSchemaOperation {
9386 command: "snapshot-import <artifact.db.gz> [--replace]",
9387 description: "Stage and validate a compressed SQLite graph.db artifact through doctor and freshness checks before replacing the local graph.db",
9388 },
9389 GraphDbSchemaOperation {
9390 command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
9391 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",
9392 },
9393 GraphDbSchemaOperation {
9394 command: "evidence <target> [--depth N] [--limit N]",
9395 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",
9396 },
9397 GraphDbSchemaOperation {
9398 command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
9399 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",
9400 },
9401 GraphDbSchemaOperation {
9402 command: "dispatch-trace [target...] --path <session> [--format json|html]",
9403 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",
9404 },
9405 GraphDbSchemaOperation {
9406 command: "dependency-dag [target...] --path <session>",
9407 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",
9408 },
9409 GraphDbSchemaOperation {
9410 command: "schema",
9411 description: "Return record and operation schemas",
9412 },
9413 GraphDbSchemaOperation {
9414 command: "node <id>",
9415 description: "Return one node by stable id",
9416 },
9417 GraphDbSchemaOperation {
9418 command: "edge <id>",
9419 description: "Return one edge by stable edge id",
9420 },
9421 GraphDbSchemaOperation {
9422 command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9423 description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
9424 },
9425 GraphDbSchemaOperation {
9426 command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9427 description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
9428 },
9429 GraphDbSchemaOperation {
9430 command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
9431 description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
9432 },
9433 GraphDbSchemaOperation {
9434 command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
9435 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",
9436 },
9437 GraphDbSchemaOperation {
9438 command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
9439 description: "Return the shortest directed path by node id, optionally bounded by hop count",
9440 },
9441 ],
9442 }
9443}
9444
9445pub(crate) fn sqlite_graph_freshness(
9446 store: &SqliteGraphStore,
9447 scope: &str,
9448) -> Result<GraphDbFreshnessReport> {
9449 let version = store.projection_version(scope)?;
9450 let Some(version) = version else {
9451 return Ok(GraphDbFreshnessReport {
9452 status: "missing".to_string(),
9453 fail_closed: true,
9454 projection_version: None,
9455 content_hash: None,
9456 source_watermark: None,
9457 diagnostics: vec![
9458 "graph projection metadata is missing; rebuild the graph before trusting reads"
9459 .to_string(),
9460 ],
9461 });
9462 };
9463 let mut diagnostics = Vec::new();
9464 let fail_closed =
9465 version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
9466 if version.projection_version != GRAPH_PROJECTION_VERSION {
9467 diagnostics.push(format!(
9468 "projection version mismatch: expected {} got {}",
9469 GRAPH_PROJECTION_VERSION, version.projection_version
9470 ));
9471 }
9472 if version.content_hash.is_none() {
9473 diagnostics.push("projection content hash is missing".to_string());
9474 }
9475 Ok(GraphDbFreshnessReport {
9476 status: if fail_closed { "stale" } else { "current" }.to_string(),
9477 fail_closed,
9478 projection_version: Some(version.projection_version),
9479 content_hash: version.content_hash,
9480 source_watermark: version.source_watermark,
9481 diagnostics,
9482 })
9483}
9484
9485pub(crate) fn convex_graph_freshness(
9486 local: &ConvexProjectionRows,
9487 snapshot: &ConvexProjectionRows,
9488 scope: Option<&str>,
9489) -> GraphDbFreshnessReport {
9490 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
9491 GraphDbFreshnessReport {
9492 status: freshness.status,
9493 fail_closed: freshness.fail_closed,
9494 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
9495 content_hash: freshness.snapshot_hash,
9496 source_watermark: None,
9497 diagnostics: freshness.diagnostics,
9498 }
9499}
9500
9501pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
9502 let (nodes, edges) = store.graph_counts()?;
9503 let files = store.file_count()?;
9504 Ok(GraphDbFreshnessReport {
9505 status: "current".to_string(),
9506 fail_closed: false,
9507 projection_version: Some("tokensave-readonly".to_string()),
9508 content_hash: None,
9509 source_watermark: Some(store.db_path().to_string_lossy().to_string()),
9510 diagnostics: vec![format!(
9511 "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
9512 nodes, edges, files
9513 )],
9514 })
9515}
9516
9517pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
9518 match TokensaveDb::discover(root) {
9519 Ok(Some(store)) => {
9520 report.push_check(GraphDbDoctorCheck {
9521 name: "tokensave_db_open".to_string(),
9522 status: "ok".to_string(),
9523 fail_closed: false,
9524 diagnostics: vec![format!(
9525 "opened tokensave database at {}",
9526 store.db_path().display()
9527 )],
9528 repair_commands: Vec::new(),
9529 });
9530 match (store.node_count(), store.edge_count(), store.file_count()) {
9531 (Ok(nodes), Ok(edges), Ok(files)) => {
9532 report.push_check(GraphDbDoctorCheck {
9533 name: "tokensave_counts".to_string(),
9534 status: "ok".to_string(),
9535 fail_closed: false,
9536 diagnostics: vec![format!(
9537 "tokensave contains {} node(s), {} edge(s), {} file(s)",
9538 nodes, edges, files
9539 )],
9540 repair_commands: Vec::new(),
9541 });
9542 }
9543 (nodes, edges, files) => {
9544 report.push_check(graph_db_doctor_check(
9545 "tokensave_counts",
9546 vec![format!(
9547 "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
9548 nodes.err(),
9549 edges.err(),
9550 files.err()
9551 )],
9552 Vec::new(),
9553 ));
9554 }
9555 }
9556 }
9557 Ok(None) => report.push_check(graph_db_doctor_check(
9558 "tokensave_db_exists",
9559 vec![format!(
9560 "tokensave database is missing at {}",
9561 root.join(".tokensave").join("tokensave.db").display()
9562 )],
9563 Vec::new(),
9564 )),
9565 Err(err) => report.push_check(graph_db_doctor_check(
9566 "tokensave_db_open",
9567 vec![err.to_string()],
9568 Vec::new(),
9569 )),
9570 }
9571}
9572
9573const GRAPH_DB_EVIDENCE_TARGET_KINDS: &[&str] = &[
9574 "backlog",
9575 "job_packet",
9576 "worker_result",
9577 "worker_context",
9578 "source_handle",
9579];
9580
9581pub(crate) fn graph_db_evidence_preferred_path(root: &Path, path_hint: &Path) -> Option<String> {
9582 hinted_markdown_file(root, path_hint).map(|path| {
9583 relativize_pathbuf(&path, root)
9584 .to_string_lossy()
9585 .replace('\\', "/")
9586 })
9587}
9588
9589fn graph_db_ambiguous_target_message(
9590 target: &str,
9591 kind: &str,
9592 candidates: &[SubstrateGraphNode],
9593) -> String {
9594 let mut by_path = BTreeMap::<String, String>::new();
9595 for candidate in candidates.iter().filter(|node| node.kind == kind) {
9596 let path = candidate
9597 .properties
9598 .get("path")
9599 .cloned()
9600 .unwrap_or_else(|| "<no path>".to_string());
9601 by_path.entry(path).or_insert_with(|| candidate.id.clone());
9602 }
9603 let examples = by_path
9604 .iter()
9605 .take(5)
9606 .map(|(path, node_id)| format!("{node_id} path={path}"))
9607 .collect::<Vec<_>>()
9608 .join(", ");
9609 format!(
9610 "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",
9611 by_path.len()
9612 )
9613}
9614
9615pub(crate) fn graph_db_resolve_evidence_target_with_path(
9616 store: &impl GraphStore,
9617 target: &str,
9618 preferred_path: Option<&str>,
9619) -> Result<Option<SubstrateGraphNode>> {
9620 if let Some(node) = store.node(target)? {
9621 return Ok(Some(node));
9622 }
9623 let candidates =
9624 store.evidence_target_candidates(target, GRAPH_DB_EVIDENCE_TARGET_KINDS, preferred_path)?;
9625 if candidates.is_empty() {
9626 return Ok(None);
9627 }
9628 if preferred_path.is_none() {
9629 let first_kind = candidates[0].kind.as_str();
9630 let distinct_paths = candidates
9631 .iter()
9632 .filter(|node| node.kind == first_kind)
9633 .map(|node| {
9634 node.properties
9635 .get("path")
9636 .map(String::as_str)
9637 .unwrap_or("")
9638 })
9639 .collect::<BTreeSet<_>>();
9640 if distinct_paths.len() > 1 {
9641 bail!(
9642 "{}",
9643 graph_db_ambiguous_target_message(target, first_kind, &candidates)
9644 );
9645 }
9646 }
9647 Ok(candidates.into_iter().next())
9648}
9649
9650pub(crate) fn graph_db_resolve_evidence_target(
9651 store: &impl GraphStore,
9652 target: &str,
9653) -> Result<Option<SubstrateGraphNode>> {
9654 graph_db_resolve_evidence_target_with_path(store, target, None)
9655}
9656
9657fn graph_db_reachable_nodes_by_kind(
9658 store: &impl GraphStore,
9659 from_id: &str,
9660 kind: &str,
9661 depth: usize,
9662 limit: usize,
9663) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
9664 store.reachable_nodes_by_kind(from_id, kind, depth, limit)
9665}
9666
9667fn graph_db_evidence_completed_queue_drift_warnings(
9668 store: &impl GraphStore,
9669 target: &SubstrateGraphNode,
9670 worker_results: &[SubstrateGraphNode],
9671) -> Result<Vec<String>> {
9672 let ref_id = target.properties.get("ref_id").map(String::as_str);
9673 let has_completed_result = worker_results.iter().any(|node| {
9674 node.properties.get("status").map(String::as_str) == Some("completed")
9675 && node.properties.get("ref_id").map(String::as_str) == ref_id
9676 });
9677 if !has_completed_result {
9678 return Ok(Vec::new());
9679 }
9680 let active_jobs = store
9681 .nodes_by_kind("job_packet")?
9682 .into_iter()
9683 .filter(|node| {
9684 node.properties.get("ref_id").map(String::as_str) == ref_id
9685 && node.label.starts_with("do #")
9686 })
9687 .collect::<Vec<_>>();
9688 if active_jobs.is_empty() {
9689 return Ok(Vec::new());
9690 }
9691 let repair = match (target.properties.get("path"), ref_id) {
9692 (Some(path), Some(id)) => format!(
9693 "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9694 shell_quote(path),
9695 shell_quote(id),
9696 shell_quote(id)
9697 ),
9698 _ => {
9699 "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9700 }
9701 };
9702 Ok(vec![format!(
9703 "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",
9704 target.label,
9705 active_jobs.len()
9706 )])
9707}
9708
9709fn graph_db_evidence_next_commands(
9710 root: &Path,
9711 scope: Option<&str>,
9712 target: &SubstrateGraphNode,
9713 worker_context: &[SubstrateGraphNode],
9714 source_handles: &[SubstrateGraphNode],
9715 worker_results: &[SubstrateGraphNode],
9716 semantic_related: &[SubstrateGraphNode],
9717) -> Vec<String> {
9718 let mut commands = BTreeSet::new();
9719 if let Some(expand) = target.properties.get("expand") {
9720 commands.insert(expand.clone());
9721 }
9722 for worker in worker_context {
9723 if let Some(expand) = worker.properties.get("expand") {
9724 commands.insert(expand.clone());
9725 }
9726 }
9727 for source in source_handles {
9728 if let Some(expand) = source.properties.get("expand") {
9729 commands.insert(expand.clone());
9730 }
9731 }
9732 for result in worker_results {
9733 if let Some(expand) = result.properties.get("expand") {
9734 commands.insert(expand.clone());
9735 }
9736 }
9737 for semantic in semantic_related {
9738 if let Some(expand) = semantic.properties.get("expand") {
9739 commands.insert(expand.clone());
9740 }
9741 }
9742 commands.insert(format!(
9743 "tsift graph-db --path {}{} status --json",
9744 shell_quote(root.to_string_lossy().as_ref()),
9745 graph_db_scope_arg(scope)
9746 ));
9747 commands.insert(format!(
9748 "tsift graph-db --path {}{} doctor --json",
9749 shell_quote(root.to_string_lossy().as_ref()),
9750 graph_db_scope_arg(scope)
9751 ));
9752 commands.into_iter().collect()
9753}
9754
9755fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9756 vec![
9757 format!(
9758 "tsift graph-db --path {}{} refresh --json",
9759 shell_quote(root.to_string_lossy().as_ref()),
9760 graph_db_scope_arg(scope)
9761 ),
9762 format!(
9763 "tsift graph-db --path {}{} doctor --json",
9764 shell_quote(root.to_string_lossy().as_ref()),
9765 graph_db_scope_arg(scope)
9766 ),
9767 ]
9768}
9769
9770fn graph_db_evidence_replay_commands(
9771 root: &Path,
9772 scope: Option<&str>,
9773 target: &str,
9774 depth: usize,
9775 limit: usize,
9776) -> Vec<String> {
9777 vec![
9778 format!(
9779 "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9780 shell_quote(root.to_string_lossy().as_ref()),
9781 graph_db_scope_arg(scope),
9782 shell_quote(target),
9783 depth,
9784 limit
9785 ),
9786 format!(
9787 "tsift conflict-matrix --path {} {} --json",
9788 shell_quote(root.to_string_lossy().as_ref()),
9789 shell_quote(target)
9790 ),
9791 ]
9792}
9793
9794fn graph_db_evidence_packet_id(
9795 target: &str,
9796 target_node: &SubstrateGraphNode,
9797 freshness: &GraphDbFreshnessReport,
9798) -> String {
9799 stable_handle(
9800 "gevd",
9801 &format!(
9802 "{}:{}:{}:{}",
9803 GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9804 target,
9805 target_node.id,
9806 freshness.content_hash.as_deref().unwrap_or("no-hash")
9807 ),
9808 )
9809}
9810
9811pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9812 input: GraphDbEvidenceInput<'_, S>,
9813) -> Result<GraphDbEvidenceReport> {
9814 let GraphDbEvidenceInput {
9815 root,
9816 scope,
9817 backend,
9818 target,
9819 preferred_path,
9820 depth,
9821 limit,
9822 cursor,
9823 store,
9824 freshness,
9825 mut warnings,
9826 } = input;
9827 let repair_commands = graph_db_repair_commands(root, scope);
9828 if freshness.fail_closed {
9829 bail!(
9830 "graph database evidence failed closed for {} backend: {}; repair: {}",
9831 backend,
9832 freshness.diagnostics.join("; "),
9833 repair_commands.join("; ")
9834 );
9835 }
9836 let semantic_readiness =
9837 graph_db_semantic_readiness(root, scope, graph_store_semantic_node_count(store).ok());
9838 if semantic_readiness.fail_closed {
9839 warnings.push(format!(
9840 "graph evidence semantic readiness blocked: {} — {}",
9841 semantic_readiness.reason,
9842 semantic_readiness.diagnostics.join("; ")
9843 ));
9844 warnings.push(format!(
9845 "repair: {}",
9846 semantic_readiness.next_commands.join("; then ")
9847 ));
9848 }
9849 let target_node = graph_db_resolve_evidence_target_with_path(store, target, preferred_path)?
9850 .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9851 let max_rows = if limit == 0 { usize::MAX } else { limit };
9852 let mut reachable = store.reachable_nodes_by_kinds(
9853 &target_node.id,
9854 &[
9855 "worker_context",
9856 "source_handle",
9857 "worker_result",
9858 "semantic_concept",
9859 "semantic_entity",
9860 ],
9861 depth,
9862 max_rows,
9863 )?;
9864 let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9865 let source_paths = reachable.remove("source_handle").unwrap_or_default();
9866 let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9867 let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9868 semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9869 semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9870 left_path
9871 .hops
9872 .cmp(&right_path.hops)
9873 .then(left_node.kind.cmp(&right_node.kind))
9874 .then(left_node.label.cmp(&right_node.label))
9875 .then(left_node.id.cmp(&right_node.id))
9876 });
9877 if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9878 semantic_paths.truncate(max_rows);
9879 }
9880
9881 let evidence_nodes = worker_paths
9882 .iter()
9883 .chain(source_paths.iter())
9884 .chain(worker_result_paths.iter())
9885 .chain(semantic_paths.iter())
9886 .map(|(node, _)| node.clone())
9887 .collect::<Vec<_>>();
9888 let evidence_depth_by_id = worker_paths
9889 .iter()
9890 .chain(source_paths.iter())
9891 .chain(worker_result_paths.iter())
9892 .chain(semantic_paths.iter())
9893 .map(|(node, path)| (node.id.clone(), path.hops))
9894 .collect::<BTreeMap<_, _>>();
9895 let target_query = graph_db_node_search_text(&target_node);
9896 let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9897 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9898 std::slice::from_ref(&target_node.id),
9899 &semantic_scores,
9900 evidence_nodes,
9901 Vec::new(),
9902 Some(limit),
9903 Some(&evidence_depth_by_id),
9904 cursor,
9905 );
9906 let output_budget = budgeted.report;
9907 let truncated = budgeted.truncated;
9908 let next_cursor = budgeted.next_cursor;
9909 let retained_evidence_ids = budgeted
9910 .nodes
9911 .iter()
9912 .map(|node| node.id.as_str())
9913 .collect::<BTreeSet<_>>();
9914 let worker_context = worker_paths
9915 .iter()
9916 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9917 .map(|(node, _)| node.clone())
9918 .collect::<Vec<_>>();
9919 let source_handles = source_paths
9920 .iter()
9921 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9922 .map(|(node, _)| node.clone())
9923 .collect::<Vec<_>>();
9924 let worker_results = worker_result_paths
9925 .iter()
9926 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9927 .map(|(node, _)| node.clone())
9928 .collect::<Vec<_>>();
9929 let semantic_related = semantic_paths
9930 .iter()
9931 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9932 .map(|(node, _)| node.clone())
9933 .collect::<Vec<_>>();
9934 warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9935 store,
9936 &target_node,
9937 &worker_results,
9938 )?);
9939 if worker_context.is_empty()
9940 && source_handles.is_empty()
9941 && worker_results.is_empty()
9942 && semantic_related.is_empty()
9943 {
9944 warnings.push(format!(
9945 "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",
9946 target, target_node.kind
9947 ));
9948 }
9949 let shortest_paths = worker_paths
9950 .iter()
9951 .chain(source_paths.iter())
9952 .chain(worker_result_paths.iter())
9953 .chain(semantic_paths.iter())
9954 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9955 .map(|(node, path)| GraphDbEvidencePath {
9956 to: node.id.clone(),
9957 kind: node.kind.clone(),
9958 label: node.label.clone(),
9959 path: Some(path.clone()),
9960 expand: node.properties.get("expand").cloned(),
9961 })
9962 .collect::<Vec<_>>();
9963 let next_commands = graph_db_evidence_next_commands(
9964 root,
9965 scope,
9966 &target_node,
9967 &worker_context,
9968 &source_handles,
9969 &worker_results,
9970 &semantic_related,
9971 );
9972 let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9973 let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9974 let projection_hash = freshness.content_hash.clone();
9975
9976 Ok(GraphDbEvidenceReport {
9977 root: root.to_string_lossy().to_string(),
9978 scope: scope.map(str::to_string),
9979 backend: backend.to_string(),
9980 contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9981 target: target.to_string(),
9982 packet_id,
9983 projection_hash,
9984 freshness,
9985 target_node: target_node.into(),
9986 worker_context: worker_context.into_iter().map(Into::into).collect(),
9987 source_handles: source_handles.into_iter().map(Into::into).collect(),
9988 worker_results: worker_results.into_iter().map(Into::into).collect(),
9989 semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9990 shortest_paths,
9991 output_budget: Some(output_budget),
9992 truncated,
9993 next_cursor,
9994 next_commands,
9995 replay_commands,
9996 repair_commands,
9997 fixture_coverage: GraphDbFixtureCoverage {
9998 test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9999 .to_string(),
10000 fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
10001 assertions: vec![
10002 "backlog id and job packet handle resolve to graph nodes".to_string(),
10003 "worker_context rows are reachable from queued work".to_string(),
10004 "source_handle rows are reachable through bounded shortest paths".to_string(),
10005 "worker_result rows are reachable from completed or blocked work".to_string(),
10006 ],
10007 },
10008 warnings,
10009 })
10010}
10011
10012fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
10013 println!(
10014 "graph-db evidence backend: {} target: {} [{}] packet:{}",
10015 report.backend, report.target_node.id, report.target_node.kind, report.packet_id
10016 );
10017 let page_info = if report.truncated {
10018 let cursor = report.next_cursor.as_deref().unwrap_or("?");
10019 format!(" (truncated, next_cursor: {cursor})")
10020 } else {
10021 String::new()
10022 };
10023 println!(
10024 "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
10025 report.worker_context.len(),
10026 report.source_handles.len(),
10027 report.worker_results.len(),
10028 report.semantic_related.len(),
10029 report.shortest_paths.len()
10030 );
10031 for path in &report.shortest_paths {
10032 if let Some(graph_path) = &path.path {
10033 println!(
10034 "path: {} hop(s) {}",
10035 graph_path.hops,
10036 graph_path.nodes.join(" -> ")
10037 );
10038 }
10039 }
10040 for command in &report.next_commands {
10041 println!("next: {command}");
10042 }
10043 for warning in &report.warnings {
10044 println!("warning: {warning}");
10045 }
10046}
10047
10048pub(crate) fn print_graph_db_evidence_report(
10049 report: &GraphDbEvidenceReport,
10050 format: OutputFormat,
10051) -> Result<()> {
10052 if format.json_output {
10053 let page_info = if report.truncated {
10054 let cursor = report.next_cursor.as_deref().unwrap_or("?");
10055 format!(" (truncated, next_cursor: {cursor})")
10056 } else {
10057 String::new()
10058 };
10059 print_json_or_envelope(
10060 report,
10061 &format,
10062 "graph-db",
10063 "evidence",
10064 ToolEnvelopeSummary {
10065 text: format!(
10066 "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}",
10067 report.target,
10068 report.worker_context.len(),
10069 report.source_handles.len(),
10070 report.worker_results.len(),
10071 report.semantic_related.len(),
10072 report.shortest_paths.len()
10073 ),
10074 metrics: vec![
10075 envelope_metric("backend", &report.backend),
10076 envelope_metric("worker_context", report.worker_context.len()),
10077 envelope_metric("source_handles", report.source_handles.len()),
10078 envelope_metric("worker_results", report.worker_results.len()),
10079 envelope_metric("semantic_related", report.semantic_related.len()),
10080 envelope_metric("paths", report.shortest_paths.len()),
10081 ],
10082 },
10083 report.truncated,
10084 report.next_commands.clone(),
10085 )
10086 } else {
10087 print_graph_db_evidence_human(report);
10088 Ok(())
10089 }
10090}
10091
10092pub(crate) fn graph_db_report_from_store(
10093 root: &Path,
10094 scope: Option<&str>,
10095 backend: &str,
10096 query: GraphDbQuery,
10097 store: &impl GraphStore,
10098 freshness: GraphDbFreshnessReport,
10099 warnings: Vec<String>,
10100) -> Result<GraphDbReport> {
10101 if freshness.fail_closed {
10102 bail!(
10103 "graph database read failed closed for {} backend: {}",
10104 backend,
10105 freshness.diagnostics.join("; ")
10106 );
10107 }
10108 let mut report = GraphDbReport {
10109 root: root.to_string_lossy().to_string(),
10110 scope: scope.map(str::to_string),
10111 backend: backend.to_string(),
10112 query: format!("{query:?}"),
10113 freshness,
10114 readiness: None,
10115 schema: None,
10116 node: None,
10117 edge: None,
10118 nodes: Vec::new(),
10119 edges: Vec::new(),
10120 ranked_neighbors: Vec::new(),
10121 semantic_related: Vec::new(),
10122 neighborhood_ranking_gate: None,
10123 ranked_neighborhood_comparison: None,
10124 knowledge_retrieval: None,
10125 output_budget: None,
10126 path: None,
10127 page: None,
10128 warnings,
10129 };
10130
10131 match query {
10132 GraphDbQuery::Refresh => {
10133 bail!("graph-db refresh must be handled by the refresh command path");
10134 }
10135 GraphDbQuery::Status => {
10136 bail!("graph-db status must be handled by the status command path");
10137 }
10138 GraphDbQuery::Doctor => {
10139 bail!("graph-db doctor must be handled by the doctor command path");
10140 }
10141 GraphDbQuery::Drift => {
10142 bail!("graph-db drift must be handled by the drift command path");
10143 }
10144 GraphDbQuery::Compact { .. } => {
10145 bail!("graph-db compact must be handled by the compact command path");
10146 }
10147 GraphDbQuery::SnapshotExport { .. } => {
10148 bail!("graph-db snapshot-export must be handled by the snapshot command path");
10149 }
10150 GraphDbQuery::SnapshotImport { .. } => {
10151 bail!("graph-db snapshot-import must be handled by the snapshot command path");
10152 }
10153 GraphDbQuery::BackendEval { .. } => {
10154 bail!("graph-db backend-eval must be handled by the benchmark command path");
10155 }
10156 GraphDbQuery::Evidence { .. } => {
10157 bail!("graph-db evidence must be handled by the evidence command path");
10158 }
10159 GraphDbQuery::Related {
10160 query,
10161 kind,
10162 depth,
10163 seed_limit,
10164 limit,
10165 } => {
10166 let semantic =
10167 semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
10168 let SemanticRelatedReport {
10169 items,
10170 warnings: semantic_warnings,
10171 ..
10172 } = semantic;
10173 let readiness = graph_db_semantic_readiness(
10174 root,
10175 scope,
10176 (!items.is_empty()).then_some(items.len()),
10177 );
10178 report.warnings.extend(semantic_warnings);
10179 let seed_ids = items
10180 .iter()
10181 .map(|item| item.handle.clone())
10182 .collect::<Vec<_>>();
10183 let semantic_scores = items
10184 .iter()
10185 .map(|item| (item.handle.clone(), item.score))
10186 .collect::<BTreeMap<_, _>>();
10187 let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
10188 let seed_count = seed_ids.len();
10189 let mut diagnostics = subgraph.diagnostics;
10190 let budgeted = graph_db_apply_output_budget(
10191 &seed_ids,
10192 &semantic_scores,
10193 subgraph.nodes,
10194 subgraph.edges,
10195 Some(limit),
10196 );
10197 let budget_report = budgeted.report;
10198 let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
10199 diagnostics.extend(budget_report.diagnostics.clone());
10200 diagnostics.extend(readiness.diagnostics.clone());
10201
10202 report.readiness = Some(readiness);
10203 report.semantic_related = items;
10204 if let Some(seed_id) = seed_ids.first() {
10205 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
10206 report.ranked_neighbors = graph_db_ranked_neighbors(
10207 seed_id,
10208 &budgeted.nodes,
10209 &budgeted.edges,
10210 ranked_neighbor_cap,
10211 );
10212 report.neighborhood_ranking_gate =
10213 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10214 }
10215 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10216 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10217 report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
10218 mode: "semantic_seeded_neighborhood".to_string(),
10219 query,
10220 seed_kind: semantic_related_kind_name(kind).to_string(),
10221 seed_limit,
10222 seed_count,
10223 depth,
10224 limit,
10225 node_count: report.nodes.len(),
10226 edge_count: report.edges.len(),
10227 truncated: subgraph.truncated || dropped_by_budget,
10228 traversal: "incident_plus_outgoing_edges".to_string(),
10229 freshness_boundary:
10230 "semantic rows must come from refreshed summary or tsift-memory graph records"
10231 .to_string(),
10232 privacy_boundary:
10233 "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
10234 .to_string(),
10235 diagnostics,
10236 });
10237 report.output_budget = Some(budget_report);
10238 }
10239 GraphDbQuery::Schema => {
10240 report.schema = Some(graph_db_schema());
10241 }
10242 GraphDbQuery::Node { id } => {
10243 report.node = store.node(&id)?.map(Into::into);
10244 }
10245 GraphDbQuery::Edge { id } => {
10246 report.edge = store.edge(&id)?.map(Into::into);
10247 }
10248 GraphDbQuery::Edges {
10249 edge_kind,
10250 cursor,
10251 limit,
10252 property_filters,
10253 } => {
10254 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10255 let paged = store.paged_edges(
10256 edge_kind.as_deref(),
10257 graph_db_query_options_for_store(&options),
10258 )?;
10259 report.edges = paged.edges.into_iter().map(Into::into).collect();
10260 report.page = Some(graph_db_page_report_from_store(
10261 paged.page,
10262 options.property_filters,
10263 ));
10264 }
10265 GraphDbQuery::Incident {
10266 id,
10267 edge_kind,
10268 cursor,
10269 limit,
10270 property_filters,
10271 } => {
10272 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10273 let paged = store.paged_incident_edges(
10274 &id,
10275 edge_kind.as_deref(),
10276 graph_db_query_options_for_store(&options),
10277 )?;
10278 report.edges = paged.edges.into_iter().map(Into::into).collect();
10279 report.page = Some(graph_db_page_report_from_store(
10280 paged.page,
10281 options.property_filters,
10282 ));
10283 }
10284 GraphDbQuery::Kind {
10285 kind,
10286 cursor,
10287 limit,
10288 property_filters,
10289 } => {
10290 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10291 let paged =
10292 store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
10293 report.nodes = paged.nodes.into_iter().map(Into::into).collect();
10294 report.edges = paged.edges.into_iter().map(Into::into).collect();
10295 report.page = Some(graph_db_page_report_from_store(
10296 paged.page,
10297 options.property_filters,
10298 ));
10299 }
10300 GraphDbQuery::Neighborhood {
10301 id,
10302 depth,
10303 edge_kind,
10304 cursor,
10305 limit,
10306 property_filters,
10307 } => {
10308 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10309 if let Some(paged) = store.paged_neighborhood(
10310 &id,
10311 depth,
10312 edge_kind.as_deref(),
10313 graph_db_query_options_for_store(&options),
10314 )? {
10315 let budgeted = graph_db_apply_output_budget(
10316 std::slice::from_ref(&id),
10317 &BTreeMap::new(),
10318 paged.nodes,
10319 paged.edges,
10320 options.limit,
10321 );
10322 let budget_report = budgeted.report;
10323 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
10324 let ranked_neighbors = graph_db_ranked_neighbors(
10325 &id,
10326 &budgeted.nodes,
10327 &budgeted.edges,
10328 ranked_neighbor_cap,
10329 );
10330 let comparison = graph_db_ranked_neighborhood_comparison(
10331 &id,
10332 depth,
10333 edge_kind.as_deref(),
10334 options.limit,
10335 &budgeted.nodes,
10336 &budgeted.edges,
10337 store,
10338 )?;
10339 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10340 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10341 report.ranked_neighbors = ranked_neighbors;
10342 report.neighborhood_ranking_gate =
10343 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10344 let mut page =
10345 graph_db_page_report_from_store(paged.page, options.property_filters);
10346 page.returned_nodes = report.nodes.len();
10347 page.returned_edges = report.edges.len();
10348 page.truncated |= !budget_report.dropped_by_budget.is_empty();
10349 page.diagnostics.extend(budget_report.diagnostics.clone());
10350 report.page = Some(page);
10351 report.output_budget = Some(budget_report);
10352 if let Some(comparison) = comparison {
10353 report.ranked_neighborhood_comparison = Some(comparison);
10354 }
10355 }
10356 }
10357 GraphDbQuery::Path {
10358 from,
10359 to,
10360 edge_kind,
10361 max_hops,
10362 } => {
10363 report.path =
10364 store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
10365 if let Some(max_hops) = max_hops
10366 && report.path.is_none()
10367 {
10368 report.warnings.push(format!(
10369 "no directed path found within --max-hops {}",
10370 max_hops
10371 ));
10372 }
10373 }
10374 GraphDbQuery::Map { .. } => {
10375 bail!("graph-db map must be handled by the map command path");
10376 }
10377 }
10378 Ok(report)
10379}
10380
10381pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
10382 if compact {
10383 println!(
10384 "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
10385 report.backend,
10386 report.query,
10387 report.nodes.len() + usize::from(report.node.is_some()),
10388 report.edges.len() + usize::from(report.edge.is_some()),
10389 report.freshness.status
10390 );
10391 return;
10392 }
10393 println!("graph-db backend: {}", report.backend);
10394 println!("freshness: {}", report.freshness.status);
10395 if let Some(readiness) = &report.readiness {
10396 println!(
10397 "readiness: {} reason: {} fail_closed: {}",
10398 readiness.status, readiness.reason, readiness.fail_closed
10399 );
10400 for diagnostic in &readiness.diagnostics {
10401 println!("readiness diagnostic: {diagnostic}");
10402 }
10403 for command in &readiness.next_commands {
10404 println!("readiness next: {command}");
10405 }
10406 }
10407 if let Some(schema) = &report.schema {
10408 println!(
10409 "schema: {} node fields, {} edge fields, {} operations",
10410 schema.node_fields.len(),
10411 schema.edge_fields.len(),
10412 schema.operations.len()
10413 );
10414 }
10415 if let Some(node) = &report.node {
10416 println!("node: {} [{}] {}", node.id, node.kind, node.label);
10417 }
10418 if let Some(edge) = &report.edge {
10419 let edge_full: SubstrateGraphEdge = edge.into();
10420 println!(
10421 "edge: {} {} -{}-> {}",
10422 graph_db_edge_key(&edge_full),
10423 edge.from_id,
10424 edge.kind,
10425 edge.to_id
10426 );
10427 }
10428 if let Some(knowledge) = &report.knowledge_retrieval {
10429 println!(
10430 "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
10431 knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
10432 );
10433 }
10434 for item in &report.semantic_related {
10435 println!(
10436 "semantic_seed: {:.3} [{}] {} ({})",
10437 item.score, item.kind, item.label, item.handle
10438 );
10439 }
10440 for node in &report.nodes {
10441 println!("node: {} [{}] {}", node.id, node.kind, node.label);
10442 }
10443 for edge in &report.edges {
10444 let edge_full: SubstrateGraphEdge = edge.into();
10445 println!(
10446 "edge: {} {} -{}-> {}",
10447 graph_db_edge_key(&edge_full),
10448 edge.from_id,
10449 edge.kind,
10450 edge.to_id
10451 );
10452 }
10453 for neighbor in &report.ranked_neighbors {
10454 println!(
10455 "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
10456 neighbor.rank,
10457 neighbor.score,
10458 neighbor
10459 .depth
10460 .map(|depth| depth.to_string())
10461 .unwrap_or_else(|| "unknown".to_string()),
10462 neighbor.node_id,
10463 neighbor.kind,
10464 neighbor.label
10465 );
10466 }
10467 if let Some(gate) = &report.neighborhood_ranking_gate {
10468 println!(
10469 "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
10470 gate.status, gate.default_order, gate.ranked_output_default
10471 );
10472 }
10473 if let Some(path) = &report.path {
10474 println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
10475 }
10476 if let Some(page) = &report.page {
10477 if let Some(next_cursor) = &page.next_cursor {
10478 println!("next_cursor: {next_cursor}");
10479 }
10480 for diagnostic in &page.diagnostics {
10481 println!("page: {diagnostic}");
10482 }
10483 }
10484 for warning in &report.warnings {
10485 println!("warning: {warning}");
10486 }
10487}
10488
10489pub(crate) fn graph_db_backend_eval_phase_timing(
10490 name: &str,
10491 duration_micros: u128,
10492 detail: &str,
10493) -> GraphDbBackendEvalPhaseTiming {
10494 GraphDbBackendEvalPhaseTiming {
10495 name: name.to_string(),
10496 duration_micros,
10497 detail: detail.to_string(),
10498 }
10499}
10500
10501pub(crate) fn graph_db_backend_eval_timed_phase<T>(
10502 phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
10503 name: &str,
10504 detail: &str,
10505 run: impl FnOnce() -> Result<T>,
10506) -> Result<T> {
10507 let started = Instant::now();
10508 let result = run();
10509 phases.push(graph_db_backend_eval_phase_timing(
10510 name,
10511 started.elapsed().as_micros(),
10512 detail,
10513 ));
10514 result
10515}
10516
10517pub(crate) fn graph_db_backend_eval_refresh_total_micros(
10518 phases: &[GraphDbBackendEvalPhaseTiming],
10519) -> u128 {
10520 phases
10521 .iter()
10522 .filter(|phase| phase.name != "conflict_matrix_preparation")
10523 .map(|phase| phase.duration_micros)
10524 .sum()
10525}
10526
10527pub(crate) fn graph_db_backend_eval_cached_refresh(
10528 root: &Path,
10529 scope: Option<&str>,
10530 source_watermark: Option<&str>,
10531) -> Result<
10532 Option<(
10533 TraversalGraphBuild,
10534 SqliteProjectionRefresh,
10535 Vec<GraphDbBackendEvalPhaseTiming>,
10536 )>,
10537> {
10538 let Some(source_watermark) = source_watermark else {
10539 return Ok(None);
10540 };
10541 let graph_db = graph_substrate_db_path(root, scope);
10542 if !graph_db.exists() {
10543 return Ok(None);
10544 }
10545
10546 let started = Instant::now();
10547 let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
10548 Ok(store) => store,
10549 Err(_) => return Ok(None),
10550 };
10551 if store.has_user_triggers().unwrap_or(true) {
10552 return Ok(None);
10553 }
10554 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
10555 if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
10556 return Ok(None);
10557 }
10558
10559 let phases = vec![
10560 graph_db_backend_eval_phase_timing(
10561 "source_graph_build",
10562 started.elapsed().as_micros(),
10563 "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10564 ),
10565 graph_db_backend_eval_phase_timing(
10566 "projection_rows",
10567 0,
10568 "reused cached provider-neutral projection rows from graph.db",
10569 ),
10570 graph_db_backend_eval_phase_timing(
10571 "sqlite_open",
10572 0,
10573 "reused existing graph.db projection without opening a write transaction",
10574 ),
10575 ];
10576 let refresh = SqliteProjectionRefresh {
10577 scope: scope.unwrap_or("root").to_string(),
10578 projection_version: freshness
10579 .projection_version
10580 .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
10581 source_watermark: Some(source_watermark.to_string()),
10582 tombstoned_nodes: Vec::new(),
10583 tombstoned_edges: Vec::new(),
10584 upserted_nodes: 0,
10585 upserted_edges: 0,
10586 unchanged_nodes: 0,
10587 unchanged_edges: 0,
10588 upserted_properties: 0,
10589 unchanged_properties: 0,
10590 deleted_properties: 0,
10591 deleted_nodes: 0,
10592 deleted_edges: 0,
10593 pruned_tombstones: 0,
10594 file_size_bytes_before: None,
10595 file_size_bytes_after: None,
10596 phase_timings: Vec::new(),
10597 };
10598 Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
10599}
10600
10601pub(crate) fn graph_db_backend_eval_reused_cached_projection(
10602 phases: &[GraphDbBackendEvalPhaseTiming],
10603) -> bool {
10604 phases.iter().any(|phase| {
10605 phase.name == "source_graph_build"
10606 && phase.detail.contains("reused current graph.db projection")
10607 })
10608}
10609
10610pub(crate) fn graph_db_backend_eval_update_source_watermark(
10611 root: &Path,
10612 path_hint: &Path,
10613 scope: Option<&str>,
10614) -> Result<()> {
10615 let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
10616 return Ok(());
10617 };
10618 let graph_db = graph_substrate_db_path(root, scope);
10619 let mut store = SqliteGraphStore::open(&graph_db)?;
10620 store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
10621 Ok(())
10622}
10623
10624pub(crate) fn graph_db_backend_eval_refresh_with_profile(
10625 root: &Path,
10626 path_hint: &Path,
10627 scope: Option<&str>,
10628) -> Result<(
10629 TraversalGraphBuild,
10630 SqliteProjectionRefresh,
10631 Vec<GraphDbBackendEvalPhaseTiming>,
10632)> {
10633 let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
10634 if let Some(cached) =
10635 graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
10636 {
10637 return Ok(cached);
10638 }
10639
10640 let mut phases = Vec::new();
10641 let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
10642 "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"
10643 } else {
10644 "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
10645 };
10646 let source_graph = graph_db_backend_eval_timed_phase(
10647 &mut phases,
10648 "source_graph_build",
10649 source_graph_detail,
10650 || build_traversal_graph_source_with_options(root, path_hint, scope, false),
10651 )?;
10652 let projection = graph_db_backend_eval_timed_phase(
10653 &mut phases,
10654 "projection_rows",
10655 "provider-neutral GraphStore node/edge row construction before SQLite persistence",
10656 || traversal_projection_from_graph(root, scope, &source_graph),
10657 )?;
10658 let graph_db = graph_substrate_db_path(root, scope);
10659 let mut store = graph_db_backend_eval_timed_phase(
10660 &mut phases,
10661 "sqlite_open",
10662 "open the local SQLite graph.db with WAL and busy-timeout settings",
10663 || SqliteGraphStore::open(&graph_db),
10664 )?;
10665 let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
10666 .ok()
10667 .flatten();
10668 let refresh = store.replace_projection_with_version(
10669 scope.unwrap_or("root"),
10670 &projection,
10671 Some(GRAPH_PROJECTION_VERSION),
10672 refreshed_source_watermark
10673 .or(source_watermark)
10674 .or_else(|| graph_projection_content_hash(&projection)),
10675 )?;
10676 phases.extend(
10677 refresh
10678 .phase_timings
10679 .iter()
10680 .map(|phase| GraphDbBackendEvalPhaseTiming {
10681 name: phase.name.clone(),
10682 duration_micros: phase.duration_micros,
10683 detail: phase.detail.clone(),
10684 }),
10685 );
10686 Ok((source_graph, refresh, phases))
10687}
10688
10689fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10690 root.join(".tsift/backend-eval-cache")
10691}
10692
10693fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10694 graph_db_backend_eval_disk_cache_dir(root)
10695 .join(kind)
10696 .join(format!("{key}.json.gz"))
10697}
10698
10699fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10700 graph_db_backend_eval_disk_cache_dir(root)
10701 .join(kind)
10702 .join(format!("{key}.json"))
10703}
10704
10705#[derive(Default, Clone)]
10706struct GraphDbBackendEvalDiskCacheReadProfile {
10707 file_read_micros: u128,
10708 gzip_decode_micros: u128,
10709 serde_decode_micros: u128,
10710 legacy: bool,
10711}
10712
10713fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10714 root: &Path,
10715 kind: &str,
10716 key: &str,
10717) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10718 let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10719 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10720 let read_started = Instant::now();
10721 let read_result = fs::read(&path);
10722 profile.file_read_micros = read_started.elapsed().as_micros();
10723 if let Ok(bytes) = read_result {
10724 let decode_started = Instant::now();
10725 let mut decoder = GzDecoder::new(bytes.as_slice());
10726 let mut decoded = Vec::new();
10727 let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10728 profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10729 if decode_ok {
10730 let serde_started = Instant::now();
10731 let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10732 profile.serde_decode_micros = serde_started.elapsed().as_micros();
10733 if let Some(value) = parsed {
10734 return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10735 }
10736 }
10737 }
10738
10739 let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10740 let legacy_started = Instant::now();
10741 let bytes = fs::read(legacy_path).ok()?;
10742 profile.file_read_micros = profile
10743 .file_read_micros
10744 .saturating_add(legacy_started.elapsed().as_micros());
10745 let serde_started = Instant::now();
10746 let value = serde_json::from_slice(&bytes).ok()?;
10747 profile.serde_decode_micros = profile
10748 .serde_decode_micros
10749 .saturating_add(serde_started.elapsed().as_micros());
10750 profile.legacy = true;
10751 Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10752}
10753
10754#[derive(Default, Clone)]
10755struct GraphDbBackendEvalDiskCacheWriteProfile {
10756 serde_encode_micros: u128,
10757 gzip_encode_micros: u128,
10758 file_write_micros: u128,
10759}
10760
10761fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10762 root: &Path,
10763 kind: &str,
10764 key: &str,
10765 value: &T,
10766) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10767 let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10768 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10769 let parent = path.parent()?;
10770 if fs::create_dir_all(parent).is_err() {
10771 return None;
10772 }
10773 let serde_started = Instant::now();
10774 let bytes = serde_json::to_vec(value).ok()?;
10775 profile.serde_encode_micros = serde_started.elapsed().as_micros();
10776 let gzip_started = Instant::now();
10777 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10778 if encoder.write_all(&bytes).is_err() {
10779 return None;
10780 }
10781 let encoded = encoder.finish().ok()?;
10782 profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10783 let write_started = Instant::now();
10784 if fs::write(&path, &encoded).is_err() {
10785 return None;
10786 }
10787 profile.file_write_micros = write_started.elapsed().as_micros();
10788 Some((encoded.len() as u64, bytes.len() as u64, profile))
10789}
10790
10791fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10792 let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10793 let Ok(entries) = fs::read_dir(dir) else {
10794 return (0, 0);
10795 };
10796 let keep_name = format!("{keep_key}.json.gz");
10797 let mut pruned_files = 0usize;
10798 let mut pruned_bytes = 0u64;
10799 for entry in entries.flatten() {
10800 let path = entry.path();
10801 if !path.is_file() {
10802 continue;
10803 }
10804 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10805 continue;
10806 };
10807 if name == keep_name {
10808 continue;
10809 }
10810 let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10811 if !is_backend_eval_cache {
10812 continue;
10813 }
10814 let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10815 if fs::remove_file(&path).is_ok() {
10816 pruned_files += 1;
10817 pruned_bytes += bytes;
10818 }
10819 }
10820 (pruned_files, pruned_bytes)
10821}
10822
10823fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10824 root: &Path,
10825 source_root: &Path,
10826) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10827 let mut rows = Vec::new();
10828 let mut entries = walk::walk_files(source_root)?;
10829 entries.sort_by(|left, right| left.path.cmp(&right.path));
10830 for entry in entries {
10831 if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10832 continue;
10833 }
10834 if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10835 continue;
10836 }
10837 let bytes = fs::read(&entry.path)
10838 .with_context(|| format!("reading source input {}", entry.path.display()))?;
10839 rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10840 path: traversal_watermark_path(root, &entry.path),
10841 bytes: bytes.len() as u64,
10842 content_hash: content_hash(&bytes)?,
10843 });
10844 }
10845 Ok(rows)
10846}
10847
10848fn graph_db_backend_eval_full_projection_source_watermark(
10849 root: &Path,
10850 scope: Option<&str>,
10851) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10852 let path_hint = root;
10853 let mut detail_parts = Vec::new();
10854 let mut parts = vec![
10855 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10856 format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10857 "watermark_kind:stable_full_projection_inputs".to_string(),
10858 format!("scope:{}", scope.unwrap_or("root")),
10859 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10860 ];
10861
10862 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10863 match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10864 Some(db_path) => {
10865 let db = index::IndexDb::open_read_only_resilient(db_path)?;
10866 parts.push("index_mode:indexed".to_string());
10867 detail_parts.push("mode=indexed".to_string());
10868 parts.push(format!(
10869 "index_source_root:{}",
10870 traversal_watermark_path(root, &gate.source_root)
10871 ));
10872
10873 let symbols = db
10874 .all_symbols()?
10875 .into_iter()
10876 .filter(|symbol| {
10877 !traversal_path_is_generated_artifact(
10878 root,
10879 &gate.source_root,
10880 Path::new(&symbol.file),
10881 ) && !traversal_path_is_session_markdown(
10882 root,
10883 &gate.source_root,
10884 Path::new(&symbol.file),
10885 )
10886 })
10887 .collect::<Vec<_>>();
10888 let symbols_hash = content_hash(&symbols)?;
10889 detail_parts.push(format!("symbols={symbols_hash}"));
10890 parts.push(format!("index_symbols:{symbols_hash}"));
10891
10892 let edges = db
10893 .all_stored_edges()?
10894 .into_iter()
10895 .filter(|edge| {
10896 !traversal_path_is_generated_artifact(
10897 root,
10898 &gate.source_root,
10899 Path::new(&edge.caller_file),
10900 ) && !traversal_path_is_session_markdown(
10901 root,
10902 &gate.source_root,
10903 Path::new(&edge.caller_file),
10904 )
10905 })
10906 .collect::<Vec<_>>();
10907 let edges_hash = content_hash(&edges)?;
10908 detail_parts.push(format!("call_edges={edges_hash}"));
10909 parts.push(format!("index_call_edges:{edges_hash}"));
10910
10911 let routes = db
10912 .all_routes()?
10913 .into_iter()
10914 .filter(|route| {
10915 !traversal_path_is_generated_artifact(
10916 root,
10917 &gate.source_root,
10918 Path::new(&route.file),
10919 ) && !traversal_path_is_session_markdown(
10920 root,
10921 &gate.source_root,
10922 Path::new(&route.file),
10923 )
10924 })
10925 .collect::<Vec<_>>();
10926 let routes_hash = content_hash(&routes)?;
10927 detail_parts.push(format!("routes={routes_hash}"));
10928 parts.push(format!("index_routes:{routes_hash}"));
10929 }
10930 None => {
10931 parts.push("index_mode:raw_fallback".to_string());
10932 detail_parts.push("mode=raw_fallback".to_string());
10933 parts.push(format!(
10934 "raw_source_root:{}",
10935 traversal_watermark_path(root, &gate.source_root)
10936 ));
10937 let raw_rows =
10938 graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10939 let raw_hash = content_hash(&raw_rows)?;
10940 detail_parts.push(format!("raw_source_files={raw_hash}"));
10941 parts.push(format!("raw_source_files:{raw_hash}"));
10942 }
10943 }
10944
10945 parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10946 detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10947 let summaries_start = parts.len();
10948 push_traversal_summaries_watermark_part(root, &mut parts)?;
10949 let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10950 detail_parts.push(format!("summaries={summaries_hash}"));
10951 let value = content_hash(&parts)?;
10952 detail_parts.push(format!("watermark={value}"));
10953 Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10954 value,
10955 detail: detail_parts.join(" "),
10956 })
10957}
10958
10959fn graph_db_backend_eval_full_projection_cache_key(
10960 root: &Path,
10961 scope: Option<&str>,
10962) -> Result<(String, String, String)> {
10963 let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10964 let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10965 root,
10966 scope,
10967 &source_watermark.value,
10968 )?;
10969 Ok((source_watermark.value, key, source_watermark.detail))
10970}
10971
10972fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10973 root: &Path,
10974 scope: Option<&str>,
10975 source_watermark: &str,
10976) -> Result<String> {
10977 content_hash(&serde_json::json!({
10978 "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10979 "root": root.display().to_string(),
10980 "scope": scope.unwrap_or("root"),
10981 "source_watermark": source_watermark,
10982 }))
10983}
10984
10985pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10986 root: &Path,
10987 scope: Option<&str>,
10988) -> Result<(
10989 GraphProjection,
10990 Vec<String>,
10991 Vec<GraphDbBackendEvalPhaseTiming>,
10992 GraphDbBackendEvalFullProjectionCacheStats,
10993)> {
10994 let (source_watermark, key, source_watermark_detail) =
10995 graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10996 let lookup_started = Instant::now();
10997 if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10998 graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10999 root,
11000 "full_projection",
11001 &key,
11002 )
11003 && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
11004 && cached.key == key
11005 && cached.source_watermark == source_watermark
11006 {
11007 let lookup_overhead_micros = lookup_started
11008 .elapsed()
11009 .as_micros()
11010 .saturating_sub(read_profile.file_read_micros)
11011 .saturating_sub(read_profile.gzip_decode_micros)
11012 .saturating_sub(read_profile.serde_decode_micros);
11013 let prune_started = Instant::now();
11014 let (pruned_files, pruned_bytes) =
11015 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
11016 let prune_micros = prune_started.elapsed().as_micros();
11017 let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
11018 hit: true,
11019 disk_bytes,
11020 json_bytes,
11021 pruned_files,
11022 pruned_bytes,
11023 };
11024 let read_detail_suffix = if read_profile.legacy {
11025 " (legacy uncompressed cache path)"
11026 } else {
11027 ""
11028 };
11029 return Ok((
11030 cached.projection,
11031 cached.warnings,
11032 vec![
11033 graph_db_backend_eval_phase_timing(
11034 "full_projection.cache_lookup",
11035 lookup_overhead_micros,
11036 &format!(
11037 "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
11038 ),
11039 ),
11040 graph_db_backend_eval_phase_timing(
11041 "full_projection.cache.file_read",
11042 read_profile.file_read_micros,
11043 &format!(
11044 "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
11045 ),
11046 ),
11047 graph_db_backend_eval_phase_timing(
11048 "full_projection.cache.gzip_decode",
11049 read_profile.gzip_decode_micros,
11050 "gunzip the compressed projection cache bytes",
11051 ),
11052 graph_db_backend_eval_phase_timing(
11053 "full_projection.cache.serde_decode",
11054 read_profile.serde_decode_micros,
11055 "serde_json deserialize the decoded projection cache payload",
11056 ),
11057 graph_db_backend_eval_phase_timing(
11058 "full_projection.cache.prune",
11059 prune_micros,
11060 "prune sibling cache files older than the current key",
11061 ),
11062 graph_db_backend_eval_phase_timing(
11063 "full_projection.source_graph_build",
11064 0,
11065 "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
11066 ),
11067 graph_db_backend_eval_phase_timing(
11068 "full_projection.projection_rows",
11069 0,
11070 "reused cached provider-neutral full-project projection rows",
11071 ),
11072 ],
11073 cache_stats,
11074 ));
11075 }
11076
11077 let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
11078 let mut phases = vec![graph_db_backend_eval_phase_timing(
11079 "full_projection.cache_lookup",
11080 lookup_started.elapsed().as_micros(),
11081 &format!(
11082 "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
11083 ),
11084 )];
11085 let full_source = graph_db_backend_eval_timed_phase(
11086 &mut phases,
11087 "full_projection.source_graph_build",
11088 "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
11089 || build_traversal_graph_source_with_options(root, root, scope, false),
11090 )?;
11091 let projection = graph_db_backend_eval_timed_phase(
11092 &mut phases,
11093 "full_projection.projection_rows",
11094 "provider-neutral row construction for the opt-in full-project projection dataset",
11095 || traversal_projection_from_graph(root, scope, &full_source),
11096 )?;
11097 let warnings = full_source.warnings;
11098 let refreshed_source_watermark =
11099 graph_db_backend_eval_full_projection_source_watermark(root, scope)
11100 .map(|watermark| watermark.value)
11101 .unwrap_or_else(|_| source_watermark.clone());
11102 let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
11103 root,
11104 scope,
11105 &refreshed_source_watermark,
11106 )?;
11107 let cache = GraphDbBackendEvalFullProjectionCache {
11108 version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
11109 key: write_key.clone(),
11110 source_watermark: refreshed_source_watermark,
11111 projection: projection.clone(),
11112 warnings: warnings.clone(),
11113 };
11114 if let Some((disk_bytes, json_bytes, write_profile)) =
11115 graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
11116 {
11117 cache_stats.disk_bytes = disk_bytes;
11118 cache_stats.json_bytes = json_bytes;
11119 phases.push(graph_db_backend_eval_phase_timing(
11120 "full_projection.cache.serde_encode",
11121 write_profile.serde_encode_micros,
11122 "serde_json serialize the projection cache payload before compression",
11123 ));
11124 phases.push(graph_db_backend_eval_phase_timing(
11125 "full_projection.cache.gzip_encode",
11126 write_profile.gzip_encode_micros,
11127 "gzip-compress the serialized projection cache payload",
11128 ));
11129 phases.push(graph_db_backend_eval_phase_timing(
11130 "full_projection.cache.file_write",
11131 write_profile.file_write_micros,
11132 "write the compressed projection cache bytes to .tsift/backend-eval-cache",
11133 ));
11134 }
11135 let prune_started = Instant::now();
11136 let (pruned_files, pruned_bytes) =
11137 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
11138 phases.push(graph_db_backend_eval_phase_timing(
11139 "full_projection.cache.prune",
11140 prune_started.elapsed().as_micros(),
11141 "prune sibling cache files older than the current key",
11142 ));
11143 cache_stats.pruned_files = pruned_files;
11144 cache_stats.pruned_bytes = pruned_bytes;
11145 Ok((projection, warnings, phases, cache_stats))
11146}
11147
11148fn graph_db_backend_eval_timed(
11149 name: &str,
11150 run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
11151) -> (
11152 GraphDbBackendEvalOperation,
11153 Option<GraphDbBackendEvalSignature>,
11154) {
11155 let started = Instant::now();
11156 match run() {
11157 Ok((rows, value)) => (
11158 GraphDbBackendEvalOperation {
11159 name: name.to_string(),
11160 supported: true,
11161 status: "ok".to_string(),
11162 duration_micros: started.elapsed().as_micros(),
11163 rows,
11164 error: None,
11165 },
11166 Some(GraphDbBackendEvalSignature {
11167 operation: name.to_string(),
11168 value,
11169 }),
11170 ),
11171 Err(err) => (
11172 GraphDbBackendEvalOperation {
11173 name: name.to_string(),
11174 supported: false,
11175 status: "error".to_string(),
11176 duration_micros: started.elapsed().as_micros(),
11177 rows: None,
11178 error: Some(format!("{err:#}")),
11179 },
11180 None,
11181 ),
11182 }
11183}
11184
11185fn graph_db_backend_eval_parity(
11186 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11187 candidate_signatures: &[GraphDbBackendEvalSignature],
11188) -> GraphDbBackendEvalParity {
11189 let Some(sqlite_signatures) = sqlite_signatures else {
11190 return GraphDbBackendEvalParity {
11191 matches_sqlite: true,
11192 diagnostics: Vec::new(),
11193 };
11194 };
11195 let sqlite = sqlite_signatures
11196 .iter()
11197 .map(|signature| (signature.operation.as_str(), &signature.value))
11198 .collect::<BTreeMap<_, _>>();
11199 let candidate = candidate_signatures
11200 .iter()
11201 .map(|signature| (signature.operation.as_str(), &signature.value))
11202 .collect::<BTreeMap<_, _>>();
11203 let mut diagnostics = Vec::new();
11204 for (operation, sqlite_value) in sqlite {
11205 match candidate.get(operation) {
11206 Some(candidate_value) if *candidate_value == sqlite_value => {}
11207 Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
11208 None => diagnostics.push(format!(
11209 "{operation} did not complete for candidate backend"
11210 )),
11211 }
11212 }
11213 GraphDbBackendEvalParity {
11214 matches_sqlite: diagnostics.is_empty(),
11215 diagnostics,
11216 }
11217}
11218
11219pub(crate) fn graph_db_backend_eval_targets(
11220 store: &impl GraphStore,
11221 requested: &[String],
11222) -> Result<Vec<String>> {
11223 let requested = requested
11224 .iter()
11225 .filter_map(|target| normalize_conflict_target(target))
11226 .collect::<Vec<_>>();
11227 if !requested.is_empty() {
11228 return Ok(requested);
11229 }
11230
11231 for kind in ["backlog", "job_packet"] {
11232 let nodes = store.nodes_by_kind(kind)?;
11233 if let Some(node) = nodes.first() {
11234 if let Some(ref_id) = node.properties.get("ref_id") {
11235 return Ok(vec![ref_id.clone()]);
11236 }
11237 return Ok(vec![node.id.clone()]);
11238 }
11239 }
11240 Ok(Vec::new())
11241}
11242
11243fn graph_db_backend_eval_path_targets(
11244 store: &impl GraphStore,
11245 max_hops: usize,
11246) -> Result<Option<(String, String, usize)>> {
11247 let synthetic_from = "gsym-synthetic-0000";
11248 let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
11249 if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
11250 let outgoing = store.outgoing_edges(synthetic_from, None)?;
11251 if outgoing.len() > 1
11252 && let Some(edge) = outgoing.first()
11253 {
11254 return Ok(Some((
11255 edge.from_id.clone(),
11256 edge.to_id.clone(),
11257 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11258 )));
11259 }
11260 return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
11261 }
11262
11263 Ok(store.sample_edge(None)?.map(|edge| {
11264 (
11265 edge.from_id,
11266 edge.to_id,
11267 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11268 )
11269 }))
11270}
11271
11272fn graph_db_backend_eval_path_operation<S: GraphStore>(
11273 store: &S,
11274 configured_max_hops: usize,
11275) -> (
11276 GraphDbBackendEvalOperation,
11277 Option<GraphDbBackendEvalSignature>,
11278) {
11279 let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11280 "path_max_hops".to_string()
11281 } else {
11282 format!("path_max_hops_{configured_max_hops}")
11283 };
11284 graph_db_backend_eval_timed(&operation_name, || {
11285 let (from, to, effective_max_hops) =
11286 graph_db_backend_eval_path_targets(store, configured_max_hops)?
11287 .context("backend-eval path probe requires at least one traversable edge")?;
11288 let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
11289 let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11290 Some(format!(
11291 "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
11292 GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
11293 ))
11294 } else if path.is_none() && effective_max_hops == configured_max_hops {
11295 Some(format!(
11296 "path probe truncated at {configured_max_hops} hops before a route was found"
11297 ))
11298 } else {
11299 None
11300 };
11301 Ok((
11302 path.as_ref().map(|path| path.nodes.len()),
11303 serde_json::json!({
11304 "from": from,
11305 "to": to,
11306 "configured_max_hops": configured_max_hops,
11307 "effective_max_hops": effective_max_hops,
11308 "hops": path.as_ref().map(|path| path.hops),
11309 "nodes": path.as_ref().map(|path| &path.nodes),
11310 "found": path.is_some(),
11311 "warning": warning,
11312 }),
11313 ))
11314 })
11315}
11316
11317fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
11318 store: &S,
11319 depth: usize,
11320 limit: usize,
11321) -> (
11322 GraphDbBackendEvalOperation,
11323 Option<GraphDbBackendEvalSignature>,
11324) {
11325 graph_db_backend_eval_timed("neighborhood", || {
11326 let edge = match store.sample_edge(Some("calls"))? {
11327 Some(edge) => edge,
11328 None => store.sample_edge(None)?.context(
11329 "backend-eval neighborhood probe requires at least one traversable edge",
11330 )?,
11331 };
11332 let page = store
11333 .paged_neighborhood(
11334 &edge.from_id,
11335 depth,
11336 Some(&edge.kind),
11337 GraphQueryOptions {
11338 limit: Some(limit.max(1)),
11339 ..GraphQueryOptions::default()
11340 },
11341 )?
11342 .with_context(|| {
11343 format!(
11344 "backend-eval neighborhood target not found: {}",
11345 edge.from_id
11346 )
11347 })?;
11348 Ok((
11349 Some(page.nodes.len() + page.edges.len()),
11350 serde_json::json!({
11351 "center": edge.from_id,
11352 "kind": edge.kind,
11353 "depth": depth,
11354 "limit": limit.max(1),
11355 "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11356 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11357 "truncated": page.page.truncated,
11358 }),
11359 ))
11360 })
11361}
11362
11363fn graph_db_backend_eval_related_operation<S: GraphStore>(
11364 root: &Path,
11365 scope: Option<&str>,
11366 store: &S,
11367 depth: usize,
11368 limit: usize,
11369) -> (
11370 GraphDbBackendEvalOperation,
11371 Option<GraphDbBackendEvalSignature>,
11372) {
11373 graph_db_backend_eval_timed("related", || {
11374 let query = "backend evaluation";
11375 let semantic = semantic_related_report_from_store(
11376 root,
11377 scope,
11378 query,
11379 3,
11380 SemanticRelatedKind::All,
11381 store,
11382 )?;
11383 let seed_ids = semantic
11384 .items
11385 .iter()
11386 .map(|item| item.handle.clone())
11387 .collect::<Vec<_>>();
11388 let subgraph =
11389 graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
11390 Ok((
11391 Some(subgraph.nodes.len() + subgraph.edges.len()),
11392 serde_json::json!({
11393 "query": query,
11394 "seed_ids": seed_ids,
11395 "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11396 "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11397 "truncated": subgraph.truncated,
11398 "warnings": semantic.warnings,
11399 "diagnostics": subgraph.diagnostics,
11400 }),
11401 ))
11402 })
11403}
11404
11405fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
11406 serde_json::json!({
11407 "target": report.target,
11408 "target_node_id": report.target_node.id,
11409 "target_kind": report.target_node.kind,
11410 "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
11411 "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
11412 "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
11413 "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
11414 "path_count": report.shortest_paths.len(),
11415 })
11416}
11417
11418fn graph_db_backend_eval_target_resolution_signature(
11419 resolved: &[(String, SubstrateGraphNode)],
11420) -> serde_json::Value {
11421 serde_json::json!({
11422 "targets": resolved.iter().map(|(target, node)| {
11423 serde_json::json!({
11424 "target": target,
11425 "target_node_id": node.id,
11426 "target_kind": node.kind,
11427 "target_label": node.label,
11428 })
11429 }).collect::<Vec<_>>(),
11430 })
11431}
11432
11433fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
11434 serde_json::json!({
11435 "targets": report.targets,
11436 "can_parallel": report.can_parallel,
11437 "fail_closed": report.fail_closed,
11438 "cross_target_parallel_safe": report.cross_target_parallel_safe,
11439 "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
11440 "candidates": report.candidates.iter().map(|candidate| {
11441 serde_json::json!({
11442 "target": candidate.target,
11443 "risk": conflict_risk_label(candidate.risk),
11444 "owned_files": candidate.owned_files,
11445 "owned_symbols": candidate.owned_symbols,
11446 "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
11447 "previously_completed": candidate.previously_completed,
11448 "parallel_safe": candidate.parallel_safe,
11449 })
11450 }).collect::<Vec<_>>(),
11451 "conflicts": report.conflicts.iter().map(|pair| {
11452 serde_json::json!({
11453 "left": pair.left,
11454 "right": pair.right,
11455 "risk": conflict_risk_label(pair.risk),
11456 })
11457 }).collect::<Vec<_>>(),
11458 })
11459}
11460
11461fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
11462 serde_json::json!({
11463 "targets": report.targets,
11464 "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11465 "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
11466 "evidence_packet_ids": report.evidence_packet_ids,
11467 "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
11468 "truncated": report.truncated,
11469 })
11470}
11471
11472fn graph_db_backend_eval_edge_scan_probe(
11473 store: &impl GraphStore,
11474) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
11475 if let Some((edge, filter)) = store.sample_edge_with_property()? {
11476 return Ok((edge, vec![filter]));
11477 }
11478 let edge = store
11479 .sample_edge(None)?
11480 .context("backend-eval edge scan requires at least one edge")?;
11481 Ok((edge, Vec::new()))
11482}
11483
11484#[allow(clippy::too_many_arguments)]
11485fn graph_db_backend_eval_report_for_store<S: GraphStore>(
11486 backend: &str,
11487 adapter: &str,
11488 read_only: bool,
11489 root: &Path,
11490 path: &Path,
11491 scope: Option<&str>,
11492 targets: &[String],
11493 depth: usize,
11494 limit: usize,
11495 impact_limit: usize,
11496 store: &S,
11497 freshness: GraphDbFreshnessReport,
11498 refresh_operation: GraphDbBackendEvalOperation,
11499 refresh_signature: Option<GraphDbBackendEvalSignature>,
11500 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11501 extra_warnings: Vec<String>,
11502 prepared: &ConflictMatrixPreparedInputs,
11503 projection_load: &str,
11504 lock_behavior: &str,
11505 install_portability: &str,
11506) -> (
11507 GraphDbBackendEvalBackendReport,
11508 Vec<GraphDbBackendEvalSignature>,
11509) {
11510 let mut operations = vec![refresh_operation];
11511 let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
11512
11513 let (operation, signature) = graph_db_backend_eval_timed("status", || {
11514 let (nodes, edges) = store.graph_counts()?;
11515 Ok((
11516 Some(nodes + edges),
11517 serde_json::json!({
11518 "freshness": freshness.status,
11519 "nodes": nodes,
11520 "edges": edges,
11521 }),
11522 ))
11523 });
11524 operations.push(operation);
11525 signatures.extend(signature);
11526
11527 let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
11528 let edge = store
11529 .sample_edge(None)?
11530 .context("backend-eval edge lookup requires at least one edge")?;
11531 let edge_id = graph_db_edge_key(&edge);
11532 let found = store
11533 .edge(&edge_id)?
11534 .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
11535 Ok((
11536 Some(1),
11537 serde_json::json!({
11538 "edge_id": edge_id,
11539 "from_id": found.from_id,
11540 "to_id": found.to_id,
11541 "kind": found.kind,
11542 }),
11543 ))
11544 });
11545 operations.push(operation);
11546 signatures.extend(signature);
11547
11548 let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
11549 let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
11550 let page = store.paged_edges(
11551 Some(&edge.kind),
11552 GraphQueryOptions {
11553 limit: Some(limit.max(1)),
11554 property_filters: filters.clone(),
11555 ..GraphQueryOptions::default()
11556 },
11557 )?;
11558 Ok((
11559 Some(page.edges.len()),
11560 serde_json::json!({
11561 "kind": edge.kind,
11562 "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
11563 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11564 "truncated": page.page.truncated,
11565 }),
11566 ))
11567 });
11568 operations.push(operation);
11569 signatures.extend(signature);
11570
11571 let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
11572 let edge = store
11573 .sample_edge(None)?
11574 .context("backend-eval incident edge scan requires at least one edge")?;
11575 let page = store.paged_incident_edges(
11576 &edge.from_id,
11577 Some(&edge.kind),
11578 GraphQueryOptions {
11579 limit: Some(limit.max(1)),
11580 ..GraphQueryOptions::default()
11581 },
11582 )?;
11583 Ok((
11584 Some(page.edges.len()),
11585 serde_json::json!({
11586 "node_id": edge.from_id,
11587 "kind": edge.kind,
11588 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11589 "truncated": page.page.truncated,
11590 }),
11591 ))
11592 });
11593 operations.push(operation);
11594 signatures.extend(signature);
11595
11596 let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
11597 operations.push(operation);
11598 signatures.extend(signature);
11599
11600 let (operation, signature) =
11601 graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
11602 operations.push(operation);
11603 signatures.extend(signature);
11604
11605 for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
11606 .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
11607 {
11608 let (operation, signature) =
11609 graph_db_backend_eval_path_operation(store, configured_max_hops);
11610 operations.push(operation);
11611 signatures.extend(signature);
11612 }
11613
11614 let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
11615 let resolved = targets
11616 .iter()
11617 .map(|target| {
11618 let node = graph_db_resolve_evidence_target(store, target)?
11619 .with_context(|| format!("backend-eval target not found: {target}"))?;
11620 Ok((target.clone(), node))
11621 })
11622 .collect::<Result<Vec<_>>>()?;
11623 let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
11624 Ok((Some(resolved.len()), signature))
11625 });
11626 operations.push(operation);
11627 signatures.extend(signature);
11628
11629 let mut evidence_for_report = None;
11630 let mut graph_snapshot_for_trace = None;
11631 let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
11632 let resolved_targets =
11633 resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
11634 let evidence = collect_conflict_matrix_evidence_packets(
11635 root,
11636 scope,
11637 backend,
11638 &resolved_targets,
11639 depth,
11640 limit,
11641 store,
11642 freshness.clone(),
11643 )?;
11644 let report = &evidence
11645 .first()
11646 .context("backend-eval evidence requires at least one target")?
11647 .report;
11648 let rows = evidence
11649 .iter()
11650 .map(|entry| {
11651 entry.report.worker_context.len()
11652 + entry.report.source_handles.len()
11653 + entry.report.worker_results.len()
11654 + entry.report.semantic_related.len()
11655 })
11656 .sum();
11657 let signature = graph_db_backend_eval_evidence_signature(report);
11658 evidence_for_report = Some((resolved_targets, evidence));
11659 Ok((Some(rows), signature))
11660 });
11661 operations.push(operation);
11662 signatures.extend(signature);
11663
11664 let mut conflict_for_trace = None;
11665 let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
11666 let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
11667 let graph =
11668 conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
11669 let shared_preparation =
11670 conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
11671 ConflictMatrixGraphPreparedInputs {
11672 targets,
11673 graph,
11674 evidence,
11675 shared_preparation,
11676 }
11677 } else {
11678 prepare_conflict_matrix_graph_orchestration(
11679 root,
11680 scope,
11681 backend,
11682 targets,
11683 prepared,
11684 depth,
11685 limit,
11686 store,
11687 freshness.clone(),
11688 )?
11689 };
11690 let report = build_conflict_matrix_report_from_prepared_graph(
11691 root,
11692 path,
11693 scope,
11694 depth,
11695 limit,
11696 impact_limit,
11697 freshness.clone(),
11698 extra_warnings.clone(),
11699 prepared,
11700 &graph_prepared,
11701 )?;
11702 let signature = graph_db_backend_eval_conflict_signature(&report);
11703 let rows = report.candidates.len() + report.conflicts.len();
11704 conflict_for_trace = Some(report);
11705 graph_snapshot_for_trace = Some(graph_prepared.graph);
11706 Ok((Some(rows), signature))
11707 });
11708 operations.push(operation);
11709 signatures.extend(signature);
11710
11711 let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11712 let conflict = conflict_for_trace
11713 .take()
11714 .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11715 let graph = graph_snapshot_for_trace
11716 .take()
11717 .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11718 let report = build_dispatch_trace_report_from_conflict_snapshot(
11719 root,
11720 scope,
11721 conflict,
11722 graph.nodes,
11723 graph.edges,
11724 depth,
11725 limit,
11726 Vec::new(),
11727 )?;
11728 Ok((
11729 Some(report.nodes.len() + report.edges.len()),
11730 graph_db_backend_eval_dispatch_signature(&report),
11731 ))
11732 });
11733 operations.push(operation);
11734 signatures.extend(signature);
11735
11736 let total_micros = operations
11737 .iter()
11738 .map(|operation| operation.duration_micros)
11739 .sum();
11740 let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11741 (
11742 GraphDbBackendEvalBackendReport {
11743 backend: backend.to_string(),
11744 adapter: adapter.to_string(),
11745 read_only,
11746 projection_load: projection_load.to_string(),
11747 operations,
11748 total_micros,
11749 parity,
11750 lock_behavior: lock_behavior.to_string(),
11751 install_portability: install_portability.to_string(),
11752 },
11753 signatures,
11754 )
11755}
11756
11757pub(crate) fn graph_db_backend_eval_refresh_operation(
11758 duration_micros: u128,
11759 rows: usize,
11760 value: serde_json::Value,
11761) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11762 (
11763 GraphDbBackendEvalOperation {
11764 name: "refresh".to_string(),
11765 supported: true,
11766 status: "ok".to_string(),
11767 duration_micros,
11768 rows: Some(rows),
11769 error: None,
11770 },
11771 GraphDbBackendEvalSignature {
11772 operation: "refresh".to_string(),
11773 value,
11774 },
11775 )
11776}
11777
11778pub(crate) fn graph_db_backend_eval_synthetic_projection(
11779 nodes: usize,
11780 fanout: usize,
11781) -> GraphProjection {
11782 let nodes = nodes.max(12);
11783 let symbol_count = nodes.saturating_sub(9).max(1);
11784 let source = GraphProvenance::new("backend-eval", "synthetic");
11785 let mut projection_nodes = vec![
11786 SubstrateGraphNode::new(
11787 "projection:tsift-traversal:synthetic",
11788 GRAPH_PROJECTION_META_KIND,
11789 "synthetic projection",
11790 )
11791 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11792 .with_property(
11793 "content_hash",
11794 format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11795 )
11796 .with_provenance(source.clone()),
11797 SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11798 .with_property("ref_id", "synthetic-session"),
11799 SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11800 .with_property("ref_id", "synthetic")
11801 .with_property("path", "tasks/software/synthetic.md")
11802 .with_property("line", "1")
11803 .with_property(
11804 "expand",
11805 "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11806 ),
11807 SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11808 .with_property("ref_id", "synthetic"),
11809 SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11810 .with_property("target", "synthetic")
11811 .with_property("summary", "Synthetic worker owns synthetic.rs")
11812 .with_property(
11813 "expand",
11814 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11815 ),
11816 SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11817 .with_property("file", "synthetic.rs")
11818 .with_property("start", "1")
11819 .with_property("end", "80")
11820 .with_property(
11821 "expand",
11822 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11823 ),
11824 SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11825 .with_property("path", "synthetic.rs"),
11826 SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11827 .with_property("handle", "gsem-synthetic")
11828 .with_property("label", "backend evaluation")
11829 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11830 .with_property(
11831 "embedding",
11832 semantic_embedding_property("backend evaluation"),
11833 ),
11834 SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11835 .with_property("ref_id", "synthetic")
11836 .with_property("status", "completed")
11837 .with_property("touched_files", "synthetic.rs")
11838 .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11839 ];
11840 for idx in 0..symbol_count {
11841 projection_nodes.push(
11842 SubstrateGraphNode::new(
11843 format!("gsym-synthetic-{idx:04}"),
11844 "symbol",
11845 format!("synthetic_symbol_{idx:04}"),
11846 )
11847 .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11848 .with_property("path", "synthetic.rs")
11849 .with_property("line", (idx + 1).to_string()),
11850 );
11851 }
11852
11853 let mut projection_edges = vec![
11854 SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11855 SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11856 SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11857 SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11858 SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11859 SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11860 SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11861 SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11862 SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11863 ];
11864 for idx in 0..symbol_count {
11865 let from = format!("gsym-synthetic-{idx:04}");
11866 for offset in 1..=fanout.max(1).min(symbol_count) {
11867 let to_idx = (idx + offset) % symbol_count;
11868 if to_idx != idx {
11869 projection_edges.push(SubstrateGraphEdge::new(
11870 from.clone(),
11871 format!("gsym-synthetic-{to_idx:04}"),
11872 "calls",
11873 ));
11874 }
11875 }
11876 }
11877
11878 GraphProjection {
11879 nodes: projection_nodes,
11880 edges: projection_edges
11881 .into_iter()
11882 .map(|edge| {
11883 edge.with_property("dataset", "synthetic")
11884 .with_provenance(source.clone())
11885 })
11886 .collect(),
11887 }
11888}
11889
11890pub(crate) fn graph_db_backend_eval_promotion(
11891 datasets: &[GraphDbBackendEvalDataset],
11892 candidates: &[GraphDbExperimentalBackend],
11893) -> Vec<GraphDbBackendPromotionDecision> {
11894 let mut decisions = Vec::new();
11895 for candidate in candidates {
11896 let mut reasons = Vec::new();
11897 let mut faster_everywhere = true;
11898 let mut parity_everywhere = true;
11899 for dataset in datasets {
11900 let Some(sqlite_report) = dataset
11901 .backends
11902 .iter()
11903 .find(|backend| backend.backend == "sqlite")
11904 else {
11905 parity_everywhere = false;
11906 faster_everywhere = false;
11907 reasons.push(format!(
11908 "{} dataset is missing SQLite baseline",
11909 dataset.name
11910 ));
11911 continue;
11912 };
11913 let sqlite_total = sqlite_report.total_micros;
11914 let Some(candidate_report) = dataset
11915 .backends
11916 .iter()
11917 .find(|backend| backend.backend == candidate.name())
11918 else {
11919 parity_everywhere = false;
11920 reasons.push(format!("{} dataset did not run", dataset.name));
11921 continue;
11922 };
11923 if !candidate_report.parity.matches_sqlite {
11924 parity_everywhere = false;
11925 reasons.push(format!("{} parity differed from SQLite", dataset.name));
11926 }
11927 if candidate_report.total_micros >= sqlite_total {
11928 faster_everywhere = false;
11929 reasons.push(format!(
11930 "{} total {}us did not beat SQLite {}us",
11931 dataset.name, candidate_report.total_micros, sqlite_total
11932 ));
11933 }
11934 let sqlite_operations = sqlite_report
11935 .operations
11936 .iter()
11937 .map(|operation| (operation.name.as_str(), operation.duration_micros))
11938 .collect::<BTreeMap<_, _>>();
11939 for operation in &candidate_report.operations {
11940 if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11941 && operation.duration_micros >= *sqlite_duration
11942 {
11943 faster_everywhere = false;
11944 reasons.push(format!(
11945 "{} {} operation {}us did not beat SQLite {}us",
11946 dataset.name, operation.name, operation.duration_micros, sqlite_duration
11947 ));
11948 }
11949 }
11950 if candidate_report
11951 .operations
11952 .iter()
11953 .any(|operation| operation.status != "ok")
11954 {
11955 parity_everywhere = false;
11956 reasons.push(format!("{} has failed benchmark operations", dataset.name));
11957 }
11958 }
11959 let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11960 reasons.push(reason.to_string());
11961 reasons.push(
11962 "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11963 .to_string(),
11964 );
11965 "hold"
11966 } else if parity_everywhere && faster_everywhere {
11967 reasons.push(
11968 "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11969 .to_string(),
11970 );
11971 "eligible"
11972 } else {
11973 reasons.push(
11974 "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11975 .to_string(),
11976 );
11977 "hold"
11978 };
11979 decisions.push(GraphDbBackendPromotionDecision {
11980 backend: candidate.name().to_string(),
11981 decision: decision.to_string(),
11982 reasons: dedupe_preserve_order(reasons),
11983 gate: candidate.promotion_gate(),
11984 });
11985 }
11986 decisions
11987}
11988
11989pub(crate) fn graph_db_backend_eval_metrics(
11990 datasets: &[GraphDbBackendEvalDataset],
11991) -> BTreeMap<String, f64> {
11992 let mut metrics = BTreeMap::new();
11993 for dataset in datasets {
11994 let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11995 metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11996 metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11997 metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11998 for backend in &dataset.backends {
11999 let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
12000 metrics.insert(
12001 format!("{prefix}.total_duration_micros"),
12002 backend.total_micros as f64,
12003 );
12004 append_graph_db_backend_eval_normalized_duration_metric(
12005 &mut metrics,
12006 &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
12007 backend.total_micros,
12008 graph_rows,
12009 );
12010 for operation in &backend.operations {
12011 metrics.insert(
12012 format!("{prefix}.{}.duration_micros", operation.name),
12013 operation.duration_micros as f64,
12014 );
12015 append_graph_db_backend_eval_normalized_duration_metric(
12016 &mut metrics,
12017 &format!(
12018 "{prefix}.{}.duration_micros_per_1k_graph_rows",
12019 operation.name
12020 ),
12021 operation.duration_micros,
12022 graph_rows,
12023 );
12024 if let Some(rows) = operation.rows {
12025 metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
12026 }
12027 }
12028 }
12029 }
12030 metrics
12031}
12032
12033pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
12034 dataset.nodes + dataset.edges
12035}
12036
12037pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
12038 metrics: &mut BTreeMap<String, f64>,
12039 key: &str,
12040 duration_micros: u128,
12041 graph_rows: usize,
12042) {
12043 if graph_rows == 0 {
12044 return;
12045 }
12046 metrics.insert(
12047 key.to_string(),
12048 duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
12049 );
12050}
12051
12052pub(crate) fn append_graph_db_backend_eval_phase_metrics(
12053 metrics: &mut BTreeMap<String, f64>,
12054 dataset: &str,
12055 graph_rows: usize,
12056 phases: &[GraphDbBackendEvalPhaseTiming],
12057) {
12058 for phase in phases {
12059 metrics.insert(
12060 format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
12061 phase.duration_micros as f64,
12062 );
12063 append_graph_db_backend_eval_normalized_duration_metric(
12064 metrics,
12065 &format!(
12066 "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
12067 phase.name
12068 ),
12069 phase.duration_micros,
12070 graph_rows,
12071 );
12072 }
12073}
12074
12075fn graph_db_backend_eval_base_command(
12076 root: &Path,
12077 scope: Option<&str>,
12078 full_projection: bool,
12079) -> String {
12080 let full_projection_arg = if full_projection {
12081 " --full-projection"
12082 } else {
12083 ""
12084 };
12085 format!(
12086 "tsift graph-db --path {}{} --json backend-eval{}",
12087 shell_quote(root.to_string_lossy().as_ref()),
12088 graph_db_scope_arg(scope),
12089 full_projection_arg
12090 )
12091}
12092
12093pub(crate) fn graph_db_backend_eval_metric_digest_command(
12094 root: &Path,
12095 scope: Option<&str>,
12096 full_projection: bool,
12097) -> String {
12098 format!(
12099 "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12100 graph_db_backend_eval_base_command(root, scope, full_projection)
12101 )
12102}
12103
12104fn graph_db_backend_eval_repeated_sample_command(
12105 root: &Path,
12106 scope: Option<&str>,
12107 full_projection: bool,
12108) -> String {
12109 format!(
12110 "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12111 graph_db_backend_eval_base_command(root, scope, full_projection)
12112 )
12113}
12114
12115fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
12116 let mut required_metrics = Vec::new();
12117 for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
12118 required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
12119 required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
12120 for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
12121 required_metrics.push(format!(
12122 "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
12123 ));
12124 required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
12125 }
12126 }
12127 GraphDbHopCapPromotionGate {
12128 status: "hold_64_default_until_gate_passes".to_string(),
12129 current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
12130 candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
12131 required_backend: perf_gate::BASELINE_BACKEND.to_string(),
12132 required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
12133 .iter()
12134 .map(|workload| (*workload).to_string())
12135 .collect(),
12136 required_metrics,
12137 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12138 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12139 decision_rule:
12140 "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"
12141 .to_string(),
12142 }
12143}
12144
12145fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
12146 let candidate_backends = [
12147 GraphDbExperimentalBackend::Falkordb,
12148 GraphDbExperimentalBackend::Kuzu,
12149 GraphDbExperimentalBackend::Surrealdb,
12150 ]
12151 .into_iter()
12152 .map(|backend| GraphDbBackendAdapterSpikeCandidate {
12153 backend: backend.name().to_string(),
12154 adapter_label: backend.adapter_label().to_string(),
12155 projection_load: backend.projection_load().to_string(),
12156 lock_behavior: backend.lock_behavior().to_string(),
12157 install_portability: backend.install_portability().to_string(),
12158 })
12159 .collect();
12160
12161 GraphDbBackendAdapterSpikeGate {
12162 status: "hold_real_optional_adapter_required".to_string(),
12163 candidate_backends,
12164 required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
12165 .iter()
12166 .map(|workload| (*workload).to_string())
12167 .collect(),
12168 required_checks: vec![
12169 "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
12170 "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
12171 "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
12172 "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
12173 "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
12174 .to_string(),
12175 "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
12176 "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
12177 ],
12178 decision_rule:
12179 "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"
12180 .to_string(),
12181 evidence_plan: "plans/gback-evidence.md".to_string(),
12182 }
12183}
12184
12185pub(crate) fn graph_db_backend_eval_performance_gate(
12186 root: &Path,
12187 scope: Option<&str>,
12188 full_projection: bool,
12189) -> GraphDbBackendEvalPerformanceGate {
12190 let mut required_metrics = vec![
12191 "real.sqlite.refresh.duration_micros".to_string(),
12192 "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
12193 "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
12194 "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
12195 "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12196 "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12197 "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
12198 "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
12199 "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12200 "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
12201 "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
12202 "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
12203 "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
12204 "real.sqlite.conflict_matrix.duration_micros".to_string(),
12205 "real.sqlite.dispatch_trace.duration_micros".to_string(),
12206 "real.sqlite.path_max_hops.duration_micros".to_string(),
12207 "real.sqlite.path_max_hops_128.duration_micros".to_string(),
12208 "real.sqlite.path_max_hops_256.duration_micros".to_string(),
12209 "real.sqlite.path_max_hops_512.duration_micros".to_string(),
12210 "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
12211 "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
12212 "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
12213 "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
12214 "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12215 "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12216 "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
12217 .to_string(),
12218 "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12219 .to_string(),
12220 "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12221 "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12222 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
12223 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
12224 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
12225 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
12226 "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12227 .to_string(),
12228 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
12229 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
12230 .to_string(),
12231 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
12232 .to_string(),
12233 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
12234 .to_string(),
12235 ];
12236 if full_projection {
12237 required_metrics.extend([
12238 "full_projection.cache.hit".to_string(),
12239 "full_projection.cache.disk_bytes".to_string(),
12240 "full_projection.cache.compression_ratio".to_string(),
12241 "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
12242 "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12243 "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
12244 .to_string(),
12245 "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
12246 .to_string(),
12247 "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
12248 "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
12249 "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
12250 "full_projection.sqlite.neighborhood.duration_micros".to_string(),
12251 "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
12252 "full_projection.sqlite.evidence.duration_micros".to_string(),
12253 "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
12254 "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
12255 "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
12256 "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
12257 "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
12258 "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
12259 ]);
12260 }
12261 GraphDbBackendEvalPerformanceGate {
12262 baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
12263 ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
12264 .to_string(),
12265 opt_in_real_profile:
12266 "pass --full-projection to add the full-project dataset when checking for large projection regressions"
12267 .to_string(),
12268 full_projection_cache_hit_gate: if full_projection {
12269 "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"
12270 .to_string()
12271 } else {
12272 "not evaluated until --full-projection is enabled".to_string()
12273 },
12274 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12275 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12276 normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
12277 required_metrics,
12278 digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
12279 repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
12280 root,
12281 scope,
12282 full_projection,
12283 ),
12284 hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
12285 backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
12286 }
12287}
12288
12289#[cfg(feature = "backend-surrealdb")]
12290fn graph_db_backend_eval_path_segment(value: &str) -> String {
12291 value
12292 .chars()
12293 .map(|ch| {
12294 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
12295 ch
12296 } else {
12297 '_'
12298 }
12299 })
12300 .collect()
12301}
12302
12303#[cfg(feature = "backend-surrealdb")]
12304fn graph_db_backend_eval_surrealdb_store_path(
12305 root: &Path,
12306 scope: Option<&str>,
12307 dataset: &str,
12308) -> PathBuf {
12309 root.join(".tsift/backend-eval-cache/surrealdb")
12310 .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
12311 .join(graph_db_backend_eval_path_segment(dataset))
12312 .join("surrealkv")
12313}
12314
12315pub(crate) struct GraphDbBackendEvalOptions<'a> {
12316 path: &'a Path,
12317 scope: Option<&'a str>,
12318 candidates: &'a [String],
12319 targets: &'a [String],
12320 full_projection: bool,
12321}
12322
12323#[allow(clippy::too_many_arguments)]
12324pub(crate) fn graph_db_backend_eval_dataset(
12325 name: &str,
12326 root: &Path,
12327 path: &Path,
12328 scope: Option<&str>,
12329 targets: &[String],
12330 depth: usize,
12331 limit: usize,
12332 impact_limit: usize,
12333 candidates: &[GraphDbExperimentalBackend],
12334 sqlite_store: &SqliteGraphStore,
12335 sqlite_freshness: GraphDbFreshnessReport,
12336 sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
12337 sqlite_rows: ConvexProjectionRows,
12338 extra_warnings: Vec<String>,
12339 prepared: &ConflictMatrixPreparedInputs,
12340) -> Result<GraphDbBackendEvalDataset> {
12341 let (nodes, edges) = sqlite_store.graph_counts()?;
12342 let (sqlite_operation, sqlite_signature) = sqlite_refresh;
12343 let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
12344 "sqlite",
12345 "SQLite GraphStore correctness baseline",
12346 false,
12347 root,
12348 path,
12349 scope,
12350 targets,
12351 depth,
12352 limit,
12353 impact_limit,
12354 sqlite_store,
12355 sqlite_freshness,
12356 sqlite_operation,
12357 Some(sqlite_signature),
12358 None,
12359 extra_warnings.clone(),
12360 prepared,
12361 "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
12362 "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
12363 "bundled rusqlite baseline; no external service or runtime required",
12364 );
12365
12366 let mut backends = vec![sqlite_report];
12367 for candidate in candidates {
12368 #[cfg(feature = "backend-surrealdb")]
12369 if *candidate == GraphDbExperimentalBackend::Surrealdb {
12370 let started = Instant::now();
12371 let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
12372 let (store, warm_start) =
12373 SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
12374 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12375 let rows = candidate_nodes + candidate_edges;
12376 let mut refresh_meta = serde_json::json!({
12377 "nodes": candidate_nodes,
12378 "edges": candidate_edges,
12379 });
12380 if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
12381 refresh_meta["warm_start"] = serde_json::json!("cache_hit");
12382 }
12383 let refresh = graph_db_backend_eval_refresh_operation(
12384 started.elapsed().as_micros(),
12385 rows,
12386 refresh_meta,
12387 );
12388 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12389 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12390 candidate.name(),
12391 "SurrealDB SurrealKV optional adapter spike",
12392 false,
12393 root,
12394 path,
12395 scope,
12396 targets,
12397 depth,
12398 limit,
12399 impact_limit,
12400 &store,
12401 freshness,
12402 refresh.0,
12403 Some(refresh.1),
12404 Some(&sqlite_signatures),
12405 extra_warnings.clone(),
12406 prepared,
12407 "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",
12408 "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
12409 "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
12410 );
12411 backends.push(candidate_report);
12412 continue;
12413 }
12414 let started = Instant::now();
12415 let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
12416 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12417 let rows = candidate_nodes + candidate_edges;
12418 let refresh = graph_db_backend_eval_refresh_operation(
12419 started.elapsed().as_micros(),
12420 rows,
12421 serde_json::json!({
12422 "nodes": candidate_nodes,
12423 "edges": candidate_edges,
12424 }),
12425 );
12426 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12427 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12428 candidate.name(),
12429 candidate.adapter_label(),
12430 true,
12431 root,
12432 path,
12433 scope,
12434 targets,
12435 depth,
12436 limit,
12437 impact_limit,
12438 &store,
12439 freshness,
12440 refresh.0,
12441 Some(refresh.1),
12442 Some(&sqlite_signatures),
12443 extra_warnings.clone(),
12444 prepared,
12445 candidate.projection_load(),
12446 candidate.lock_behavior(),
12447 candidate.install_portability(),
12448 );
12449 backends.push(candidate_report);
12450 }
12451
12452 Ok(GraphDbBackendEvalDataset {
12453 name: name.to_string(),
12454 target_count: targets.len(),
12455 nodes,
12456 edges,
12457 backends,
12458 })
12459}
12460
12461pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
12462 println!(
12463 "graph-db backend-eval baseline:{} candidates:{}",
12464 report.baseline_backend,
12465 report.candidates.join(", ")
12466 );
12467 for phase in &report.phase_timings {
12468 println!(
12469 "phase:{} {}us {}",
12470 phase.name, phase.duration_micros, phase.detail
12471 );
12472 }
12473 for dataset in &report.datasets {
12474 println!(
12475 "dataset:{} targets:{} rows:{}",
12476 dataset.name,
12477 dataset.target_count,
12478 dataset.nodes + dataset.edges
12479 );
12480 for backend in &dataset.backends {
12481 println!(
12482 " backend:{} total:{}us parity:{}",
12483 backend.backend, backend.total_micros, backend.parity.matches_sqlite
12484 );
12485 println!(" projection-load: {}", backend.projection_load);
12486 println!(" lock-behavior: {}", backend.lock_behavior);
12487 println!(" install-portability: {}", backend.install_portability);
12488 for operation in &backend.operations {
12489 println!(
12490 " {} {} {}us",
12491 operation.name, operation.status, operation.duration_micros
12492 );
12493 }
12494 for diagnostic in &backend.parity.diagnostics {
12495 println!(" parity: {diagnostic}");
12496 }
12497 }
12498 }
12499 for decision in &report.promotion {
12500 println!("promotion {}: {}", decision.backend, decision.decision);
12501 println!(" gate: {}", decision.gate.status);
12502 for reason in &decision.reasons {
12503 println!(" reason: {reason}");
12504 }
12505 for check in &decision.gate.required_checks {
12506 println!(" check: {check}");
12507 }
12508 }
12509 println!("metric-digest: {}", report.metric_digest_command);
12510 println!(
12511 "repeat-samples: {}",
12512 report.performance_gate.repeated_sample_command
12513 );
12514}
12515
12516fn traversal_expand_command(root: &Path, handle: &str) -> String {
12517 format!(
12518 "tsift traverse {} --path {} --depth 1 --limit 50",
12519 shell_quote(handle),
12520 shell_quote(root.to_string_lossy().as_ref())
12521 )
12522}
12523
12524fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
12525 let display = relativize(file, root);
12526 let handle = stable_handle("gfil", &format!("file:{display}"));
12527 TraversalNode {
12528 handle: handle.clone(),
12529 kind: "file".to_string(),
12530 label: display.clone(),
12531 ref_id: Some(display.clone()),
12532 path: Some(display),
12533 line: None,
12534 detail: None,
12535 properties: BTreeMap::new(),
12536 expand: traversal_expand_command(root, &handle),
12537 }
12538}
12539
12540fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
12541 let mut node = traversal_file_node(root, file);
12542 if let Some(path) = node.path.clone() {
12543 node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
12544 node.expand = source_read_command(root, &path, 1, 80);
12545 }
12546 node
12547}
12548
12549fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
12550 let file = relativize(&symbol.file, root);
12551 let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
12552 let handle = stable_handle("gsym", &key);
12553 TraversalNode {
12554 handle: handle.clone(),
12555 kind: "symbol".to_string(),
12556 label: symbol.name.clone(),
12557 ref_id: Some(symbol.name.clone()),
12558 path: Some(file),
12559 line: Some(symbol.line),
12560 detail: Some(format!("{} {}", symbol.language, symbol.kind)),
12561 properties: BTreeMap::new(),
12562 expand: traversal_expand_command(root, &handle),
12563 }
12564}
12565
12566fn traversal_ast_span_expand_command(
12567 root: &Path,
12568 file: &str,
12569 symbol: &index::StoredSymbol,
12570 span: &AstSpanPreview,
12571) -> String {
12572 if symbol.language == "markdown" {
12573 markdown_ast_command(root, file, Some(&span.handle))
12574 } else {
12575 let line_count = span
12576 .end_line
12577 .saturating_sub(span.start_line)
12578 .saturating_add(1)
12579 .max(1);
12580 source_read_command(root, file, span.start_line, line_count)
12581 }
12582}
12583
12584fn traversal_ast_span_node(
12585 root: &Path,
12586 symbol: &index::StoredSymbol,
12587 source: &[u8],
12588 symbols: &[&index::StoredSymbol],
12589) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
12590 let span = stored_symbol_ast_span_in_file(symbol, source, symbols, usize::MAX)?;
12591 let file = relativize(&symbol.file, root);
12592 let mut properties = BTreeMap::new();
12593 properties.insert("layer".to_string(), "ast_navigation".to_string());
12594 properties.insert("language".to_string(), symbol.language.clone());
12595 properties.insert("symbol_kind".to_string(), symbol.kind.clone());
12596 properties.insert("node_kind".to_string(), span.node_kind.clone());
12597 properties.insert("start_byte".to_string(), span.start_byte.to_string());
12598 properties.insert("end_byte".to_string(), span.end_byte.to_string());
12599 properties.insert("end_line".to_string(), span.end_line.to_string());
12600 if let Some(body_start_byte) = span.body_start_byte {
12601 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12602 }
12603 if let Some(body_end_byte) = span.body_end_byte {
12604 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12605 }
12606 if let Some(body_start_line) = span.body_start_line {
12607 properties.insert("body_start_line".to_string(), body_start_line.to_string());
12608 }
12609 if let Some(body_end_line) = span.body_end_line {
12610 properties.insert("body_end_line".to_string(), body_end_line.to_string());
12611 }
12612 if let Some(parent_handle) = &span.parent_handle {
12613 properties.insert("parent_handle".to_string(), parent_handle.clone());
12614 }
12615 if !span.child_handles.is_empty() {
12616 properties.insert("child_handles".to_string(), span.child_handles.join(","));
12617 }
12618 if let Some(parent_module) = &symbol.parent_module {
12619 properties.insert("parent_module".to_string(), parent_module.clone());
12620 }
12621 if let Some(markdown) = &span.markdown {
12622 properties.insert(
12623 "markdown_block_kind".to_string(),
12624 markdown_ast_block_kind(&symbol.kind),
12625 );
12626 if let Some(heading_level) = markdown.heading_level {
12627 properties.insert("heading_level".to_string(), heading_level.to_string());
12628 }
12629 if !markdown.section_path.is_empty() {
12630 properties.insert(
12631 "section_path".to_string(),
12632 markdown.section_path.join(" > "),
12633 );
12634 }
12635 if let Some(section_handle) = &markdown.section_handle {
12636 properties.insert("section_handle".to_string(), section_handle.clone());
12637 }
12638 if let Some(list_depth) = markdown.list_depth {
12639 properties.insert("list_depth".to_string(), list_depth.to_string());
12640 }
12641 if let Some(fence_language) = &markdown.fence_language {
12642 properties.insert("fence_language".to_string(), fence_language.clone());
12643 }
12644 }
12645
12646 let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
12647 let node = TraversalNode {
12648 handle: span.handle.clone(),
12649 kind: "ast_span".to_string(),
12650 label: symbol.name.clone(),
12651 ref_id: Some(symbol.name.clone()),
12652 path: Some(file.clone()),
12653 line: Some(line),
12654 detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
12655 properties,
12656 expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
12657 };
12658 let entry = TraversalAstSpanIndexEntry {
12659 handle: span.handle,
12660 symbol_handle: String::new(),
12661 file_handle: None,
12662 file,
12663 name: symbol.name.clone(),
12664 kind: symbol.kind.clone(),
12665 language: symbol.language.clone(),
12666 node_kind: span.node_kind,
12667 start_byte: span.start_byte,
12668 end_byte: span.end_byte,
12669 parent_module: symbol.parent_module.clone(),
12670 markdown: span.markdown,
12671 };
12672 Some((node, entry))
12673}
12674
12675fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
12676 let handle = stable_handle("gsym", &format!("symbol:{name}"));
12677 TraversalNode {
12678 handle: handle.clone(),
12679 kind: "symbol".to_string(),
12680 label: name.to_string(),
12681 ref_id: Some(name.to_string()),
12682 path: None,
12683 line: None,
12684 detail: Some("unresolved call target".to_string()),
12685 properties: BTreeMap::new(),
12686 expand: traversal_expand_command(root, &handle),
12687 }
12688}
12689
12690fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12691 let file = relativize(&route.file, root);
12692 let method = route.method.as_deref().unwrap_or("any");
12693 let key = format!(
12694 "route:{file}:{}:{}:{}",
12695 route.line, method, route.route_path
12696 );
12697 let handle = stable_handle("grte", &key);
12698 TraversalNode {
12699 handle: handle.clone(),
12700 kind: "route".to_string(),
12701 label: format!("{} {}", method.to_uppercase(), route.route_path),
12702 ref_id: Some(route.route_path.clone()),
12703 path: Some(file),
12704 line: Some(route.line),
12705 detail: Some(format!(
12706 "{} route handled by {}",
12707 route.framework, route.handler_name
12708 )),
12709 properties: BTreeMap::new(),
12710 expand: traversal_expand_command(root, &handle),
12711 }
12712}
12713
12714fn traversal_cargo_workspace_node(
12715 root: &Path,
12716 workspace: &multiplicity::CargoWorkspaceInfo,
12717) -> TraversalNode {
12718 let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12719 .to_string_lossy()
12720 .replace('\\', "/");
12721 let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12722 .to_string_lossy()
12723 .replace('\\', "/");
12724 let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12725 let mut properties = BTreeMap::new();
12726 properties.insert("layer".to_string(), "cargo_workspace".to_string());
12727 properties.insert("workspace_root".to_string(), workspace_root.clone());
12728 properties.insert("members".to_string(), workspace.members.join(","));
12729 properties.insert(
12730 "default_members".to_string(),
12731 workspace.default_members.join(","),
12732 );
12733 TraversalNode {
12734 handle: handle.clone(),
12735 kind: "cargo_workspace".to_string(),
12736 label: if workspace_root.is_empty() {
12737 "root cargo workspace".to_string()
12738 } else {
12739 workspace_root
12740 },
12741 ref_id: Some(workspace.id.clone()),
12742 path: Some(manifest),
12743 line: None,
12744 detail: Some("Cargo workspace manifest".to_string()),
12745 properties,
12746 expand: traversal_expand_command(root, &handle),
12747 }
12748}
12749
12750fn traversal_cargo_package_node(
12751 root: &Path,
12752 package: &multiplicity::CargoPackageInfo,
12753) -> TraversalNode {
12754 let manifest = relativize_pathbuf(&package.manifest_path, root)
12755 .to_string_lossy()
12756 .replace('\\', "/");
12757 let package_root = relativize_pathbuf(&package.package_root, root)
12758 .to_string_lossy()
12759 .replace('\\', "/");
12760 let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12761 .to_string_lossy()
12762 .replace('\\', "/");
12763 let handle = stable_handle(
12764 "gcpk",
12765 &format!("cargo-package:{manifest}:{}", package.name),
12766 );
12767 let mut properties = BTreeMap::new();
12768 properties.insert("layer".to_string(), "cargo_package".to_string());
12769 properties.insert("package_name".to_string(), package.name.clone());
12770 properties.insert(
12771 "normalized_name".to_string(),
12772 package.normalized_name.clone(),
12773 );
12774 properties.insert("package_root".to_string(), package_root.clone());
12775 properties.insert("workspace_root".to_string(), workspace_root);
12776 properties.insert("features".to_string(), package.features.join(","));
12777 properties.insert("targets".to_string(), package.targets.join(","));
12778 properties.insert(
12779 "dependencies".to_string(),
12780 package
12781 .dependencies
12782 .iter()
12783 .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12784 .collect::<Vec<_>>()
12785 .join(","),
12786 );
12787 TraversalNode {
12788 handle: handle.clone(),
12789 kind: "cargo_package".to_string(),
12790 label: package.name.clone(),
12791 ref_id: Some(package.scope_id.clone()),
12792 path: Some(manifest),
12793 line: None,
12794 detail: Some(format!(
12795 "Cargo package in {}",
12796 if package_root.is_empty() {
12797 "."
12798 } else {
12799 package_root.as_str()
12800 }
12801 )),
12802 properties,
12803 expand: traversal_expand_command(root, &handle),
12804 }
12805}
12806
12807fn traversal_session_node(
12808 root: &Path,
12809 markdown_path: &Path,
12810 session_id: Option<&str>,
12811) -> TraversalNode {
12812 let display = relativize_pathbuf(markdown_path, root)
12813 .to_string_lossy()
12814 .replace('\\', "/");
12815 let handle = stable_handle("gses", &format!("session:{display}"));
12816 TraversalNode {
12817 handle: handle.clone(),
12818 kind: "session".to_string(),
12819 label: session_id.unwrap_or(&display).to_string(),
12820 ref_id: session_id.map(str::to_string),
12821 path: Some(display),
12822 line: None,
12823 detail: Some("agent-doc session artifact".to_string()),
12824 properties: BTreeMap::new(),
12825 expand: traversal_expand_command(root, &handle),
12826 }
12827}
12828
12829fn traversal_backlog_node(
12830 root: &Path,
12831 markdown_path: &Path,
12832 id: &str,
12833 text: &str,
12834 line: i64,
12835) -> TraversalNode {
12836 let display = relativize_pathbuf(markdown_path, root)
12837 .to_string_lossy()
12838 .replace('\\', "/");
12839 let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12840 TraversalNode {
12841 handle: handle.clone(),
12842 kind: "backlog".to_string(),
12843 label: format!("#{id}"),
12844 ref_id: Some(id.to_string()),
12845 path: Some(display),
12846 line: Some(line),
12847 detail: Some(text.to_string()),
12848 properties: BTreeMap::new(),
12849 expand: traversal_expand_command(root, &handle),
12850 }
12851}
12852
12853fn traversal_job_packet_node(
12854 root: &Path,
12855 markdown_path: &Path,
12856 label: &str,
12857 ref_id: Option<&str>,
12858 detail: &str,
12859 line: i64,
12860) -> TraversalNode {
12861 let display = relativize_pathbuf(markdown_path, root)
12862 .to_string_lossy()
12863 .replace('\\', "/");
12864 let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12865 TraversalNode {
12866 handle: handle.clone(),
12867 kind: "job_packet".to_string(),
12868 label: label.to_string(),
12869 ref_id: ref_id.map(str::to_string),
12870 path: Some(display),
12871 line: Some(line),
12872 detail: Some(detail.to_string()),
12873 properties: BTreeMap::new(),
12874 expand: traversal_expand_command(root, &handle),
12875 }
12876}
12877
12878#[derive(Clone, Debug)]
12879struct ParsedWorkerResult {
12880 id: String,
12881 status: String,
12882 touched_files: Vec<String>,
12883 tests: Vec<String>,
12884 follow_up_ids: Vec<String>,
12885}
12886
12887fn traversal_worker_result_node(
12888 root: &Path,
12889 markdown_path: &Path,
12890 parsed: &ParsedWorkerResult,
12891 line_text: &str,
12892 line: i64,
12893) -> TraversalNode {
12894 let display = relativize_pathbuf(markdown_path, root)
12895 .to_string_lossy()
12896 .replace('\\', "/");
12897 let handle = stable_handle(
12898 "wres",
12899 &format!(
12900 "worker-result:{display}:{}:{}:{}",
12901 parsed.id, parsed.status, line
12902 ),
12903 );
12904 let mut properties = BTreeMap::new();
12905 properties.insert("status".to_string(), parsed.status.clone());
12906 if !parsed.touched_files.is_empty() {
12907 properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12908 }
12909 if !parsed.tests.is_empty() {
12910 properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12911 }
12912 if !parsed.follow_up_ids.is_empty() {
12913 properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12914 }
12915 TraversalNode {
12916 handle: handle.clone(),
12917 kind: "worker_result".to_string(),
12918 label: format!("{} #{}", parsed.status, parsed.id),
12919 ref_id: Some(parsed.id.clone()),
12920 path: Some(display),
12921 line: Some(line),
12922 detail: Some(line_text.trim().to_string()),
12923 properties,
12924 expand: traversal_expand_command(root, &handle),
12925 }
12926}
12927
12928fn traversal_tokens(input: &str) -> BTreeSet<String> {
12929 input
12930 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12931 .flat_map(|part| part.split(['_', '-']))
12932 .map(str::trim)
12933 .filter(|part| part.len() >= 3)
12934 .map(|part| part.to_ascii_lowercase())
12935 .collect()
12936}
12937
12938fn traversal_ast_span_contains(
12939 parent: &TraversalAstSpanIndexEntry,
12940 child: &TraversalAstSpanIndexEntry,
12941) -> bool {
12942 parent.handle != child.handle
12943 && parent.file == child.file
12944 && parent.start_byte <= child.start_byte
12945 && parent.end_byte >= child.end_byte
12946}
12947
12948fn traversal_ast_parent_handle<'a>(
12949 entry: &TraversalAstSpanIndexEntry,
12950 entries: &'a [TraversalAstSpanIndexEntry],
12951) -> Option<&'a str> {
12952 entries
12953 .iter()
12954 .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12955 .min_by_key(|candidate| {
12956 (
12957 candidate.end_byte.saturating_sub(candidate.start_byte),
12958 candidate.start_byte,
12959 candidate.end_byte,
12960 candidate.kind.as_str(),
12961 candidate.name.as_str(),
12962 candidate.node_kind.as_str(),
12963 )
12964 })
12965 .map(|candidate| candidate.handle.as_str())
12966}
12967
12968fn traversal_ast_enclosing_module_handle<'a>(
12969 entry: &TraversalAstSpanIndexEntry,
12970 entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12971 parent_by_handle: &BTreeMap<String, String>,
12972) -> Option<&'a str> {
12973 let mut current = parent_by_handle.get(&entry.handle);
12974 while let Some(handle) = current {
12975 let Some(parent) = entries_by_handle.get(handle) else {
12976 break;
12977 };
12978 if matches!(parent.kind.as_str(), "module" | "mod")
12979 || entry
12980 .parent_module
12981 .as_deref()
12982 .is_some_and(|module| module == parent.name)
12983 {
12984 return Some(parent.handle.as_str());
12985 }
12986 current = parent_by_handle.get(&parent.handle);
12987 }
12988 None
12989}
12990
12991fn link_ast_navigation_edges(
12992 graph: &mut TraversalGraphBuild,
12993 entries: &[TraversalAstSpanIndexEntry],
12994) {
12995 let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12996 let entries_by_handle = entries
12997 .iter()
12998 .map(|entry| (entry.handle.clone(), entry.clone()))
12999 .collect::<BTreeMap<_, _>>();
13000 let mut parent_by_handle = BTreeMap::<String, String>::new();
13001 let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
13002
13003 for entry in entries {
13004 entries_by_file
13005 .entry(entry.file.clone())
13006 .or_default()
13007 .push(entry.clone());
13008 }
13009
13010 for file_entries in entries_by_file.values() {
13011 for entry in file_entries {
13012 let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
13013 if let Some(parent) = &parent {
13014 parent_by_handle.insert(entry.handle.clone(), parent.clone());
13015 }
13016 let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
13017 children_by_parent
13018 .entry(sibling_key)
13019 .or_default()
13020 .push(entry.clone());
13021 }
13022 }
13023
13024 for entry in entries {
13025 let parent = parent_by_handle.get(&entry.handle);
13026 if let Some(parent) = parent {
13027 graph.add_edge(
13028 parent,
13029 &entry.handle,
13030 "contains",
13031 Some("AST parent contains child span".to_string()),
13032 1,
13033 );
13034 graph.add_edge(
13035 parent,
13036 &entry.handle,
13037 "child",
13038 Some("AST child span".to_string()),
13039 1,
13040 );
13041 graph.add_edge(
13042 &entry.handle,
13043 parent,
13044 "parent",
13045 Some("AST parent span".to_string()),
13046 1,
13047 );
13048 } else if let Some(file_handle) = &entry.file_handle {
13049 graph.add_edge(
13050 file_handle,
13051 &entry.handle,
13052 "contains",
13053 Some("file contains top-level AST span".to_string()),
13054 1,
13055 );
13056 }
13057
13058 if let Some(module_handle) =
13059 traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
13060 {
13061 graph.add_edge(
13062 &entry.handle,
13063 module_handle,
13064 "enclosing_module",
13065 Some("nearest enclosing module AST span".to_string()),
13066 1,
13067 );
13068 }
13069
13070 if entry.language == "markdown"
13071 && let Some(markdown) = &entry.markdown
13072 && let Some(section_handle) = &markdown.section_handle
13073 && section_handle != &entry.handle
13074 {
13075 graph.add_edge(
13076 section_handle,
13077 &entry.handle,
13078 "contains_markdown_block",
13079 Some("Markdown section contains block".to_string()),
13080 1,
13081 );
13082 graph.add_edge(
13083 &entry.handle,
13084 section_handle,
13085 "enclosing_section",
13086 Some("Markdown enclosing section".to_string()),
13087 1,
13088 );
13089 }
13090 }
13091
13092 for siblings in children_by_parent.values_mut() {
13093 siblings.sort_by(|left, right| {
13094 left.start_byte
13095 .cmp(&right.start_byte)
13096 .then(left.end_byte.cmp(&right.end_byte))
13097 .then(left.kind.cmp(&right.kind))
13098 .then(left.name.cmp(&right.name))
13099 .then(left.node_kind.cmp(&right.node_kind))
13100 .then(left.handle.cmp(&right.handle))
13101 });
13102 for pair in siblings.windows(2) {
13103 let previous = &pair[0];
13104 let next = &pair[1];
13105 graph.add_edge(
13106 &previous.handle,
13107 &next.handle,
13108 "next_sibling",
13109 Some("next AST sibling span".to_string()),
13110 1,
13111 );
13112 graph.add_edge(
13113 &next.handle,
13114 &previous.handle,
13115 "previous_sibling",
13116 Some("previous AST sibling span".to_string()),
13117 1,
13118 );
13119 }
13120 }
13121}
13122
13123fn traversal_markdown_embedded_symbol_node(
13124 root: &Path,
13125 entry: &TraversalAstSpanIndexEntry,
13126 markdown: &MarkdownSpanMetadata,
13127 embedded: &MarkdownEmbeddedSymbol,
13128) -> TraversalNode {
13129 let mut properties = BTreeMap::new();
13130 properties.insert("layer".to_string(), "embedded_code".to_string());
13131 properties.insert("embedded".to_string(), "true".to_string());
13132 properties.insert("language".to_string(), embedded.language.clone());
13133 properties.insert("symbol_kind".to_string(), embedded.kind.clone());
13134 properties.insert("node_kind".to_string(), embedded.node_kind.clone());
13135 properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
13136 properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
13137 properties.insert("end_line".to_string(), embedded.end_line.to_string());
13138 properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
13139 properties.insert(
13140 "markdown_block_kind".to_string(),
13141 markdown_ast_block_kind(&entry.kind),
13142 );
13143 if let Some(body_start_byte) = embedded.body_start_byte {
13144 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
13145 }
13146 if let Some(body_end_byte) = embedded.body_end_byte {
13147 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
13148 }
13149 if let Some(body_start_line) = embedded.body_start_line {
13150 properties.insert("body_start_line".to_string(), body_start_line.to_string());
13151 }
13152 if let Some(body_end_line) = embedded.body_end_line {
13153 properties.insert("body_end_line".to_string(), body_end_line.to_string());
13154 }
13155 if let Some(fence_language) = &markdown.fence_language {
13156 properties.insert("fence_language".to_string(), fence_language.clone());
13157 }
13158 if !markdown.section_path.is_empty() {
13159 properties.insert(
13160 "section_path".to_string(),
13161 markdown.section_path.join(" > "),
13162 );
13163 }
13164 if let Some(section_handle) = &markdown.section_handle {
13165 properties.insert("section_handle".to_string(), section_handle.clone());
13166 }
13167 let line_count = embedded
13168 .end_line
13169 .saturating_sub(embedded.start_line)
13170 .saturating_add(1)
13171 .max(1);
13172 TraversalNode {
13173 handle: embedded.handle.clone(),
13174 kind: "ast_span".to_string(),
13175 label: embedded.name.clone(),
13176 ref_id: Some(embedded.name.clone()),
13177 path: Some(entry.file.clone()),
13178 line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
13179 detail: Some(format!(
13180 "{} {} embedded in Markdown fence",
13181 embedded.language, embedded.kind
13182 )),
13183 properties,
13184 expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
13185 }
13186}
13187
13188fn link_markdown_embedded_code_edges(
13189 graph: &mut TraversalGraphBuild,
13190 root: &Path,
13191 entries: &[TraversalAstSpanIndexEntry],
13192) {
13193 for entry in entries {
13194 let Some(markdown) = &entry.markdown else {
13195 continue;
13196 };
13197 for embedded in &markdown.embedded_symbols {
13198 let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
13199 graph.add_node(node);
13200 graph.add_edge(
13201 &entry.handle,
13202 &embedded.handle,
13203 "contains",
13204 Some("Markdown fence contains embedded AST symbol".to_string()),
13205 1,
13206 );
13207 graph.add_edge(
13208 &entry.handle,
13209 &embedded.handle,
13210 "child",
13211 Some("embedded code symbol".to_string()),
13212 1,
13213 );
13214 graph.add_edge(
13215 &entry.handle,
13216 &embedded.handle,
13217 "contains_embedded_symbol",
13218 Some("Markdown fence contains embedded code symbol".to_string()),
13219 1,
13220 );
13221 graph.add_edge(
13222 &embedded.handle,
13223 &entry.handle,
13224 "parent",
13225 Some("Markdown fence parent span".to_string()),
13226 1,
13227 );
13228 graph.add_edge(
13229 &embedded.handle,
13230 &entry.handle,
13231 "embedded_in_fence",
13232 Some("embedded code symbol belongs to Markdown fence".to_string()),
13233 1,
13234 );
13235 if let Some(section_handle) = &markdown.section_handle
13236 && section_handle != &entry.handle
13237 {
13238 graph.add_edge(
13239 section_handle,
13240 &embedded.handle,
13241 "contains_embedded_code",
13242 Some("Markdown section contains embedded code symbol".to_string()),
13243 1,
13244 );
13245 graph.add_edge(
13246 &embedded.handle,
13247 section_handle,
13248 "enclosing_section",
13249 Some("Markdown enclosing section".to_string()),
13250 1,
13251 );
13252 }
13253 }
13254 }
13255}
13256
13257fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
13258 let mut tokens = traversal_tokens(&node.label);
13259 if let Some(ref_id) = &node.ref_id {
13260 tokens.extend(traversal_tokens(ref_id));
13261 }
13262 if let Some(path) = &node.path {
13263 tokens.extend(traversal_tokens(path));
13264 }
13265 if let Some(detail) = &node.detail {
13266 tokens.extend(traversal_tokens(detail));
13267 }
13268 tokens
13269}
13270
13271fn markdown_code_spans(input: &str) -> Vec<String> {
13272 input
13273 .split('`')
13274 .enumerate()
13275 .filter(|(idx, _)| idx % 2 == 1)
13276 .map(|(_, part)| part.trim().to_string())
13277 .filter(|part| !part.is_empty())
13278 .collect()
13279}
13280
13281fn push_traversal_token_index(
13282 index: &mut HashMap<String, Vec<usize>>,
13283 tokens: &BTreeSet<String>,
13284 entry_index: usize,
13285) {
13286 for token in tokens {
13287 index.entry(token.clone()).or_default().push(entry_index);
13288 }
13289}
13290
13291impl<'a> TraversalCodeLookup<'a> {
13292 fn new(
13293 symbols: &'a [TraversalSymbolIndexEntry],
13294 files: &'a [TraversalFileIndexEntry],
13295 routes: &'a [TraversalRouteIndexEntry],
13296 multiplicities: &'a [TraversalMultiplicityIndexEntry],
13297 ) -> Self {
13298 let mut symbol_index = HashMap::new();
13299 for (idx, entry) in symbols.iter().enumerate() {
13300 push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
13301 }
13302 let mut file_index = HashMap::new();
13303 let mut file_path_index = HashMap::new();
13304 for (idx, entry) in files.iter().enumerate() {
13305 push_traversal_token_index(&mut file_index, &entry.tokens, idx);
13306 if let Some(path) = entry.node.path.as_ref() {
13307 file_path_index.insert(path.clone(), path.clone());
13308 }
13309 }
13310 let mut route_index = HashMap::new();
13311 for (idx, entry) in routes.iter().enumerate() {
13312 push_traversal_token_index(&mut route_index, &entry.tokens, idx);
13313 }
13314 let mut multiplicity_index = HashMap::new();
13315 for (idx, entry) in multiplicities.iter().enumerate() {
13316 push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
13317 }
13318 Self {
13319 symbols,
13320 files,
13321 routes,
13322 multiplicities,
13323 symbol_index,
13324 file_index,
13325 route_index,
13326 multiplicity_index,
13327 file_path_index,
13328 }
13329 }
13330
13331 fn touched_files_for_line(&self, line: &str) -> Vec<String> {
13332 let mut touched_files = BTreeSet::new();
13333 for candidate in markdown_code_spans(line)
13334 .into_iter()
13335 .chain(line.split_whitespace().map(str::to_string))
13336 {
13337 for path in traversal_path_candidates(&candidate) {
13338 if let Some(file) = self.file_path_index.get(&path) {
13339 touched_files.insert(file.clone());
13340 }
13341 }
13342 }
13343 touched_files.into_iter().collect()
13344 }
13345}
13346
13347fn traversal_path_candidates(candidate: &str) -> Vec<String> {
13348 let trimmed = candidate.trim_matches(|ch: char| {
13349 matches!(
13350 ch,
13351 '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
13352 )
13353 });
13354 if trimmed.is_empty() {
13355 return Vec::new();
13356 }
13357 let mut candidates = vec![trimmed.to_string()];
13358 if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
13359 && !path.is_empty()
13360 && line_suffix.chars().all(|ch| ch.is_ascii_digit())
13361 {
13362 candidates.push(path.to_string());
13363 }
13364 candidates
13365}
13366
13367fn parse_worker_result_line(
13368 line: &str,
13369 lookup: &TraversalCodeLookup<'_>,
13370) -> Vec<ParsedWorkerResult> {
13371 if line.trim_start().starts_with("- [") {
13372 return Vec::new();
13373 }
13374 let lower = line.to_ascii_lowercase();
13375 let status =
13376 if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
13377 {
13378 "completed"
13379 } else if lower.contains("blocked") || lower.contains("externally blocked") {
13380 "blocked"
13381 } else {
13382 return Vec::new();
13383 };
13384 let result_prefix_end = ["follow-up", "follow up", "next:"]
13385 .iter()
13386 .filter_map(|marker| lower.find(marker))
13387 .min()
13388 .unwrap_or(line.len());
13389 let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
13390 if ids.is_empty() {
13391 return Vec::new();
13392 }
13393 let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
13394 let all_ids = extract_conflict_target_refs(line);
13395
13396 let touched_files = lookup.touched_files_for_line(line);
13397 let tests = markdown_code_spans(line)
13398 .into_iter()
13399 .filter(|span| span.to_ascii_lowercase().contains("test"))
13400 .collect::<Vec<_>>();
13401
13402 ids.iter()
13403 .map(|id| ParsedWorkerResult {
13404 id: id.clone(),
13405 status: status.to_string(),
13406 touched_files: touched_files.clone(),
13407 tests: tests.clone(),
13408 follow_up_ids: all_ids
13409 .iter()
13410 .filter(|other| *other != id && !result_ids.contains(*other))
13411 .cloned()
13412 .collect(),
13413 })
13414 .collect()
13415}
13416
13417fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
13418 let hinted_path = if path_hint.is_absolute() {
13419 path_hint.to_path_buf()
13420 } else {
13421 root.join(path_hint)
13422 };
13423 if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
13424 return Some(hinted_path);
13425 }
13426 None
13427}
13428
13429fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
13430 let candidate = if path.is_absolute() {
13431 path.to_path_buf()
13432 } else {
13433 source_root.join(path)
13434 };
13435 if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
13436 return false;
13437 }
13438 if !matches!(
13439 candidate.extension().and_then(|ext| ext.to_str()),
13440 Some("md" | "mdx")
13441 ) {
13442 return false;
13443 }
13444 fs::read_to_string(&candidate)
13445 .map(|content| session_markdown::markdown_content_looks_like_agent_doc_session(&content))
13446 .unwrap_or(false)
13447}
13448
13449fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
13450 if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
13451 return Ok(vec![hinted_path]);
13452 }
13453 let mut files = Vec::new();
13454 let walker = ignore::WalkBuilder::new(root)
13455 .hidden(true)
13456 .git_ignore(true)
13457 .git_global(true)
13458 .git_exclude(true)
13459 .build();
13460 for result in walker {
13461 let entry =
13462 result.with_context(|| format!("walking markdown files under {}", root.display()))?;
13463 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
13464 continue;
13465 }
13466 if traversal_path_is_generated_artifact(root, root, entry.path()) {
13467 continue;
13468 }
13469 if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
13470 files.push(entry.path().to_path_buf());
13471 }
13472 }
13473 files.sort();
13474 Ok(files)
13475}
13476
13477fn traversal_watermark_path(root: &Path, path: &Path) -> String {
13478 path.strip_prefix(root)
13479 .unwrap_or(path)
13480 .to_string_lossy()
13481 .replace('\\', "/")
13482}
13483
13484fn push_traversal_metadata_watermark_part(
13485 root: &Path,
13486 path: &Path,
13487 label: &str,
13488 parts: &mut Vec<String>,
13489) {
13490 let display = traversal_watermark_path(root, path);
13491 match fs::metadata(path) {
13492 Ok(metadata) => {
13493 let (secs, nanos) = metadata
13494 .modified()
13495 .ok()
13496 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
13497 .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
13498 .unwrap_or((0, 0));
13499 parts.push(format!(
13500 "{label}:{display}:len={}:mtime={secs}.{nanos}",
13501 metadata.len()
13502 ));
13503 }
13504 Err(_) => parts.push(format!("{label}:{display}:missing")),
13505 }
13506}
13507
13508#[derive(Serialize)]
13509struct TraversalSummaryWatermarkRow<'a> {
13510 symbol_name: &'a str,
13511 file_path: &'a str,
13512 entities: &'a Option<Vec<summarize::Entity>>,
13513 relationships: &'a Option<Vec<summarize::Relationship>>,
13514 concept_labels: &'a Option<Vec<String>>,
13515}
13516
13517fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
13518 let summaries_db = root.join(".tsift/summaries.db");
13519 if !summaries_db.exists() {
13520 parts.push("summaries_db:absent".to_string());
13521 return Ok(());
13522 }
13523
13524 match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
13525 .and_then(|summary_db| summary_db.all())
13526 {
13527 Ok(summaries) => {
13528 let rows = summaries
13529 .iter()
13530 .map(|summary| TraversalSummaryWatermarkRow {
13531 symbol_name: &summary.symbol_name,
13532 file_path: &summary.file_path,
13533 entities: &summary.entities,
13534 relationships: &summary.relationships,
13535 concept_labels: &summary.concept_labels,
13536 })
13537 .collect::<Vec<_>>();
13538 parts.push(format!(
13539 "summaries_db:rows={}:semantic_hash={}",
13540 rows.len(),
13541 content_hash(&rows)?
13542 ));
13543 }
13544 Err(_) => {
13545 push_traversal_metadata_watermark_part(
13546 root,
13547 &summaries_db,
13548 "summaries_db_unreadable",
13549 parts,
13550 );
13551 }
13552 }
13553 Ok(())
13554}
13555
13556#[cfg(test)]
13557fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
13558 resolution::relative_path_is_generated_artifact(relative)
13559}
13560
13561fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
13562 resolution::path_is_generated_artifact(root, source_root, path)
13563}
13564
13565fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
13566 resolution::index_snapshot_part_is_generated(root, source_root, part)
13567}
13568
13569pub(crate) fn traversal_source_watermark(
13570 root: &Path,
13571 path_hint: &Path,
13572 scope: Option<&str>,
13573 session_only: bool,
13574) -> Result<Option<String>> {
13575 let mut parts = vec![
13576 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
13577 format!("scope:{}", scope.unwrap_or("root")),
13578 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
13579 format!("session_only:{session_only}"),
13580 ];
13581
13582 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13583 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13584 Ok(targets) => targets,
13585 Err(_) => return Ok(None),
13586 };
13587 let Some(target) = targets.into_iter().next() else {
13588 return Ok(None);
13589 };
13590 let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
13591 Ok(db) => db,
13592 Err(_) => return Ok(None),
13593 };
13594 parts.push(format!("index_label:{}", target.label));
13595 parts.push(format!(
13596 "index_scope:{}",
13597 target.scope_name.as_deref().unwrap_or("root")
13598 ));
13599 parts.push(format!(
13600 "index_source_root:{}",
13601 traversal_watermark_path(root, &target.source_root)
13602 ));
13603 let mut snapshot_rows = 0usize;
13604 for part in db.source_snapshot_parts()? {
13605 if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
13606 continue;
13607 }
13608 snapshot_rows += 1;
13609 parts.push(format!("index_snapshot:{part}"));
13610 }
13611 parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
13612 }
13613
13614 let markdown_files = markdown_files_for_traversal(root, path_hint)?;
13615 parts.push(format!("markdown_count:{}", markdown_files.len()));
13616 for markdown_path in markdown_files {
13617 push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
13618 }
13619
13620 push_traversal_summaries_watermark_part(root, &mut parts)?;
13621
13622 Ok(Some(content_hash(&parts)?))
13623}
13624
13625fn ranked_symbol_matches<'a>(
13626 query_tokens: &BTreeSet<String>,
13627 entries: &'a [TraversalSymbolIndexEntry],
13628 index: &HashMap<String, Vec<usize>>,
13629) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13630 let mut scores = BTreeMap::<usize, usize>::new();
13631 for token in query_tokens {
13632 if let Some(indices) = index.get(token) {
13633 for idx in indices {
13634 *scores.entry(*idx).or_default() += 1;
13635 }
13636 }
13637 }
13638 let mut matches = scores
13639 .into_iter()
13640 .map(|(idx, score)| (score, &entries[idx]))
13641 .collect::<Vec<_>>();
13642 matches.sort_by(|(left_score, left), (right_score, right)| {
13643 right_score
13644 .cmp(left_score)
13645 .then_with(|| left.node.label.cmp(&right.node.label))
13646 .then_with(|| left.handle.cmp(&right.handle))
13647 });
13648 matches
13649}
13650
13651fn ranked_file_matches<'a>(
13652 query_tokens: &BTreeSet<String>,
13653 entries: &'a [TraversalFileIndexEntry],
13654 index: &HashMap<String, Vec<usize>>,
13655) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13656 let mut scores = BTreeMap::<usize, usize>::new();
13657 for token in query_tokens {
13658 if let Some(indices) = index.get(token) {
13659 for idx in indices {
13660 *scores.entry(*idx).or_default() += 1;
13661 }
13662 }
13663 }
13664 let mut matches = scores
13665 .into_iter()
13666 .map(|(idx, score)| (score, &entries[idx]))
13667 .collect::<Vec<_>>();
13668 matches.sort_by(|(left_score, left), (right_score, right)| {
13669 right_score
13670 .cmp(left_score)
13671 .then_with(|| left.node.label.cmp(&right.node.label))
13672 .then_with(|| left.handle.cmp(&right.handle))
13673 });
13674 matches
13675}
13676
13677fn ranked_route_matches<'a>(
13678 query_tokens: &BTreeSet<String>,
13679 entries: &'a [TraversalRouteIndexEntry],
13680 index: &HashMap<String, Vec<usize>>,
13681) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13682 let mut scores = BTreeMap::<usize, usize>::new();
13683 for token in query_tokens {
13684 if let Some(indices) = index.get(token) {
13685 for idx in indices {
13686 *scores.entry(*idx).or_default() += 1;
13687 }
13688 }
13689 }
13690 let mut matches = scores
13691 .into_iter()
13692 .map(|(idx, score)| (score, &entries[idx]))
13693 .collect::<Vec<_>>();
13694 matches.sort_by(|(left_score, left), (right_score, right)| {
13695 right_score
13696 .cmp(left_score)
13697 .then_with(|| left.node.label.cmp(&right.node.label))
13698 .then_with(|| left.handle.cmp(&right.handle))
13699 });
13700 matches
13701}
13702
13703fn ranked_multiplicity_matches<'a>(
13704 query_tokens: &BTreeSet<String>,
13705 entries: &'a [TraversalMultiplicityIndexEntry],
13706 index: &HashMap<String, Vec<usize>>,
13707) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13708 let mut scores = BTreeMap::<usize, usize>::new();
13709 for token in query_tokens {
13710 if let Some(indices) = index.get(token) {
13711 for idx in indices {
13712 *scores.entry(*idx).or_default() += 1;
13713 }
13714 }
13715 }
13716 let mut matches = scores
13717 .into_iter()
13718 .map(|(idx, score)| (score, &entries[idx]))
13719 .collect::<Vec<_>>();
13720 matches.sort_by(|(left_score, left), (right_score, right)| {
13721 right_score
13722 .cmp(left_score)
13723 .then_with(|| left.node.kind.cmp(&right.node.kind))
13724 .then_with(|| left.node.label.cmp(&right.node.label))
13725 .then_with(|| left.handle.cmp(&right.handle))
13726 });
13727 matches
13728}
13729
13730fn link_backlog_to_code_nodes(
13731 graph: &mut TraversalGraphBuild,
13732 backlog: &TraversalNode,
13733 text: &str,
13734 lookup: &TraversalCodeLookup<'_>,
13735 limit: usize,
13736) {
13737 let mut query_tokens = traversal_tokens(text);
13738 if let Some(ref_id) = &backlog.ref_id {
13739 query_tokens.extend(traversal_tokens(ref_id));
13740 }
13741 if query_tokens.is_empty() {
13742 return;
13743 }
13744
13745 for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13746 .into_iter()
13747 .take(limit)
13748 {
13749 graph.add_edge(
13750 &backlog.handle,
13751 &entry.handle,
13752 "mentions",
13753 Some("backlog text matches symbol tokens".to_string()),
13754 score,
13755 );
13756 }
13757
13758 for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13759 .into_iter()
13760 .take(limit.min(5))
13761 {
13762 graph.add_edge(
13763 &backlog.handle,
13764 &entry.handle,
13765 "mentions",
13766 Some("backlog text matches file tokens".to_string()),
13767 score,
13768 );
13769 }
13770
13771 for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13772 .into_iter()
13773 .take(limit.min(5))
13774 {
13775 graph.add_edge(
13776 &backlog.handle,
13777 &entry.handle,
13778 "mentions",
13779 Some("backlog text matches route tokens".to_string()),
13780 score,
13781 );
13782 }
13783
13784 for (score, entry) in ranked_multiplicity_matches(
13785 &query_tokens,
13786 lookup.multiplicities,
13787 &lookup.multiplicity_index,
13788 )
13789 .into_iter()
13790 .take(limit.min(5))
13791 {
13792 graph.add_edge(
13793 &backlog.handle,
13794 &entry.handle,
13795 "mentions",
13796 Some("backlog text matches multiplicity tokens".to_string()),
13797 score,
13798 );
13799 }
13800}
13801
13802fn load_agent_doc_traversal_nodes(
13803 root: &Path,
13804 path_hint: &Path,
13805 graph: &mut TraversalGraphBuild,
13806 lookup: &TraversalCodeLookup<'_>,
13807) -> Result<()> {
13808 for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13809 let content = match fs::read_to_string(&markdown_path) {
13810 Ok(content) => content,
13811 Err(err) => {
13812 graph.warnings.push(format!(
13813 "session artifact unavailable: {}: {err}",
13814 markdown_path.display()
13815 ));
13816 continue;
13817 }
13818 };
13819 let Some(document) = AgentDocSessionDocument::parse_if_session(&content) else {
13820 continue;
13821 };
13822
13823 let session = traversal_session_node(root, &markdown_path, document.session_id.as_deref());
13824 graph.add_node(session.clone());
13825 let lines = content.lines().collect::<Vec<_>>();
13826 let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13827 for item in &document.backlog_items {
13828 let backlog = traversal_backlog_node(
13829 root,
13830 &markdown_path,
13831 &item.id,
13832 &item.text,
13833 item.line as i64,
13834 );
13835 graph.add_node(backlog.clone());
13836 backlog_by_id.insert(item.id.clone(), backlog.clone());
13837 graph.add_edge(
13838 &session.handle,
13839 &backlog.handle,
13840 "contains",
13841 Some("session backlog item".to_string()),
13842 1,
13843 );
13844 link_backlog_to_code_nodes(graph, &backlog, &item.text, lookup, 8);
13845 }
13846
13847 let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13848 for item in &document.queue_items {
13849 match item {
13850 AgentDocQueueItem::Dispatch { value, line }
13851 | AgentDocQueueItem::Preset { value, line } => {
13852 let dispatch_ref = value.strip_prefix('#').unwrap_or(value.as_str());
13853 let node = traversal_job_packet_node(
13854 root,
13855 &markdown_path,
13856 &format!("dispatch {value}"),
13857 Some(dispatch_ref),
13858 "agent-doc dispatch preset",
13859 *line as i64,
13860 );
13861 graph.add_node(node.clone());
13862 graph.add_edge(
13863 &session.handle,
13864 &node.handle,
13865 "contains",
13866 Some("session queued dispatch".to_string()),
13867 1,
13868 );
13869 }
13870 AgentDocQueueItem::Do { id, line } => {
13871 let detail = backlog_by_id
13872 .get(id)
13873 .and_then(|node| node.detail.clone())
13874 .unwrap_or_else(|| "queued backlog item".to_string());
13875 let node = traversal_job_packet_node(
13876 root,
13877 &markdown_path,
13878 &format!("do #{id}"),
13879 Some(id),
13880 &detail,
13881 *line as i64,
13882 );
13883 graph.add_node(node.clone());
13884 graph.add_edge(
13885 &session.handle,
13886 &node.handle,
13887 "contains",
13888 Some("session queued job packet".to_string()),
13889 1,
13890 );
13891 if let Some(backlog) = backlog_by_id.get(id) {
13892 graph.add_edge(
13893 &node.handle,
13894 &backlog.handle,
13895 "targets",
13896 Some("queued backlog item".to_string()),
13897 1,
13898 );
13899 }
13900 job_by_id.insert(id.clone(), node);
13901 }
13902 }
13903 }
13904
13905 let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13906 for (idx, line) in lines.iter().enumerate() {
13907 for parsed in parse_worker_result_line(line, lookup) {
13908 let line_no = idx as i64 + 1;
13909 if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13910 continue;
13911 }
13912 let result =
13913 traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13914 graph.add_node(result.clone());
13915 graph.add_edge(
13916 &session.handle,
13917 &result.handle,
13918 "contains",
13919 Some("session worker result".to_string()),
13920 1,
13921 );
13922 if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13923 graph.add_edge(
13924 &backlog.handle,
13925 &result.handle,
13926 "has_result",
13927 Some(format!("worker result {}", parsed.status)),
13928 1,
13929 );
13930 }
13931 if let Some(job) = job_by_id.get(&parsed.id) {
13932 graph.add_edge(
13933 &job.handle,
13934 &result.handle,
13935 "has_result",
13936 Some(format!("queued worker result {}", parsed.status)),
13937 1,
13938 );
13939 }
13940 let mut result_text = line.to_string();
13941 if !parsed.touched_files.is_empty() {
13942 result_text.push(' ');
13943 result_text.push_str(&parsed.touched_files.join(" "));
13944 }
13945 link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13946 }
13947 }
13948 }
13949 Ok(())
13950}
13951
13952#[derive(Debug, Clone)]
13953struct AgentDocIndexGate {
13954 db_path: Option<PathBuf>,
13955 source_root: PathBuf,
13956 diagnostics: Vec<String>,
13957}
13958
13959#[derive(Clone, Hash, PartialEq, Eq)]
13960struct AgentDocIndexGateCacheKey {
13961 root: PathBuf,
13962 path_hint: PathBuf,
13963 scope: Option<String>,
13964 packet_label: String,
13965}
13966
13967fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13968 std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13969> {
13970 static CACHE: std::sync::OnceLock<
13971 std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13972 > = std::sync::OnceLock::new();
13973 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13974}
13975
13976fn prepare_agent_doc_index_gate_cached(
13977 root: &Path,
13978 path_hint: &Path,
13979 scope: Option<&str>,
13980 packet_label: &str,
13981) -> (AgentDocIndexGate, String) {
13982 let key = AgentDocIndexGateCacheKey {
13983 root: root.to_path_buf(),
13984 path_hint: path_hint.to_path_buf(),
13985 scope: scope.map(str::to_string),
13986 packet_label: packet_label.to_string(),
13987 };
13988 if let Ok(cache) = agent_doc_index_gate_cache().lock()
13989 && let Some(cached) = cache.get(&key)
13990 {
13991 return (
13992 cached.clone(),
13993 "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13994 );
13995 }
13996 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13997 if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13998 cache.insert(key, gate.clone());
13999 }
14000 (
14001 gate,
14002 "fresh inspection/refresh — cache miss on this preparation key".to_string(),
14003 )
14004}
14005
14006fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
14007 match state {
14008 SearchIndexState::Fresh => None,
14009 SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
14010 SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
14011 }
14012}
14013
14014fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
14015 rebuild_search_target_detail(&RebuildSearchTarget {
14016 label: target.label.clone(),
14017 reason,
14018 reindex_cmd: target.reindex_cmd.clone(),
14019 })
14020}
14021
14022fn index_refresh_diagnostic(
14023 target: &SearchIndexTarget,
14024 reason: RebuildSearchReason,
14025 summary: &index::IndexSummary,
14026 packet_label: &str,
14027) -> String {
14028 let changed = summary.new + summary.modified + summary.deleted;
14029 format!(
14030 "index refreshed: {}; updated {} changed file{} before {}",
14031 index_reason_detail(target, reason),
14032 changed,
14033 if changed == 1 { "" } else { "s" },
14034 packet_label
14035 )
14036}
14037
14038fn index_refresh_fallback_diagnostic(
14039 target: &SearchIndexTarget,
14040 reason: RebuildSearchReason,
14041 err: &anyhow::Error,
14042 packet_label: &str,
14043) -> String {
14044 format!(
14045 "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
14046 index_reason_detail(target, reason),
14047 packet_label
14048 )
14049}
14050
14051fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
14052 if let Some(scope_name) = scope
14053 && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
14054 {
14055 return scope.source_root;
14056 }
14057 if let Some(scope_name) = scope
14058 && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
14059 {
14060 return package.package_root;
14061 }
14062 if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
14063 return scope.source_root;
14064 }
14065 if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
14066 return package.package_root;
14067 }
14068 if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
14069 return scope.source_root;
14070 }
14071 root.to_path_buf()
14072}
14073
14074fn prepare_agent_doc_index_gate(
14075 root: &Path,
14076 path_hint: &Path,
14077 scope: Option<&str>,
14078 packet_label: &str,
14079) -> AgentDocIndexGate {
14080 let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
14081 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
14082 Ok(targets) => targets,
14083 Err(err) => {
14084 return AgentDocIndexGate {
14085 db_path: None,
14086 source_root: fallback_source_root,
14087 diagnostics: vec![format!(
14088 "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14089 )],
14090 };
14091 }
14092 };
14093 let Some(target) = targets.into_iter().next() else {
14094 return AgentDocIndexGate {
14095 db_path: None,
14096 source_root: fallback_source_root,
14097 diagnostics: vec![format!(
14098 "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
14099 )],
14100 };
14101 };
14102
14103 let state = match inspect_search_index(&target) {
14104 Ok(state) => state,
14105 Err(err) => {
14106 return AgentDocIndexGate {
14107 db_path: None,
14108 source_root: target.source_root,
14109 diagnostics: vec![format!(
14110 "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14111 )],
14112 };
14113 }
14114 };
14115
14116 let Some(reason) = index_reason_for_state(state) else {
14117 return AgentDocIndexGate {
14118 db_path: Some(target.db_path),
14119 source_root: target.source_root,
14120 diagnostics: Vec::new(),
14121 };
14122 };
14123
14124 match apply_search_index_update(root, &target) {
14125 Ok(summary) => {
14126 index::inspect_scope_invalidate_all();
14132 let diagnostics = vec![index_refresh_diagnostic(
14133 &target,
14134 reason,
14135 &summary,
14136 packet_label,
14137 )];
14138 AgentDocIndexGate {
14139 db_path: Some(target.db_path),
14140 source_root: target.source_root,
14141 diagnostics,
14142 }
14143 }
14144 Err(err) => {
14145 let diagnostics = vec![index_refresh_fallback_diagnostic(
14146 &target,
14147 reason,
14148 &err,
14149 packet_label,
14150 )];
14151 AgentDocIndexGate {
14152 db_path: None,
14153 source_root: target.source_root,
14154 diagnostics,
14155 }
14156 }
14157 }
14158}
14159
14160fn add_raw_source_file_nodes(
14161 root: &Path,
14162 source_root: &Path,
14163 graph: &mut TraversalGraphBuild,
14164 file_entries: &mut Vec<TraversalFileIndexEntry>,
14165) -> Result<()> {
14166 let mut entries = walk::walk_files(source_root)?;
14167 entries.sort_by(|left, right| left.path.cmp(&right.path));
14168 for entry in entries {
14169 let file = entry.path.to_string_lossy();
14170 let node = traversal_raw_source_file_node(root, file.as_ref());
14171 let entry = TraversalFileIndexEntry {
14172 handle: node.handle.clone(),
14173 tokens: traversal_node_tokens(&node),
14174 node: node.clone(),
14175 };
14176 graph.add_node(node);
14177 file_entries.push(entry);
14178 }
14179 Ok(())
14180}
14181
14182fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
14183 if scope_root.is_empty() {
14184 return true;
14185 }
14186 path == scope_root || path.starts_with(&format!("{scope_root}/"))
14187}
14188
14189fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
14190 let path = Path::new(file);
14191 if path.is_absolute() {
14192 return path.to_path_buf();
14193 }
14194 let source_candidate = source_root.join(path);
14195 if source_candidate.exists() {
14196 source_candidate
14197 } else {
14198 root.join(path)
14199 }
14200}
14201
14202fn cargo_import_alias_from_line(line: &str) -> Option<String> {
14203 let trimmed = line.trim();
14204 let rest = trimmed
14205 .strip_prefix("pub use ")
14206 .or_else(|| trimmed.strip_prefix("use "))
14207 .or_else(|| trimmed.strip_prefix("extern crate "))?;
14208 let alias = rest
14209 .split([':', ';', ' ', '\t'])
14210 .next()
14211 .unwrap_or_default()
14212 .trim();
14213 (!alias.is_empty()).then(|| alias.to_string())
14214}
14215
14216fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
14217 let mut aliases = BTreeSet::new();
14218 for entry in walk::walk_files(&package.package_root)? {
14219 if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
14220 continue;
14221 }
14222 let content = fs::read_to_string(&entry.path)
14223 .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
14224 aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
14225 }
14226 Ok(aliases)
14227}
14228
14229fn load_multiplicity_traversal_nodes(
14230 root: &Path,
14231 source_root: &Path,
14232 graph: &mut TraversalGraphBuild,
14233 file_handle_by_path: &HashMap<String, String>,
14234 multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
14235) -> Result<()> {
14236 let inventory = multiplicity::discover_cargo_inventory(source_root)?;
14237 let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
14238 for workspace in &inventory.workspaces {
14239 let node = traversal_cargo_workspace_node(root, workspace);
14240 workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
14241 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14242 handle: node.handle.clone(),
14243 tokens: traversal_node_tokens(&node),
14244 node: node.clone(),
14245 });
14246 graph.add_node(node);
14247 }
14248
14249 let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
14250 let mut package_nodes = Vec::new();
14251 for package in &inventory.packages {
14252 let node = traversal_cargo_package_node(root, package);
14253 package_handle_by_name
14254 .entry(package.name.clone())
14255 .or_default()
14256 .push(node.handle.clone());
14257 package_handle_by_name
14258 .entry(package.normalized_name.clone())
14259 .or_default()
14260 .push(node.handle.clone());
14261 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14262 handle: node.handle.clone(),
14263 tokens: traversal_node_tokens(&node),
14264 node: node.clone(),
14265 });
14266 graph.add_node(node.clone());
14267 package_nodes.push((package, node));
14268 }
14269
14270 for (package, node) in &package_nodes {
14271 if let Some(workspace_handle) =
14272 workspace_handle_by_root.get(&package.relative_workspace_root)
14273 {
14274 graph.add_edge(
14275 workspace_handle,
14276 &node.handle,
14277 "contains_package",
14278 Some("Cargo workspace member package".to_string()),
14279 1,
14280 );
14281 }
14282 let package_root = relativize_pathbuf(&package.package_root, root)
14283 .to_string_lossy()
14284 .replace('\\', "/");
14285 for (file, handle) in file_handle_by_path {
14286 if relative_path_inside_scope(file, &package_root) {
14287 graph.add_edge(
14288 &node.handle,
14289 handle,
14290 "owns_file",
14291 Some("Cargo package owns source file".to_string()),
14292 1,
14293 );
14294 }
14295 }
14296 for dependency in &package.dependencies {
14297 if let Some(handles) = package_handle_by_name.get(&dependency.name)
14298 && handles.len() == 1
14299 {
14300 graph.add_edge(
14301 &node.handle,
14302 &handles[0],
14303 "declares_dependency",
14304 Some(format!("{} Cargo dependency", dependency.kind)),
14305 1,
14306 );
14307 }
14308 }
14309 for alias in cargo_import_aliases(package)? {
14310 if let Some(handles) = package_handle_by_name.get(&alias)
14311 && handles.len() == 1
14312 && handles[0] != node.handle
14313 {
14314 graph.add_edge(
14315 &node.handle,
14316 &handles[0],
14317 "uses_crate",
14318 Some("Rust use/extern crate reference".to_string()),
14319 1,
14320 );
14321 graph.add_edge(
14322 &node.handle,
14323 &handles[0],
14324 "imports",
14325 Some("Rust use/extern crate import".to_string()),
14326 1,
14327 );
14328 }
14329 }
14330 }
14331
14332 Ok(())
14333}
14334
14335fn build_traversal_graph_source_with_options(
14336 root: &Path,
14337 path_hint: &Path,
14338 scope: Option<&str>,
14339 session_only: bool,
14340) -> Result<TraversalGraphBuild> {
14341 let mut graph = TraversalGraphBuild::default();
14342 let mut symbol_entries = Vec::new();
14343 let mut file_entries = Vec::new();
14344 let mut route_entries = Vec::new();
14345 let mut multiplicity_entries = Vec::new();
14346 let mut file_handle_by_path = HashMap::<String, String>::new();
14347 let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
14348 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
14349 let (gate, _cache_detail) =
14350 prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
14351 graph.warnings.extend(gate.diagnostics);
14352 let gate_source_root = gate.source_root.clone();
14353
14354 match gate.db_path {
14355 Some(db_path) if db_path.exists() => {
14356 let db = index::IndexDb::open_read_only_resilient(&db_path)?;
14357 let file_paths = db.file_paths()?;
14358 for file in file_paths {
14359 if traversal_path_is_generated_artifact(
14360 root,
14361 &gate_source_root,
14362 Path::new(&file),
14363 ) {
14364 continue;
14365 }
14366 let node = traversal_file_node(root, &file);
14367 let entry = TraversalFileIndexEntry {
14368 handle: node.handle.clone(),
14369 tokens: traversal_node_tokens(&node),
14370 node: node.clone(),
14371 };
14372 if let Some(path) = entry.node.path.as_ref() {
14373 file_handle_by_path.insert(path.clone(), entry.handle.clone());
14374 }
14375 graph.add_node(node);
14376 file_entries.push(entry);
14377 }
14378
14379 let symbols = db.all_symbols()?;
14380 let mut symbols_by_file = HashMap::<String, Vec<&index::StoredSymbol>>::new();
14383 for symbol in &symbols {
14384 symbols_by_file
14385 .entry(symbol.file.clone())
14386 .or_default()
14387 .push(symbol);
14388 }
14389 let mut symbol_by_file_name_line = HashMap::new();
14390 let mut span_by_file_name_line = HashMap::new();
14391 let mut first_symbol_by_name = BTreeMap::<String, String>::new();
14392 let mut first_span_by_name = BTreeMap::<String, String>::new();
14393 let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
14394 let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
14395 for symbol in symbols.iter().filter(|symbol| {
14396 !traversal_path_is_generated_artifact(
14397 root,
14398 &gate_source_root,
14399 Path::new(&symbol.file),
14400 )
14401 }) {
14402 let node = traversal_symbol_node(root, symbol);
14403 let file = relativize(&symbol.file, root);
14404 symbol_by_file_name_line.insert(
14405 format!("{file}:{}:{}", symbol.line, symbol.name),
14406 node.handle.clone(),
14407 );
14408 first_symbol_by_name
14409 .entry(symbol.name.clone())
14410 .or_insert_with(|| node.handle.clone());
14411 let entry = TraversalSymbolIndexEntry {
14412 handle: node.handle.clone(),
14413 tokens: traversal_node_tokens(&node),
14414 node: node.clone(),
14415 };
14416 graph.add_node(node.clone());
14417 if let Some(file_handle) = file_handle_by_path.get(&file) {
14418 graph.add_edge(
14419 file_handle,
14420 &node.handle,
14421 "defines",
14422 Some("file defines symbol".to_string()),
14423 1,
14424 );
14425 }
14426 if !source_by_file.contains_key(&symbol.file) {
14427 let source_path =
14428 traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
14429 source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
14430 }
14431 if let Some(Some(source)) = source_by_file.get(&symbol.file)
14432 && let Some((ast_node, mut ast_entry)) =
14433 traversal_ast_span_node(
14434 root,
14435 symbol,
14436 source,
14437 symbols_by_file
14438 .get(&symbol.file)
14439 .map(Vec::as_slice)
14440 .unwrap_or(&[]),
14441 )
14442 {
14443 ast_entry.symbol_handle = node.handle.clone();
14444 ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
14445 span_by_file_name_line.insert(
14446 format!("{file}:{}:{}", symbol.line, symbol.name),
14447 ast_node.handle.clone(),
14448 );
14449 first_span_by_name
14450 .entry(symbol.name.clone())
14451 .or_insert_with(|| ast_node.handle.clone());
14452 graph.add_node(ast_node.clone());
14453 graph.add_edge(
14454 &node.handle,
14455 &ast_node.handle,
14456 "has_ast_span",
14457 Some("symbol projects to indexed AST span".to_string()),
14458 1,
14459 );
14460 graph.add_edge(
14461 &ast_node.handle,
14462 &node.handle,
14463 "represents_symbol",
14464 Some("AST span represents indexed symbol".to_string()),
14465 1,
14466 );
14467 ast_entries.push(ast_entry);
14468 }
14469 symbol_entries.push(entry);
14470 }
14471 link_ast_navigation_edges(&mut graph, &ast_entries);
14472 link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
14473
14474 if !bounded_session_projection {
14475 for edge in db.all_stored_edges()? {
14476 if traversal_path_is_generated_artifact(
14477 root,
14478 &gate_source_root,
14479 Path::new(&edge.caller_file),
14480 ) {
14481 continue;
14482 }
14483 let caller_file = relativize(&edge.caller_file, root);
14484 let caller_key =
14485 format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
14486 let Some(caller_handle) =
14487 symbol_by_file_name_line.get(&caller_key).cloned()
14488 else {
14489 continue;
14490 };
14491 let callee_handle = if let Some(handle) =
14492 first_symbol_by_name.get(&edge.callee_name)
14493 {
14494 handle.clone()
14495 } else {
14496 let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
14497 let handle = node.handle.clone();
14498 graph.add_node(node);
14499 handle
14500 };
14501 graph.add_edge(
14502 &caller_handle,
14503 &callee_handle,
14504 "calls",
14505 Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
14506 1,
14507 );
14508 if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
14509 && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
14510 {
14511 graph.add_edge(
14512 caller_span,
14513 callee_span,
14514 "calls",
14515 Some(format!(
14516 "AST call site {}:{}",
14517 caller_file, edge.call_site_line
14518 )),
14519 1,
14520 );
14521 }
14522 }
14523 }
14524
14525 for route in db.all_routes()? {
14526 if traversal_path_is_generated_artifact(
14527 root,
14528 &gate_source_root,
14529 Path::new(&route.file),
14530 ) {
14531 continue;
14532 }
14533 let node = traversal_route_node(root, &route);
14534 let entry = TraversalRouteIndexEntry {
14535 handle: node.handle.clone(),
14536 tokens: traversal_node_tokens(&node),
14537 node: node.clone(),
14538 };
14539 graph.add_node(node.clone());
14540 if let Some(path) = node.path.as_ref()
14541 && let Some(file_handle) = file_handle_by_path.get(path)
14542 {
14543 graph.add_edge(
14544 file_handle,
14545 &node.handle,
14546 "defines_route",
14547 Some("file declares route".to_string()),
14548 1,
14549 );
14550 }
14551 let handler_handle =
14552 if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
14553 handle.clone()
14554 } else {
14555 let node = traversal_unresolved_symbol_node(root, &route.handler_name);
14556 let handle = node.handle.clone();
14557 graph.add_node(node);
14558 handle
14559 };
14560 graph.add_edge(
14561 &entry.handle,
14562 &handler_handle,
14563 "handled_by",
14564 Some("route handler reference".to_string()),
14565 1,
14566 );
14567 if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
14568 graph.add_edge(
14569 &entry.handle,
14570 handler_span,
14571 "handled_by",
14572 Some("route handler AST span".to_string()),
14573 1,
14574 );
14575 graph.add_edge(
14576 handler_span,
14577 &entry.handle,
14578 "handles_route",
14579 Some("AST span handles route".to_string()),
14580 1,
14581 );
14582 }
14583 route_entries.push(entry);
14584 }
14585 }
14586 _ => {
14587 add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
14588 .with_context(|| {
14589 format!(
14590 "loading raw source fallback nodes from {}",
14591 gate_source_root.display()
14592 )
14593 })?;
14594 for entry in &file_entries {
14595 if let Some(path) = entry.node.path.as_ref() {
14596 file_handle_by_path.insert(path.clone(), entry.handle.clone());
14597 }
14598 }
14599 }
14600 }
14601 load_multiplicity_traversal_nodes(
14602 root,
14603 &gate_source_root,
14604 &mut graph,
14605 &file_handle_by_path,
14606 &mut multiplicity_entries,
14607 )?;
14608 }
14609
14610 let code_lookup = TraversalCodeLookup::new(
14611 &symbol_entries,
14612 &file_entries,
14613 &route_entries,
14614 &multiplicity_entries,
14615 );
14616 load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14617 Ok(graph)
14618}
14619
14620#[cfg(test)]
14621fn build_traversal_graph_source(
14622 root: &Path,
14623 path_hint: &Path,
14624 scope: Option<&str>,
14625) -> Result<TraversalGraphBuild> {
14626 build_traversal_graph_source_with_options(root, path_hint, scope, false)
14627}
14628
14629const GRAPH_DB_WRITE_LOCK_TIMEOUT: Duration = Duration::from_secs(15);
14634const GRAPH_DB_WRITE_LOCK_POLL: Duration = Duration::from_millis(50);
14635
14636pub(crate) struct GraphDbWriteLock {
14638 file: std::fs::File,
14639}
14640
14641impl Drop for GraphDbWriteLock {
14642 fn drop(&mut self) {
14643 let _ = fs4::fs_std::FileExt::unlock(&self.file);
14644 }
14645}
14646
14647pub(crate) fn graph_db_write_lock_path(graph_db: &Path) -> PathBuf {
14648 let stem = graph_db
14649 .file_stem()
14650 .and_then(|stem| stem.to_str())
14651 .unwrap_or("graph");
14652 graph_db.with_file_name(format!("{stem}.write.lock"))
14653}
14654
14655pub(crate) fn acquire_graph_db_write_lock(graph_db: &Path) -> Result<GraphDbWriteLock> {
14662 acquire_graph_db_write_lock_with_timeout(graph_db, GRAPH_DB_WRITE_LOCK_TIMEOUT)
14663}
14664
14665pub(crate) fn acquire_graph_db_write_lock_with_timeout(
14666 graph_db: &Path,
14667 timeout: Duration,
14668) -> Result<GraphDbWriteLock> {
14669 use fs4::fs_std::FileExt;
14670
14671 let lock_path = graph_db_write_lock_path(graph_db);
14672 if let Some(parent) = lock_path.parent() {
14673 fs::create_dir_all(parent)
14674 .with_context(|| format!("creating graph-db lock dir: {}", parent.display()))?;
14675 }
14676 let file = std::fs::OpenOptions::new()
14677 .read(true)
14678 .write(true)
14679 .create(true)
14680 .truncate(false)
14681 .open(&lock_path)
14682 .with_context(|| format!("opening graph-db write lock {}", lock_path.display()))?;
14683
14684 let deadline = Instant::now() + timeout;
14685 loop {
14686 match file.try_lock_exclusive() {
14687 Ok(true) => return Ok(GraphDbWriteLock { file }),
14688 Ok(false) => {
14689 if Instant::now() >= deadline {
14690 bail!(
14691 "another tsift graph-db writer is active for {} (lock: {}); \
14692 a concurrent graph-db refresh or snapshot-import is in progress, \
14693 wait for it to finish before retrying",
14694 graph_db.display(),
14695 lock_path.display()
14696 );
14697 }
14698 std::thread::sleep(GRAPH_DB_WRITE_LOCK_POLL);
14699 }
14700 Err(err) => {
14701 return Err(err).with_context(|| {
14702 format!("locking graph-db write lock {}", lock_path.display())
14703 });
14704 }
14705 }
14706 }
14707}
14708
14709pub(crate) fn write_traversal_graph_store_with_options(
14710 root: &Path,
14711 path_hint: &Path,
14712 scope: Option<&str>,
14713 session_only: bool,
14714) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14715 let source_graph =
14716 build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14717 let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14718 let graph_db = graph_substrate_db_path(root, scope);
14719 let _write_lock = acquire_graph_db_write_lock(&graph_db)?;
14721 let mut store = SqliteGraphStore::open(&graph_db)?;
14722 let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14723 .ok()
14724 .flatten()
14725 .or_else(|| graph_projection_content_hash(&projection));
14726 let refresh = store.replace_projection_with_version(
14727 scope.unwrap_or("root"),
14728 &projection,
14729 Some(GRAPH_PROJECTION_VERSION),
14730 source_watermark,
14731 )?;
14732 Ok((source_graph, refresh))
14733}
14734
14735pub(crate) fn write_traversal_graph_store(
14736 root: &Path,
14737 path_hint: &Path,
14738 scope: Option<&str>,
14739) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14740 write_traversal_graph_store_with_options(root, path_hint, scope, false)
14741}
14742
14743fn refresh_traversal_graph_store_with_options(
14744 root: &Path,
14745 path_hint: &Path,
14746 scope: Option<&str>,
14747 session_only: bool,
14748) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14749 let (source_graph, refresh) =
14750 write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14751 let graph_db = graph_substrate_db_path(root, scope);
14752 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14753 let mut graph = traversal_graph_from_store(root, &store)?;
14754 graph.warnings = source_graph.warnings;
14755 Ok((graph, refresh))
14756}
14757
14758fn refresh_traversal_graph_store(
14759 root: &Path,
14760 path_hint: &Path,
14761 scope: Option<&str>,
14762) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14763 refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14764}
14765
14766pub(crate) fn build_traversal_graph(
14767 root: &Path,
14768 path_hint: &Path,
14769 scope: Option<&str>,
14770) -> Result<TraversalGraphBuild> {
14771 let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14772 Ok(graph)
14773}
14774
14775fn traversal_query_kind_priority(kind: &str) -> usize {
14776 match kind {
14777 "backlog" => 0,
14778 "job_packet" => 1,
14779 "worker_result" => 2,
14780 "symbol" => 3,
14781 "ast_span" => 4,
14782 "file" => 5,
14783 "route" => 6,
14784 "cargo_package" => 7,
14785 "cargo_workspace" => 8,
14786 "session" => 9,
14787 "semantic_concept" => 10,
14788 "semantic_entity" => 11,
14789 _ => 12,
14790 }
14791}
14792
14793fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14794 let trimmed = query.trim();
14795 if trimmed.is_empty() {
14796 return None;
14797 }
14798 let kind_priority = traversal_query_kind_priority(&node.kind);
14799 if node.handle == trimmed {
14800 return Some((0, kind_priority, node.handle.clone()));
14801 }
14802 if node.path.as_deref() == Some(trimmed) {
14803 let path_priority = if node.kind == "file" {
14804 0
14805 } else {
14806 kind_priority.saturating_add(1)
14807 };
14808 return Some((1, path_priority, node.handle.clone()));
14809 }
14810 let normalized_backlog = trimmed.trim_start_matches('#');
14811 if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14812 {
14813 return Some((2, kind_priority, node.handle.clone()));
14814 }
14815 if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14816 return Some((3, kind_priority, node.handle.clone()));
14817 }
14818 None
14819}
14820
14821fn resolve_traversal_node<'a>(
14822 graph: &'a TraversalGraphBuild,
14823 query: &str,
14824) -> Option<&'a TraversalNode> {
14825 graph
14826 .nodes
14827 .values()
14828 .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14829 .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14830 .map(|(_, node)| node)
14831}
14832
14833fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14834 let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14835 for edge in edges {
14836 adj.entry(edge.from.clone())
14837 .or_default()
14838 .insert(edge.to.clone());
14839 adj.entry(edge.to.clone())
14840 .or_default()
14841 .insert(edge.from.clone());
14842 }
14843 adj.into_iter()
14844 .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14845 .collect()
14846}
14847
14848fn traversal_shortest_handles(
14849 edges: &[TraversalEdge],
14850 from: &str,
14851 to: &str,
14852) -> Option<Vec<String>> {
14853 if from == to {
14854 return Some(vec![from.to_string()]);
14855 }
14856 let adj = traversal_adjacency(edges);
14857 if !adj.contains_key(from) || !adj.contains_key(to) {
14858 return None;
14859 }
14860 let mut visited = BTreeSet::new();
14861 let mut queue = VecDeque::new();
14862 let mut parent = BTreeMap::<String, String>::new();
14863 visited.insert(from.to_string());
14864 queue.push_back(from.to_string());
14865 while let Some(current) = queue.pop_front() {
14866 if let Some(neighbors) = adj.get(¤t) {
14867 for neighbor in neighbors {
14868 if visited.insert(neighbor.clone()) {
14869 parent.insert(neighbor.clone(), current.clone());
14870 if neighbor == to {
14871 let mut path = vec![to.to_string()];
14872 let mut cursor = to.to_string();
14873 while let Some(prev) = parent.get(&cursor) {
14874 path.push(prev.clone());
14875 cursor = prev.clone();
14876 }
14877 path.reverse();
14878 return Some(path);
14879 }
14880 queue.push_back(neighbor.clone());
14881 }
14882 }
14883 }
14884 }
14885 None
14886}
14887
14888fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14889 let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14890 for edge in edges {
14891 let neighbor = if edge.from == current {
14892 edge.to.as_str()
14893 } else if edge.to == current {
14894 edge.from.as_str()
14895 } else {
14896 continue;
14897 };
14898 let score = traversal_relation_score(edge, current);
14899 best_score_by_neighbor
14900 .entry(neighbor.to_string())
14901 .and_modify(|best| *best = (*best).max(score))
14902 .or_insert(score);
14903 }
14904 let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14905 ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14906 right_score
14907 .cmp(left_score)
14908 .then_with(|| left_handle.cmp(right_handle))
14909 });
14910 ranked.into_iter().map(|(handle, _)| handle).collect()
14911}
14912
14913fn traversal_neighborhood_handles(
14914 edges: &[TraversalEdge],
14915 origin: &str,
14916 depth: usize,
14917 limit: usize,
14918) -> BTreeSet<String> {
14919 let mut seen = BTreeSet::new();
14920 let mut queue = VecDeque::new();
14921 seen.insert(origin.to_string());
14922 queue.push_back((origin.to_string(), 0usize));
14923 while let Some((current, current_depth)) = queue.pop_front() {
14924 if current_depth >= depth {
14925 continue;
14926 }
14927 for neighbor in traversal_scored_neighbors(edges, ¤t) {
14928 if limit > 0 && seen.len() >= limit {
14929 return seen;
14930 }
14931 if seen.insert(neighbor.clone()) {
14932 queue.push_back((neighbor, current_depth + 1));
14933 }
14934 }
14935 }
14936 seen
14937}
14938
14939fn traversal_edges_between(
14940 handles: &BTreeSet<String>,
14941 edges: &[TraversalEdge],
14942) -> Vec<TraversalEdge> {
14943 edges
14944 .iter()
14945 .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14946 .cloned()
14947 .collect()
14948}
14949
14950fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14951 let mut result = Vec::new();
14952 for pair in path.windows(2) {
14953 if let Some(edge) = edges.iter().find(|edge| {
14954 (edge.from == pair[0] && edge.to == pair[1])
14955 || (edge.from == pair[1] && edge.to == pair[0])
14956 }) {
14957 result.push(edge.clone());
14958 }
14959 }
14960 result
14961}
14962
14963fn sorted_traversal_nodes<'a>(
14964 nodes: impl IntoIterator<Item = &'a TraversalNode>,
14965) -> Vec<TraversalNode> {
14966 let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14967 nodes.sort_by(|left, right| {
14968 left.kind
14969 .cmp(&right.kind)
14970 .then_with(|| left.label.cmp(&right.label))
14971 .then_with(|| left.path.cmp(&right.path))
14972 .then_with(|| left.handle.cmp(&right.handle))
14973 });
14974 nodes
14975}
14976
14977fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14978 let base = match edge.relation.as_str() {
14979 "mentions" => 100,
14980 "contains" => 80,
14981 "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14982 "contains_embedded_symbol" | "embedded_in_fence" => 77,
14983 "contains_markdown_block"
14984 | "contains_embedded_code"
14985 | "enclosing_module"
14986 | "enclosing_section" => 76,
14987 "calls" => {
14988 if edge.from == origin {
14989 70
14990 } else {
14991 65
14992 }
14993 }
14994 "handled_by" | "handles_route" => 68,
14995 "defines_route" => 62,
14996 "imports" => 62,
14997 "previous_sibling" | "next_sibling" => 54,
14998 "mentions_concept" | "mentions_entity" => 66,
14999 "semantic_relation" => 64,
15000 "tagged_concept" | "related_concept" => 58,
15001 "defines" => {
15002 if edge.from == origin {
15003 60
15004 } else {
15005 55
15006 }
15007 }
15008 _ => 10,
15009 };
15010 base + edge.weight
15011}
15012
15013fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
15014 match edge.relation.as_str() {
15015 "mentions" => "matched from backlog/session text".to_string(),
15016 "contains" => "contained in the selected session artifact".to_string(),
15017 "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
15018 "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
15019 "parent" => "parent AST span".to_string(),
15020 "child" => "child AST span".to_string(),
15021 "previous_sibling" => "previous AST sibling".to_string(),
15022 "next_sibling" => "next AST sibling".to_string(),
15023 "contains_markdown_block" => "Markdown section block".to_string(),
15024 "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
15025 "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
15026 "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
15027 "enclosing_module" => "nearest enclosing module".to_string(),
15028 "enclosing_section" => "nearest enclosing Markdown section".to_string(),
15029 "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
15030 "defines" => "file that defines the selected symbol".to_string(),
15031 "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
15032 "defines_route" => "file that declares the selected route".to_string(),
15033 "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
15034 "handled_by" => "route handled by the selected symbol".to_string(),
15035 "handles_route" => "route handled by the selected AST span".to_string(),
15036 "imports" => "import dependency from the selected package".to_string(),
15037 "mentions_concept" => "cached summary concept for the selected source".to_string(),
15038 "mentions_entity" => "cached summary entity for the selected source".to_string(),
15039 "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
15040 "tagged_concept" => "concept label attached to the selected entity".to_string(),
15041 "related_concept" => "co-occurring cached summary concept".to_string(),
15042 "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
15043 "calls" => "caller of the selected symbol".to_string(),
15044 other => format!("connected by {other}"),
15045 }
15046}
15047
15048fn traversal_recommendations(
15049 graph: &TraversalGraphBuild,
15050 origin: Option<&str>,
15051 shortest_path: Option<&[String]>,
15052 limit: usize,
15053) -> Vec<TraversalRecommendation> {
15054 let Some(origin) = origin else {
15055 return Vec::new();
15056 };
15057 let mut recommendations = Vec::new();
15058 let mut seen = BTreeSet::new();
15059
15060 if let Some(path) = shortest_path
15061 && path.len() > 1
15062 && path.first().is_some_and(|handle| handle == origin)
15063 && let Some(next) = graph.nodes.get(&path[1])
15064 {
15065 seen.insert(next.handle.clone());
15066 recommendations.push(TraversalRecommendation {
15067 handle: next.handle.clone(),
15068 kind: next.kind.clone(),
15069 label: next.label.clone(),
15070 reason: "next hop on shortest path".to_string(),
15071 score: 1_000,
15072 expand: next.expand.clone(),
15073 });
15074 }
15075
15076 let mut candidates = graph
15077 .edges
15078 .iter()
15079 .filter_map(|edge| {
15080 let neighbor = if edge.from == origin {
15081 edge.to.as_str()
15082 } else if edge.to == origin {
15083 edge.from.as_str()
15084 } else {
15085 return None;
15086 };
15087 let node = graph.nodes.get(neighbor)?;
15088 Some((traversal_relation_score(edge, origin), edge, node))
15089 })
15090 .collect::<Vec<_>>();
15091 candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
15092 right_score
15093 .cmp(left_score)
15094 .then_with(|| left.kind.cmp(&right.kind))
15095 .then_with(|| left.label.cmp(&right.label))
15096 .then_with(|| left.handle.cmp(&right.handle))
15097 });
15098
15099 let max = if limit == 0 { usize::MAX } else { limit };
15100 for (score, edge, node) in candidates {
15101 if recommendations.len() >= max {
15102 break;
15103 }
15104 if seen.insert(node.handle.clone()) {
15105 recommendations.push(TraversalRecommendation {
15106 handle: node.handle.clone(),
15107 kind: node.kind.clone(),
15108 label: node.label.clone(),
15109 reason: traversal_recommendation_reason(edge, origin),
15110 score,
15111 expand: node.expand.clone(),
15112 });
15113 }
15114 }
15115
15116 recommendations
15117}
15118
15119fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
15120 let scale = nodes.saturating_add(edges);
15121 if scale <= 80 {
15122 ExplorationBudget {
15123 project_size: "small".to_string(),
15124 max_source_windows: 8,
15125 lines_per_window: 96,
15126 relationship_limit: 40,
15127 }
15128 } else if scale <= 800 {
15129 ExplorationBudget {
15130 project_size: "medium".to_string(),
15131 max_source_windows: 6,
15132 lines_per_window: 80,
15133 relationship_limit: 32,
15134 }
15135 } else {
15136 ExplorationBudget {
15137 project_size: "large".to_string(),
15138 max_source_windows: 4,
15139 lines_per_window: 64,
15140 relationship_limit: 24,
15141 }
15142 }
15143}
15144
15145fn exploration_node_label(node: &TraversalNode) -> String {
15146 format!("{}:{}", node.kind, node.label)
15147}
15148
15149fn exploration_source_window_for_node(
15150 root: &Path,
15151 node: &TraversalNode,
15152 budget: &ExplorationBudget,
15153) -> Option<ExplorationSourceWindow> {
15154 let file = node.path.as_ref()?;
15155 let anchor = node
15156 .line
15157 .and_then(|line| usize::try_from(line).ok())
15158 .and_then(|line| line.checked_add(1))
15159 .unwrap_or(1);
15160 let context_before = budget.lines_per_window / 3;
15161 let start = anchor.saturating_sub(context_before).max(1);
15162 let end = start
15163 .saturating_add(budget.lines_per_window)
15164 .saturating_sub(1);
15165 let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
15166 Some(ExplorationSourceWindow {
15167 handle,
15168 file: file.clone(),
15169 start,
15170 end,
15171 reason: format!("cluster around {}", exploration_node_label(node)),
15172 expand: source_read_command(root, file, start, budget.lines_per_window),
15173 })
15174}
15175
15176fn build_exploration_packet(
15177 root: &Path,
15178 totals: &TraversalTotals,
15179 selected_nodes: &[TraversalNode],
15180 selected_edges: &[TraversalEdge],
15181) -> ExplorationPacket {
15182 let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
15183 let node_by_handle = selected_nodes
15184 .iter()
15185 .map(|node| (node.handle.as_str(), node))
15186 .collect::<BTreeMap<_, _>>();
15187 let relationship_map = selected_edges
15188 .iter()
15189 .take(budget.relationship_limit)
15190 .filter_map(|edge| {
15191 let from = node_by_handle.get(edge.from.as_str())?;
15192 let to = node_by_handle.get(edge.to.as_str())?;
15193 Some(ExplorationRelation {
15194 from: exploration_node_label(from),
15195 relation: edge.relation.clone(),
15196 to: exploration_node_label(to),
15197 label: edge.label.clone(),
15198 })
15199 })
15200 .collect::<Vec<_>>();
15201
15202 let mut seen_windows = BTreeSet::new();
15203 let mut source_windows = Vec::new();
15204 for node in selected_nodes {
15205 if source_windows.len() >= budget.max_source_windows {
15206 break;
15207 }
15208 let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
15209 continue;
15210 };
15211 let key = (window.file.clone(), window.start, window.end);
15212 if seen_windows.insert(key) {
15213 source_windows.push(window);
15214 }
15215 }
15216
15217 ExplorationPacket {
15218 budget,
15219 relationship_map,
15220 source_windows,
15221 worker_context: Vec::new(),
15222 no_reread_guidance:
15223 "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
15224 .to_string(),
15225 }
15226}
15227
15228pub(crate) fn traversal_report(
15229 root: &Path,
15230 scope: Option<&str>,
15231 graph: TraversalGraphBuild,
15232 query: Option<&str>,
15233 target: Option<&str>,
15234 depth: usize,
15235 limit: usize,
15236) -> Result<TraversalReport> {
15237 let totals = TraversalTotals {
15238 nodes: graph.nodes.len(),
15239 edges: graph.edges.len(),
15240 };
15241 let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
15242 let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
15243 if let Some(query) = query
15244 && origin_node.is_none()
15245 {
15246 bail!("traversal node not found: {}", query);
15247 }
15248 if let Some(target) = target
15249 && target_node.is_none()
15250 {
15251 bail!("traversal target not found: {}", target);
15252 }
15253
15254 let (mode, selected_nodes, selected_edges, shortest_path) =
15255 if let (Some(origin), Some(target)) = (origin_node, target_node) {
15256 if let Some(handles) =
15257 traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
15258 {
15259 let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
15260 let nodes = handles
15261 .iter()
15262 .filter_map(|handle| graph.nodes.get(handle).cloned())
15263 .collect::<Vec<_>>();
15264 let edges = traversal_path_edges(&handles, &graph.edges);
15265 let path = TraversalPathReport {
15266 from: origin.clone(),
15267 to: target.clone(),
15268 hops: handles.len().saturating_sub(1),
15269 nodes: nodes.clone(),
15270 edges: edges.clone(),
15271 };
15272 (
15273 "path".to_string(),
15274 nodes,
15275 traversal_edges_between(&handle_set, &graph.edges),
15276 Some(path),
15277 )
15278 } else {
15279 (
15280 "path".to_string(),
15281 vec![origin.clone(), target.clone()],
15282 Vec::new(),
15283 None,
15284 )
15285 }
15286 } else if let Some(origin) = origin_node {
15287 let handles =
15288 traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
15289 let nodes =
15290 sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
15291 let edges = traversal_edges_between(&handles, &graph.edges);
15292 ("neighborhood".to_string(), nodes, edges, None)
15293 } else {
15294 let mut nodes = sorted_traversal_nodes(graph.nodes.values());
15295 let truncated_nodes = limit > 0 && nodes.len() > limit;
15296 if truncated_nodes {
15297 nodes.truncate(limit);
15298 }
15299 let handles = nodes
15300 .iter()
15301 .map(|node| node.handle.clone())
15302 .collect::<BTreeSet<_>>();
15303 let mut edges = traversal_edges_between(&handles, &graph.edges);
15304 let truncated_edges = limit > 0 && edges.len() > limit;
15305 if truncated_edges {
15306 edges.truncate(limit);
15307 }
15308 ("export".to_string(), nodes, edges, None)
15309 };
15310
15311 let shortest_handles = shortest_path.as_ref().map(|path| {
15312 path.nodes
15313 .iter()
15314 .map(|node| node.handle.clone())
15315 .collect::<Vec<_>>()
15316 });
15317 let recommendations = traversal_recommendations(
15318 &graph,
15319 origin_node.map(|node| node.handle.as_str()),
15320 shortest_handles.as_deref(),
15321 if limit == 0 { 10 } else { limit.min(10) },
15322 );
15323 let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
15324 let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
15325
15326 Ok(TraversalReport {
15327 root: root.to_string_lossy().to_string(),
15328 scope: scope.map(str::to_string),
15329 mode,
15330 totals,
15331 query: query.map(str::to_string),
15332 target: target.map(str::to_string),
15333 nodes: selected_nodes,
15334 edges: selected_edges,
15335 shortest_path,
15336 recommendations,
15337 exploration,
15338 truncated,
15339 warnings: graph.warnings,
15340 })
15341}
15342
15343fn html_escape(input: &str) -> String {
15344 input
15345 .replace('&', "&")
15346 .replace('<', "<")
15347 .replace('>', ">")
15348 .replace('"', """)
15349 .replace('\'', "'")
15350}
15351
15352pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
15353 let json = serde_json::to_string(report)?.replace("</", "<\\/");
15354 let mut html = String::new();
15355 html.push_str(
15356 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
15357 );
15358 html.push_str(
15359 r#"<style>
15360:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
15361@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
15362*{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}}
15363</style>"#,
15364 );
15365 html.push_str("</head><body>");
15366 html.push_str("<div class=\"page\">");
15367 html.push_str(&format!(
15368 "<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>",
15369 html_escape(&report.mode),
15370 report.nodes.len(),
15371 report.totals.nodes,
15372 report.edges.len(),
15373 report.totals.edges
15374 ));
15375 html.push_str(
15376 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>"#,
15377 );
15378 html.push_str("<script id=\"graph-data\" type=\"application/json\">");
15379 html.push_str(&json);
15380 html.push_str(
15381 r##"</script><script>
15382const report = JSON.parse(document.getElementById("graph-data").textContent);
15383const svg = document.getElementById("graph-canvas");
15384const list = document.getElementById("node-list");
15385const selected = document.getElementById("selected");
15386const filter = document.getElementById("filter");
15387const legend = document.getElementById("legend");
15388const nodes = report.nodes.map((node, index) => ({...node, index}));
15389const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
15390const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
15391const colorByKind = new Map([
15392 ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
15393 ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
15394 ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
15395 ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
15396]);
15397function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
15398function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
15399function text(value){ return value == null ? "" : String(value); }
15400function matches(node, query){
15401 if (!query) return true;
15402 const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
15403 return haystack.includes(query);
15404}
15405function layout(){
15406 const rect = svg.getBoundingClientRect();
15407 const width = rect.width || 900;
15408 const height = rect.height || 650;
15409 const cx = width / 2;
15410 const cy = height / 2;
15411 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15412 const counts = new Map();
15413 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
15414 const offsets = new Map();
15415 for (const node of nodes) {
15416 const group = kinds.indexOf(node.kind);
15417 const index = offsets.get(node.kind) || 0;
15418 offsets.set(node.kind, index + 1);
15419 const groupCount = counts.get(node.kind) || 1;
15420 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
15421 const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
15422 node.x = cx + Math.cos(angle) * ring;
15423 node.y = cy + Math.sin(angle) * ring;
15424 }
15425}
15426function draw(){
15427 const query = filter.value.trim().toLowerCase();
15428 const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
15429 svg.innerHTML = "";
15430 for (const edge of edges) {
15431 if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
15432 const from = nodeByHandle.get(edge.from);
15433 const to = nodeByHandle.get(edge.to);
15434 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
15435 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
15436 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
15437 line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
15438 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
15439 svg.appendChild(line);
15440 }
15441 for (const node of nodes) {
15442 if (!visible.has(node.handle)) continue;
15443 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
15444 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
15445 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
15446 circle.setAttribute("fill", color(node.kind));
15447 circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
15448 circle.addEventListener("click", () => selectNode(node));
15449 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
15450 svg.appendChild(circle);
15451 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
15452 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
15453 label.setAttribute("class", "node-label");
15454 label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
15455 svg.appendChild(label);
15456 }
15457 renderList(query);
15458}
15459function renderLegend(){
15460 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15461 legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">●</b> ${kind}</span>`).join("");
15462}
15463function renderList(query){
15464 const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
15465 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("");
15466 for (const row of list.querySelectorAll(".row")) {
15467 row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
15468 }
15469}
15470function selectNode(node){
15471 const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
15472 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>`;
15473}
15474function escapeHtml(value){
15475 return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));
15476}
15477filter.addEventListener("input", draw);
15478window.addEventListener("resize", () => { layout(); draw(); });
15479renderLegend();
15480layout();
15481draw();
15482if (nodes.length) selectNode(nodes[0]);
15483</script></div></body></html>"##,
15484 );
15485 Ok(html)
15486}
15487
15488fn semantic_related_report_from_store(
15489 root: &Path,
15490 scope: Option<&str>,
15491 query: &str,
15492 limit: usize,
15493 kind: SemanticRelatedKind,
15494 store: &impl GraphStore,
15495) -> Result<SemanticRelatedReport> {
15496 if query.trim().is_empty() {
15497 bail!("semantic query cannot be empty");
15498 }
15499
15500 let query_embedding = semantic_embedding(query);
15501 let node_kinds: &[&str] = match kind {
15502 SemanticRelatedKind::Concept => &["semantic_concept"],
15503 SemanticRelatedKind::Entity => &["semantic_entity"],
15504 SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
15505 };
15506
15507 let items = store
15508 .semantic_top_candidates(&query_embedding, node_kinds, limit)?
15509 .into_iter()
15510 .map(|candidate| {
15511 let node = candidate.node;
15512 SemanticRelatedItem {
15513 handle: node
15514 .properties
15515 .get("handle")
15516 .cloned()
15517 .unwrap_or_else(|| node.id.clone()),
15518 kind: node.kind,
15519 label: node.label,
15520 score: candidate.score,
15521 file_path: node
15522 .properties
15523 .get("source_file")
15524 .or_else(|| node.properties.get("path"))
15525 .cloned(),
15526 source_symbol: node.properties.get("source_symbol").cloned(),
15527 detail: node
15528 .properties
15529 .get("description")
15530 .or_else(|| node.properties.get("detail"))
15531 .cloned(),
15532 expand: node
15533 .properties
15534 .get("expand")
15535 .cloned()
15536 .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
15537 }
15538 })
15539 .collect::<Vec<_>>();
15540
15541 let mut warnings = Vec::new();
15542 if items.is_empty() {
15543 warnings.push(
15544 "no semantic graph rows found; run `tsift summarize --extract <path>` first"
15545 .to_string(),
15546 );
15547 }
15548
15549 Ok(SemanticRelatedReport {
15550 root: root.to_string_lossy().to_string(),
15551 scope: scope.map(str::to_string),
15552 query: query.to_string(),
15553 embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
15554 count: items.len(),
15555 items,
15556 warnings,
15557 })
15558}
15559
15560fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
15561 Ok(store.nodes_by_kind("semantic_concept")?.len()
15562 + store.nodes_by_kind("semantic_entity")?.len())
15563}
15564
15565fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
15566 if limit == 0 {
15567 return 0;
15568 }
15569 limit.saturating_mul(4).clamp(
15570 GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
15571 GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
15572 )
15573}
15574
15575fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
15576 if limit == 0 {
15577 return usize::MAX;
15578 }
15579 limit.saturating_mul(3).max(limit).max(seed_count)
15580}
15581
15582fn graph_db_semantic_seeded_neighborhood(
15583 store: &impl GraphStore,
15584 seed_ids: &[String],
15585 depth: usize,
15586 limit: usize,
15587) -> Result<GraphDbSemanticSeededSubgraph> {
15588 let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
15589 let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
15590 let mut diagnostics = vec![
15591 "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
15592 "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(),
15593 format!(
15594 "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
15595 if edge_scan_cap == 0 {
15596 "unbounded".to_string()
15597 } else {
15598 edge_scan_cap.to_string()
15599 },
15600 if node_discovery_cap == usize::MAX {
15601 "unbounded".to_string()
15602 } else {
15603 node_discovery_cap.to_string()
15604 }
15605 ),
15606 ];
15607
15608 let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
15609 .with_edge_scan_cap(edge_scan_cap)
15610 .with_node_discovery_cap(node_discovery_cap);
15611 let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
15612
15613 for seed_id in &result.missing_seed_ids {
15614 diagnostics.push(format!(
15615 "semantic seed {seed_id} was not present in the graph store"
15616 ));
15617 }
15618
15619 if result.skipped_by_edge_cap > 0 {
15620 diagnostics.push(format!(
15621 "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
15622 result.skipped_by_edge_cap
15623 ));
15624 }
15625 if result.skipped_by_node_cap > 0 {
15626 diagnostics.push(format!(
15627 "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
15628 result.skipped_by_node_cap
15629 ));
15630 }
15631
15632 if result.truncated {
15633 diagnostics.push(format!(
15634 "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
15635 result.total_discovered
15636 ));
15637 }
15638
15639 Ok(GraphDbSemanticSeededSubgraph {
15640 nodes: result.nodes,
15641 edges: result.edges,
15642 truncated: result.truncated,
15643 diagnostics,
15644 })
15645}
15646
15647#[allow(clippy::too_many_arguments)]
15648fn cmd_semantic_related(
15649 query: &str,
15650 path: &Path,
15651 scope: Option<&str>,
15652 limit: usize,
15653 kind: SemanticRelatedKind,
15654 json_output: bool,
15655 compact: bool,
15656 pretty: bool,
15657 terse: bool,
15658 schema: bool,
15659 profile: Option<String>,
15660) -> Result<()> {
15661 let root = lint::resolve_project_root_or_canonical_path(path)?;
15662 write_traversal_graph_store(&root, path, scope)?;
15663 let graph_db = graph_substrate_db_path(&root, scope);
15664 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15665 let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15666 if let Some(recovery) = store.read_only_recovery() {
15667 report
15668 .warnings
15669 .push(graph_db_read_recovery_diagnostic(recovery));
15670 }
15671 if let Some(note) =
15672 profile_preference_note(profile.as_deref(), tsift_local_model::ModelRole::Embed)
15673 {
15674 report.warnings.push(note);
15675 }
15676
15677 if json_output {
15678 println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15679 } else if compact {
15680 for item in &report.items {
15681 println!(
15682 "{:.3}\t{}\t{}\t{}",
15683 item.score, item.kind, item.label, item.handle
15684 );
15685 }
15686 for warning in &report.warnings {
15687 eprintln!("warning: {warning}");
15688 }
15689 } else {
15690 println!(
15691 "Related semantic graph rows for {:?} ({})",
15692 report.query, report.embedding_model
15693 );
15694 for item in &report.items {
15695 println!(
15696 " {:.3} [{}] {} ({})",
15697 item.score, item.kind, item.label, item.handle
15698 );
15699 if let Some(detail) = &item.detail {
15700 println!(" {}", detail);
15701 }
15702 if let Some(file_path) = &item.file_path {
15703 println!(" file: {}", file_path);
15704 }
15705 println!(" expand: {}", item.expand);
15706 }
15707 for warning in &report.warnings {
15708 eprintln!("warning: {warning}");
15709 }
15710 }
15711
15712 Ok(())
15713}
15714
15715fn profile_preference_note(
15720 profile: Option<&str>,
15721 role: tsift_local_model::ModelRole,
15722) -> Option<String> {
15723 let preference = tsift_local_model::ProfilePreference::from_cli(profile);
15724 if matches!(preference, tsift_local_model::ProfilePreference::Auto) {
15725 return None;
15726 }
15727 let probe = tsift_local_model::probe_nvidia_smi();
15728 let resolution = tsift_local_model::resolve_profile_preference(&preference, role, &probe);
15729 Some(format!(
15730 "profile preference {} -> {} ({})",
15731 preference.describe(),
15732 resolution.profile.id,
15733 resolution.reason
15734 ))
15735}
15736
15737#[derive(Serialize)]
15738struct SourceLinePreview {
15739 line: usize,
15740 text: String,
15741}
15742
15743#[derive(Serialize)]
15744pub(crate) struct SourceRangePreview {
15745 start: usize,
15746 end: usize,
15747 total_lines: usize,
15748 truncated_before: bool,
15749 truncated_after: bool,
15750}
15751
15752#[derive(Serialize)]
15753struct SourceExpandCommands {
15754 #[serde(skip_serializing_if = "Option::is_none")]
15755 before: Option<String>,
15756 #[serde(skip_serializing_if = "Option::is_none")]
15757 after: Option<String>,
15758 #[serde(skip_serializing_if = "Option::is_none")]
15759 body: Option<String>,
15760 file: String,
15761 #[serde(skip_serializing_if = "Option::is_none")]
15762 markdown_ast: Option<String>,
15763}
15764
15765#[derive(Serialize)]
15766struct SourceSymbolRef {
15767 handle: String,
15768 name: String,
15769 kind: String,
15770 language: String,
15771 file: String,
15772 line: usize,
15773 #[serde(skip_serializing_if = "Option::is_none")]
15774 end_line: Option<usize>,
15775 #[serde(skip_serializing_if = "Option::is_none")]
15776 signature: Option<String>,
15777 #[serde(skip_serializing_if = "Option::is_none")]
15778 span: Option<AstSpanPreview>,
15779 expand: String,
15780}
15781
15782#[derive(Serialize)]
15783struct SourceSummaryRef {
15784 handle: String,
15785 symbol_name: String,
15786 file_path: String,
15787 summary: String,
15788 expand: String,
15789}
15790
15791#[derive(Serialize)]
15792struct SourceReadReport {
15793 handle: String,
15794 root: String,
15795 file: String,
15796 range: SourceRangePreview,
15797 preview: Vec<SourceLinePreview>,
15798 symbols: Vec<SourceSymbolRef>,
15799 summaries: Vec<SourceSummaryRef>,
15800 #[serde(skip_serializing_if = "Option::is_none")]
15801 markdown: Option<SourceReadMarkdownProjection>,
15802 expand: SourceExpandCommands,
15803 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15804 warnings: Vec<String>,
15805}
15806
15807#[derive(Serialize)]
15808struct SourceReadAstExpandCommands {
15809 window: String,
15810 file_window: String,
15811 #[serde(skip_serializing_if = "Option::is_none")]
15812 markdown_ast: Option<String>,
15813}
15814
15815#[derive(Serialize)]
15816struct SourceReadAstReport {
15817 handle: String,
15818 root: String,
15819 file: String,
15820 range: SourceRangePreview,
15821 symbols: Vec<SourceSymbolRef>,
15822 summaries: Vec<SourceSummaryRef>,
15823 #[serde(skip_serializing_if = "Option::is_none")]
15824 markdown: Option<SourceReadMarkdownProjection>,
15825 expand: SourceReadAstExpandCommands,
15826 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15827 warnings: Vec<String>,
15828}
15829
15830#[derive(Serialize)]
15831struct SymbolReadTarget {
15832 handle: String,
15833 name: String,
15834 kind: String,
15835 language: String,
15836 file: String,
15837 line: usize,
15838 #[serde(skip_serializing_if = "Option::is_none")]
15839 end_line: Option<usize>,
15840 #[serde(skip_serializing_if = "Option::is_none")]
15841 signature: Option<String>,
15842 #[serde(skip_serializing_if = "Option::is_none")]
15843 parent_module: Option<String>,
15844 #[serde(skip_serializing_if = "Option::is_none")]
15845 visibility: Option<String>,
15846 #[serde(skip_serializing_if = "Option::is_none")]
15847 span: Option<AstSpanPreview>,
15848}
15849
15850#[derive(Serialize)]
15851struct SymbolReadExpandCommands {
15852 source_window: String,
15853 #[serde(skip_serializing_if = "Option::is_none")]
15854 body: Option<String>,
15855 file: String,
15856 explain: String,
15857 callers: String,
15858 callees: String,
15859 #[serde(skip_serializing_if = "Option::is_none")]
15860 markdown_ast: Option<String>,
15861}
15862
15863#[derive(Serialize)]
15864struct SymbolReadReport {
15865 handle: String,
15866 root: String,
15867 query: String,
15868 symbol: SymbolReadTarget,
15869 range: SourceRangePreview,
15870 body: Vec<SourceLinePreview>,
15871 child_symbols: Vec<SourceSymbolRef>,
15872 summaries: Vec<SourceSummaryRef>,
15873 expand: SymbolReadExpandCommands,
15874 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15875 warnings: Vec<String>,
15876}
15877
15878#[derive(Clone)]
15879pub(crate) struct MarkdownAstRawNode {
15880 handle: String,
15881 span_handle: String,
15882 name: String,
15883 kind: String,
15884 block_kind: String,
15885 node_kind: String,
15886 start_byte: usize,
15887 end_byte: usize,
15888 body_start_byte: Option<usize>,
15889 body_end_byte: Option<usize>,
15890}
15891
15892#[derive(Clone)]
15893pub(crate) struct MarkdownAstProjection {
15894 source_hash: String,
15895 nodes: Vec<MarkdownAstRawNode>,
15896 parse_duration_micros: u128,
15897 cache_hit: bool,
15898}
15899
15900#[derive(Clone)]
15901struct MarkdownAstCacheEntry {
15902 source_hash: String,
15903 nodes: Vec<MarkdownAstRawNode>,
15904 parse_duration_micros: u128,
15905}
15906
15907static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15908 OnceLock::new();
15909
15910#[derive(Serialize, Clone)]
15911struct MarkdownAstNodeMetadata {
15912 #[serde(skip_serializing_if = "Option::is_none")]
15913 heading_level: Option<usize>,
15914 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15915 section_path: Vec<String>,
15916 #[serde(skip_serializing_if = "Option::is_none")]
15917 section_handle: Option<String>,
15918 #[serde(skip_serializing_if = "Option::is_none")]
15919 list_depth: Option<usize>,
15920 #[serde(skip_serializing_if = "Option::is_none")]
15921 list_marker: Option<String>,
15922 #[serde(skip_serializing_if = "Option::is_none")]
15923 list_order: Option<usize>,
15924 #[serde(skip_serializing_if = "Option::is_none")]
15925 fence_language: Option<String>,
15926 #[serde(skip_serializing_if = "Option::is_none")]
15927 fence_marker: Option<String>,
15928 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15929 embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15930}
15931
15932#[derive(Serialize, Clone)]
15933struct MarkdownAstNodeExpand {
15934 source_window: String,
15935 source_body: String,
15936 symbol_read: String,
15937 edit_intents: String,
15938}
15939
15940#[derive(Serialize, Clone)]
15941struct MarkdownAstCacheReport {
15942 source_hash: String,
15943 cache_hit: bool,
15944 parse_duration_micros: u128,
15945 node_count: usize,
15946 section_count: usize,
15947 list_item_count: usize,
15948 code_block_count: usize,
15949}
15950
15951#[derive(Serialize, Clone)]
15952struct MarkdownAstPhaseTiming {
15953 name: String,
15954 duration_micros: u128,
15955 detail: String,
15956}
15957
15958#[derive(Serialize, Clone)]
15959struct MarkdownAstOutlineEntry {
15960 handle: String,
15961 span_handle: String,
15962 name: String,
15963 kind: String,
15964 block_kind: String,
15965 line: usize,
15966 end_line: usize,
15967 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15968 section_path: Vec<String>,
15969 child_count: usize,
15970 expand: String,
15971}
15972
15973#[derive(Serialize, Clone)]
15974struct MarkdownAstProjectionPreview {
15975 mode: String,
15976 total_nodes: usize,
15977 returned_nodes: usize,
15978 omitted_nodes: usize,
15979 selected_node: Option<String>,
15980 cache: MarkdownAstCacheReport,
15981 outline: Vec<MarkdownAstOutlineEntry>,
15982 phase_timings: Vec<MarkdownAstPhaseTiming>,
15983}
15984
15985#[derive(Serialize)]
15986struct SourceReadMarkdownProjection {
15987 handle: String,
15988 mode: String,
15989 total_nodes: usize,
15990 visible_nodes: usize,
15991 outline: Vec<MarkdownAstOutlineEntry>,
15992 expand: String,
15993}
15994
15995#[derive(Serialize, Clone)]
15996struct SourceByteRangePreview {
15997 start: usize,
15998 end: usize,
15999}
16000
16001#[derive(Serialize, Clone)]
16002struct MarkdownAstNode {
16003 handle: String,
16004 span_handle: String,
16005 name: String,
16006 kind: String,
16007 block_kind: String,
16008 node_kind: String,
16009 line: usize,
16010 end_line: usize,
16011 byte_span: SourceByteRangePreview,
16012 #[serde(skip_serializing_if = "Option::is_none")]
16013 body_byte_span: Option<SourceByteRangePreview>,
16014 parent_handle: Option<String>,
16015 #[serde(skip_serializing_if = "Vec::is_empty", default)]
16016 child_handles: Vec<String>,
16017 metadata: MarkdownAstNodeMetadata,
16018 expand: MarkdownAstNodeExpand,
16019}
16020
16021#[derive(Serialize)]
16022struct MarkdownAstExpandCommands {
16023 file: String,
16024 source_read: String,
16025 edit_intents: String,
16026}
16027
16028#[derive(Serialize)]
16029struct MarkdownAstReport {
16030 handle: String,
16031 root: String,
16032 file: String,
16033 range: SourceRangePreview,
16034 projection: MarkdownAstProjectionPreview,
16035 nodes: Vec<MarkdownAstNode>,
16036 expand: MarkdownAstExpandCommands,
16037 #[serde(skip_serializing_if = "Vec::is_empty", default)]
16038 warnings: Vec<String>,
16039}
16040
16041pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
16042 let candidate = if file.is_absolute() {
16043 file.to_path_buf()
16044 } else {
16045 root.join(file)
16046 };
16047 let canonical = candidate
16048 .canonicalize()
16049 .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
16050 if !canonical.is_file() {
16051 bail!("source file is not a regular file: {}", canonical.display());
16052 }
16053 let canonical_root = root
16054 .canonicalize()
16055 .with_context(|| format!("canonicalizing project root {}", root.display()))?;
16056 if !canonical.starts_with(&canonical_root) {
16057 bail!(
16058 "source file {} is outside project root {}",
16059 canonical.display(),
16060 canonical_root.display()
16061 );
16062 }
16063 Ok(canonical)
16064}
16065
16066pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
16067 source_read_window_command(root, file, start, lines)
16068}
16069
16070pub(crate) fn source_read_window_command(
16071 root: &Path,
16072 file: &str,
16073 start: usize,
16074 lines: usize,
16075) -> String {
16076 format!(
16077 "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
16078 shell_quote(file),
16079 shell_quote(&root.to_string_lossy()),
16080 start,
16081 lines
16082 )
16083}
16084
16085pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
16086 format!(
16087 "tsift --envelope source-read {} --path {} --budget normal",
16088 shell_quote(file),
16089 shell_quote(&root.to_string_lossy())
16090 )
16091}
16092
16093pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
16094 format!(
16095 "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
16096 shell_quote(symbol),
16097 shell_quote(&root.to_string_lossy()),
16098 shell_quote(file)
16099 )
16100}
16101
16102fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
16103 format!(
16104 "tsift --envelope explain {} --path {} --budget normal",
16105 shell_quote(symbol),
16106 shell_quote(&root.to_string_lossy())
16107 )
16108}
16109
16110fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
16111 format!(
16112 "tsift graph {} --path {} --{} --json",
16113 shell_quote(symbol),
16114 shell_quote(&root.to_string_lossy()),
16115 relation
16116 )
16117}
16118
16119fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
16120 format!(
16121 "tsift summarize {} --path {} --json",
16122 shell_quote(symbol),
16123 shell_quote(&root.to_string_lossy())
16124 )
16125}
16126
16127pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
16128 let mut command = format!(
16129 "tsift --envelope markdown-ast {} --path {} --budget normal",
16130 shell_quote(file),
16131 shell_quote(&root.to_string_lossy())
16132 );
16133 if let Some(node) = node {
16134 command.push_str(" --node ");
16135 command.push_str(&shell_quote(node));
16136 }
16137 command
16138}
16139
16140fn markdown_edit_intents_command(root: &Path) -> String {
16141 format!(
16142 "tsift --envelope edit-intents --path {} --budget normal",
16143 shell_quote(&root.to_string_lossy())
16144 )
16145}
16146
16147pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
16148 usize::try_from(symbol.line)
16149 .ok()
16150 .and_then(|line| line.checked_add(1))
16151 .unwrap_or(1)
16152}
16153
16154fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
16155 symbol
16156 .end_line
16157 .and_then(|line| usize::try_from(line).ok())
16158 .and_then(|line| line.checked_add(1))
16159}
16160
16161fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
16162 value.and_then(|byte| usize::try_from(byte).ok())
16163}
16164
16165fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
16166 let byte = byte.min(source.len());
16167 source[..byte]
16168 .iter()
16169 .filter(|value| **value == b'\n')
16170 .count()
16171 .saturating_add(1)
16172}
16173
16174fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
16175 source_line_for_byte(source, end_byte.saturating_sub(1))
16176}
16177
16178fn ast_span_handle(
16179 file: &str,
16180 name: &str,
16181 kind: &str,
16182 start_byte: usize,
16183 end_byte: usize,
16184) -> String {
16185 stable_handle(
16186 "span",
16187 &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
16188 )
16189}
16190
16191pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
16192 Some((
16193 symbol_span_byte(symbol.start_byte)?,
16194 symbol_span_byte(symbol.end_byte)?,
16195 ))
16196}
16197
16198pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
16199 Some((
16200 symbol_span_byte(symbol.start_byte)?,
16201 symbol_span_byte(symbol.end_byte)?,
16202 ))
16203}
16204
16205pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
16206 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16207 Some(ast_span_handle(
16208 &symbol.file,
16209 &symbol.name,
16210 &symbol.kind,
16211 start_byte,
16212 end_byte,
16213 ))
16214}
16215
16216fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
16217 left.file == right.file
16218 && left.name == right.name
16219 && left.kind == right.kind
16220 && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
16221}
16222
16223fn stored_symbol_parent_span_handle_in_file(
16224 symbol: &index::StoredSymbol,
16225 symbols: &[&index::StoredSymbol],
16226) -> Option<String> {
16227 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16228 symbols
16229 .iter()
16230 .copied()
16231 .filter(|candidate| {
16232 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16233 return false;
16234 }
16235 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16236 else {
16237 return false;
16238 };
16239 candidate_start <= start_byte && candidate_end >= end_byte
16240 })
16241 .min_by_key(|candidate| {
16242 stored_symbol_span_bounds(candidate)
16243 .map(|(start, end)| end.saturating_sub(start))
16244 .unwrap_or(usize::MAX)
16245 })
16246 .and_then(stored_symbol_span_handle)
16247}
16248
16249fn stored_symbol_child_span_handles_in_file(
16250 symbol: &index::StoredSymbol,
16251 symbols: &[&index::StoredSymbol],
16252 limit: usize,
16253) -> Vec<String> {
16254 let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
16255 return Vec::new();
16256 };
16257 symbols
16258 .iter()
16259 .copied()
16260 .filter(|candidate| {
16261 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16262 return false;
16263 }
16264 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16265 else {
16266 return false;
16267 };
16268 candidate_start >= start_byte && candidate_end <= end_byte
16269 })
16270 .take(limit)
16271 .filter_map(stored_symbol_span_handle)
16272 .collect()
16273}
16274
16275fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
16276 let start = start_byte.min(source.len());
16277 let line_end = source[start..]
16278 .iter()
16279 .position(|value| *value == b'\n')
16280 .map(|pos| start + pos)
16281 .unwrap_or(source.len());
16282 let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
16283 let marker = line.trim_start();
16284 let level = marker.chars().take_while(|ch| *ch == '#').count();
16285 (1..=6).contains(&level).then_some(level)
16286}
16287
16288fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
16289 let start = start_byte.min(source.len());
16290 let line_start = source[..start]
16291 .iter()
16292 .rposition(|value| *value == b'\n')
16293 .map(|pos| pos + 1)
16294 .unwrap_or(0);
16295 source[line_start..start]
16296 .iter()
16297 .map(|byte| match byte {
16298 b'\t' => 4,
16299 b' ' => 1,
16300 _ => 0,
16301 })
16302 .sum::<usize>()
16303 / 2
16304}
16305
16306fn markdown_enclosing_heading_symbols_in_file<'a>(
16307 file: &str,
16308 start_byte: usize,
16309 end_byte: usize,
16310 symbols: &[&'a index::StoredSymbol],
16311) -> Vec<&'a index::StoredSymbol> {
16312 let mut headings = symbols
16313 .iter()
16314 .copied()
16315 .filter(|candidate| candidate.file == file && candidate.kind == "heading")
16316 .filter(|candidate| {
16317 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16318 else {
16319 return false;
16320 };
16321 candidate_start <= start_byte && candidate_end >= end_byte
16322 })
16323 .collect::<Vec<_>>();
16324 headings.sort_by(|left, right| {
16325 stored_symbol_span_bounds(left)
16326 .map(|(start, _)| start)
16327 .unwrap_or(usize::MAX)
16328 .cmp(
16329 &stored_symbol_span_bounds(right)
16330 .map(|(start, _)| start)
16331 .unwrap_or(usize::MAX),
16332 )
16333 .then(left.name.cmp(&right.name))
16334 });
16335 headings
16336}
16337
16338fn markdown_stored_symbol_metadata_in_file(
16339 symbol: &index::StoredSymbol,
16340 source: &[u8],
16341 symbols: &[&index::StoredSymbol],
16342) -> Option<MarkdownSpanMetadata> {
16343 if symbol.language != "markdown" {
16344 return None;
16345 }
16346 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16347 let section_symbols =
16348 markdown_enclosing_heading_symbols_in_file(&symbol.file, start_byte, end_byte, symbols);
16349 let section_path = section_symbols
16350 .iter()
16351 .map(|heading| heading.name.clone())
16352 .collect::<Vec<_>>();
16353 let section_handle = section_symbols
16354 .last()
16355 .and_then(|heading| stored_symbol_span_handle(heading));
16356 let heading_level = (symbol.kind == "heading")
16357 .then(|| markdown_heading_level(source, start_byte))
16358 .flatten();
16359 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16360 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16361 let embedded_symbols = if symbol.kind == "code_block" {
16362 markdown_embedded_symbols(
16363 &symbol.file,
16364 source,
16365 symbol_span_byte(symbol.body_start_byte),
16366 symbol_span_byte(symbol.body_end_byte),
16367 fence_language.as_deref(),
16368 )
16369 } else {
16370 Vec::new()
16371 };
16372
16373 (heading_level.is_some()
16374 || !section_path.is_empty()
16375 || section_handle.is_some()
16376 || list_depth.is_some()
16377 || fence_language.is_some()
16378 || !embedded_symbols.is_empty())
16379 .then_some(MarkdownSpanMetadata {
16380 heading_level,
16381 section_path,
16382 section_handle,
16383 list_depth,
16384 fence_language,
16385 embedded_symbols,
16386 })
16387}
16388
16389fn markdown_symbol_hit_metadata(
16390 symbol: &index::SymbolHit,
16391 source: &[u8],
16392 start_byte: usize,
16393) -> Option<MarkdownSpanMetadata> {
16394 if symbol.language != "markdown" {
16395 return None;
16396 }
16397 let heading_level = (symbol.kind == "heading")
16398 .then(|| markdown_heading_level(source, start_byte))
16399 .flatten();
16400 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16401 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16402 let embedded_symbols = if symbol.kind == "code_block" {
16403 markdown_embedded_symbols(
16404 &symbol.file,
16405 source,
16406 symbol_span_byte(symbol.body_start_byte),
16407 symbol_span_byte(symbol.body_end_byte),
16408 fence_language.as_deref(),
16409 )
16410 } else {
16411 Vec::new()
16412 };
16413 (heading_level.is_some()
16414 || list_depth.is_some()
16415 || fence_language.is_some()
16416 || !embedded_symbols.is_empty())
16417 .then_some(MarkdownSpanMetadata {
16418 heading_level,
16419 section_path: Vec::new(),
16420 section_handle: None,
16421 list_depth,
16422 fence_language,
16423 embedded_symbols,
16424 })
16425}
16426
16427fn is_markdown_path(path: &Path) -> bool {
16428 path.extension()
16429 .and_then(|ext| ext.to_str())
16430 .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
16431 .unwrap_or(false)
16432}
16433
16434fn markdown_ast_block_kind(kind: &str) -> String {
16435 match kind {
16436 "heading" => "section",
16437 "code_block" => "fenced_code_block",
16438 "list_item" => "list_item",
16439 other => other,
16440 }
16441 .to_string()
16442}
16443
16444fn markdown_embedded_language_key(language: &str) -> Option<String> {
16445 let key = language
16446 .split_whitespace()
16447 .next()
16448 .unwrap_or("")
16449 .trim()
16450 .trim_start_matches("language-")
16451 .trim_start_matches("lang-")
16452 .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
16453 .to_ascii_lowercase();
16454 (!key.is_empty()).then_some(key)
16455}
16456
16457fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
16458 let key = markdown_embedded_language_key(language)?;
16459 let extension = match key.as_str() {
16460 "rust" => "rs",
16461 "python" => "py",
16462 "typescript" => "ts",
16463 "javascript" => "js",
16464 "kotlin" => "kt",
16465 "shell" | "sh" | "zsh" => "bash",
16466 other => other,
16467 };
16468 let lang = graph::Lang::from_extension(extension)?;
16469 (lang.name() != "markdown").then_some(lang)
16470}
16471
16472fn markdown_embedded_ast_span_handle(
16473 file: &str,
16474 language: &str,
16475 name: &str,
16476 kind: &str,
16477 start_byte: usize,
16478 end_byte: usize,
16479) -> String {
16480 stable_handle(
16481 "span",
16482 &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
16483 )
16484}
16485
16486fn markdown_embedded_symbols(
16487 file: &str,
16488 source: &[u8],
16489 body_start_byte: Option<usize>,
16490 body_end_byte: Option<usize>,
16491 fence_language: Option<&str>,
16492) -> Vec<MarkdownEmbeddedSymbol> {
16493 let Some(fence_language) = fence_language else {
16494 return Vec::new();
16495 };
16496 let Some(lang) = markdown_embedded_lang(fence_language) else {
16497 return Vec::new();
16498 };
16499 let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
16500 return Vec::new();
16501 };
16502 let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
16503 else {
16504 return Vec::new();
16505 };
16506 if body.is_empty() {
16507 return Vec::new();
16508 }
16509
16510 let Ok(symbols) = lang.extract_symbols(body) else {
16511 return Vec::new();
16512 };
16513 let language = lang.name().to_string();
16514 symbols
16515 .into_iter()
16516 .map(|symbol| {
16517 let start_byte = body_start_byte.saturating_add(symbol.start_byte);
16518 let end_byte = body_start_byte.saturating_add(symbol.end_byte);
16519 let body_start = symbol
16520 .body_start_byte
16521 .map(|byte| body_start_byte.saturating_add(byte));
16522 let body_end = symbol
16523 .body_end_byte
16524 .map(|byte| body_start_byte.saturating_add(byte));
16525 let start_line = source_line_for_byte(source, start_byte);
16526 let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
16527 MarkdownEmbeddedSymbol {
16528 handle: markdown_embedded_ast_span_handle(
16529 file,
16530 &language,
16531 &symbol.name,
16532 &symbol.kind,
16533 start_byte,
16534 end_byte,
16535 ),
16536 name: symbol.name,
16537 kind: symbol.kind,
16538 language: language.clone(),
16539 node_kind: symbol.node_kind,
16540 start_byte,
16541 end_byte,
16542 start_line,
16543 end_line,
16544 body_start_byte: body_start,
16545 body_end_byte: body_end,
16546 body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
16547 body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
16548 }
16549 })
16550 .collect()
16551}
16552
16553fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
16554 let start = start_byte.min(source.len());
16555 let line_start = source[..start]
16556 .iter()
16557 .rposition(|value| *value == b'\n')
16558 .map(|pos| pos + 1)
16559 .unwrap_or(0);
16560 let line_end = source[start..]
16561 .iter()
16562 .position(|value| *value == b'\n')
16563 .map(|pos| start + pos)
16564 .unwrap_or(source.len());
16565 std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
16566}
16567
16568fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
16569 let line = markdown_source_line(source, start_byte);
16570 let trimmed = line.trim_start();
16571 for marker in ["-", "*", "+"] {
16572 if trimmed
16573 .strip_prefix(marker)
16574 .and_then(|rest| rest.strip_prefix(' '))
16575 .is_some()
16576 {
16577 return (Some(marker.to_string()), None);
16578 }
16579 }
16580
16581 let digit_end = trimmed
16582 .find(|ch: char| !ch.is_ascii_digit())
16583 .unwrap_or(trimmed.len());
16584 let (digits, rest) = trimmed.split_at(digit_end);
16585 if !digits.is_empty() {
16586 for marker in [".", ")"] {
16587 if rest
16588 .strip_prefix(marker)
16589 .and_then(|value| value.strip_prefix(' '))
16590 .is_some()
16591 {
16592 return (
16593 Some(format!("{digits}{marker}")),
16594 digits.parse::<usize>().ok(),
16595 );
16596 }
16597 }
16598 }
16599 (None, None)
16600}
16601
16602fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
16603 let line = markdown_source_line(source, start_byte);
16604 let trimmed = line.trim_start();
16605 ["```", "~~~"]
16606 .into_iter()
16607 .find(|marker| trimmed.starts_with(marker))
16608 .map(str::to_string)
16609}
16610
16611fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16612 let mut nodes = graph::Lang::Markdown
16613 .extract_symbols(source)
16614 .context("extracting Markdown AST nodes")?
16615 .into_iter()
16616 .map(|symbol| {
16617 let body_start_byte = symbol.body_start_byte;
16618 let body_end_byte = symbol.body_end_byte;
16619 let span_handle = ast_span_handle(
16620 file,
16621 &symbol.name,
16622 &symbol.kind,
16623 symbol.start_byte,
16624 symbol.end_byte,
16625 );
16626 MarkdownAstRawNode {
16627 handle: stable_handle(
16628 "mdast",
16629 &format!(
16630 "{}:{}:{}:{}:{}",
16631 file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16632 ),
16633 ),
16634 span_handle,
16635 name: symbol.name,
16636 kind: symbol.kind.clone(),
16637 block_kind: markdown_ast_block_kind(&symbol.kind),
16638 node_kind: symbol.node_kind,
16639 start_byte: symbol.start_byte,
16640 end_byte: symbol.end_byte,
16641 body_start_byte,
16642 body_end_byte,
16643 }
16644 })
16645 .collect::<Vec<_>>();
16646 nodes.sort_by(|left, right| {
16647 left.start_byte
16648 .cmp(&right.start_byte)
16649 .then(left.end_byte.cmp(&right.end_byte))
16650 .then(left.kind.cmp(&right.kind))
16651 .then(left.name.cmp(&right.name))
16652 });
16653 Ok(nodes)
16654}
16655
16656pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16657 let source_hash = blake3::hash(source).to_hex().to_string();
16658 let cache_key = format!("{file}:{source_hash}");
16659 let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16660 if let Some(entry) = cache
16661 .lock()
16662 .expect("markdown ast cache poisoned")
16663 .get(&cache_key)
16664 {
16665 return Ok(MarkdownAstProjection {
16666 source_hash: entry.source_hash.clone(),
16667 nodes: entry.nodes.clone(),
16668 parse_duration_micros: entry.parse_duration_micros,
16669 cache_hit: true,
16670 });
16671 }
16672
16673 let started = Instant::now();
16674 let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16675 let parse_duration_micros = started.elapsed().as_micros();
16676 cache.lock().expect("markdown ast cache poisoned").insert(
16677 cache_key,
16678 MarkdownAstCacheEntry {
16679 source_hash: source_hash.clone(),
16680 nodes: nodes.clone(),
16681 parse_duration_micros,
16682 },
16683 );
16684 Ok(MarkdownAstProjection {
16685 source_hash,
16686 nodes,
16687 parse_duration_micros,
16688 cache_hit: false,
16689 })
16690}
16691
16692fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16693 MarkdownAstCacheReport {
16694 source_hash: projection.source_hash.clone(),
16695 cache_hit: projection.cache_hit,
16696 parse_duration_micros: projection.parse_duration_micros,
16697 node_count: projection.nodes.len(),
16698 section_count: projection
16699 .nodes
16700 .iter()
16701 .filter(|node| node.kind == "heading")
16702 .count(),
16703 list_item_count: projection
16704 .nodes
16705 .iter()
16706 .filter(|node| node.kind == "list_item")
16707 .count(),
16708 code_block_count: projection
16709 .nodes
16710 .iter()
16711 .filter(|node| node.kind == "code_block")
16712 .count(),
16713 }
16714}
16715
16716fn markdown_ast_node_direct_child_count(
16717 node: &MarkdownAstRawNode,
16718 nodes: &[MarkdownAstRawNode],
16719) -> usize {
16720 nodes
16721 .iter()
16722 .filter(|candidate| {
16723 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16724 })
16725 .count()
16726}
16727
16728fn markdown_ast_outline_entry(
16729 root: &Path,
16730 file: &str,
16731 source: &[u8],
16732 nodes: &[MarkdownAstRawNode],
16733 node: &MarkdownAstRawNode,
16734 max_bytes: usize,
16735) -> MarkdownAstOutlineEntry {
16736 let line = source_line_for_byte(source, node.start_byte);
16737 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16738 MarkdownAstOutlineEntry {
16739 handle: node.handle.clone(),
16740 span_handle: node.span_handle.clone(),
16741 name: truncate_for_budget(&node.name, max_bytes),
16742 kind: node.kind.clone(),
16743 block_kind: node.block_kind.clone(),
16744 line,
16745 end_line,
16746 section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16747 child_count: markdown_ast_node_direct_child_count(node, nodes),
16748 expand: markdown_ast_command(root, file, Some(&node.handle)),
16749 }
16750}
16751
16752fn markdown_ast_outline_entries(
16753 root: &Path,
16754 file: &str,
16755 source: &[u8],
16756 nodes: &[MarkdownAstRawNode],
16757 limit: usize,
16758 max_bytes: usize,
16759) -> Vec<MarkdownAstOutlineEntry> {
16760 let mut headings = nodes
16761 .iter()
16762 .filter(|node| node.kind == "heading")
16763 .collect::<Vec<_>>();
16764 let mut blocks = nodes
16765 .iter()
16766 .filter(|node| node.kind != "heading")
16767 .collect::<Vec<_>>();
16768 headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16769 blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16770 headings
16771 .into_iter()
16772 .chain(blocks)
16773 .take(limit)
16774 .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16775 .collect()
16776}
16777
16778fn markdown_ast_node_intersects_lines(
16779 source: &[u8],
16780 node: &MarkdownAstRawNode,
16781 start: usize,
16782 end: usize,
16783) -> bool {
16784 let line = source_line_for_byte(source, node.start_byte);
16785 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16786 line <= end && end_line >= start
16787}
16788
16789fn source_read_markdown_projection(
16790 root: &Path,
16791 file: &str,
16792 source: &[u8],
16793 start: usize,
16794 end: usize,
16795 budget: ResponseBudget,
16796) -> Result<SourceReadMarkdownProjection> {
16797 let projection = markdown_ast_projection(file, source)?;
16798 let visible_nodes = projection
16799 .nodes
16800 .iter()
16801 .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16802 .collect::<Vec<_>>();
16803 let mut outline_nodes = visible_nodes.clone();
16804 outline_nodes.sort_by_key(|node| {
16805 (
16806 node.kind != "heading",
16807 node.start_byte,
16808 node.end_byte,
16809 node.name.as_str(),
16810 )
16811 });
16812 let outline = outline_nodes
16813 .into_iter()
16814 .take(budget.preview_items())
16815 .map(|node| {
16816 markdown_ast_outline_entry(
16817 root,
16818 file,
16819 source,
16820 &projection.nodes,
16821 node,
16822 budget.preview_bytes(),
16823 )
16824 })
16825 .collect::<Vec<_>>();
16826 Ok(SourceReadMarkdownProjection {
16827 handle: stable_handle(
16828 "mdproj",
16829 &format!("{file}:{start}:{end}:{}", projection.source_hash),
16830 ),
16831 mode: "window_outline".to_string(),
16832 total_nodes: projection.nodes.len(),
16833 visible_nodes: visible_nodes.len(),
16834 outline,
16835 expand: markdown_ast_command(root, file, None),
16836 })
16837}
16838
16839fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16840 if parent.handle == child.handle {
16841 return false;
16842 }
16843 parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16844}
16845
16846fn markdown_ast_parent_handle(
16847 node: &MarkdownAstRawNode,
16848 nodes: &[MarkdownAstRawNode],
16849) -> Option<String> {
16850 nodes
16851 .iter()
16852 .filter(|candidate| markdown_ast_contains(candidate, node))
16853 .min_by_key(|candidate| {
16854 (
16855 candidate.end_byte.saturating_sub(candidate.start_byte),
16856 candidate.start_byte,
16857 )
16858 })
16859 .map(|candidate| candidate.handle.clone())
16860}
16861
16862fn markdown_ast_child_handles(
16863 node: &MarkdownAstRawNode,
16864 nodes: &[MarkdownAstRawNode],
16865 limit: usize,
16866) -> Vec<String> {
16867 nodes
16868 .iter()
16869 .filter(|candidate| {
16870 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16871 })
16872 .take(limit)
16873 .map(|candidate| candidate.handle.clone())
16874 .collect()
16875}
16876
16877fn markdown_ast_section_nodes<'a>(
16878 node: &MarkdownAstRawNode,
16879 nodes: &'a [MarkdownAstRawNode],
16880) -> Vec<&'a MarkdownAstRawNode> {
16881 let mut headings = nodes
16882 .iter()
16883 .filter(|candidate| candidate.kind == "heading")
16884 .filter(|candidate| {
16885 candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16886 })
16887 .collect::<Vec<_>>();
16888 headings.sort_by(|left, right| {
16889 left.start_byte
16890 .cmp(&right.start_byte)
16891 .then(left.end_byte.cmp(&right.end_byte))
16892 .then(left.name.cmp(&right.name))
16893 });
16894 headings
16895}
16896
16897fn markdown_ast_node_metadata(
16898 file: &str,
16899 node: &MarkdownAstRawNode,
16900 source: &[u8],
16901 nodes: &[MarkdownAstRawNode],
16902) -> MarkdownAstNodeMetadata {
16903 let section_nodes = markdown_ast_section_nodes(node, nodes);
16904 let section_path = section_nodes
16905 .iter()
16906 .map(|heading| heading.name.clone())
16907 .collect::<Vec<_>>();
16908 let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16909 let heading_level = (node.kind == "heading")
16910 .then(|| markdown_heading_level(source, node.start_byte))
16911 .flatten();
16912 let (list_marker, list_order) = if node.kind == "list_item" {
16913 markdown_list_attributes(source, node.start_byte)
16914 } else {
16915 (None, None)
16916 };
16917 let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16918 let embedded_symbols = if node.kind == "code_block" {
16919 markdown_embedded_symbols(
16920 file,
16921 source,
16922 node.body_start_byte,
16923 node.body_end_byte,
16924 fence_language.as_deref(),
16925 )
16926 } else {
16927 Vec::new()
16928 };
16929 MarkdownAstNodeMetadata {
16930 heading_level,
16931 section_path,
16932 section_handle,
16933 list_depth: (node.kind == "list_item")
16934 .then(|| markdown_list_depth(source, node.start_byte)),
16935 list_marker,
16936 list_order,
16937 fence_language,
16938 fence_marker: (node.kind == "code_block")
16939 .then(|| markdown_fence_marker(source, node.start_byte))
16940 .flatten(),
16941 embedded_symbols,
16942 }
16943}
16944
16945fn markdown_ast_node_expand(
16946 root: &Path,
16947 file: &str,
16948 node: &MarkdownAstRawNode,
16949 source: &[u8],
16950) -> MarkdownAstNodeExpand {
16951 let start_line = source_line_for_byte(source, node.start_byte);
16952 let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16953 let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16954 let body_start_line = node
16955 .body_start_byte
16956 .map(|byte| source_line_for_byte(source, byte))
16957 .unwrap_or(start_line);
16958 let body_end_line = node
16959 .body_end_byte
16960 .map(|byte| source_line_for_end_byte(source, byte))
16961 .unwrap_or(end_line)
16962 .max(body_start_line);
16963 let body_line_count = body_end_line
16964 .saturating_sub(body_start_line)
16965 .saturating_add(1)
16966 .max(1);
16967 MarkdownAstNodeExpand {
16968 source_window: source_read_command(root, file, start_line, line_count),
16969 source_body: source_read_command(root, file, body_start_line, body_line_count),
16970 symbol_read: source_symbol_read_command(root, &node.name, file),
16971 edit_intents: markdown_edit_intents_command(root),
16972 }
16973}
16974
16975fn markdown_ast_node(
16976 root: &Path,
16977 file: &str,
16978 node: &MarkdownAstRawNode,
16979 source: &[u8],
16980 nodes: &[MarkdownAstRawNode],
16981 child_limit: usize,
16982) -> MarkdownAstNode {
16983 let line = source_line_for_byte(source, node.start_byte);
16984 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16985 let body_byte_span = node
16986 .body_start_byte
16987 .zip(node.body_end_byte)
16988 .map(|(start, end)| SourceByteRangePreview { start, end });
16989 MarkdownAstNode {
16990 handle: node.handle.clone(),
16991 span_handle: node.span_handle.clone(),
16992 name: node.name.clone(),
16993 kind: node.kind.clone(),
16994 block_kind: node.block_kind.clone(),
16995 node_kind: node.node_kind.clone(),
16996 line,
16997 end_line,
16998 byte_span: SourceByteRangePreview {
16999 start: node.start_byte,
17000 end: node.end_byte,
17001 },
17002 body_byte_span,
17003 parent_handle: markdown_ast_parent_handle(node, nodes),
17004 child_handles: markdown_ast_child_handles(node, nodes, child_limit),
17005 metadata: markdown_ast_node_metadata(file, node, source, nodes),
17006 expand: markdown_ast_node_expand(root, file, node, source),
17007 }
17008}
17009
17010pub(crate) fn stored_symbol_ast_span(
17011 symbol: &index::StoredSymbol,
17012 source: &[u8],
17013 symbols: &[index::StoredSymbol],
17014 child_limit: usize,
17015) -> Option<AstSpanPreview> {
17016 let file_symbols = symbols.iter().collect::<Vec<_>>();
17017 stored_symbol_ast_span_in_file(symbol, source, &file_symbols, child_limit)
17018}
17019
17020fn stored_symbol_ast_span_in_file(
17021 symbol: &index::StoredSymbol,
17022 source: &[u8],
17023 symbols: &[&index::StoredSymbol],
17024 child_limit: usize,
17025) -> Option<AstSpanPreview> {
17026 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
17027 let node_kind = symbol.node_kind.clone()?;
17028 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17029 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17030 Some(AstSpanPreview {
17031 handle: ast_span_handle(
17032 &symbol.file,
17033 &symbol.name,
17034 &symbol.kind,
17035 start_byte,
17036 end_byte,
17037 ),
17038 node_kind,
17039 start_byte,
17040 end_byte,
17041 start_line: source_line_for_byte(source, start_byte),
17042 end_line: source_line_for_end_byte(source, end_byte),
17043 body_start_byte,
17044 body_end_byte,
17045 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17046 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17047 parent_handle: stored_symbol_parent_span_handle_in_file(symbol, symbols),
17048 child_handles: stored_symbol_child_span_handles_in_file(symbol, symbols, child_limit),
17049 markdown: markdown_stored_symbol_metadata_in_file(symbol, source, symbols),
17050 })
17051}
17052
17053pub(crate) fn symbol_hit_ast_span(
17054 symbol: &index::SymbolHit,
17055 source: &[u8],
17056) -> Option<AstSpanPreview> {
17057 let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
17058 let node_kind = symbol.node_kind.clone()?;
17059 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17060 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17061 Some(AstSpanPreview {
17062 handle: ast_span_handle(
17063 &symbol.file,
17064 &symbol.name,
17065 &symbol.kind,
17066 start_byte,
17067 end_byte,
17068 ),
17069 node_kind,
17070 start_byte,
17071 end_byte,
17072 start_line: source_line_for_byte(source, start_byte),
17073 end_line: source_line_for_end_byte(source, end_byte),
17074 body_start_byte,
17075 body_end_byte,
17076 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17077 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17078 parent_handle: None,
17079 child_handles: Vec::new(),
17080 markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
17081 })
17082}
17083
17084pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
17085 usize::try_from(symbol.line)
17086 .ok()
17087 .and_then(|line| line.checked_add(1))
17088 .unwrap_or(1)
17089}
17090
17091pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
17092 symbol
17093 .end_line
17094 .and_then(|line| usize::try_from(line).ok())
17095 .and_then(|line| line.checked_add(1))
17096}
17097
17098fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
17099 if end == 0 {
17100 return false;
17101 }
17102 let symbol_start = source_symbol_line(symbol);
17103 let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
17104 symbol_start <= end && symbol_end >= start
17105}
17106
17107#[allow(clippy::too_many_arguments)]
17108fn load_source_symbols(
17109 root: &Path,
17110 file_abs: &Path,
17111 file_display: &str,
17112 source: &[u8],
17113 scope: Option<&str>,
17114 start: usize,
17115 end: usize,
17116 limit: usize,
17117 max_bytes: usize,
17118 warnings: &mut Vec<String>,
17119) -> Vec<SourceSymbolRef> {
17120 let target = match resolve_query_index_target(root, file_abs, scope) {
17121 Ok(target) => target,
17122 Err(err) => {
17123 warnings.push(format!("index refs unavailable: {err:#}"));
17124 return Vec::new();
17125 }
17126 };
17127 if let Err(err) = ensure_query_index_current(root, &target) {
17136 warnings.push(format!("index refs unavailable: {err:#}"));
17137 return Vec::new();
17138 }
17139 let db_path = target.db_path;
17140 if !db_path.exists() {
17141 warnings.push(format!(
17142 "index refs unavailable: no index found at {}",
17143 db_path.display()
17144 ));
17145 return Vec::new();
17146 }
17147
17148 let db = match index::IndexDb::open_read_only_resilient(&db_path) {
17149 Ok(db) => db,
17150 Err(err) => {
17151 warnings.push(format!("index refs unavailable: {err:#}"));
17152 return Vec::new();
17153 }
17154 };
17155
17156 let file_key = file_abs.to_string_lossy().to_string();
17157 let symbols = match db.symbols_for_file(&file_key) {
17158 Ok(symbols) => symbols,
17159 Err(err) => {
17160 warnings.push(format!("symbol refs unavailable: {err:#}"));
17161 return Vec::new();
17162 }
17163 };
17164
17165 symbols
17166 .iter()
17167 .filter(|symbol| source_symbol_intersects(symbol, start, end))
17168 .take(limit)
17169 .map(|symbol| {
17170 let line = source_symbol_line(symbol);
17171 let end_line = source_symbol_end_line(symbol);
17172 let handle = stable_handle(
17173 "ssym",
17174 &format!("{}:{}:{}", file_display, symbol.name, line),
17175 );
17176 SourceSymbolRef {
17177 handle,
17178 name: truncate_for_budget(&symbol.name, max_bytes),
17179 kind: symbol.kind.clone(),
17180 language: symbol.language.clone(),
17181 file: file_display.to_string(),
17182 line,
17183 end_line,
17184 signature: symbol
17185 .signature
17186 .clone()
17187 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17188 span: stored_symbol_ast_span(symbol, source, &symbols, limit),
17189 expand: source_symbol_read_command(root, &symbol.name, file_display),
17190 }
17191 })
17192 .collect()
17193}
17194
17195fn load_source_summaries(
17196 root: &Path,
17197 file_display: &str,
17198 limit: usize,
17199 max_bytes: usize,
17200 warnings: &mut Vec<String>,
17201) -> Vec<SourceSummaryRef> {
17202 let db_path = root.join(".tsift/summaries.db");
17203 if !db_path.exists() {
17204 return Vec::new();
17205 }
17206 let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
17207 Ok(db) => db,
17208 Err(err) => {
17209 warnings.push(format!("summary refs unavailable: {err:#}"));
17210 return Vec::new();
17211 }
17212 };
17213 let summaries = match db.get_by_file(file_display) {
17214 Ok(summaries) => summaries,
17215 Err(err) => {
17216 warnings.push(format!("summary refs unavailable: {err:#}"));
17217 return Vec::new();
17218 }
17219 };
17220
17221 summaries
17222 .into_iter()
17223 .take(limit)
17224 .map(|summary| SourceSummaryRef {
17225 handle: stable_handle(
17226 "sum",
17227 &format!(
17228 "{}:{}:{}",
17229 summary.file_path, summary.symbol_name, summary.id
17230 ),
17231 ),
17232 symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
17233 file_path: summary.file_path,
17234 summary: truncate_for_budget(&summary.summary, max_bytes),
17235 expand: source_summary_expand_command(root, &summary.symbol_name),
17236 })
17237 .collect()
17238}
17239
17240fn cmd_markdown_ast(
17241 file: &Path,
17242 path: &Path,
17243 node: Option<&str>,
17244 format: OutputFormat,
17245 absolute: bool,
17246 budget: ResponseBudget,
17247) -> Result<()> {
17248 let root = lint::resolve_project_root_or_canonical_path(path)?;
17249 let file_abs = resolve_source_file(&root, file)?;
17250 if !is_markdown_path(&file_abs) {
17251 bail!(
17252 "markdown-ast only supports Markdown files (.md/.mdx): {}",
17253 file_abs.display()
17254 );
17255 }
17256 let file_display = if absolute {
17257 file_abs.to_string_lossy().to_string()
17258 } else {
17259 relativize_pathbuf(&file_abs, &root)
17260 .to_string_lossy()
17261 .to_string()
17262 };
17263 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17264 let text = String::from_utf8_lossy(&source);
17265 let total_lines = text.lines().count();
17266 let projection = markdown_ast_projection(&file_display, &source)?;
17267 let raw_nodes = &projection.nodes;
17268 let max_items = budget.preview_items();
17269 let max_bytes = budget.preview_bytes();
17270
17271 let selected_nodes = if let Some(handle) = node {
17272 let matches = raw_nodes
17273 .iter()
17274 .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
17275 .collect::<Vec<_>>();
17276 if matches.is_empty() {
17277 bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
17278 }
17279 matches
17280 } else {
17281 raw_nodes.iter().take(max_items).collect::<Vec<_>>()
17282 };
17283 let nodes = selected_nodes
17284 .into_iter()
17285 .map(|raw| {
17286 let mut node =
17287 markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
17288 node.name = truncate_for_budget(&node.name, max_bytes);
17289 node
17290 })
17291 .collect::<Vec<_>>();
17292 let outline_started = Instant::now();
17293 let outline = markdown_ast_outline_entries(
17294 &root,
17295 &file_display,
17296 &source,
17297 raw_nodes,
17298 max_items,
17299 max_bytes,
17300 );
17301 let outline_duration_micros = outline_started.elapsed().as_micros();
17302 let projection_preview = MarkdownAstProjectionPreview {
17303 mode: if node.is_some() {
17304 "selected_node".to_string()
17305 } else {
17306 "outline_first".to_string()
17307 },
17308 total_nodes: raw_nodes.len(),
17309 returned_nodes: nodes.len(),
17310 omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
17311 selected_node: node.map(str::to_string),
17312 cache: markdown_ast_cache_report(&projection),
17313 outline,
17314 phase_timings: vec![
17315 MarkdownAstPhaseTiming {
17316 name: "parse_extract".to_string(),
17317 duration_micros: projection.parse_duration_micros,
17318 detail: if projection.cache_hit {
17319 "reused cached tree-sitter Markdown symbol extraction".to_string()
17320 } else {
17321 "tree-sitter Markdown symbol extraction".to_string()
17322 },
17323 },
17324 MarkdownAstPhaseTiming {
17325 name: "outline_projection".to_string(),
17326 duration_micros: outline_duration_micros,
17327 detail: "outline-first section/block preview construction".to_string(),
17328 },
17329 ],
17330 };
17331 let report = MarkdownAstReport {
17332 handle: stable_handle("mdastrep", &file_display),
17333 root: root.to_string_lossy().to_string(),
17334 file: file_display.clone(),
17335 range: SourceRangePreview {
17336 start: 1,
17337 end: total_lines,
17338 total_lines,
17339 truncated_before: false,
17340 truncated_after: false,
17341 },
17342 projection: projection_preview,
17343 nodes,
17344 expand: MarkdownAstExpandCommands {
17345 file: markdown_ast_command(&root, &file_display, None),
17346 source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
17347 edit_intents: markdown_edit_intents_command(&root),
17348 },
17349 warnings: Vec::new(),
17350 };
17351
17352 if format.json_output {
17353 let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
17354 let mut follow_up = vec![
17355 report.expand.file.clone(),
17356 report.expand.source_read.clone(),
17357 report.expand.edit_intents.clone(),
17358 ];
17359 follow_up.extend(
17360 report
17361 .nodes
17362 .iter()
17363 .map(|node| node.expand.source_window.clone()),
17364 );
17365 print_json_or_envelope(
17366 &report,
17367 &format,
17368 "markdown-ast",
17369 "ast",
17370 ToolEnvelopeSummary {
17371 text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
17372 metrics: vec![
17373 envelope_metric("nodes", report.nodes.len()),
17374 envelope_metric("total_nodes", report.projection.total_nodes),
17375 envelope_metric(
17376 "parse_duration_micros",
17377 report.projection.cache.parse_duration_micros,
17378 ),
17379 envelope_metric("total_lines", report.range.total_lines),
17380 ],
17381 },
17382 truncated,
17383 follow_up,
17384 )?;
17385 } else if format.compact {
17386 println!(
17387 "markdown-ast {} nodes:{} handle:{}",
17388 report.file,
17389 report.nodes.len(),
17390 report.handle
17391 );
17392 for node in &report.nodes {
17393 println!(
17394 " {} {} {}:{}-{}",
17395 node.handle, node.kind, node.name, node.line, node.end_line
17396 );
17397 }
17398 if node.is_none() && raw_nodes.len() > report.nodes.len() {
17399 println!("expand: {}", report.expand.file);
17400 }
17401 } else {
17402 println!(
17403 "Markdown AST `{}` nodes {} of {} ({})",
17404 report.file,
17405 report.nodes.len(),
17406 raw_nodes.len(),
17407 report.handle
17408 );
17409 for node in &report.nodes {
17410 println!(
17411 " {} `{}` {}:{}-{} — {}",
17412 node.handle,
17413 node.name,
17414 node.kind,
17415 node.line,
17416 node.end_line,
17417 node.expand.source_window
17418 );
17419 }
17420 if node.is_none() && raw_nodes.len() > report.nodes.len() {
17421 println!();
17422 println!("Expand:");
17423 println!(" file: {}", report.expand.file);
17424 }
17425 }
17426
17427 Ok(())
17428}
17429
17430#[allow(clippy::too_many_arguments)]
17431fn cmd_source_read(
17432 file: &Path,
17433 path: &Path,
17434 style: SourceReadStyle,
17435 start: usize,
17436 lines: usize,
17437 end: Option<usize>,
17438 scope: Option<&str>,
17439 format: OutputFormat,
17440 absolute: bool,
17441 budget: ResponseBudget,
17442) -> Result<()> {
17443 if start == 0 {
17444 bail!("--start is 1-based and must be greater than zero");
17445 }
17446 if lines == 0 {
17447 bail!("--lines must be greater than zero");
17448 }
17449 if let Some(end) = end
17450 && end < start
17451 {
17452 bail!("--end must be greater than or equal to --start");
17453 }
17454
17455 let root = lint::resolve_project_root_or_canonical_path(path)?;
17456 let file_abs = resolve_source_file(&root, file)?;
17457 let file_display = if absolute {
17458 file_abs.to_string_lossy().to_string()
17459 } else {
17460 relativize_pathbuf(&file_abs, &root)
17461 .to_string_lossy()
17462 .to_string()
17463 };
17464
17465 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17466 let text = String::from_utf8_lossy(&source);
17467 let all_lines: Vec<&str> = text.lines().collect();
17468 let total_lines = all_lines.len();
17469 if total_lines > 0 && start > total_lines {
17470 bail!(
17471 "--start {} is beyond end of {} ({} lines)",
17472 start,
17473 file_display,
17474 total_lines
17475 );
17476 }
17477 let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
17478 let end_line = requested_end.min(total_lines);
17479 let mut warnings = Vec::new();
17480 let max_items = budget.preview_items();
17481 let max_bytes = budget.preview_bytes();
17482 if style == SourceReadStyle::Ast {
17483 let symbols = load_source_symbols(
17484 &root,
17485 &file_abs,
17486 &file_display,
17487 &source,
17488 scope,
17489 start,
17490 end_line,
17491 max_items,
17492 max_bytes,
17493 &mut warnings,
17494 );
17495 let summaries =
17496 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17497 let markdown = if is_markdown_path(&file_abs) {
17498 match source_read_markdown_projection(
17499 &root,
17500 &file_display,
17501 &source,
17502 start,
17503 end_line,
17504 budget,
17505 ) {
17506 Ok(markdown) => Some(markdown),
17507 Err(err) => {
17508 warnings.push(format!("markdown projection unavailable: {err:#}"));
17509 None
17510 }
17511 }
17512 } else {
17513 None
17514 };
17515 let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
17516 let report = SourceReadAstReport {
17517 handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
17518 root: root.to_string_lossy().to_string(),
17519 file: file_display.clone(),
17520 range: SourceRangePreview {
17521 start,
17522 end: end_line,
17523 total_lines,
17524 truncated_before: start > 1,
17525 truncated_after: end_line < total_lines,
17526 },
17527 symbols,
17528 summaries,
17529 markdown,
17530 expand: SourceReadAstExpandCommands {
17531 window: source_read_window_command(&root, &file_display, start, window_lines),
17532 file_window: source_read_window_command(
17533 &root,
17534 &file_display,
17535 1,
17536 total_lines.max(window_lines),
17537 ),
17538 markdown_ast: is_markdown_path(&file_abs)
17539 .then(|| markdown_ast_command(&root, &file_display, None)),
17540 },
17541 warnings,
17542 };
17543
17544 if format.json_output {
17545 let truncated = report.range.truncated_before
17546 || report.range.truncated_after
17547 || report.symbols.len() >= max_items
17548 || report.summaries.len() >= max_items;
17549 let follow_up = [
17550 Some(report.expand.window.clone()),
17551 Some(report.expand.file_window.clone()),
17552 report.expand.markdown_ast.clone(),
17553 ]
17554 .into_iter()
17555 .flatten()
17556 .collect::<Vec<_>>();
17557 print_json_or_envelope(
17558 &report,
17559 &format,
17560 "source-read",
17561 "ast",
17562 ToolEnvelopeSummary {
17563 text: format!(
17564 "source ast {}:{}-{}",
17565 report.file, report.range.start, report.range.end
17566 ),
17567 metrics: vec![
17568 envelope_metric("symbols", report.symbols.len()),
17569 envelope_metric("summaries", report.summaries.len()),
17570 envelope_metric(
17571 "markdown_nodes",
17572 report
17573 .markdown
17574 .as_ref()
17575 .map_or(0, |markdown| markdown.visible_nodes),
17576 ),
17577 ],
17578 },
17579 truncated,
17580 follow_up,
17581 )?;
17582 } else if format.compact {
17583 println!(
17584 "source-ast {}:{}-{} / {} handle:{}",
17585 report.file,
17586 report.range.start,
17587 report.range.end,
17588 report.range.total_lines,
17589 report.handle
17590 );
17591 for symbol in &report.symbols {
17592 println!(
17593 " {} {}:{} {}",
17594 symbol.name, symbol.file, symbol.line, symbol.expand
17595 );
17596 }
17597 if !report.summaries.is_empty() {
17598 println!("summaries[{}]", report.summaries.len());
17599 }
17600 for warning in &report.warnings {
17601 eprintln!("warning: {warning}");
17602 }
17603 } else {
17604 println!(
17605 "Source AST `{}` lines {}-{} of {} ({})",
17606 report.file,
17607 report.range.start,
17608 report.range.end,
17609 report.range.total_lines,
17610 report.handle
17611 );
17612 if !report.symbols.is_empty() {
17613 println!();
17614 println!("Symbol refs:");
17615 for symbol in &report.symbols {
17616 println!(
17617 " {} `{}` {}:{} — {}",
17618 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17619 );
17620 }
17621 }
17622 if !report.summaries.is_empty() {
17623 println!();
17624 println!("Summary refs:");
17625 for summary in &report.summaries {
17626 println!(
17627 " {} `{}` — {}",
17628 summary.handle, summary.symbol_name, summary.expand
17629 );
17630 }
17631 }
17632 println!();
17633 println!("Expand:");
17634 println!(" window: {}", report.expand.window);
17635 println!(" file window: {}", report.expand.file_window);
17636 if let Some(markdown_ast) = &report.expand.markdown_ast {
17637 println!(" markdown: {}", markdown_ast);
17638 }
17639 for warning in &report.warnings {
17640 eprintln!("warning: {warning}");
17641 }
17642 }
17643
17644 return Ok(());
17645 }
17646 let max_bytes = budget.preview_bytes();
17647 let token_cap = budget.body_token_cap();
17648 let (preview, preview_end, body_truncated) = if total_lines == 0 {
17649 (Vec::new(), end_line, false)
17650 } else {
17651 let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17652 (capped.preview, capped.capped_end, capped.was_capped)
17653 };
17654 let effective_end = if body_truncated {
17655 preview_end
17656 } else {
17657 end_line
17658 };
17659
17660 if body_truncated {
17661 warnings.push(format!(
17662 "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17663 ));
17664 }
17665 let symbols = load_source_symbols(
17666 &root,
17667 &file_abs,
17668 &file_display,
17669 &source,
17670 scope,
17671 start,
17672 effective_end,
17673 max_items,
17674 max_bytes,
17675 &mut warnings,
17676 );
17677 let summaries =
17678 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17679 let markdown = if is_markdown_path(&file_abs) {
17680 match source_read_markdown_projection(
17681 &root,
17682 &file_display,
17683 &source,
17684 start,
17685 effective_end,
17686 budget,
17687 ) {
17688 Ok(markdown) => Some(markdown),
17689 Err(err) => {
17690 warnings.push(format!("markdown projection unavailable: {err:#}"));
17691 None
17692 }
17693 }
17694 } else {
17695 None
17696 };
17697
17698 let expand = SourceExpandCommands {
17699 before: (start > 1).then(|| {
17700 let before_start = start.saturating_sub(lines).max(1);
17701 source_read_window_command(&root, &file_display, before_start, start - before_start)
17702 }),
17703 after: (effective_end < total_lines)
17704 .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17705 body: body_truncated.then(|| {
17706 let remaining = end_line.saturating_sub(effective_end);
17707 source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17708 }),
17709 file: source_read_ast_command(&root, &file_display),
17710 markdown_ast: is_markdown_path(&file_abs)
17711 .then(|| markdown_ast_command(&root, &file_display, None)),
17712 };
17713
17714 let report = SourceReadReport {
17715 handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17716 root: root.to_string_lossy().to_string(),
17717 file: file_display,
17718 range: SourceRangePreview {
17719 start,
17720 end: effective_end,
17721 total_lines,
17722 truncated_before: start > 1,
17723 truncated_after: effective_end < total_lines,
17724 },
17725 preview,
17726 symbols,
17727 summaries,
17728 markdown,
17729 expand,
17730 warnings,
17731 };
17732
17733 if format.json_output {
17734 let truncated = report.range.truncated_before || report.range.truncated_after;
17735 let follow_up = [
17736 report.expand.before.clone(),
17737 report.expand.after.clone(),
17738 report.expand.body.clone(),
17739 Some(report.expand.file.clone()),
17740 report.expand.markdown_ast.clone(),
17741 ]
17742 .into_iter()
17743 .flatten()
17744 .collect::<Vec<_>>();
17745 print_json_or_envelope(
17746 &report,
17747 &format,
17748 "source-read",
17749 "window",
17750 ToolEnvelopeSummary {
17751 text: format!(
17752 "source window {}:{}-{}",
17753 report.file, report.range.start, report.range.end
17754 ),
17755 metrics: vec![
17756 envelope_metric("lines", report.preview.len()),
17757 envelope_metric("symbols", report.symbols.len()),
17758 envelope_metric("summaries", report.summaries.len()),
17759 envelope_metric(
17760 "markdown_nodes",
17761 report
17762 .markdown
17763 .as_ref()
17764 .map_or(0, |markdown| markdown.visible_nodes),
17765 ),
17766 ],
17767 },
17768 truncated,
17769 follow_up,
17770 )?;
17771 } else if format.compact {
17772 println!(
17773 "source {}:{}-{} / {} handle:{}",
17774 report.file,
17775 report.range.start,
17776 report.range.end,
17777 report.range.total_lines,
17778 report.handle
17779 );
17780 for line in &report.preview {
17781 println!("{:>5} {}", line.line, line.text);
17782 }
17783 if !report.symbols.is_empty() {
17784 println!("syms[{}]:", report.symbols.len());
17785 for symbol in &report.symbols {
17786 println!(" {} {}:{}", symbol.name, symbol.file, symbol.line);
17787 }
17788 }
17789 if report.range.truncated_before || report.range.truncated_after {
17790 println!("expand: {}", report.expand.file);
17791 }
17792 } else {
17793 println!(
17794 "Source window `{}` lines {}-{} of {} ({})",
17795 report.file,
17796 report.range.start,
17797 report.range.end,
17798 report.range.total_lines,
17799 report.handle
17800 );
17801 for line in &report.preview {
17802 println!("{:>5} | {}", line.line, line.text);
17803 }
17804 if !report.symbols.is_empty() {
17805 println!();
17806 println!("Symbol refs:");
17807 for symbol in &report.symbols {
17808 println!(
17809 " {} `{}` {}:{} — {}",
17810 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17811 );
17812 }
17813 }
17814 if !report.summaries.is_empty() {
17815 println!();
17816 println!("Summary refs:");
17817 for summary in &report.summaries {
17818 println!(
17819 " {} `{}` — {}",
17820 summary.handle, summary.symbol_name, summary.expand
17821 );
17822 }
17823 }
17824 if report.range.truncated_before || report.range.truncated_after {
17825 println!();
17826 println!("Expand:");
17827 if let Some(before) = &report.expand.before {
17828 println!(" before: {}", before);
17829 }
17830 if let Some(after) = &report.expand.after {
17831 println!(" after: {}", after);
17832 }
17833 println!(" file: {}", report.expand.file);
17834 }
17835 for warning in &report.warnings {
17836 eprintln!("warning: {warning}");
17837 }
17838 }
17839
17840 Ok(())
17841}
17842
17843#[allow(clippy::too_many_arguments)]
17844fn cmd_symbol_read(
17845 symbol: &str,
17846 file_hint: Option<&Path>,
17847 path: &Path,
17848 scope: Option<&str>,
17849 format: OutputFormat,
17850 absolute: bool,
17851 budget: ResponseBudget,
17852) -> Result<()> {
17853 let root = lint::resolve_project_root_or_canonical_path(path)?;
17854 let hinted_file_abs = file_hint
17855 .map(|file| resolve_source_file(&root, file))
17856 .transpose()?;
17857 let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17858 let target = resolve_query_index_target(&root, path_hint, scope)?;
17864 ensure_query_index_current(&root, &target)?;
17865 let db_path = target.db_path;
17866 if !db_path.exists() {
17867 bail!(
17868 "index refs unavailable: no index found at {}",
17869 db_path.display()
17870 );
17871 }
17872 let db = index::IndexDb::open_read_only_resilient(&db_path)
17873 .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17874 let search_limit = budget.follow_up_items().max(10);
17875 let hits = db
17876 .symbol_search(symbol, search_limit)
17877 .with_context(|| format!("searching symbols for {symbol:?}"))?;
17878 let selected = hits
17879 .into_iter()
17880 .find(|hit| {
17881 let Some(hinted_file_abs) = &hinted_file_abs else {
17882 return true;
17883 };
17884 resolve_source_file(&root, Path::new(&hit.file))
17885 .map(|hit_file| hit_file == *hinted_file_abs)
17886 .unwrap_or(false)
17887 })
17888 .with_context(|| {
17889 let hint = file_hint
17890 .map(|file| format!(" in {}", file.display()))
17891 .unwrap_or_default();
17892 format!("no indexed symbol matched {symbol:?}{hint}")
17893 })?;
17894
17895 let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17896 let file_display = if absolute {
17897 file_abs.to_string_lossy().to_string()
17898 } else {
17899 relativize_pathbuf(&file_abs, &root)
17900 .to_string_lossy()
17901 .to_string()
17902 };
17903 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17904 let content_hash = blake3::hash(&source).to_hex().to_string();
17905 let text = String::from_utf8_lossy(&source);
17906 let all_lines: Vec<&str> = text.lines().collect();
17907 let total_lines = all_lines.len();
17908 let file_symbols = db
17909 .symbols_for_file(&file_abs.to_string_lossy())
17910 .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17911 let max_items = budget.preview_items();
17912 let max_bytes = budget.preview_bytes();
17913 let selected_start = symbol_hit_line(&selected);
17914 let selected_end = symbol_hit_end_line(&selected)
17915 .unwrap_or(selected_start)
17916 .max(selected_start);
17917 let stored_target = file_symbols.iter().find(|candidate| {
17918 candidate.name == selected.name
17919 && candidate.kind == selected.kind
17920 && source_symbol_line(candidate) == selected_start
17921 });
17922 let target_span = stored_target
17923 .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17924 .or_else(|| symbol_hit_ast_span(&selected, &source));
17925 let target_start = target_span
17926 .as_ref()
17927 .map(|span| span.start_line)
17928 .unwrap_or(selected_start);
17929 let target_end = target_span
17930 .as_ref()
17931 .map(|span| span.end_line)
17932 .or_else(|| stored_target.and_then(source_symbol_end_line))
17933 .unwrap_or(selected_end)
17934 .max(target_start);
17935 let target_bounds = stored_target
17936 .and_then(stored_symbol_span_bounds)
17937 .or_else(|| symbol_hit_span_bounds(&selected));
17938 let target_end = stored_target
17939 .and_then(source_symbol_end_line)
17940 .unwrap_or(target_end)
17941 .max(target_start);
17942 let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17943 let line_capped_end = target_start
17944 .saturating_add(body_line_budget)
17945 .saturating_sub(1)
17946 .min(target_end)
17947 .min(total_lines.max(target_start));
17948 let token_cap = budget.body_token_cap();
17949 let (body, effective_preview_end, body_truncated) =
17950 if total_lines == 0 || target_start > total_lines {
17951 (Vec::new(), line_capped_end, false)
17952 } else {
17953 let capped = build_token_capped_preview(
17954 &all_lines,
17955 target_start,
17956 line_capped_end,
17957 max_bytes,
17958 token_cap,
17959 );
17960 (capped.preview, capped.capped_end, capped.was_capped)
17961 };
17962 let preview_end = if body_truncated {
17963 effective_preview_end
17964 } else {
17965 line_capped_end
17966 };
17967 let child_symbols = file_symbols
17968 .iter()
17969 .filter(|candidate| {
17970 if let Some((target_start_byte, target_end_byte)) = target_bounds {
17971 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17972 else {
17973 return false;
17974 };
17975 return candidate_start >= target_start_byte
17976 && candidate_end <= target_end_byte
17977 && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17978 }
17979 let line = source_symbol_line(candidate);
17980 line > target_start && line <= target_end
17981 })
17982 .take(max_items)
17983 .map(|symbol| {
17984 let line = source_symbol_line(symbol);
17985 let end_line = source_symbol_end_line(symbol);
17986 SourceSymbolRef {
17987 handle: stable_handle(
17988 "ssym",
17989 &format!("{}:{}:{}", file_display, symbol.name, line),
17990 ),
17991 name: truncate_for_budget(&symbol.name, max_bytes),
17992 kind: symbol.kind.clone(),
17993 language: symbol.language.clone(),
17994 file: file_display.clone(),
17995 line,
17996 end_line,
17997 signature: symbol
17998 .signature
17999 .clone()
18000 .map(|signature| truncate_for_budget(&signature, max_bytes)),
18001 span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
18002 expand: source_symbol_read_command(&root, &symbol.name, &file_display),
18003 }
18004 })
18005 .collect::<Vec<_>>();
18006 let mut warnings = Vec::new();
18007 if body_truncated {
18008 warnings.push(format!(
18009 "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
18010 ));
18011 }
18012 let summaries =
18013 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
18014 let symbol_handle = stable_handle(
18015 "sread",
18016 &format!("{}:{}:{}", file_display, selected.name, target_start),
18017 );
18018 let source_lines = preview_end
18019 .saturating_sub(target_start)
18020 .saturating_add(1)
18021 .max(1);
18022 let expand = SymbolReadExpandCommands {
18023 source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
18024 body: body_truncated.then(|| {
18025 let remaining = target_end.saturating_sub(preview_end);
18026 source_read_window_command(&root, &file_display, preview_end + 1, remaining)
18027 }),
18028 file: source_read_ast_command(&root, &file_display),
18029 explain: source_symbol_expand_command(&root, &selected.name),
18030 callers: source_symbol_graph_command(&root, &selected.name, "callers"),
18031 callees: source_symbol_graph_command(&root, &selected.name, "callees"),
18032 markdown_ast: (selected.language == "markdown").then(|| {
18033 markdown_ast_command(
18034 &root,
18035 &file_display,
18036 target_span.as_ref().map(|span| span.handle.as_str()),
18037 )
18038 }),
18039 };
18040 let report = SymbolReadReport {
18041 handle: symbol_handle.clone(),
18042 root: root.to_string_lossy().to_string(),
18043 query: symbol.to_string(),
18044 symbol: SymbolReadTarget {
18045 handle: symbol_handle,
18046 name: selected.name.clone(),
18047 kind: selected.kind.clone(),
18048 language: selected.language.clone(),
18049 file: file_display.clone(),
18050 line: target_start,
18051 end_line: Some(target_end),
18052 signature: stored_target
18053 .and_then(|stored| stored.signature.clone())
18054 .map(|signature| truncate_for_budget(&signature, max_bytes)),
18055 parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
18056 visibility: stored_target.and_then(|stored| stored.visibility.clone()),
18057 span: target_span,
18058 },
18059 range: SourceRangePreview {
18060 start: target_start,
18061 end: preview_end,
18062 total_lines,
18063 truncated_before: false,
18064 truncated_after: preview_end < target_end,
18065 },
18066 body,
18067 child_symbols,
18068 summaries,
18069 expand,
18070 warnings,
18071 };
18072
18073 if format.json_output {
18074 let truncated = report.range.truncated_after
18075 || report.body.iter().any(|line| line.text.len() >= max_bytes)
18076 || report.child_symbols.len() >= max_items;
18077 let follow_up = [
18078 Some(report.expand.source_window.clone()),
18079 report.expand.body.clone(),
18080 Some(report.expand.file.clone()),
18081 Some(report.expand.explain.clone()),
18082 Some(report.expand.callers.clone()),
18083 Some(report.expand.callees.clone()),
18084 ]
18085 .into_iter()
18086 .flatten()
18087 .chain(report.expand.markdown_ast.clone())
18088 .collect::<Vec<_>>();
18089 print_json_or_envelope(
18090 &report,
18091 &format,
18092 "symbol-read",
18093 "symbol",
18094 ToolEnvelopeSummary {
18095 text: format!(
18096 "symbol {} {}:{}-{}",
18097 report.symbol.name, report.symbol.file, report.range.start, report.range.end
18098 ),
18099 metrics: vec![
18100 envelope_metric("body_lines", report.body.len()),
18101 envelope_metric("child_symbols", report.child_symbols.len()),
18102 envelope_metric("summaries", report.summaries.len()),
18103 ],
18104 },
18105 truncated,
18106 follow_up,
18107 )?;
18108 } else if format.compact {
18109 println!(
18110 "symbol {} {}:{}-{} handle:{} hash:{}",
18111 report.symbol.name,
18112 report.symbol.file,
18113 report.range.start,
18114 report.range.end,
18115 report.handle,
18116 content_hash
18117 );
18118 for line in &report.body {
18119 println!("{:>5} {}", line.line, line.text);
18120 }
18121 if !report.child_symbols.is_empty() {
18122 println!("children[{}]:", report.child_symbols.len());
18123 for child in &report.child_symbols {
18124 println!(" {} {}:{}", child.name, child.file, child.line);
18125 }
18126 }
18127 } else {
18128 println!(
18129 "Symbol `{}` in `{}` lines {}-{} ({})",
18130 report.symbol.name,
18131 report.symbol.file,
18132 report.range.start,
18133 report.range.end,
18134 report.handle
18135 );
18136 for line in &report.body {
18137 println!("{:>5} | {}", line.line, line.text);
18138 }
18139 if !report.child_symbols.is_empty() {
18140 println!();
18141 println!("Child symbols:");
18142 for child in &report.child_symbols {
18143 println!(
18144 " {} `{}` {}:{} — {}",
18145 child.handle, child.name, child.file, child.line, child.expand
18146 );
18147 }
18148 }
18149 println!();
18150 println!("Expand:");
18151 println!(" source: {}", report.expand.source_window);
18152 println!(" file: {}", report.expand.file);
18153 println!(" explain: {}", report.expand.explain);
18154 println!(" callers: {}", report.expand.callers);
18155 println!(" callees: {}", report.expand.callees);
18156 for warning in &report.warnings {
18157 eprintln!("warning: {warning}");
18158 }
18159 }
18160
18161 Ok(())
18162}
18163
18164#[allow(clippy::too_many_arguments)]
18165#[derive(Serialize)]
18166struct ExplainBudgetDefinitionPreview {
18167 handle: String,
18168 #[serde(skip_serializing_if = "Option::is_none")]
18169 tag_alias: Option<String>,
18170 kind: String,
18171 name: String,
18172 file: String,
18173 line: i64,
18174 expand: String,
18175}
18176
18177#[derive(Serialize)]
18178struct ExplainBudgetEdgePreview {
18179 handle: String,
18180 #[serde(skip_serializing_if = "Option::is_none")]
18181 tag_alias: Option<String>,
18182 name: String,
18183 file: String,
18184 line: i64,
18185 expand: String,
18186}
18187
18188#[derive(Serialize)]
18189struct ExplainBudgetCommunityPreview {
18190 size: usize,
18191 members: Vec<String>,
18192}
18193
18194#[derive(Serialize)]
18195struct ExplainBudgetReport {
18196 symbol: String,
18197 max_items: usize,
18198 max_bytes: usize,
18199 definition_total: usize,
18200 callers_total: usize,
18201 callers_truncated_by_limit: bool,
18202 callees_total: usize,
18203 callees_truncated_by_limit: bool,
18204 truncated: bool,
18205 definitions: Vec<ExplainBudgetDefinitionPreview>,
18206 callers: Vec<ExplainBudgetEdgePreview>,
18207 callees: Vec<ExplainBudgetEdgePreview>,
18208 #[serde(skip_serializing_if = "Option::is_none")]
18209 community: Option<ExplainBudgetCommunityPreview>,
18210}
18211
18212#[allow(clippy::too_many_arguments)]
18213pub(crate) fn build_explain_budget_report(
18214 symbol: &str,
18215 _root: &Path,
18216 symbols: &[index::StoredSymbol],
18217 callers: &[index::StoredEdge],
18218 callers_total: usize,
18219 callers_truncated_by_limit: bool,
18220 callees: &[index::StoredEdge],
18221 callees_total: usize,
18222 callees_truncated_by_limit: bool,
18223 community: Option<&graph::Community>,
18224 budget: ResponseBudget,
18225) -> ExplainBudgetReport {
18226 let max_items = budget.preview_items();
18227 let max_bytes = budget.preview_bytes();
18228 let definitions = symbols
18229 .iter()
18230 .take(max_items)
18231 .map(|entry| {
18232 let symbol_ref = build_compact_symbol_ref(
18233 "edef",
18234 &format!(
18235 "{}:{}:{}:{}",
18236 entry.kind, entry.name, entry.file, entry.line
18237 ),
18238 &entry.name,
18239 entry.tags.as_deref(),
18240 max_bytes,
18241 );
18242 ExplainBudgetDefinitionPreview {
18243 handle: symbol_ref.handle,
18244 tag_alias: symbol_ref.tag_alias,
18245 kind: entry.kind.clone(),
18246 name: symbol_ref.name,
18247 file: truncate_for_budget(&entry.file, max_bytes),
18248 line: entry.line,
18249 expand: format!(
18250 "tsift search {} --exact --path {} --limit 20",
18251 shell_quote(&entry.name),
18252 shell_quote(&entry.file)
18253 ),
18254 }
18255 })
18256 .collect();
18257 let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
18258 .iter()
18259 .take(max_items)
18260 .map(|entry| {
18261 let symbol_ref = build_compact_symbol_ref(
18262 "ecall",
18263 &format!(
18264 "{}:{}:{}:{}",
18265 entry.caller_name, entry.caller_file, entry.call_site_line, symbol
18266 ),
18267 &entry.caller_name,
18268 None,
18269 max_bytes,
18270 );
18271 ExplainBudgetEdgePreview {
18272 handle: symbol_ref.handle,
18273 tag_alias: symbol_ref.tag_alias,
18274 name: symbol_ref.name,
18275 file: truncate_for_budget(&entry.caller_file, max_bytes),
18276 line: entry.call_site_line,
18277 expand: format!(
18278 "tsift explain {} --path {} --limit 0",
18279 shell_quote(&entry.caller_name),
18280 shell_quote(&entry.caller_file)
18281 ),
18282 }
18283 })
18284 .collect();
18285 let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
18286 .iter()
18287 .take(max_items)
18288 .map(|entry| {
18289 let symbol_ref = build_compact_symbol_ref(
18290 "eces",
18291 &format!(
18292 "{}:{}:{}:{}",
18293 entry.callee_name, entry.caller_file, entry.call_site_line, symbol
18294 ),
18295 &entry.callee_name,
18296 None,
18297 max_bytes,
18298 );
18299 ExplainBudgetEdgePreview {
18300 handle: symbol_ref.handle,
18301 tag_alias: symbol_ref.tag_alias,
18302 name: symbol_ref.name,
18303 file: truncate_for_budget(&entry.caller_file, max_bytes),
18304 line: entry.call_site_line,
18305 expand: format!(
18306 "tsift explain {} --path {} --limit 0",
18307 shell_quote(&entry.callee_name),
18308 shell_quote(&entry.caller_file)
18309 ),
18310 }
18311 })
18312 .collect();
18313 let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
18314 size: entry.members.len(),
18315 members: entry
18316 .members
18317 .iter()
18318 .take(max_items)
18319 .map(|member| truncate_for_budget(&member.name, max_bytes))
18320 .collect(),
18321 });
18322
18323 ExplainBudgetReport {
18324 symbol: symbol.to_string(),
18325 max_items,
18326 max_bytes,
18327 definition_total: symbols.len(),
18328 callers_total,
18329 callers_truncated_by_limit,
18330 callees_total,
18331 callees_truncated_by_limit,
18332 truncated: symbols.len() > max_items
18333 || callers_total > callers_preview.len()
18334 || callees_total > callees_preview.len()
18335 || community
18336 .map(|entry| entry.members.len() > max_items)
18337 .unwrap_or(false),
18338 definitions,
18339 callers: callers_preview,
18340 callees: callees_preview,
18341 community: community_preview,
18342 }
18343}
18344
18345pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
18346 println!(
18347 "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
18348 shell_quote(&report.symbol),
18349 report.definitions.len(),
18350 report.definition_total,
18351 report.callers.len(),
18352 report.callers_total,
18353 report.callees.len(),
18354 report.callees_total
18355 );
18356 for entry in &report.definitions {
18357 println!(
18358 "def {} {} {}:{} expand:{}",
18359 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18360 entry.kind,
18361 entry.file,
18362 entry.line,
18363 entry.expand
18364 );
18365 }
18366 for entry in &report.callers {
18367 println!(
18368 "caller {} {}:{} expand:{}",
18369 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18370 entry.file,
18371 entry.line,
18372 entry.expand
18373 );
18374 }
18375 for entry in &report.callees {
18376 println!(
18377 "callee {} {}:{} expand:{}",
18378 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18379 entry.file,
18380 entry.line,
18381 entry.expand
18382 );
18383 }
18384 if let Some(community) = &report.community {
18385 println!(
18386 "community size:{} members:{}",
18387 community.size,
18388 community.members.join(", ")
18389 );
18390 }
18391 if report.truncated {
18392 println!(
18393 "budget truncated items:{} bytes:{}",
18394 report.max_items, report.max_bytes
18395 );
18396 }
18397}
18398
18399const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
18409 ".git",
18410 "node_modules",
18411 "target",
18412 "__pycache__",
18413 ".venv",
18414 "vendor",
18415];
18416
18417const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
18418 "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
18419 "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
18420 "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
18421 "sh", "bash", "zsh", "sql", "css", "tsx",
18422];
18423
18424pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
18425 let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
18426 .iter()
18427 .map(|ext| (*ext).to_string())
18428 .collect::<BTreeSet<_>>();
18429
18430 let config_path = root.join(".naming.toml");
18431 if !config_path.exists() {
18432 return extensions;
18433 }
18434
18435 match tagpath::config::resolve(&config_path) {
18436 Ok(config) => {
18437 if let Some(grammars) = config.grammars {
18438 for grammar in grammars.languages.values() {
18439 for ext in &grammar.extensions {
18440 if let Some(normalized) = normalize_extension(ext) {
18441 extensions.insert(normalized);
18442 }
18443 }
18444 }
18445 }
18446 }
18447 Err(err) => {
18448 eprintln!("tagpath_policy_hint_config_unreadable: {err}");
18449 }
18450 }
18451 extensions
18452}
18453
18454pub(crate) fn tagpath_audit_policy_hints(
18455 rel_path: &str,
18456 supported_extensions: &BTreeSet<String>,
18457) -> Vec<String> {
18458 let path = Path::new(rel_path);
18459 let mut hints = BTreeSet::new();
18460 if let Some(parent) = path.parent() {
18461 for component in parent.components() {
18462 if let std::path::Component::Normal(name) = component {
18463 let name = name.to_string_lossy();
18464 if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
18465 hints.insert(format!("skip_dir:{name}"));
18466 }
18467 }
18468 }
18469 }
18470 if path
18471 .extension()
18472 .and_then(|ext| ext.to_str())
18473 .and_then(normalize_extension)
18474 .is_some_and(|ext| !supported_extensions.contains(&ext))
18475 {
18476 hints.insert("extension_unsupported".to_string());
18477 }
18478 hints.into_iter().collect()
18479}
18480
18481fn normalize_extension(ext: &str) -> Option<String> {
18482 let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
18483 if normalized.is_empty() {
18484 None
18485 } else {
18486 Some(normalized)
18487 }
18488}
18489
18490pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
18491 match status {
18492 diff_digest::DiffDigestFileStatus::Added => "added",
18493 diff_digest::DiffDigestFileStatus::Modified => "modified",
18494 diff_digest::DiffDigestFileStatus::Deleted => "deleted",
18495 }
18496}
18497
18498pub(crate) fn diff_digest_summary_label(
18499 state: diff_digest::DiffDigestSummaryState,
18500) -> &'static str {
18501 match state {
18502 diff_digest::DiffDigestSummaryState::Current => "current",
18503 diff_digest::DiffDigestSummaryState::Stale => "stale",
18504 diff_digest::DiffDigestSummaryState::Missing => "missing",
18505 diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
18506 }
18507}
18508
18509fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
18510 match state {
18511 test_digest::TestDigestSummaryState::Current => "current",
18512 test_digest::TestDigestSummaryState::Stale => "stale",
18513 test_digest::TestDigestSummaryState::Missing => "missing",
18514 test_digest::TestDigestSummaryState::Unavailable => "unavailable",
18515 }
18516}
18517
18518fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
18519 match state {
18520 log_digest::LogDigestSummaryState::Current => "current",
18521 log_digest::LogDigestSummaryState::Stale => "stale",
18522 log_digest::LogDigestSummaryState::Missing => "missing",
18523 log_digest::LogDigestSummaryState::Unavailable => "unavailable",
18524 }
18525}
18526
18527pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
18528 match mode {
18529 diff_digest::DiffDigestMode::WorkingTree => "worktree",
18530 diff_digest::DiffDigestMode::Cached => "cached",
18531 diff_digest::DiffDigestMode::Revision => "revision",
18532 }
18533}
18534
18535pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
18536 match (&report.mode, &report.revision) {
18537 (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
18538 (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
18539 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18540 format!("revision {revision}")
18541 }
18542 (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
18543 }
18544}
18545
18546pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
18547 match (&report.mode, &report.revision) {
18548 (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
18549 (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
18550 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18551 format!("No diff found for revision {revision}.")
18552 }
18553 (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
18554 }
18555}
18556
18557fn cmd_impact(
18558 path: &Path,
18559 cached: bool,
18560 revision: Option<&str>,
18561 scope: Option<&str>,
18562 limit: usize,
18563 format: OutputFormat,
18564) -> Result<()> {
18565 let report = impact::compute(
18566 path,
18567 impact::ImpactOptions {
18568 cached,
18569 revision,
18570 scope,
18571 limit,
18572 },
18573 )?;
18574 if format.json_output {
18575 println!(
18576 "{}",
18577 to_json_schema(
18578 &report,
18579 format.pretty,
18580 format.terse,
18581 format.ultra_terse,
18582 format.schema
18583 )?
18584 );
18585 return Ok(());
18586 }
18587
18588 if format.compact {
18589 println!(
18590 "impact mode:{} changed:{} symbols:{} tests:{}/{}",
18591 diff_digest_mode_label(report.mode),
18592 report.changed_files.len(),
18593 report.changed_symbols.len(),
18594 report.affected_tests.len(),
18595 report.affected_tests_total
18596 );
18597 for target in &report.affected_tests {
18598 println!(
18599 "{} reasons:{} command:{}",
18600 target.path,
18601 target.reasons.len(),
18602 target.commands.join(" && ")
18603 );
18604 }
18605 for warning in &report.warnings {
18606 println!("warning {warning}");
18607 }
18608 return Ok(());
18609 }
18610
18611 println!("Impact ({})", diff_digest_mode_label(report.mode));
18612 println!(" changed files: {}", report.changed_files.len());
18613 println!(" changed symbols: {}", report.changed_symbols.len());
18614 println!(
18615 " affected tests: {}/{}",
18616 report.affected_tests.len(),
18617 report.affected_tests_total
18618 );
18619 for target in &report.affected_tests {
18620 println!();
18621 println!("{}", target.path);
18622 for reason in &target.reasons {
18623 println!(" - {reason}");
18624 }
18625 if !target.symbols.is_empty() {
18626 println!(" symbols: {}", target.symbols.join(", "));
18627 }
18628 for command in &target.commands {
18629 println!(" run: {}", command);
18630 }
18631 }
18632 for warning in &report.warnings {
18633 println!("warning: {warning}");
18634 }
18635 Ok(())
18636}
18637
18638pub(crate) fn render_test_digest_from_input(
18639 path: &Path,
18640 input: &str,
18641 runner: Option<&str>,
18642 format: OutputFormat,
18643) -> Result<()> {
18644 let report = test_digest::compute(path, input, runner)?;
18645 if format.json_output {
18646 println!(
18647 "{}",
18648 to_json_schema(
18649 &report,
18650 format.pretty,
18651 format.terse,
18652 format.ultra_terse,
18653 format.schema
18654 )?
18655 );
18656 return Ok(());
18657 }
18658
18659 if report.failure_groups.is_empty() {
18660 println!("No failures detected (runner: {}).", report.runner);
18661 for warning in &report.warnings {
18662 println!("warning: {warning}");
18663 }
18664 return Ok(());
18665 }
18666
18667 if format.compact {
18668 println!(
18669 "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18670 report.runner,
18671 report.failures,
18672 report.grouped_failures,
18673 report.counts.passed.unwrap_or(0),
18674 report.counts.failed.unwrap_or(report.grouped_failures),
18675 report.counts.skipped.unwrap_or(0),
18676 );
18677 for failure in &report.failure_groups {
18678 let tests = truncate_for_compact(&failure.tests.join(","), 60);
18679 let location = match (&failure.path, failure.line) {
18680 (Some(path), Some(line)) => format!("{path}:{line}"),
18681 (Some(path), None) => path.clone(),
18682 _ => "-".to_string(),
18683 };
18684 println!(
18685 "{} tests:{} count:{} summaries:{} msg:{}",
18686 location,
18687 tests,
18688 failure.occurrences,
18689 test_digest_summary_label(failure.summary_state),
18690 truncate_for_compact(&failure.message, 80)
18691 );
18692 }
18693 for warning in &report.warnings {
18694 println!("warning: {warning}");
18695 }
18696 return Ok(());
18697 }
18698
18699 println!("Test digest ({})", report.runner);
18700 println!(" failures: {}", report.failures);
18701 println!(" failure groups: {}", report.grouped_failures);
18702 if let Some(passed) = report.counts.passed {
18703 println!(" passed: {}", passed);
18704 }
18705 if let Some(failed) = report.counts.failed {
18706 println!(" failed: {}", failed);
18707 }
18708 if let Some(skipped) = report.counts.skipped {
18709 println!(" skipped: {}", skipped);
18710 }
18711
18712 for failure in &report.failure_groups {
18713 println!();
18714 match (&failure.path, failure.line, failure.column) {
18715 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18716 (Some(path), Some(line), None) => println!("{path}:{line}"),
18717 (Some(path), None, _) => println!("{path}"),
18718 (None, _, _) => println!("(no file anchor)"),
18719 }
18720 println!(" tests: {}", failure.tests.join(", "));
18721 println!(" occurrences: {}", failure.occurrences);
18722 println!(" message: {}", failure.message);
18723 println!(
18724 " cached summaries: {}",
18725 test_digest_summary_label(failure.summary_state)
18726 );
18727 for summary in &failure.current_summaries {
18728 println!(
18729 " - {}: {}",
18730 summary.symbol,
18731 truncate_for_compact(&summary.summary, 160)
18732 );
18733 }
18734 }
18735 for warning in &report.warnings {
18736 println!("warning: {warning}");
18737 }
18738 Ok(())
18739}
18740
18741#[derive(Clone, Serialize, Deserialize)]
18742struct DispatchTraceSummary {
18743 backlog: usize,
18744 job_packet: usize,
18745 worker_result: usize,
18746 worker_context: usize,
18747 source_handle: usize,
18748 semantic_rows: usize,
18749}
18750
18751#[derive(Clone, Serialize, Deserialize)]
18752struct DispatchTraceReport {
18753 contract_version: String,
18754 root: String,
18755 #[serde(skip_serializing_if = "Option::is_none")]
18756 scope: Option<String>,
18757 targets: Vec<String>,
18758 projection_freshness: GraphDbFreshnessReport,
18759 projection_hashes: Vec<String>,
18760 evidence_packet_ids: Vec<String>,
18761 shared_preparation: ConflictMatrixSharedPreparationSummary,
18762 worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18763 worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18764 summary: DispatchTraceSummary,
18765 nodes: Vec<SubstrateTerseGraphNode>,
18766 edges: Vec<SubstrateTerseGraphEdge>,
18767 conflict_matrix_decisions: Vec<String>,
18768 replay_commands: Vec<String>,
18769 repair_commands: Vec<String>,
18770 truncated: bool,
18771 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18772 warnings: Vec<String>,
18773}
18774
18775fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18776 matches!(
18777 kind,
18778 "session"
18779 | "backlog"
18780 | "job_packet"
18781 | "worker_result"
18782 | "worker_context"
18783 | "source_handle"
18784 | "semantic_concept"
18785 | "semantic_entity"
18786 | "file"
18787 | "symbol"
18788 | "route"
18789 )
18790}
18791
18792fn dispatch_trace_kind_rank(kind: &str) -> usize {
18793 match kind {
18794 "backlog" => 0,
18795 "job_packet" => 1,
18796 "worker_result" => 2,
18797 "worker_context" => 3,
18798 "source_handle" => 4,
18799 "file" => 5,
18800 "symbol" => 6,
18801 "route" => 7,
18802 "semantic_concept" => 8,
18803 "semantic_entity" => 9,
18804 "session" => 10,
18805 _ => 99,
18806 }
18807}
18808
18809fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18810 DispatchTraceSummary {
18811 backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18812 job_packet: nodes
18813 .iter()
18814 .filter(|node| node.kind == "job_packet")
18815 .count(),
18816 worker_result: nodes
18817 .iter()
18818 .filter(|node| node.kind == "worker_result")
18819 .count(),
18820 worker_context: nodes
18821 .iter()
18822 .filter(|node| node.kind == "worker_context")
18823 .count(),
18824 source_handle: nodes
18825 .iter()
18826 .filter(|node| node.kind == "source_handle")
18827 .count(),
18828 semantic_rows: nodes
18829 .iter()
18830 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18831 .count(),
18832 }
18833}
18834
18835fn dispatch_trace_shared_preparation_summary(
18836 graph_nodes: &[SubstrateGraphNode],
18837 graph_edges: &[SubstrateGraphEdge],
18838 conflict: &ConflictMatrixReport,
18839) -> ConflictMatrixSharedPreparationSummary {
18840 ConflictMatrixSharedPreparationSummary {
18841 evidence_cache_status: conflict
18842 .inputs
18843 .shared_preparation
18844 .evidence_cache_status
18845 .clone(),
18846 graph_nodes: graph_nodes.len(),
18847 graph_edges: graph_edges.len(),
18848 evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18849 source_handles: conflict
18850 .candidates
18851 .iter()
18852 .map(|candidate| candidate.source_handles.len())
18853 .sum(),
18854 worker_context: conflict
18855 .candidates
18856 .iter()
18857 .map(|candidate| candidate.worker_context_handles.len())
18858 .sum(),
18859 worker_results: conflict
18860 .candidates
18861 .iter()
18862 .map(|candidate| candidate.worker_feedback.total)
18863 .sum(),
18864 semantic_rows: conflict
18865 .candidates
18866 .iter()
18867 .map(|candidate| candidate.semantic_related.len())
18868 .sum(),
18869 dispatch_trace_snapshot_nodes: graph_nodes.len(),
18870 dispatch_trace_snapshot_edges: graph_edges.len(),
18871 }
18872}
18873
18874fn dispatch_trace_collect_ids(
18875 targets: &[String],
18876 candidates: &[ConflictMatrixCandidate],
18877 graph_nodes: &[SubstrateGraphNode],
18878 graph_edges: &[SubstrateGraphEdge],
18879 depth: usize,
18880 limit: usize,
18881) -> (BTreeSet<String>, bool) {
18882 let target_refs = targets
18883 .iter()
18884 .map(|target| target.trim_start_matches('#').to_string())
18885 .collect::<BTreeSet<_>>();
18886 let mut ids = BTreeSet::new();
18887 for candidate in candidates {
18888 ids.insert(candidate.target_node_id.clone());
18889 for source in &candidate.source_handles {
18890 ids.insert(source.handle.clone());
18891 }
18892 for handle in &candidate.worker_context_handles {
18893 ids.insert(handle.clone());
18894 }
18895 for semantic in &candidate.semantic_related {
18896 ids.insert(semantic.handle.clone());
18897 }
18898 }
18899 for node in graph_nodes {
18900 if !dispatch_trace_allowed_node_kind(&node.kind) {
18901 continue;
18902 }
18903 if node
18904 .properties
18905 .get("ref_id")
18906 .is_some_and(|ref_id| target_refs.contains(ref_id))
18907 {
18908 ids.insert(node.id.clone());
18909 }
18910 }
18911
18912 let node_by_id = graph_nodes
18913 .iter()
18914 .map(|node| (node.id.as_str(), node))
18915 .collect::<BTreeMap<_, _>>();
18916 let max_nodes = if limit == 0 {
18917 usize::MAX
18918 } else {
18919 limit
18920 .saturating_mul(targets.len().max(1))
18921 .saturating_mul(12)
18922 .max(64)
18923 };
18924 let mut truncated = false;
18925 for _ in 0..depth.max(1) {
18926 let before = ids.len();
18927 let current_ids = ids.clone();
18928 for edge in graph_edges {
18929 if ids.len() >= max_nodes {
18930 truncated = true;
18931 break;
18932 }
18933 let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18934 if !touches {
18935 continue;
18936 }
18937 for endpoint in [&edge.from_id, &edge.to_id] {
18938 let Some(node) = node_by_id.get(endpoint.as_str()) else {
18939 continue;
18940 };
18941 if dispatch_trace_allowed_node_kind(&node.kind) {
18942 ids.insert(endpoint.clone());
18943 }
18944 }
18945 }
18946 if ids.len() == before || truncated {
18947 break;
18948 }
18949 }
18950 (ids, truncated)
18951}
18952
18953#[allow(clippy::too_many_arguments)]
18954fn build_dispatch_trace_report_from_conflict_snapshot(
18955 root: &Path,
18956 scope: Option<&str>,
18957 conflict: ConflictMatrixReport,
18958 graph_nodes: Vec<SubstrateGraphNode>,
18959 graph_edges: Vec<SubstrateGraphEdge>,
18960 depth: usize,
18961 limit: usize,
18962 extra_warnings: Vec<String>,
18963) -> Result<DispatchTraceReport> {
18964 let shared_preparation =
18965 dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18966 let (ids, truncated) = dispatch_trace_collect_ids(
18967 &conflict.targets,
18968 &conflict.candidates,
18969 &graph_nodes,
18970 &graph_edges,
18971 depth,
18972 limit,
18973 );
18974 let mut nodes = graph_nodes
18975 .into_iter()
18976 .filter(|node| ids.contains(&node.id))
18977 .collect::<Vec<_>>();
18978 nodes.sort_by(|left, right| {
18979 dispatch_trace_kind_rank(&left.kind)
18980 .cmp(&dispatch_trace_kind_rank(&right.kind))
18981 .then(left.id.cmp(&right.id))
18982 });
18983 let node_ids = nodes
18984 .iter()
18985 .map(|node| node.id.as_str())
18986 .collect::<BTreeSet<_>>();
18987 let mut edges = graph_edges
18988 .into_iter()
18989 .filter(|edge| {
18990 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18991 })
18992 .collect::<Vec<_>>();
18993 edges.sort_by(|left, right| {
18994 left.from_id
18995 .cmp(&right.from_id)
18996 .then(left.kind.cmp(&right.kind))
18997 .then(left.to_id.cmp(&right.to_id))
18998 });
18999 let mut warnings = conflict.warnings;
19000 warnings.extend(extra_warnings);
19001
19002 Ok(DispatchTraceReport {
19003 contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
19004 root: conflict.root,
19005 scope: conflict.scope,
19006 targets: conflict.targets,
19007 projection_freshness: conflict.orchestration.projection_freshness,
19008 projection_hashes: conflict.orchestration.projection_hashes,
19009 evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
19010 shared_preparation,
19011 worker_prompt_packets: conflict.worker_prompt_packets,
19012 worker_feedback: conflict
19013 .candidates
19014 .iter()
19015 .map(|candidate| candidate.worker_feedback.clone())
19016 .collect(),
19017 summary: dispatch_trace_summary(&nodes),
19018 nodes: nodes.into_iter().map(Into::into).collect(),
19019 edges: edges.into_iter().map(Into::into).collect(),
19020 conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
19021 replay_commands: conflict.next_commands,
19022 repair_commands: graph_db_repair_commands(root, scope),
19023 truncated,
19024 warnings,
19025 })
19026}
19027
19028fn build_dispatch_trace_report(
19029 path: &Path,
19030 scope: Option<&str>,
19031 raw_targets: &[String],
19032 depth: usize,
19033 limit: usize,
19034 impact_limit: usize,
19035) -> Result<DispatchTraceReport> {
19036 let root = lint::resolve_project_root_or_canonical_path(path)?;
19037 let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
19038 if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
19039 write_traversal_graph_store(&root, path, scope)
19040 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19041 }
19042 let graph_db = graph_substrate_db_path(&root, scope);
19043 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19044 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19045 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19046 let extra_warnings = store
19047 .read_only_recovery()
19048 .map(graph_db_read_recovery_diagnostic)
19049 .into_iter()
19050 .collect::<Vec<_>>();
19051 let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
19052 let graph_prepared = prepare_conflict_matrix_graph_orchestration(
19053 &root,
19054 scope,
19055 "sqlite",
19056 raw_targets,
19057 &prepared,
19058 depth,
19059 limit,
19060 &store,
19061 freshness.clone(),
19062 )?;
19063 let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
19064 &prepared.preparation_cache.source_watermark,
19065 &prepared.preparation_cache.document_watermark,
19066 &prepared.preparation_cache.staged_diff_watermark,
19067 &[
19068 &format!("targets:{}", raw_targets.join(",")),
19069 &format!("depth:{depth}"),
19070 &format!("limit:{limit}"),
19071 ],
19072 );
19073 if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
19074 &root,
19075 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19076 &dt_cache_key,
19077 ) {
19078 return Ok(cached_report);
19079 }
19080 let conflict = build_conflict_matrix_report_from_prepared_graph(
19081 &root,
19082 path,
19083 scope,
19084 depth,
19085 limit,
19086 impact_limit,
19087 freshness,
19088 extra_warnings.clone(),
19089 &prepared,
19090 &graph_prepared,
19091 )?;
19092 let report = build_dispatch_trace_report_from_conflict_snapshot(
19093 &root,
19094 scope,
19095 conflict,
19096 graph_prepared.graph.nodes,
19097 graph_prepared.graph.edges,
19098 depth,
19099 limit,
19100 extra_warnings,
19101 )?;
19102 cycle_packet_cache::cycle_packet_write_cache(
19103 &root,
19104 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19105 &dt_cache_key,
19106 &report,
19107 );
19108 Ok(report)
19109}
19110
19111fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
19112 let json = serde_json::to_string(report)?.replace("</", "<\\/");
19113 let mut html = String::new();
19114 html.push_str(
19115 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
19116 );
19117 html.push_str(
19118 r#"<style>
19119:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
19120@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
19121*{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}}
19122</style>"#,
19123 );
19124 html.push_str("</head><body><div class=\"page\">");
19125 html.push_str(&format!(
19126 "<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>",
19127 html_escape(&report.targets.join(", ")),
19128 report.evidence_packet_ids.len(),
19129 report.nodes.len(),
19130 report.worker_prompt_packets.len(),
19131 html_escape(&report.contract_version)
19132 ));
19133 html.push_str(
19134 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>"#,
19135 );
19136 html.push_str("<script id=\"trace-data\" type=\"application/json\">");
19137 html.push_str(&json);
19138 html.push_str(
19139 r##"</script><script>
19140const report = JSON.parse(document.getElementById("trace-data").textContent);
19141const svg = document.getElementById("graph-canvas");
19142const nodeList = document.getElementById("nodes");
19143const packets = document.getElementById("packets");
19144const feedback = document.getElementById("feedback");
19145const nodes = report.nodes.map((node, index) => ({...node, index}));
19146const nodeById = new Map(nodes.map(node => [node.id, node]));
19147const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
19148const 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"]]);
19149function color(kind){return colorByKind.get(kind)||"#6b7280";}
19150function text(value){return value == null ? "" : String(value);}
19151function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));}
19152function layout(){
19153 const rect = svg.getBoundingClientRect();
19154 const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
19155 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
19156 const counts = new Map();
19157 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
19158 const offsets = new Map();
19159 for (const node of nodes) {
19160 const group = kinds.indexOf(node.kind);
19161 const index = offsets.get(node.kind) || 0;
19162 offsets.set(node.kind, index + 1);
19163 const total = counts.get(node.kind) || 1;
19164 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
19165 const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
19166 node.x = cx + Math.cos(angle) * ring;
19167 node.y = cy + Math.sin(angle) * ring;
19168 }
19169}
19170function draw(){
19171 svg.innerHTML = "";
19172 for (const edge of edges) {
19173 const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
19174 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
19175 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
19176 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
19177 line.setAttribute("class", "edge");
19178 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
19179 svg.appendChild(line);
19180 }
19181 for (const node of nodes) {
19182 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
19183 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
19184 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
19185 circle.setAttribute("fill", color(node.kind));
19186 circle.setAttribute("class", "node");
19187 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
19188 svg.appendChild(circle);
19189 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
19190 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
19191 label.setAttribute("class", "node-label");
19192 label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
19193 svg.appendChild(label);
19194 }
19195}
19196packets.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>";
19197feedback.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>";
19198nodeList.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("");
19199window.addEventListener("resize", () => { layout(); draw(); });
19200layout(); draw();
19201</script></div></body></html>"##,
19202 );
19203 Ok(html)
19204}
19205
19206struct DispatchTraceOptions<'a> {
19207 path: &'a Path,
19208 scope: Option<&'a str>,
19209 raw_targets: &'a [String],
19210 depth: usize,
19211 limit: usize,
19212 impact_limit: usize,
19213 trace_format: DispatchTraceFormat,
19214}
19215
19216fn cmd_dispatch_trace(
19217 options: DispatchTraceOptions<'_>,
19218 output_format: OutputFormat,
19219) -> Result<()> {
19220 let report = build_dispatch_trace_report(
19221 options.path,
19222 options.scope,
19223 options.raw_targets,
19224 options.depth,
19225 options.limit,
19226 options.impact_limit,
19227 )?;
19228 match options.trace_format {
19229 DispatchTraceFormat::Json => {
19230 if output_format.envelope {
19231 print_json_or_envelope(
19232 &report,
19233 &output_format,
19234 "dispatch-trace",
19235 "operator-review",
19236 ToolEnvelopeSummary {
19237 text: format!(
19238 "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
19239 report.targets.len(),
19240 report.nodes.len(),
19241 report.worker_prompt_packets.len()
19242 ),
19243 metrics: vec![
19244 envelope_metric("targets", report.targets.len()),
19245 envelope_metric("nodes", report.nodes.len()),
19246 envelope_metric("edges", report.edges.len()),
19247 envelope_metric(
19248 "worker_prompt_packets",
19249 report.worker_prompt_packets.len(),
19250 ),
19251 ],
19252 },
19253 report.truncated,
19254 report.replay_commands.clone(),
19255 )
19256 } else {
19257 println!(
19258 "{}",
19259 to_json_schema(
19260 &report,
19261 output_format.pretty,
19262 output_format.terse,
19263 output_format.ultra_terse,
19264 output_format.schema
19265 )?
19266 );
19267 Ok(())
19268 }
19269 }
19270 DispatchTraceFormat::Html => {
19271 println!("{}", dispatch_trace_html(&report)?);
19272 Ok(())
19273 }
19274 }
19275}
19276
19277#[derive(Clone, Debug)]
19278struct DependencyDagProfile {
19279 id: String,
19280 graph_node_id: String,
19281 label: String,
19282 path: Option<String>,
19283 line: Option<i64>,
19284 detail: Option<String>,
19285 source_files: BTreeSet<String>,
19286 source_symbols: BTreeSet<String>,
19287 config_files: BTreeSet<String>,
19288 expected_tests: BTreeSet<String>,
19289 semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
19290 worker_feedback: ConflictMatrixWorkerFeedback,
19291}
19292
19293#[derive(Clone, Debug, Serialize)]
19294struct DependencyDagNode {
19295 id: String,
19296 graph_node_id: String,
19297 label: String,
19298 #[serde(skip_serializing_if = "Option::is_none")]
19299 path: Option<String>,
19300 #[serde(skip_serializing_if = "Option::is_none")]
19301 line: Option<i64>,
19302 #[serde(skip_serializing_if = "Option::is_none")]
19303 detail: Option<String>,
19304 source_files: Vec<String>,
19305 source_symbols: Vec<String>,
19306 config_files: Vec<String>,
19307 expected_tests: Vec<String>,
19308 semantic_refs: Vec<ConflictMatrixSemanticRef>,
19309 worker_feedback: ConflictMatrixWorkerFeedback,
19310}
19311
19312#[derive(Clone, Debug, Serialize)]
19313struct DependencyDagEdge {
19314 from: String,
19315 to: String,
19316 kind: String,
19317 weight: usize,
19318 reasons: Vec<String>,
19319 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19320 shared_files: Vec<String>,
19321 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19322 shared_symbols: Vec<String>,
19323 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19324 shared_tests: Vec<String>,
19325 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19326 shared_config_files: Vec<String>,
19327 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19328 shared_semantic_refs: Vec<String>,
19329}
19330
19331#[derive(Clone, Debug, Serialize)]
19332struct DependencyDagTopoBatch {
19333 batch: usize,
19334 targets: Vec<String>,
19335}
19336
19337#[derive(Clone, Debug, Serialize)]
19338struct DependencyDagCycleDiagnostics {
19339 has_cycles: bool,
19340 blocked_nodes: Vec<String>,
19341 cycle_edges: Vec<DependencyDagEdge>,
19342}
19343
19344#[derive(Serialize)]
19345struct DependencyDagSummary {
19346 nodes: usize,
19347 edges: usize,
19348 topo_batches: usize,
19349 has_cycles: bool,
19350}
19351
19352#[derive(Serialize)]
19353struct DependencyDagReport {
19354 contract_version: &'static str,
19355 root: String,
19356 #[serde(skip_serializing_if = "Option::is_none")]
19357 scope: Option<String>,
19358 path: String,
19359 targets: Vec<String>,
19360 projection_freshness: GraphDbFreshnessReport,
19361 projection_hashes: Vec<String>,
19362 nodes: Vec<DependencyDagNode>,
19363 edges: Vec<DependencyDagEdge>,
19364 topo_batches: Vec<DependencyDagTopoBatch>,
19365 cycle_diagnostics: DependencyDagCycleDiagnostics,
19366 summary: DependencyDagSummary,
19367 replay_commands: Vec<String>,
19368 repair_commands: Vec<String>,
19369 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19370 warnings: Vec<String>,
19371}
19372
19373fn dependency_dag_backlog_node_for_target(
19374 store: &impl GraphStore,
19375 target: &str,
19376) -> Result<SubstrateGraphNode> {
19377 let resolved = graph_db_resolve_evidence_target(store, target)?
19378 .with_context(|| format!("dependency-dag target not found: {target}"))?;
19379 if resolved.kind == "backlog" {
19380 return Ok(resolved);
19381 }
19382 let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
19383 bail!(
19384 "dependency-dag target {} resolved to {} without a backlog ref_id",
19385 target,
19386 resolved.kind
19387 );
19388 };
19389 store
19390 .nodes_by_kind("backlog")?
19391 .into_iter()
19392 .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
19393 .min_by(|left, right| {
19394 left.properties
19395 .get("line")
19396 .and_then(|value| value.parse::<i64>().ok())
19397 .cmp(
19398 &right
19399 .properties
19400 .get("line")
19401 .and_then(|value| value.parse::<i64>().ok()),
19402 )
19403 .then(left.id.cmp(&right.id))
19404 })
19405 .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
19406}
19407
19408fn dependency_dag_resolve_backlog_nodes(
19409 root: &Path,
19410 path: &Path,
19411 store: &impl GraphStore,
19412 raw_targets: &[String],
19413) -> Result<Vec<SubstrateGraphNode>> {
19414 let mut nodes = Vec::new();
19415 let mut seen = BTreeSet::new();
19416 if raw_targets.is_empty() {
19417 let hinted_path = if path.is_absolute() {
19418 path.to_path_buf()
19419 } else {
19420 root.join(path)
19421 };
19422 let hinted_markdown = hinted_path
19423 .extension()
19424 .and_then(|ext| ext.to_str())
19425 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
19426 let hinted_rel = hinted_markdown.then(|| {
19427 relativize_pathbuf(&hinted_path, root)
19428 .to_string_lossy()
19429 .replace('\\', "/")
19430 });
19431 for node in store.nodes_by_kind("backlog")? {
19432 if let Some(expected_path) = &hinted_rel
19433 && node.properties.get("path") != Some(expected_path)
19434 {
19435 continue;
19436 }
19437 if seen.insert(node.id.clone()) {
19438 nodes.push(node);
19439 }
19440 }
19441 if nodes.is_empty() && hinted_rel.is_some() {
19442 for node in store.nodes_by_kind("backlog")? {
19443 if seen.insert(node.id.clone()) {
19444 nodes.push(node);
19445 }
19446 }
19447 }
19448 } else {
19449 for target in raw_targets {
19450 let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
19451 let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
19452 if seen.insert(node.id.clone()) {
19453 nodes.push(node);
19454 }
19455 }
19456 }
19457 if nodes.is_empty() {
19458 bail!("dependency-dag needs at least one resolvable backlog id");
19459 }
19460 nodes.sort_by(|left, right| {
19461 left.properties
19462 .get("line")
19463 .and_then(|value| value.parse::<i64>().ok())
19464 .cmp(
19465 &right
19466 .properties
19467 .get("line")
19468 .and_then(|value| value.parse::<i64>().ok()),
19469 )
19470 .then(left.id.cmp(&right.id))
19471 });
19472 Ok(nodes)
19473}
19474
19475fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
19476 node.properties
19477 .get("ref_id")
19478 .cloned()
19479 .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
19480}
19481
19482fn dependency_dag_node_profile(
19483 root: &Path,
19484 store: &impl GraphStore,
19485 node: &SubstrateGraphNode,
19486 graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
19487 graph_edges: &[SubstrateGraphEdge],
19488 depth: usize,
19489 limit: usize,
19490) -> Result<DependencyDagProfile> {
19491 let id = dependency_dag_node_id(node);
19492 let mut source_files = BTreeSet::new();
19493 let mut source_symbols = BTreeSet::new();
19494 for edge in graph_edges
19495 .iter()
19496 .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
19497 {
19498 let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
19499 continue;
19500 };
19501 match target.kind.as_str() {
19502 "file" | "route" => {
19503 if let Some(path) = target.properties.get("path") {
19504 source_files.insert(path.clone());
19505 }
19506 }
19507 "symbol" => {
19508 source_symbols.insert(target.label.clone());
19509 if let Some(path) = target.properties.get("path") {
19510 source_files.insert(path.clone());
19511 }
19512 }
19513 _ => {}
19514 }
19515 }
19516
19517 let max_rows = if limit == 0 { usize::MAX } else { limit };
19518 for (source, _) in
19519 graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
19520 {
19521 let terse: SubstrateTerseGraphNode = (&source).into();
19522 if let Some(handle) = conflict_matrix_source_handle(&terse) {
19523 source_files.insert(handle.file);
19524 }
19525 }
19526
19527 let worker_results = graph_nodes_by_id
19528 .values()
19529 .filter(|candidate| {
19530 candidate.kind == "worker_result"
19531 && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
19532 })
19533 .map(SubstrateTerseGraphNode::from)
19534 .collect::<Vec<_>>();
19535 let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
19536 let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
19537 let config_files = source_files
19538 .iter()
19539 .filter(|file| is_planner_config_path(file))
19540 .cloned()
19541 .collect();
19542
19543 let mut semantic_refs = BTreeMap::new();
19544 for kind in ["semantic_concept", "semantic_entity"] {
19545 for (semantic, _) in
19546 graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
19547 {
19548 let terse: SubstrateTerseGraphNode = (&semantic).into();
19549 let item = conflict_matrix_semantic_ref(root, &terse);
19550 semantic_refs
19551 .entry(format!("{}:{}", item.kind, item.label))
19552 .or_insert(item);
19553 }
19554 }
19555
19556 Ok(DependencyDagProfile {
19557 id,
19558 graph_node_id: node.id.clone(),
19559 label: node.label.clone(),
19560 path: node.properties.get("path").cloned(),
19561 line: node
19562 .properties
19563 .get("line")
19564 .and_then(|value| value.parse::<i64>().ok()),
19565 detail: node.properties.get("detail").cloned(),
19566 source_files,
19567 source_symbols,
19568 config_files,
19569 expected_tests,
19570 semantic_refs,
19571 worker_feedback,
19572 })
19573}
19574
19575fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
19576 let lower = text.to_ascii_lowercase();
19577 let mut refs = Vec::new();
19578 for marker in markers {
19579 let mut offset = 0usize;
19580 while let Some(pos) = lower[offset..].find(marker) {
19581 let start = offset + pos + marker.len();
19582 let segment = text[start..]
19583 .split(['\n', '.'])
19584 .next()
19585 .unwrap_or(&text[start..]);
19586 refs.extend(extract_conflict_target_refs(segment));
19587 offset = start;
19588 }
19589 }
19590 dedupe_preserve_order(refs)
19591}
19592
19593fn dependency_dag_push_edge(
19594 edges: &mut Vec<DependencyDagEdge>,
19595 seen: &mut BTreeSet<(String, String, String)>,
19596 edge: DependencyDagEdge,
19597) {
19598 if edge.from == edge.to {
19599 return;
19600 }
19601 if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
19602 edges.push(edge);
19603 }
19604}
19605
19606fn dependency_dag_explicit_edges(
19607 profiles: &[DependencyDagProfile],
19608 target_ids: &BTreeSet<String>,
19609 edges: &mut Vec<DependencyDagEdge>,
19610 seen: &mut BTreeSet<(String, String, String)>,
19611) {
19612 for profile in profiles {
19613 let detail = profile.detail.as_deref().unwrap_or_default();
19614 for dep in dependency_dag_marker_refs(
19615 detail,
19616 &[
19617 "depends on",
19618 "depends-on",
19619 "deps:",
19620 "after",
19621 "blocked by",
19622 "requires",
19623 ],
19624 ) {
19625 if target_ids.contains(&dep) {
19626 dependency_dag_push_edge(
19627 edges,
19628 seen,
19629 DependencyDagEdge {
19630 from: dep.clone(),
19631 to: profile.id.clone(),
19632 kind: "explicit_depends_on".to_string(),
19633 weight: 1000,
19634 reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
19635 shared_files: Vec::new(),
19636 shared_symbols: Vec::new(),
19637 shared_tests: Vec::new(),
19638 shared_config_files: Vec::new(),
19639 shared_semantic_refs: Vec::new(),
19640 },
19641 );
19642 }
19643 }
19644 for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
19645 if target_ids.contains(&downstream) {
19646 dependency_dag_push_edge(
19647 edges,
19648 seen,
19649 DependencyDagEdge {
19650 from: profile.id.clone(),
19651 to: downstream.clone(),
19652 kind: "explicit_before".to_string(),
19653 weight: 900,
19654 reasons: vec![format!(
19655 "{} declares it should run before #{downstream}",
19656 profile.id
19657 )],
19658 shared_files: Vec::new(),
19659 shared_symbols: Vec::new(),
19660 shared_tests: Vec::new(),
19661 shared_config_files: Vec::new(),
19662 shared_semantic_refs: Vec::new(),
19663 },
19664 );
19665 }
19666 }
19667 }
19668}
19669
19670fn dependency_dag_worker_follow_up_edges(
19671 profiles: &[DependencyDagProfile],
19672 target_ids: &BTreeSet<String>,
19673 edges: &mut Vec<DependencyDagEdge>,
19674 seen: &mut BTreeSet<(String, String, String)>,
19675) {
19676 for profile in profiles {
19677 for follow_up in &profile.worker_feedback.follow_up_ids {
19678 if target_ids.contains(follow_up) {
19679 dependency_dag_push_edge(
19680 edges,
19681 seen,
19682 DependencyDagEdge {
19683 from: profile.id.clone(),
19684 to: follow_up.clone(),
19685 kind: "worker_result_follow_up".to_string(),
19686 weight: 700,
19687 reasons: vec![format!(
19688 "worker_result for #{} references follow-up #{}",
19689 profile.id, follow_up
19690 )],
19691 shared_files: Vec::new(),
19692 shared_symbols: Vec::new(),
19693 shared_tests: Vec::new(),
19694 shared_config_files: Vec::new(),
19695 shared_semantic_refs: Vec::new(),
19696 },
19697 );
19698 }
19699 }
19700 }
19701}
19702
19703fn dependency_dag_overlap_edges(
19704 profiles: &[DependencyDagProfile],
19705 edges: &mut Vec<DependencyDagEdge>,
19706 seen: &mut BTreeSet<(String, String, String)>,
19707) {
19708 for left_idx in 0..profiles.len() {
19709 for right_idx in (left_idx + 1)..profiles.len() {
19710 let left = &profiles[left_idx];
19711 let right = &profiles[right_idx];
19712 let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19713 let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19714 let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19715 let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19716 let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19717 let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19718 let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19719 if shared_files.is_empty()
19720 && shared_symbols.is_empty()
19721 && shared_tests.is_empty()
19722 && shared_config_files.is_empty()
19723 && shared_semantic_refs.is_empty()
19724 {
19725 continue;
19726 }
19727 let kind = if shared_files.is_empty()
19728 && shared_symbols.is_empty()
19729 && shared_tests.is_empty()
19730 && shared_config_files.is_empty()
19731 {
19732 "semantic_relation"
19733 } else {
19734 "shared_resource"
19735 };
19736 let mut reasons = Vec::new();
19737 if !shared_files.is_empty() {
19738 reasons.push(format!("shared files: {}", shared_files.join(", ")));
19739 }
19740 if !shared_symbols.is_empty() {
19741 reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19742 }
19743 if !shared_tests.is_empty() {
19744 reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19745 }
19746 if !shared_config_files.is_empty() {
19747 reasons.push(format!(
19748 "shared config files: {}",
19749 shared_config_files.join(", ")
19750 ));
19751 }
19752 if !shared_semantic_refs.is_empty() {
19753 reasons.push(format!(
19754 "shared semantic refs: {}",
19755 shared_semantic_refs.join(", ")
19756 ));
19757 }
19758 let weight = shared_files.len() * 100
19759 + shared_config_files.len() * 100
19760 + shared_symbols.len() * 40
19761 + shared_tests.len() * 10
19762 + shared_semantic_refs.len() * 5;
19763 dependency_dag_push_edge(
19764 edges,
19765 seen,
19766 DependencyDagEdge {
19767 from: left.id.clone(),
19768 to: right.id.clone(),
19769 kind: kind.to_string(),
19770 weight,
19771 reasons,
19772 shared_files,
19773 shared_symbols,
19774 shared_tests,
19775 shared_config_files,
19776 shared_semantic_refs,
19777 },
19778 );
19779 }
19780 }
19781}
19782
19783fn dependency_dag_topo_batches(
19784 targets: &[String],
19785 edges: &[DependencyDagEdge],
19786) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19787 let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19788 let order = targets
19789 .iter()
19790 .enumerate()
19791 .map(|(idx, id)| (id.clone(), idx))
19792 .collect::<BTreeMap<_, _>>();
19793 let mut indegree = targets
19794 .iter()
19795 .map(|id| (id.clone(), 0usize))
19796 .collect::<BTreeMap<_, _>>();
19797 let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19798 let mut seen_pairs = BTreeSet::<(String, String)>::new();
19799 for edge in edges {
19800 if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19801 continue;
19802 }
19803 if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19804 continue;
19805 }
19806 *indegree.entry(edge.to.clone()).or_default() += 1;
19807 outgoing
19808 .entry(edge.from.clone())
19809 .or_default()
19810 .push(edge.to.clone());
19811 }
19812 for values in outgoing.values_mut() {
19813 values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19814 values.dedup();
19815 }
19816
19817 let mut processed = BTreeSet::new();
19818 let mut batches = Vec::new();
19819 loop {
19820 let mut ready = targets
19821 .iter()
19822 .filter(|id| !processed.contains(*id))
19823 .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19824 .cloned()
19825 .collect::<Vec<_>>();
19826 ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19827 if ready.is_empty() {
19828 break;
19829 }
19830 for id in &ready {
19831 processed.insert(id.clone());
19832 for next in outgoing.get(id).into_iter().flatten() {
19833 if let Some(value) = indegree.get_mut(next) {
19834 *value = value.saturating_sub(1);
19835 }
19836 }
19837 }
19838 batches.push(DependencyDagTopoBatch {
19839 batch: batches.len() + 1,
19840 targets: ready,
19841 });
19842 }
19843
19844 let blocked_nodes = targets
19845 .iter()
19846 .filter(|id| !processed.contains(*id))
19847 .cloned()
19848 .collect::<Vec<_>>();
19849 let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19850 let cycle_edges = edges
19851 .iter()
19852 .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19853 .cloned()
19854 .collect::<Vec<_>>();
19855 (
19856 batches,
19857 DependencyDagCycleDiagnostics {
19858 has_cycles: !blocked_nodes.is_empty(),
19859 blocked_nodes,
19860 cycle_edges,
19861 },
19862 )
19863}
19864
19865fn dependency_dag_replay_commands(
19866 path: &Path,
19867 scope: Option<&str>,
19868 targets: &[String],
19869 depth: usize,
19870 limit: usize,
19871) -> Vec<String> {
19872 let target_args = targets
19873 .iter()
19874 .map(|target| shell_quote(target))
19875 .collect::<Vec<_>>()
19876 .join(" ");
19877 let mut command = format!(
19878 "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19879 shell_quote(path.to_string_lossy().as_ref()),
19880 scope
19881 .map(|scope| format!(" --scope {}", shell_quote(scope)))
19882 .unwrap_or_default(),
19883 depth,
19884 limit
19885 );
19886 if !target_args.is_empty() {
19887 command.push(' ');
19888 command.push_str(&target_args);
19889 }
19890 vec![command]
19891}
19892
19893fn build_dependency_dag_report(
19894 path: &Path,
19895 scope: Option<&str>,
19896 raw_targets: &[String],
19897 depth: usize,
19898 limit: usize,
19899) -> Result<DependencyDagReport> {
19900 let root = lint::resolve_project_root_or_canonical_path(path)?;
19901 write_traversal_graph_store(&root, path, scope)
19902 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19903 let graph_db = graph_substrate_db_path(&root, scope);
19904 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19905 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19906 let mut warnings = Vec::new();
19907 if let Some(recovery) = store.read_only_recovery() {
19908 warnings.push(graph_db_read_recovery_diagnostic(recovery));
19909 }
19910 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19911 if freshness.fail_closed {
19912 bail!(
19913 "dependency-dag graph projection failed closed: {}; repair: {}",
19914 freshness.diagnostics.join("; "),
19915 graph_db_repair_commands(&root, scope).join("; ")
19916 );
19917 }
19918
19919 let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19920 let graph_nodes = store.all_nodes()?;
19921 let graph_edges = store.all_edges()?;
19922 let graph_nodes_by_id = graph_nodes
19923 .into_iter()
19924 .map(|node| (node.id.clone(), node))
19925 .collect::<BTreeMap<_, _>>();
19926 let profiles = target_nodes
19927 .iter()
19928 .map(|node| {
19929 dependency_dag_node_profile(
19930 &root,
19931 &store,
19932 node,
19933 &graph_nodes_by_id,
19934 &graph_edges,
19935 depth,
19936 limit,
19937 )
19938 })
19939 .collect::<Result<Vec<_>>>()?;
19940 let targets = profiles
19941 .iter()
19942 .map(|profile| profile.id.clone())
19943 .collect::<Vec<_>>();
19944 let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19945
19946 let mut edges = Vec::new();
19947 let mut seen_edges = BTreeSet::new();
19948 dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19949 dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19950 dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19951 edges.sort_by(|left, right| {
19952 left.from
19953 .cmp(&right.from)
19954 .then(left.to.cmp(&right.to))
19955 .then(left.kind.cmp(&right.kind))
19956 });
19957 let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19958
19959 let nodes = profiles
19960 .into_iter()
19961 .map(|profile| DependencyDagNode {
19962 id: profile.id,
19963 graph_node_id: profile.graph_node_id,
19964 label: profile.label,
19965 path: profile.path,
19966 line: profile.line,
19967 detail: profile.detail,
19968 source_files: sorted_set(&profile.source_files),
19969 source_symbols: sorted_set(&profile.source_symbols),
19970 config_files: sorted_set(&profile.config_files),
19971 expected_tests: sorted_set(&profile.expected_tests),
19972 semantic_refs: profile.semantic_refs.into_values().collect(),
19973 worker_feedback: profile.worker_feedback,
19974 })
19975 .collect::<Vec<_>>();
19976 let projection_hashes = freshness
19977 .content_hash
19978 .clone()
19979 .into_iter()
19980 .collect::<Vec<_>>();
19981 let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19982 let repair_commands = graph_db_repair_commands(&root, scope);
19983 let summary = DependencyDagSummary {
19984 nodes: nodes.len(),
19985 edges: edges.len(),
19986 topo_batches: topo_batches.len(),
19987 has_cycles: cycle_diagnostics.has_cycles,
19988 };
19989
19990 Ok(DependencyDagReport {
19991 contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19992 root: root.to_string_lossy().to_string(),
19993 scope: scope.map(str::to_string),
19994 path: path.to_string_lossy().to_string(),
19995 targets,
19996 projection_freshness: freshness,
19997 projection_hashes,
19998 nodes,
19999 edges,
20000 topo_batches,
20001 cycle_diagnostics,
20002 summary,
20003 replay_commands,
20004 repair_commands,
20005 warnings,
20006 })
20007}
20008
20009fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
20010 if compact {
20011 println!(
20012 "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
20013 report.targets.len(),
20014 report.edges.len(),
20015 report.topo_batches.len(),
20016 report.cycle_diagnostics.has_cycles
20017 );
20018 } else {
20019 println!("Dependency DAG");
20020 println!(" targets: {}", report.targets.join(", "));
20021 println!(" edges: {}", report.edges.len());
20022 println!(" cycles: {}", report.cycle_diagnostics.has_cycles);
20023 }
20024 for batch in &report.topo_batches {
20025 println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
20026 }
20027 for edge in &report.edges {
20028 println!(
20029 "edge {} -> {} kind:{} weight:{}",
20030 edge.from, edge.to, edge.kind, edge.weight
20031 );
20032 for reason in &edge.reasons {
20033 println!(" reason: {reason}");
20034 }
20035 }
20036 if report.cycle_diagnostics.has_cycles {
20037 println!(
20038 "cycle blocked nodes: {}",
20039 report.cycle_diagnostics.blocked_nodes.join(", ")
20040 );
20041 }
20042 for command in &report.replay_commands {
20043 println!("replay: {command}");
20044 }
20045 for command in &report.repair_commands {
20046 println!("repair: {command}");
20047 }
20048 for warning in &report.warnings {
20049 println!("warning: {warning}");
20050 }
20051}
20052
20053fn cmd_dependency_dag(
20054 path: &Path,
20055 scope: Option<&str>,
20056 raw_targets: &[String],
20057 depth: usize,
20058 limit: usize,
20059 format: OutputFormat,
20060) -> Result<()> {
20061 let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
20062 if format.json_output {
20063 print_json_or_envelope(
20064 &report,
20065 &format,
20066 "dependency-dag",
20067 "topological-planning",
20068 ToolEnvelopeSummary {
20069 text: format!(
20070 "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
20071 report.targets.len(),
20072 report.edges.len(),
20073 report.topo_batches.len(),
20074 report.cycle_diagnostics.has_cycles
20075 ),
20076 metrics: vec![
20077 envelope_metric("targets", report.targets.len()),
20078 envelope_metric("edges", report.edges.len()),
20079 envelope_metric("topo_batches", report.topo_batches.len()),
20080 envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
20081 ],
20082 },
20083 report.cycle_diagnostics.has_cycles,
20084 report.replay_commands.clone(),
20085 )
20086 } else {
20087 print_dependency_dag_human(&report, format.compact);
20088 Ok(())
20089 }
20090}
20091
20092fn maybe_attach_log_digest_raw_artifact(
20097 root: &Path,
20098 report: &mut log_digest::LogDigestReport,
20099 input: &str,
20100) -> Result<()> {
20101 if input.trim().is_empty() || !log_digest::raw_log_artifact_recommended(report, input.len()) {
20102 return Ok(());
20103 }
20104 let key = format!("logdigest:{}:{}", report.total_lines, input.len());
20105 let artifact_path = root
20106 .join(".tsift/artifacts")
20107 .join(format!("{}.log", stable_handle("logdg", &key)));
20108 let expand = format!(
20109 "tsift log-digest --path {} --input {} --json",
20110 shell_quote(root.to_string_lossy().as_ref()),
20111 shell_quote(artifact_path.to_string_lossy().as_ref())
20112 );
20113 let artifact = persist_transcript_artifact(root, "logdg", "log", &key, input, expand)?;
20114 report.raw_log_artifact = Some(log_digest::LogDigestArtifactRef {
20115 handle: artifact.handle,
20116 path: artifact.path,
20117 bytes: artifact.bytes,
20118 lines: artifact.lines,
20119 expand: artifact.expand,
20120 });
20121 Ok(())
20122}
20123
20124pub(crate) fn render_log_digest_fixture(
20128 path: &Path,
20129 fixture_path: &Path,
20130 fail_under: bool,
20131 format: OutputFormat,
20132) -> Result<()> {
20133 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20134 let fixture_body = fs::read_to_string(fixture_path)
20135 .with_context(|| format!("reading log-digest fixture: {}", fixture_path.display()))?;
20136 let fixture: log_digest::LogDigestFixture = serde_json::from_str(&fixture_body)
20137 .with_context(|| format!("parsing log-digest fixture: {}", fixture_path.display()))?;
20138 let report = log_digest::evaluate_fixture(&root, &fixture)?;
20139
20140 if format.json_output {
20141 print_json_or_envelope(
20142 &report,
20143 &format,
20144 "log-digest-fixture",
20145 "report",
20146 ToolEnvelopeSummary {
20147 text: if report.passed {
20148 format!("log-digest gate passed for {} case(s)", report.total_cases)
20149 } else {
20150 format!("log-digest gate failed {} case(s)", report.failed_cases)
20151 },
20152 metrics: vec![
20153 envelope_metric("cases", report.total_cases),
20154 envelope_metric("failed", report.failed_cases),
20155 envelope_metric("passed", report.passed),
20156 ],
20157 },
20158 false,
20159 vec![],
20160 )?;
20161 } else {
20162 println!("Log digest fixture gate");
20163 println!(" cases: {}", report.total_cases);
20164 println!(" failed: {}", report.failed_cases);
20165 println!(" status: {}", if report.passed { "pass" } else { "fail" });
20166 for case in &report.cases {
20167 println!(
20168 " [{}] {} ({}): savings {:.1}% (min {:.1}%) raw_tok {} digest_tok {}",
20169 if case.passed { "pass" } else { "FAIL" },
20170 case.name,
20171 case.ecosystem,
20172 case.savings_percent,
20173 case.minimum_savings_percent,
20174 case.raw_tokens,
20175 case.digest_tokens
20176 );
20177 if !case.missing_required_signals.is_empty() {
20178 println!(
20179 " missing required signals: {}",
20180 case.missing_required_signals.join(", ")
20181 );
20182 }
20183 if !case.present_forbidden_signals.is_empty() {
20184 println!(
20185 " present forbidden signals: {}",
20186 case.present_forbidden_signals.join(", ")
20187 );
20188 }
20189 }
20190 }
20191
20192 if fail_under && !report.passed {
20193 bail!("log-digest fixture gate failed");
20194 }
20195 Ok(())
20196}
20197
20198pub(crate) fn render_log_digest_from_input(
20199 path: &Path,
20200 input: &str,
20201 format: OutputFormat,
20202) -> Result<()> {
20203 let mut report = log_digest::compute(path, input)?;
20204 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20205 maybe_attach_log_digest_raw_artifact(&root, &mut report, input)?;
20206 if format.json_output {
20207 println!(
20208 "{}",
20209 to_json_schema(
20210 &report,
20211 format.pretty,
20212 format.terse,
20213 format.ultra_terse,
20214 format.schema
20215 )?
20216 );
20217 return Ok(());
20218 }
20219
20220 if format.compact {
20221 println!(
20222 "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
20223 report.non_empty_lines,
20224 report.signal_groups,
20225 report.repeated_line_groups,
20226 report.file_ref_groups,
20227 report.symbol_ref_groups,
20228 report.stack_groups
20229 );
20230 for signal in &report.signals {
20231 let location = match (&signal.path, signal.line) {
20232 (Some(path), Some(line)) => format!("{path}:{line}"),
20233 (Some(path), None) => path.clone(),
20234 _ => "-".to_string(),
20235 };
20236 println!(
20237 "{} sev:{} count:{} sums:{} msg:{}",
20238 location,
20239 signal.severity,
20240 signal.occurrences,
20241 log_digest_summary_label(signal.summary_state),
20242 truncate_for_compact(&signal.message, 80)
20243 );
20244 }
20245 for repeated in &report.repeated_lines {
20246 println!(
20247 "repeat count:{} line:{}",
20248 repeated.occurrences,
20249 truncate_for_compact(&repeated.line, 80)
20250 );
20251 }
20252 for family in &report.line_families {
20253 println!(
20254 "family count:{} variants:{} template:{}",
20255 family.occurrences,
20256 family.variants,
20257 truncate_for_compact(&family.template, 80)
20258 );
20259 }
20260 for symbol in &report.symbol_refs {
20261 println!(
20262 "sym:{} count:{} sums:{}",
20263 symbol.symbol,
20264 symbol.occurrences,
20265 log_digest_summary_label(symbol.summary_state)
20266 );
20267 }
20268 if let Some(artifact) = &report.raw_log_artifact {
20269 println!(
20270 "raw-artifact handle:{} lines:{} bytes:{} expand:{}",
20271 artifact.handle, artifact.lines, artifact.bytes, artifact.expand
20272 );
20273 }
20274 for warning in &report.warnings {
20275 println!("warning: {warning}");
20276 }
20277 return Ok(());
20278 }
20279
20280 println!("Log digest");
20281 println!(" lines: {}", report.total_lines);
20282 println!(" non-empty lines: {}", report.non_empty_lines);
20283 println!(" signal groups: {}", report.signal_groups);
20284 println!(
20285 " repeated lines: {}",
20286 report.repeated_line_groups
20287 );
20288 println!(
20289 " repeated line instances: {}",
20290 report.repeated_line_occurrences
20291 );
20292 println!(" line families: {}", report.line_family_groups);
20293 println!(" file refs: {}", report.file_ref_groups);
20294 println!(" symbol refs: {}", report.symbol_ref_groups);
20295 println!(" stack groups: {}", report.stack_groups);
20296
20297 if !report.signals.is_empty() {
20298 println!();
20299 println!("Signals:");
20300 for signal in &report.signals {
20301 match (&signal.path, signal.line, signal.column) {
20302 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
20303 (Some(path), Some(line), None) => println!("{path}:{line}"),
20304 (Some(path), None, _) => println!("{path}"),
20305 (None, _, _) => println!("(no file anchor)"),
20306 }
20307 println!(" severity: {}", signal.severity);
20308 println!(" occurrences: {}", signal.occurrences);
20309 println!(" message: {}", signal.message);
20310 println!(
20311 " cached summaries: {}",
20312 log_digest_summary_label(signal.summary_state)
20313 );
20314 for summary in &signal.current_summaries {
20315 println!(
20316 " - {}: {}",
20317 summary.symbol,
20318 truncate_for_compact(&summary.summary, 160)
20319 );
20320 }
20321 }
20322 }
20323
20324 if !report.repeated_lines.is_empty() {
20325 println!();
20326 println!("Repeated lines:");
20327 for repeated in &report.repeated_lines {
20328 println!(
20329 " {}x {}",
20330 repeated.occurrences,
20331 truncate_for_compact(&repeated.line, 180)
20332 );
20333 }
20334 }
20335
20336 if !report.line_families.is_empty() {
20337 println!();
20338 println!("Line families (near-duplicate folds):");
20339 for family in &report.line_families {
20340 println!(
20341 " {}x ({} variants) {}",
20342 family.occurrences,
20343 family.variants,
20344 truncate_for_compact(&family.template, 180)
20345 );
20346 println!(
20347 " first: {}",
20348 truncate_for_compact(&family.first_sample, 180)
20349 );
20350 println!(
20351 " last: {}",
20352 truncate_for_compact(&family.last_sample, 180)
20353 );
20354 }
20355 }
20356
20357 if !report.file_refs.is_empty() {
20358 println!();
20359 println!("Anchored files:");
20360 for file_ref in &report.file_refs {
20361 match (file_ref.line, file_ref.column) {
20362 (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
20363 (Some(line), None) => println!("{}:{}", file_ref.path, line),
20364 (None, _) => println!("{}", file_ref.path),
20365 }
20366 println!(" occurrences: {}", file_ref.occurrences);
20367 println!(
20368 " cached summaries: {}",
20369 log_digest_summary_label(file_ref.summary_state)
20370 );
20371 for summary in &file_ref.current_summaries {
20372 println!(
20373 " - {}: {}",
20374 summary.symbol,
20375 truncate_for_compact(&summary.summary, 160)
20376 );
20377 }
20378 }
20379 }
20380
20381 if !report.symbol_refs.is_empty() {
20382 println!();
20383 println!("Symbol candidates:");
20384 for symbol in &report.symbol_refs {
20385 println!("{}", symbol.symbol);
20386 println!(" occurrences: {}", symbol.occurrences);
20387 println!(
20388 " cached summaries: {}",
20389 log_digest_summary_label(symbol.summary_state)
20390 );
20391 for summary in &symbol.current_summaries {
20392 println!(
20393 " - {}: {}",
20394 summary.symbol,
20395 truncate_for_compact(&summary.summary, 160)
20396 );
20397 }
20398 }
20399 }
20400
20401 if !report.stack_traces.is_empty() {
20402 println!();
20403 println!("Stack groups:");
20404 for stack in &report.stack_traces {
20405 println!(" occurrences: {}", stack.occurrences);
20406 for frame in &stack.frames {
20407 println!(" - {}", frame);
20408 }
20409 }
20410 }
20411
20412 if let Some(artifact) = &report.raw_log_artifact {
20413 println!();
20414 println!("Raw log artifact:");
20415 println!(" handle: {}", artifact.handle);
20416 println!(" path: {}", artifact.path);
20417 println!(" lines: {}", artifact.lines);
20418 println!(" bytes: {}", artifact.bytes);
20419 println!(" expand: {}", artifact.expand);
20420 }
20421
20422 for warning in &report.warnings {
20423 println!("warning: {warning}");
20424 }
20425 Ok(())
20426}
20427
20428pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
20429 match trend {
20430 metric_digest::MetricDigestTrend::Improved => "improved",
20431 metric_digest::MetricDigestTrend::Regressed => "regressed",
20432 metric_digest::MetricDigestTrend::Flat => "flat",
20433 metric_digest::MetricDigestTrend::Unknown => "changed",
20434 }
20435}
20436
20437pub(crate) fn metric_digest_gate_label(
20438 decision: metric_digest::CommunitySearchGateDecision,
20439) -> &'static str {
20440 match decision {
20441 metric_digest::CommunitySearchGateDecision::Pass => "pass",
20442 metric_digest::CommunitySearchGateDecision::Block => "block",
20443 }
20444}
20445
20446pub(crate) fn memgraphrag_metric_digest_gate_label(
20447 decision: metric_digest::MemGraphRagPerformanceGateDecision,
20448) -> &'static str {
20449 match decision {
20450 metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
20451 metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
20452 }
20453}
20454
20455fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
20456 let input = fs::read_to_string(fixture_path)
20457 .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
20458 let report = dci_benchmark::compute(&input)?;
20459
20460 if format.json_output {
20461 println!(
20462 "{}",
20463 to_json_schema(
20464 &report,
20465 format.pretty,
20466 format.terse,
20467 format.ultra_terse,
20468 format.schema
20469 )?
20470 );
20471 return Ok(());
20472 }
20473
20474 if format.compact {
20475 println!(
20476 "dci tasks:{} strategies:{} warnings:{}",
20477 report.tasks_loaded,
20478 report.strategies_compared,
20479 report.warnings.len()
20480 );
20481 for summary in &report.strategy_summaries {
20482 println!(
20483 "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
20484 summary.strategy,
20485 summary.rank,
20486 summary.localized,
20487 summary.task_runs,
20488 dci_benchmark::format_number(summary.localization_rate * 100.0),
20489 dci_benchmark::format_number(summary.avg_useful_hits),
20490 dci_benchmark::format_number(summary.zero_output_rate * 100.0),
20491 dci_benchmark::format_number(summary.avg_tool_calls),
20492 dci_benchmark::format_number(summary.avg_latency_ms),
20493 dci_benchmark::format_number(summary.avg_estimated_tokens),
20494 dci_benchmark::format_number(summary.avg_output_tokens)
20495 );
20496 }
20497 if let Some(gate) = &report.memory_retrieval_gate {
20498 println!(
20499 "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
20500 gate.decision,
20501 gate.baseline_strategy,
20502 dci_benchmark::format_number(gate.min_avg_useful_hits),
20503 gate.max_zero_output_failures,
20504 gate.diagnostics.len()
20505 );
20506 }
20507 for warning in &report.warnings {
20508 println!("warning: {warning}");
20509 }
20510 return Ok(());
20511 }
20512
20513 println!("DCI benchmark");
20514 if let Some(description) = &report.description {
20515 println!(" description: {}", description);
20516 }
20517 println!(" tasks loaded: {}", report.tasks_loaded);
20518 println!(" strategies compared: {}", report.strategies_compared);
20519
20520 println!();
20521 println!("Strategy summary:");
20522 for summary in &report.strategy_summaries {
20523 println!(
20524 " #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
20525 summary.rank,
20526 summary.strategy,
20527 summary.localized,
20528 summary.task_runs,
20529 summary.localization_rate * 100.0,
20530 dci_benchmark::format_number(summary.avg_useful_hits),
20531 summary.zero_output_rate * 100.0,
20532 dci_benchmark::format_number(summary.avg_tool_calls),
20533 dci_benchmark::format_number(summary.avg_latency_ms),
20534 dci_benchmark::format_number(summary.avg_estimated_tokens),
20535 dci_benchmark::format_number(summary.avg_output_tokens)
20536 );
20537 }
20538
20539 if let Some(gate) = &report.memory_retrieval_gate {
20540 println!();
20541 println!("Memory retrieval gate:");
20542 println!(" decision: {}", gate.decision);
20543 println!(
20544 " baseline: {}, min avg useful hits {}, max zero-output failures {}",
20545 gate.baseline_strategy,
20546 dci_benchmark::format_number(gate.min_avg_useful_hits),
20547 gate.max_zero_output_failures
20548 );
20549 for row in &gate.rows {
20550 println!(
20551 " {}: status {}, avg useful hits {}, zero-output failures {}",
20552 row.strategy,
20553 row.status,
20554 dci_benchmark::format_number(row.avg_useful_hits),
20555 row.zero_output_failures
20556 );
20557 }
20558 for diagnostic in &gate.diagnostics {
20559 println!(" diagnostic: {diagnostic}");
20560 }
20561 }
20562
20563 println!();
20564 println!("Task winners:");
20565 for row in &report.task_rows {
20566 let label = row
20567 .label
20568 .as_ref()
20569 .map(|value| format!(" ({value})"))
20570 .unwrap_or_default();
20571 println!(" {}{}", row.task_id, label);
20572 println!(" localized: {}", row.best_localization.join(", "));
20573 println!(" most useful hits: {}", row.most_useful_hits.join(", "));
20574 println!(
20575 " lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
20576 row.lowest_tool_calls.as_deref().unwrap_or("-"),
20577 row.lowest_latency.as_deref().unwrap_or("-"),
20578 row.lowest_token_budget.as_deref().unwrap_or("-"),
20579 row.lowest_output_tokens.as_deref().unwrap_or("-")
20580 );
20581 if !row.zero_output_failures.is_empty() {
20582 println!(" zero output: {}", row.zero_output_failures.join(", "));
20583 }
20584 }
20585
20586 for warning in &report.warnings {
20587 println!("warning: {warning}");
20588 }
20589 Ok(())
20590}
20591
20592pub(crate) fn format_compact_count(value: u64) -> String {
20593 if value >= 1_000_000 {
20594 format!("{:.1}M", value as f64 / 1_000_000.0)
20595 } else if value >= 1_000 {
20596 format!("{:.1}K", value as f64 / 1_000.0)
20597 } else {
20598 value.to_string()
20599 }
20600}
20601
20602fn cmd_digest_runner(
20603 kind: &str,
20604 path: &Path,
20605 runner: Option<&str>,
20606 shell_command: &str,
20607 format: OutputFormat,
20608) -> Result<()> {
20609 let digest_kind = DigestRunnerKind::parse(kind)?;
20610 let root = transcript_artifact_root(path)?;
20611 let execution = run_digest_runner_command(shell_command)?;
20612 let output = &execution.output;
20613 let captured = String::from_utf8_lossy(&output.stdout).into_owned();
20614 let exit_code = output.status.code().unwrap_or(-1);
20615 if format.json_output && format.envelope {
20616 let artifact_key = format!(
20617 "{}:{}:{}:{}",
20618 digest_kind.as_str(),
20619 shell_command,
20620 execution.executed_command,
20621 captured
20622 );
20623 let artifact = if captured.trim().is_empty() {
20624 None
20625 } else {
20626 let (suffix, expand) = match digest_kind {
20627 DigestRunnerKind::Test => (
20628 "test.log",
20629 format!(
20630 "tsift test-digest --path {} --input {}{} --json",
20631 shell_quote(root.to_string_lossy().as_ref()),
20632 shell_quote(
20633 root.join(".tsift/artifacts")
20634 .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
20635 .to_string_lossy()
20636 .as_ref()
20637 ),
20638 runner
20639 .map(|value| format!(" --runner {}", shell_quote(value)))
20640 .unwrap_or_default()
20641 ),
20642 ),
20643 DigestRunnerKind::Log => (
20644 "log",
20645 format!(
20646 "tsift log-digest --path {} --input {} --json",
20647 shell_quote(root.to_string_lossy().as_ref()),
20648 shell_quote(
20649 root.join(".tsift/artifacts")
20650 .join(format!("{}.log", stable_handle("tart", &artifact_key)))
20651 .to_string_lossy()
20652 .as_ref()
20653 )
20654 ),
20655 ),
20656 };
20657 Some(persist_transcript_artifact(
20658 &root,
20659 "tart",
20660 suffix,
20661 &artifact_key,
20662 &captured,
20663 expand,
20664 )?)
20665 };
20666 let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
20667
20668 match digest_kind {
20669 DigestRunnerKind::Test => {
20670 let digest_report = test_digest::compute(path, &captured, runner)?;
20671 let report = serde_json::json!({
20672 "kind": digest_kind.as_str(),
20673 "command": shell_command,
20674 "executed_command": execution.executed_command,
20675 "exit_code": exit_code,
20676 "success": output.status.success(),
20677 "filter": filter_report,
20678 "artifact": artifact,
20679 "digest": digest_report,
20680 });
20681 let mut follow_up = artifact
20682 .as_ref()
20683 .map(|entry| vec![entry.expand.clone()])
20684 .unwrap_or_default();
20685 follow_up.push(format!(
20686 "tsift rewrite --run {}",
20687 shell_quote(shell_command)
20688 ));
20689 let summary_text = if output.status.success() && digest_report.failures == 0 {
20690 format!("test run passed for {}", runner.unwrap_or("auto"))
20691 } else {
20692 format!("test run captured {} failure(s)", digest_report.failures)
20693 };
20694 print_json_or_envelope(
20695 &report,
20696 &format,
20697 "digest-runner",
20698 "test-run",
20699 ToolEnvelopeSummary {
20700 text: summary_text,
20701 metrics: vec![
20702 envelope_metric("runner", &digest_report.runner),
20703 envelope_metric("exit_code", exit_code),
20704 envelope_metric("filter", execution.filter_label()),
20705 envelope_metric("failures", digest_report.failures),
20706 envelope_metric("groups", digest_report.grouped_failures),
20707 envelope_metric(
20708 "artifact",
20709 artifact
20710 .as_ref()
20711 .map(|entry| entry.handle.as_str())
20712 .unwrap_or("-"),
20713 ),
20714 ],
20715 },
20716 false,
20717 follow_up,
20718 )?;
20719 }
20720 DigestRunnerKind::Log => {
20721 let digest_report = log_digest::compute(path, &captured)?;
20722 let report = serde_json::json!({
20723 "kind": digest_kind.as_str(),
20724 "command": shell_command,
20725 "executed_command": execution.executed_command,
20726 "exit_code": exit_code,
20727 "success": output.status.success(),
20728 "filter": filter_report,
20729 "artifact": artifact,
20730 "digest": digest_report,
20731 });
20732 let mut follow_up = artifact
20733 .as_ref()
20734 .map(|entry| vec![entry.expand.clone()])
20735 .unwrap_or_default();
20736 follow_up.push(format!(
20737 "tsift rewrite --run {}",
20738 shell_quote(shell_command)
20739 ));
20740 let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
20741 "command finished without log signals".to_string()
20742 } else {
20743 format!(
20744 "command emitted {} log signal group(s)",
20745 digest_report.signal_groups
20746 )
20747 };
20748 print_json_or_envelope(
20749 &report,
20750 &format,
20751 "digest-runner",
20752 "command-run",
20753 ToolEnvelopeSummary {
20754 text: summary_text,
20755 metrics: vec![
20756 envelope_metric("exit_code", exit_code),
20757 envelope_metric("filter", execution.filter_label()),
20758 envelope_metric("signals", digest_report.signal_groups),
20759 envelope_metric("file_refs", digest_report.file_ref_groups),
20760 envelope_metric(
20761 "artifact",
20762 artifact
20763 .as_ref()
20764 .map(|entry| entry.handle.as_str())
20765 .unwrap_or("-"),
20766 ),
20767 ],
20768 },
20769 false,
20770 follow_up,
20771 )?;
20772 }
20773 }
20774
20775 if output.status.success() {
20776 return Ok(());
20777 }
20778 if let Some(code) = output.status.code() {
20779 std::process::exit(code);
20780 }
20781 bail!("digest-wrapped command terminated by signal: {shell_command}");
20782 }
20783
20784 if captured.trim().is_empty() {
20785 let label = match digest_kind {
20786 DigestRunnerKind::Test => "test",
20787 DigestRunnerKind::Log => "log",
20788 };
20789 println!("No {label} output captured.");
20790 } else {
20791 match digest_kind {
20792 DigestRunnerKind::Test => {
20793 render_test_digest_from_input(path, &captured, runner, format)?
20794 }
20795 DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20796 }
20797 }
20798
20799 if output.status.success() {
20800 return Ok(());
20801 }
20802 if let Some(code) = output.status.code() {
20803 std::process::exit(code);
20804 }
20805 bail!("digest-wrapped command terminated by signal: {shell_command}");
20806}
20807
20808struct DigestRunnerExecution {
20809 output: std::process::Output,
20810 executed_command: String,
20811 filter: Option<DigestRunnerFilter>,
20812}
20813
20814impl DigestRunnerExecution {
20815 fn filter_label(&self) -> &'static str {
20816 self.filter
20817 .as_ref()
20818 .map(|filter| filter.tool)
20819 .unwrap_or("none")
20820 }
20821}
20822
20823struct DigestRunnerFilter {
20824 tool: &'static str,
20825 command: String,
20826}
20827
20828impl DigestRunnerFilter {
20829 fn to_json(&self) -> serde_json::Value {
20830 serde_json::json!({
20831 "tool": self.tool,
20832 "command": self.command,
20833 })
20834 }
20835}
20836
20837fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20838 let filter = rtk_rewrite_for_digest_runner(shell_command);
20839 let executed_command = filter
20840 .as_ref()
20841 .map(|filter| filter.command.as_str())
20842 .unwrap_or(shell_command);
20843 let output = Command::new("sh")
20844 .arg("-lc")
20845 .arg(format!("({executed_command}) 2>&1"))
20846 .stdout(Stdio::piped())
20847 .output()
20848 .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20849
20850 Ok(DigestRunnerExecution {
20851 output,
20852 executed_command: executed_command.to_string(),
20853 filter,
20854 })
20855}
20856
20857fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20858 if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20859 return None;
20860 }
20861 let output = Command::new("rtk")
20862 .arg("rewrite")
20863 .arg(shell_command)
20864 .output()
20865 .ok()?;
20866 if !output.status.success() {
20867 return None;
20868 }
20869 let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20870 if rewritten.is_empty() || rewritten == shell_command {
20871 return None;
20872 }
20873 Some(DigestRunnerFilter {
20874 tool: "rtk",
20875 command: rewritten,
20876 })
20877}
20878
20879fn find_command_on_path(command: &str) -> Option<PathBuf> {
20880 let path_var = std::env::var_os("PATH")?;
20881 std::env::split_paths(&path_var)
20882 .map(|dir| dir.join(command))
20883 .find(|candidate| candidate.is_file())
20884}
20885
20886pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20887 if !db_path.exists() {
20888 bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20889 }
20890 summarize::SummaryDb::open_read_only_resilient(db_path)
20891}
20892
20893fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20894 !matches!(report.index, status::IndexStatus::Fresh { .. })
20895}
20896
20897fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20898 !matches!(report.instructions, init::InstructionStatus::Current { .. })
20899}
20900
20901pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20902 if status_instructions_need_fix(report) {
20903 eprintln!("status fix: refreshing tsift instructions");
20904 init::init(root, false, false)?;
20905 }
20906
20907 let eviction = cycle_packet_cache::cycle_packet_cache_evict(
20908 root,
20909 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
20910 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
20911 );
20912 if eviction.evicted_entries > 0 {
20913 eprintln!(
20914 "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
20915 eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
20916 );
20917 }
20918
20919 if !status_index_needs_fix(report) {
20920 return Ok(());
20921 }
20922
20923 let scopes = config::Config::submodule_dirs(root)?;
20924 if scopes.is_empty() {
20925 eprintln!("status fix: refreshing index");
20926 run_index_update(
20927 &root.join(".tsift/index.db"),
20928 root,
20929 "status --fix refreshing index".to_string(),
20930 root,
20931 None,
20932 false,
20933 false,
20934 )?;
20935 return Ok(());
20936 }
20937
20938 let cfg = config::Config::load(root)?;
20939 for scope in scopes {
20940 if !scope.source_root.exists() {
20941 eprintln!(
20942 "status fix: skipping missing submodule `{}` ({})",
20943 scope.id,
20944 scope.source_root.display()
20945 );
20946 continue;
20947 }
20948 eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20949 run_index_update(
20950 &cfg.db_path_for(root, &scope.id),
20951 &scope.source_root,
20952 format!("status --fix refreshing submodule `{}` index", scope.id),
20953 root,
20954 Some(scope.id.as_str()),
20955 false,
20956 false,
20957 )?;
20958 }
20959
20960 Ok(())
20961}
20962
20963pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20964 match &report.index {
20965 status::IndexStatus::Fresh { missing_scopes, .. }
20966 | status::IndexStatus::Stale { missing_scopes, .. }
20967 | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20968 }
20969}
20970
20971pub(crate) fn autoindex_missing_workspace_scopes(
20972 root: &Path,
20973 report: &status::StatusReport,
20974) -> Result<()> {
20975 let missing_scopes = match &report.index {
20976 status::IndexStatus::Fresh { missing_scopes, .. }
20977 | status::IndexStatus::Stale { missing_scopes, .. }
20978 | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20979 };
20980 if missing_scopes.is_empty() {
20981 return Ok(());
20982 }
20983
20984 let missing_scope_ids = missing_scopes
20985 .iter()
20986 .map(|scope| scope.scope.as_str())
20987 .collect::<std::collections::HashSet<_>>();
20988 let cfg = config::Config::load(root)?;
20989 for scope in config::Config::submodule_dirs(root)? {
20990 if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20991 continue;
20992 }
20993 let db_path = cfg.db_path_for(root, &scope.id);
20994 run_index_update(
20995 &db_path,
20996 &scope.source_root,
20997 format!(
20998 "autoindexing missing submodule `{}` during status",
20999 scope.id
21000 ),
21001 root,
21002 Some(scope.id.as_str()),
21003 false,
21004 false,
21005 )?;
21006 }
21007 Ok(())
21008}
21009
21010pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
21011 for warning in &stats.warnings {
21012 let rel_path = relativize_pathbuf(&warning.path, root);
21013 eprintln!(
21014 "warning: summarize stats {}: {}",
21015 rel_path.display(),
21016 warning.message
21017 );
21018 }
21019}
21020
21021fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
21022 Result::<(), anyhow::Error>::Err(err)
21023 .context(context)
21024 .unwrap_err()
21025}
21026
21027fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
21028 let message = err.to_string();
21029 message.contains("another tsift index writer is already active")
21030 || substrate::error_mentions_locked_db(err)
21031}
21032
21033fn add_write_lock_context(
21034 err: anyhow::Error,
21035 action: String,
21036 root: &std::path::Path,
21037 scope: Option<&str>,
21038) -> anyhow::Error {
21039 if !should_attach_lock_diagnostics(&err) {
21040 return contextualize_error(err, action);
21041 }
21042
21043 let Ok(report) = status::check_locks(root, None, scope) else {
21044 return contextualize_error(err, action);
21045 };
21046
21047 contextualize_error(
21048 err,
21049 format!(
21050 "{}\n\nlock diagnostics:\n{}",
21051 action,
21052 status::format_locks_human(&report, false).trim_end()
21053 ),
21054 )
21055}
21056
21057pub(crate) fn run_index_update(
21058 db_path: &std::path::Path,
21059 source_root: &std::path::Path,
21060 action: String,
21061 root: &std::path::Path,
21062 scope: Option<&str>,
21063 rebuild: bool,
21064 prune: bool,
21065) -> Result<index::IndexSummary> {
21066 let result = (|| {
21067 let db = index::IndexDb::open(db_path)?;
21068 if rebuild {
21069 db.rebuild(source_root)
21070 } else if prune {
21071 db.apply_changes_pruned(source_root)
21072 } else {
21073 db.apply_changes(source_root)
21074 }
21075 })();
21076
21077 let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
21078 emit_index_warnings(&summary, source_root, scope);
21079 Ok(summary)
21080}
21081
21082pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
21083 for change in &mut summary.changes {
21084 change.path = relativize_pathbuf(&change.path, root);
21085 }
21086 for warning in &mut summary.warnings {
21087 warning.path = relativize_pathbuf(&warning.path, root);
21088 }
21089}
21090
21091fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
21092 for warning in &summary.warnings {
21093 let rel_path = relativize_pathbuf(&warning.path, root);
21094 let stage = match warning.stage {
21095 index::IndexWarningStage::ReadSource => "read failed",
21096 index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
21097 index::IndexWarningStage::ExtractCallSites => "call extraction failed",
21098 index::IndexWarningStage::ExtractRoutes => "route extraction failed",
21099 };
21100 let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
21101 let lang_suffix = warning
21102 .language
21103 .as_deref()
21104 .map(|lang| format!(" [{}]", lang))
21105 .unwrap_or_default();
21106 eprintln!(
21107 "warning: {}{}{}: {}: {}",
21108 scope_prefix,
21109 rel_path.display(),
21110 lang_suffix,
21111 stage,
21112 warning.message
21113 );
21114 }
21115}
21116
21117pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
21118 let config_path = root.join(".tsift/config.toml");
21119 if !config_path.exists() {
21120 return summarize::SummarizeConfig::default();
21121 }
21122 #[derive(serde::Deserialize, Default)]
21123 struct RawConfig {
21124 #[serde(default)]
21125 summarize: Option<RawSummarize>,
21126 }
21127 #[derive(serde::Deserialize)]
21128 struct RawSummarize {
21129 model: Option<String>,
21130 max_file_tokens: Option<usize>,
21131 api_key_env: Option<String>,
21132 }
21133 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
21134 let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
21135 let defaults = summarize::SummarizeConfig::default();
21136 match raw.summarize {
21137 Some(s) => summarize::SummarizeConfig {
21138 model: s.model.unwrap_or(defaults.model),
21139 max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
21140 api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
21141 },
21142 None => defaults,
21143 }
21144}
21145
21146#[derive(Debug, Clone, PartialEq, Eq)]
21147struct ExtractSymbolContext {
21148 db_path: PathBuf,
21149 source_root: PathBuf,
21150}
21151
21152pub(crate) fn find_symbols_db_for_file(
21153 root: &Path,
21154 file_path: &Path,
21155) -> Result<Option<ExtractSymbolContext>> {
21156 let cfg = config::Config::load(root)?;
21157 let mut submodules = config::Config::submodule_dirs(root)?;
21158 submodules.sort_by(|left, right| {
21159 right
21160 .source_root
21161 .components()
21162 .count()
21163 .cmp(&left.source_root.components().count())
21164 });
21165
21166 for scope in submodules {
21167 if !file_path.starts_with(&scope.source_root) {
21168 continue;
21169 }
21170 let db_path = cfg.db_path_for(root, &scope.id);
21171 if db_path.exists() {
21172 return Ok(Some(ExtractSymbolContext {
21173 db_path,
21174 source_root: scope.source_root,
21175 }));
21176 }
21177 }
21178
21179 let single = root.join(".tsift/index.db");
21180 if single.exists() && file_path.starts_with(root) {
21181 return Ok(Some(ExtractSymbolContext {
21182 db_path: single,
21183 source_root: root.to_path_buf(),
21184 }));
21185 }
21186
21187 Ok(None)
21188}
21189
21190pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
21191 let canonical = path
21192 .canonicalize()
21193 .with_context(|| format!("canonicalizing {}", path.display()))?;
21194
21195 Ok(if canonical.is_dir() {
21196 canonical
21197 } else {
21198 canonical
21199 .parent()
21200 .map(Path::to_path_buf)
21201 .unwrap_or(canonical)
21202 })
21203}
21204
21205fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
21206 if path.exists() {
21207 return path
21208 .canonicalize()
21209 .with_context(|| format!("canonicalizing extract scope {}", path.display()));
21210 }
21211
21212 Ok(summarize::normalize_lexical_path(path))
21213}
21214
21215pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
21216 let scope = if extract_path.is_absolute() {
21217 extract_path.to_path_buf()
21218 } else {
21219 root.join(extract_path)
21220 };
21221 normalize_extract_scope_path(&scope)
21222}
21223
21224pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
21225 normalize_extract_scope_path(changed_path)
21226 .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
21227 .starts_with(extract_scope)
21228}
21229
21230pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
21231 summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
21232}
21233
21234pub(crate) fn summarize_full_extract_deleted_summary_paths(
21235 summary_db: &summarize::SummaryDb,
21236 root: &Path,
21237 extract_scope: &Path,
21238 files_to_extract: &[PathBuf],
21239) -> Result<BTreeSet<String>> {
21240 let live_paths = files_to_extract
21241 .iter()
21242 .map(|file_path| summarize_relative_file_path(root, file_path))
21243 .collect::<BTreeSet<_>>();
21244 let mut deleted = BTreeSet::new();
21245
21246 for cached_path in summary_db.cached_file_paths()? {
21247 if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
21248 continue;
21249 }
21250 if !live_paths.contains(&cached_path) {
21251 deleted.insert(cached_path);
21252 }
21253 }
21254
21255 Ok(deleted)
21256}
21257
21258#[derive(Debug, Clone)]
21259struct SearchIndexTarget {
21260 label: String,
21261 db_path: PathBuf,
21262 source_root: PathBuf,
21263 scope_name: Option<String>,
21264 reindex_cmd: String,
21265}
21266
21267fn cargo_package_index_target(
21268 root: &Path,
21269 package: multiplicity::CargoPackageInfo,
21270) -> SearchIndexTarget {
21271 SearchIndexTarget {
21272 label: format!("cargo package `{}` index", package.scope_id),
21273 db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
21274 source_root: package.package_root.clone(),
21275 scope_name: Some(package.scope_id.clone()),
21276 reindex_cmd: format!(
21277 "tsift index --submodule {} {}",
21278 package.scope_id,
21279 root.display()
21280 ),
21281 }
21282}
21283
21284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21285enum SearchIndexState {
21286 Missing,
21287 Fresh,
21288 Stale { stale_files: usize },
21289}
21290
21291fn resolve_search_index_targets(
21292 root: &Path,
21293 path_hint: &Path,
21294 scope: Option<&str>,
21295 federated: bool,
21296) -> Result<Vec<SearchIndexTarget>> {
21297 if let Some(scope_name) = scope {
21298 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
21299 let cfg = config::Config::load(root)?;
21300 return Ok(vec![SearchIndexTarget {
21301 label: format!("submodule `{}` index", scope.id),
21302 db_path: cfg.db_path_for(root, &scope.id),
21303 source_root: scope.source_root.clone(),
21304 scope_name: Some(scope.id.clone()),
21305 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21306 }]);
21307 }
21308 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
21309 return Ok(vec![cargo_package_index_target(root, package)]);
21310 }
21311 config::Config::resolve_submodule(root, scope_name)?;
21312 }
21313
21314 if federated {
21315 let cfg = config::Config::load(root)?;
21316 let mut targets = Vec::new();
21317 for scope in config::Config::submodule_dirs(root)? {
21318 if !cfg.federation_for_scope(&scope) {
21319 continue;
21320 }
21321 targets.push(SearchIndexTarget {
21322 label: format!("submodule `{}` index", scope.id),
21323 db_path: cfg.db_path_for(root, &scope.id),
21324 source_root: scope.source_root.clone(),
21325 scope_name: Some(scope.id.clone()),
21326 reindex_cmd: format!("tsift index --workspace {}", root.display()),
21327 });
21328 }
21329 return Ok(targets);
21330 }
21331
21332 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
21333 let cfg = config::Config::load(root)?;
21334 return Ok(vec![SearchIndexTarget {
21335 label: format!("submodule `{}` index", scope.id),
21336 db_path: cfg.db_path_for(root, &scope.id),
21337 source_root: scope.source_root.clone(),
21338 scope_name: Some(scope.id.clone()),
21339 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21340 }]);
21341 }
21342
21343 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
21344 return Ok(vec![cargo_package_index_target(root, package)]);
21345 }
21346
21347 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
21348 let cfg = config::Config::load(root)?;
21349 return Ok(vec![SearchIndexTarget {
21350 label: format!("submodule `{}` index", scope.id),
21351 db_path: cfg.db_path_for(root, &scope.id),
21352 source_root: scope.source_root.clone(),
21353 scope_name: Some(scope.id.clone()),
21354 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21355 }]);
21356 }
21357
21358 let scopes = config::Config::submodule_dirs(root)?;
21359 if !scopes.is_empty() {
21360 let root_db = root.join(".tsift/index.db");
21361 if !root_db.exists() {
21362 let available_scopes = scopes
21363 .iter()
21364 .map(|scope| scope.id.as_str())
21365 .collect::<Vec<_>>()
21366 .join(", ");
21367 let cfg = config::Config::load(root)?;
21368 let indexed_scopes = scopes
21369 .iter()
21370 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
21371 .map(|scope| scope.id.as_str())
21372 .collect::<Vec<_>>();
21373 let indexed_label = if indexed_scopes.is_empty() {
21374 "none".to_string()
21375 } else {
21376 indexed_scopes.join(", ")
21377 };
21378 bail!(
21379 "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: {}.",
21380 root.display(),
21381 root_db.display(),
21382 available_scopes,
21383 indexed_label,
21384 );
21385 }
21386 }
21387
21388 Ok(vec![SearchIndexTarget {
21389 label: "index".to_string(),
21390 db_path: root.join(".tsift/index.db"),
21391 source_root: root.to_path_buf(),
21392 scope_name: None,
21393 reindex_cmd: format!("tsift index {}", root.display()),
21394 }])
21395}
21396
21397fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
21398 if !target.source_root.exists() || !target.db_path.exists() {
21399 return Ok(SearchIndexState::Missing);
21400 }
21401
21402 let inspection =
21403 index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
21404 let stale_files =
21405 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
21406 if stale_files == 0 {
21407 Ok(SearchIndexState::Fresh)
21408 } else {
21409 Ok(SearchIndexState::Stale { stale_files })
21410 }
21411}
21412
21413#[derive(Debug, Clone, PartialEq, Eq)]
21414struct RebuildSearchTarget {
21415 label: String,
21416 reason: RebuildSearchReason,
21417 reindex_cmd: String,
21418}
21419
21420#[derive(Debug, Clone, PartialEq, Eq)]
21421enum RebuildSearchReason {
21422 Missing,
21423 Stale { stale_files: usize },
21424}
21425
21426#[derive(Debug, Clone, PartialEq, Eq)]
21427struct DegradedSearchTarget {
21428 label: String,
21429 reason: RebuildSearchReason,
21430 reindex_cmd: String,
21431}
21432
21433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21434pub(crate) enum DegradedSearchMode {
21435 ReadOnly,
21436 Exact,
21437}
21438
21439#[derive(Debug)]
21440struct SearchPrecheck {
21441 targets: Vec<SearchIndexTarget>,
21442 degraded_targets: Vec<DegradedSearchTarget>,
21443}
21444
21445fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
21446 err.chain().any(|cause| {
21447 cause
21448 .to_string()
21449 .contains("another tsift index writer is already active")
21450 })
21451}
21452
21453fn infer_agent_doc_task_submodule(
21454 root: &Path,
21455 path_hint: &Path,
21456) -> Result<Option<config::WorkspaceScope>> {
21457 let hinted_path = if path_hint.is_absolute() {
21458 path_hint.to_path_buf()
21459 } else {
21460 root.join(path_hint)
21461 };
21462 let Ok(relative) = hinted_path.strip_prefix(root) else {
21463 return Ok(None);
21464 };
21465 let mut components = relative.components();
21466 let Some(std::path::Component::Normal(first)) = components.next() else {
21467 return Ok(None);
21468 };
21469 if first != "tasks" {
21470 return Ok(None);
21471 }
21472 let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
21473 return Ok(None);
21474 };
21475 config::Config::find_submodule(root, file_stem)
21476}
21477
21478fn degraded_search_target(
21479 target: &SearchIndexTarget,
21480 reason: RebuildSearchReason,
21481) -> DegradedSearchTarget {
21482 DegradedSearchTarget {
21483 label: target.label.clone(),
21484 reason,
21485 reindex_cmd: target.reindex_cmd.clone(),
21486 }
21487}
21488
21489fn apply_search_index_update(
21490 root: &Path,
21491 target: &SearchIndexTarget,
21492) -> Result<index::IndexSummary> {
21493 run_index_update(
21494 &target.db_path,
21495 &target.source_root,
21496 format!("autoindexing {}", target.label),
21497 root,
21498 target.scope_name.as_deref(),
21499 false,
21500 false,
21501 )
21502}
21503
21504fn collect_rebuild_search_targets(
21505 targets: &[SearchIndexTarget],
21506) -> Result<Vec<RebuildSearchTarget>> {
21507 let mut rebuild_targets = Vec::new();
21508 for target in targets {
21509 let reason = match inspect_search_index(target)? {
21510 SearchIndexState::Missing => RebuildSearchReason::Missing,
21511 SearchIndexState::Fresh => continue,
21512 SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
21513 };
21514 rebuild_targets.push(RebuildSearchTarget {
21515 label: target.label.clone(),
21516 reason,
21517 reindex_cmd: target.reindex_cmd.clone(),
21518 });
21519 }
21520 Ok(rebuild_targets)
21521}
21522
21523fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
21524 match target.reason {
21525 RebuildSearchReason::Missing => format!("{} is missing", target.label),
21526 RebuildSearchReason::Stale { stale_files } => {
21527 let file_suffix = if stale_files == 1 { "" } else { "s" };
21528 format!(
21529 "{} is stale ({} file{})",
21530 target.label, stale_files, file_suffix
21531 )
21532 }
21533 }
21534}
21535
21536fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
21537 if rebuild_targets.len() == 1 {
21538 let target = &rebuild_targets[0];
21539 return format!(
21540 "{}. Run `{}` to rebuild before retrying.",
21541 rebuild_search_target_detail(target),
21542 target.reindex_cmd
21543 );
21544 }
21545
21546 let summary: Vec<String> = rebuild_targets
21547 .iter()
21548 .take(3)
21549 .map(rebuild_search_target_detail)
21550 .collect();
21551 let overflow = rebuild_targets.len().saturating_sub(summary.len());
21552 let mut details = summary.join(", ");
21553 if overflow > 0 {
21554 details.push_str(&format!(", +{} more", overflow));
21555 }
21556 let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
21557 format!(
21558 "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
21559 rebuild_targets.len(),
21560 details,
21561 reindex_cmd
21562 )
21563}
21564
21565pub(crate) fn precheck_search_indexes(
21566 root: &Path,
21567 path_hint: &Path,
21568 scope: Option<&str>,
21569 federated: bool,
21570 autoindex: bool,
21571) -> Result<SearchPrecheck> {
21572 let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
21573 let mut stale_targets = Vec::new();
21574 let mut degraded_targets = Vec::new();
21575
21576 for target in &targets {
21577 match inspect_search_index(target)? {
21578 SearchIndexState::Missing => {
21579 if autoindex && let Err(err) = apply_search_index_update(root, target) {
21580 if is_active_writer_lock_error(&err) {
21581 degraded_targets
21582 .push(degraded_search_target(target, RebuildSearchReason::Missing));
21583 } else {
21584 return Err(err);
21585 }
21586 }
21587 }
21588 SearchIndexState::Fresh => {}
21589 SearchIndexState::Stale { stale_files } => {
21590 if autoindex {
21591 if let Err(err) = apply_search_index_update(root, target) {
21592 if is_active_writer_lock_error(&err) {
21593 degraded_targets.push(degraded_search_target(
21594 target,
21595 RebuildSearchReason::Stale { stale_files },
21596 ));
21597 } else {
21598 return Err(err);
21599 }
21600 }
21601 } else {
21602 stale_targets.push(RebuildSearchTarget {
21603 label: target.label.clone(),
21604 reason: RebuildSearchReason::Stale { stale_files },
21605 reindex_cmd: target.reindex_cmd.clone(),
21606 });
21607 }
21608 }
21609 }
21610 }
21611
21612 if stale_targets.is_empty() {
21613 return Ok(SearchPrecheck {
21614 targets,
21615 degraded_targets,
21616 });
21617 }
21618
21619 bail!(
21620 "tsift search aborted: {} \
21621 or re-run without `--no-autoindex`.",
21622 rebuild_search_targets_message(&stale_targets),
21623 );
21624}
21625
21626pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
21627 if targets.is_empty() {
21628 return None;
21629 }
21630
21631 if targets
21632 .iter()
21633 .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
21634 {
21635 Some(DegradedSearchMode::Exact)
21636 } else {
21637 Some(DegradedSearchMode::ReadOnly)
21638 }
21639}
21640
21641fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
21642 if targets.len() == 1 {
21643 let target = &targets[0];
21644 return match target.reason {
21645 RebuildSearchReason::Missing => format!("{} is missing", target.label),
21646 RebuildSearchReason::Stale { stale_files } => {
21647 let file_suffix = if stale_files == 1 { "" } else { "s" };
21648 format!(
21649 "{} is stale ({} file{})",
21650 target.label, stale_files, file_suffix
21651 )
21652 }
21653 };
21654 }
21655
21656 let missing = targets
21657 .iter()
21658 .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
21659 .count();
21660 let stale = targets.len().saturating_sub(missing);
21661 let mut parts = Vec::new();
21662 if stale > 0 {
21663 let suffix = if stale == 1 { "" } else { "es" };
21664 parts.push(format!("{stale} stale index{suffix}"));
21665 }
21666 if missing > 0 {
21667 let suffix = if missing == 1 { "" } else { "es" };
21668 parts.push(format!("{missing} missing index{suffix}"));
21669 }
21670 parts.join(", ")
21671}
21672
21673pub(crate) fn emit_degraded_search_note(
21674 targets: &[DegradedSearchTarget],
21675 mode: DegradedSearchMode,
21676) {
21677 let summary = degraded_search_targets_summary(targets);
21678 let reindex_cmd = &targets[0].reindex_cmd;
21679 match mode {
21680 DegradedSearchMode::ReadOnly => eprintln!(
21681 "note: active tsift writer detected; skipping autoindex because {}. \
21682 Continuing with read-only search and the current index snapshot; symbol hits may lag. \
21683 Retry `{}` after the active writer finishes for fresh index results.",
21684 summary, reindex_cmd
21685 ),
21686 DegradedSearchMode::Exact => eprintln!(
21687 "note: active tsift writer detected; skipping autoindex because {}. \
21688 Continuing with exact live-file search. Retry `{}` after the active writer finishes \
21689 for indexed symbol hits.",
21690 summary, reindex_cmd
21691 ),
21692 }
21693}
21694
21695fn search_timeout_message(
21696 timeout_secs: u64,
21697 strategy: &str,
21698 targets: &[SearchIndexTarget],
21699) -> Result<String> {
21700 let rebuild_targets = collect_rebuild_search_targets(targets)?;
21701 if rebuild_targets.is_empty() {
21702 return Ok(format!(
21703 "tsift search timed out after {}s (strategy: {}). \
21704 The search root looks fresh, so reindexing is unlikely to help. \
21705 Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
21706 or try a different strategy.",
21707 timeout_secs, strategy,
21708 ));
21709 }
21710
21711 Ok(format!(
21712 "tsift search timed out after {}s (strategy: {}). {}",
21713 timeout_secs,
21714 strategy,
21715 rebuild_search_targets_message(&rebuild_targets),
21716 ))
21717}
21718
21719fn is_exact_preferring_query_char(ch: char) -> bool {
21720 matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
21721}
21722
21723fn query_prefers_exact_search(query: &str) -> bool {
21724 let trimmed = query.trim();
21725 !trimmed.is_empty()
21726 && !trimmed.chars().any(char::is_whitespace)
21727 && trimmed.chars().any(|ch| ch.is_alphanumeric())
21728 && trimmed.chars().any(is_exact_preferring_query_char)
21729 && trimmed
21730 .chars()
21731 .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
21732}
21733
21734pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
21735 strategy.unwrap_or_else(|| {
21736 if query_prefers_exact_search(query) {
21737 "exact".to_string()
21738 } else {
21739 "lexical".to_string()
21740 }
21741 })
21742}
21743
21744pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21745 let mut files = Vec::new();
21746 if path.is_file() {
21747 files.push(path.to_path_buf());
21748 return Ok(files);
21749 }
21750 let walker = ignore::WalkBuilder::new(path)
21751 .hidden(true)
21752 .git_ignore(true)
21753 .build();
21754 for entry in walker {
21755 let entry = entry?;
21756 if entry.file_type().is_some_and(|ft| ft.is_file()) {
21757 let p = entry.path();
21758 if let Some(ext) = p.extension() {
21759 let ext = ext.to_string_lossy();
21760 if matches!(
21761 ext.as_ref(),
21762 "rs" | "py"
21763 | "ts"
21764 | "tsx"
21765 | "js"
21766 | "jsx"
21767 | "kt"
21768 | "kts"
21769 | "zig"
21770 | "sh"
21771 | "bash"
21772 | "zsh"
21773 ) {
21774 files.push(p.to_path_buf());
21775 }
21776 }
21777 }
21778 }
21779 Ok(files)
21780}
21781
21782#[cfg(test)]
21783mod tests {
21784 use super::semantic_edit::{
21785 EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21786 markdown_section_spans,
21787 };
21788 use super::*;
21789 use tsift_memory::{MemoryEventKind, MemoryStore};
21790
21791 use std::cell::RefCell;
21792 use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21793
21794 #[test]
21795 fn graph_db_write_lock_serializes_concurrent_writers() {
21796 let dir = tempfile::tempdir().unwrap();
21797 let graph_db = dir.path().join(".tsift/graph.db");
21798 let short = Duration::from_millis(150);
21799
21800 let first = acquire_graph_db_write_lock_with_timeout(&graph_db, short)
21801 .expect("first writer acquires the lock");
21802 let second = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21805 assert!(
21806 second.is_err(),
21807 "a second writer must not acquire the graph-db write lock while it is held"
21808 );
21809 drop(first);
21810 let third = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21812 assert!(
21813 third.is_ok(),
21814 "graph-db write lock must be re-acquirable after release"
21815 );
21816 }
21817
21818 #[test]
21824 fn graph_db_compact_apply_blocks_on_held_write_lock() {
21825 let dir = setup_traversal_project();
21826 let session = dir.path().join("tasks/software/tsift.md");
21827 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
21828 let graph_db = graph_substrate_db_path(dir.path(), None);
21829
21830 let held = acquire_graph_db_write_lock(&graph_db).expect("hold writer lock");
21831
21832 let root = dir.path().to_path_buf();
21833 let handle = std::thread::Builder::new()
21834 .name("compact-apply".to_string())
21835 .stack_size(16 * 1024 * 1024)
21836 .spawn(move || {
21837 crate::commands::infra::cmd_graph_db_compact(
21838 &root,
21839 None,
21840 true,
21841 false,
21842 false,
21843 OutputFormat {
21844 json_output: true,
21845 compact: true,
21846 pretty: false,
21847 terse: false,
21848 ultra_terse: false,
21849 schema: false,
21850 envelope: false,
21851 },
21852 )
21853 })
21854 .unwrap();
21855
21856 std::thread::sleep(Duration::from_millis(300));
21860 assert!(
21861 !handle.is_finished(),
21862 "compact --apply must block on the held graph-db write lock, not run unguarded"
21863 );
21864
21865 drop(held);
21866 let result = handle.join().expect("compact thread joins");
21867 assert!(
21868 result.is_ok(),
21869 "compact --apply must succeed after the lock is released: {result:?}"
21870 );
21871 }
21872
21873 fn parse_cli<I, T>(itr: I) -> Cli
21874 where
21875 I: IntoIterator<Item = T> + Send + 'static,
21876 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21877 {
21878 std::thread::Builder::new()
21879 .name("cli-parse".to_string())
21880 .stack_size(16 * 1024 * 1024)
21881 .spawn(move || Cli::parse_from(itr))
21882 .unwrap()
21883 .join()
21884 .unwrap()
21885 }
21886
21887 fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21888 where
21889 I: IntoIterator<Item = T> + Send + 'static,
21890 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21891 {
21892 std::thread::Builder::new()
21893 .name("cli-try-parse".to_string())
21894 .stack_size(16 * 1024 * 1024)
21895 .spawn(move || Cli::try_parse_from(itr))
21896 .unwrap()
21897 .join()
21898 .unwrap()
21899 }
21900
21901 fn build_relative_search_budget_report(
21902 query: &str,
21903 strategy: &str,
21904 root: &Path,
21905 response: &sift::SearchResponse,
21906 symbol_hits: &[index::SymbolHit],
21907 budget: ResponseBudget,
21908 filters: &SearchFacetFilters,
21909 ) -> SearchBudgetReport {
21910 build_search_budget_report(SearchBudgetReportInput {
21911 query,
21912 strategy,
21913 root,
21914 response,
21915 symbol_hits,
21916 absolute: false,
21917 budget,
21918 filters,
21919 })
21920 }
21921
21922 #[derive(Default)]
21923 struct MemoryConvexGraphClient {
21924 nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
21925 edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
21926 }
21927
21928 impl ConvexGraphClient for MemoryConvexGraphClient {
21929 fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
21930 self.nodes
21931 .borrow_mut()
21932 .insert(row.external_id.clone(), row.clone());
21933 Ok(())
21934 }
21935
21936 fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
21937 self.edges
21938 .borrow_mut()
21939 .insert(row.edge_key.clone(), row.clone());
21940 Ok(())
21941 }
21942
21943 fn delete_node_row(&self, external_id: &str) -> Result<usize> {
21944 Ok(usize::from(
21945 self.nodes.borrow_mut().remove(external_id).is_some(),
21946 ))
21947 }
21948
21949 fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
21950 Ok(usize::from(
21951 self.edges.borrow_mut().remove(edge_key).is_some(),
21952 ))
21953 }
21954
21955 fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
21956 Ok(self.nodes.borrow().get(external_id).cloned())
21957 }
21958
21959 fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
21960 Ok(self.nodes.borrow().values().cloned().collect())
21961 }
21962
21963 fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
21964 Ok(self.edges.borrow().values().cloned().collect())
21965 }
21966
21967 fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
21968 Ok(self
21969 .nodes
21970 .borrow()
21971 .values()
21972 .filter(|row| row.kind == kind)
21973 .cloned()
21974 .collect())
21975 }
21976
21977 fn outgoing_edge_rows(
21978 &self,
21979 from_external_id: &str,
21980 kind: Option<&str>,
21981 ) -> Result<Vec<ConvexEdgeRow>> {
21982 Ok(self
21983 .edges
21984 .borrow()
21985 .values()
21986 .filter(|row| row.from_external_id == from_external_id)
21987 .filter(|row| kind.is_none_or(|kind| row.kind == kind))
21988 .cloned()
21989 .collect())
21990 }
21991 }
21992
21993 fn init_git_repo(path: &Path) {
21994 let status = std::process::Command::new("git")
21995 .args(["init"])
21996 .current_dir(path)
21997 .status()
21998 .unwrap();
21999 assert!(status.success(), "git init failed");
22000
22001 let status = std::process::Command::new("git")
22002 .args(["add", "."])
22003 .current_dir(path)
22004 .status()
22005 .unwrap();
22006 assert!(status.success(), "git add failed");
22007
22008 let status = std::process::Command::new("git")
22009 .args([
22010 "-c",
22011 "user.name=tsift-tests",
22012 "-c",
22013 "user.email=tsift-tests@example.com",
22014 "commit",
22015 "--quiet",
22016 "-m",
22017 "init",
22018 ])
22019 .current_dir(path)
22020 .status()
22021 .unwrap();
22022 assert!(status.success(), "git commit failed");
22023 }
22024
22025 fn write_empty_root_index(root: &Path) {
22026 let index_dir = root.join(".tsift");
22027 fs::create_dir_all(&index_dir).unwrap();
22028 fs::write(index_dir.join("index.db"), "").unwrap();
22029 }
22030
22031 fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
22032 if let Some(parent) = path.parent() {
22033 fs::create_dir_all(parent).unwrap();
22034 }
22035 let body = std::iter::repeat_n(line, lines)
22036 .collect::<Vec<_>>()
22037 .join("\n");
22038 fs::write(path, format!("{body}\n")).unwrap();
22039 path.to_path_buf()
22040 }
22041
22042 #[test]
22045 fn token_capped_preview_returns_all_lines_when_under_cap() {
22046 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 1", "}"];
22047 let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
22048 assert!(!result.was_capped);
22049 assert_eq!(result.preview.len(), 3);
22050 assert_eq!(result.capped_end, 3);
22051 }
22052
22053 #[test]
22054 fn token_capped_preview_truncates_when_over_cap() {
22055 let lines: Vec<&str> = (0..200)
22056 .map(|_| " let x = some_very_long_expression_here();")
22057 .collect();
22058 let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
22059 assert!(result.was_capped);
22060 assert!(result.preview.len() < 200);
22061 assert!(result.capped_end < 200);
22062 }
22063
22064 #[test]
22065 fn token_capped_preview_keeps_at_least_one_line() {
22066 let long_line: String = "x".repeat(8000);
22067 let lines: Vec<&str> = vec![&long_line];
22068 let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
22069 assert!(!result.was_capped);
22070 assert_eq!(result.preview.len(), 1);
22071 }
22072
22073 #[test]
22074 fn token_capped_preview_cap_at_boundary() {
22075 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22076 let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
22077 assert!(!result.was_capped);
22078 assert_eq!(result.preview.len(), 4);
22079 }
22080
22081 #[test]
22082 fn token_capped_preview_cap_just_over_boundary() {
22083 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22084 let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
22085 assert!(result.was_capped);
22086 assert_eq!(result.preview.len(), 3);
22087 assert_eq!(result.capped_end, 3);
22088 }
22089
22090 #[test]
22091 fn token_capped_preview_empty_lines() {
22092 let lines: Vec<&str> = vec![];
22093 let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
22094 assert!(!result.was_capped);
22095 assert!(result.preview.is_empty());
22096 }
22097
22098 #[test]
22099 fn token_capped_preview_per_line_truncation_applied() {
22100 let long_line = "x".repeat(500);
22101 let lines: Vec<&str> = vec![&long_line, "short"];
22102 let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
22103 assert!(!result.was_capped);
22104 assert_eq!(result.preview.len(), 2);
22105 assert!(result.preview[0].text.len() <= 23);
22106 assert!(result.preview[0].text.ends_with("..."));
22107 }
22108
22109 #[test]
22112 fn route_search_defaults_to_haiku() {
22113 let (tier, model) = classify_task("find all uses of authenticate");
22114 assert_eq!(tier, "haiku");
22115 assert!(
22116 model.contains("haiku"),
22117 "expected haiku model, got {}",
22118 model
22119 );
22120 }
22121
22122 #[test]
22123 fn route_edit_keywords_to_sonnet() {
22124 for kw in &[
22125 "edit the file",
22126 "fix the bug",
22127 "update the config",
22128 "remove dead code",
22129 "create a new module",
22130 ] {
22131 let (tier, _) = classify_task(kw);
22132 assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
22133 }
22134 }
22135
22136 #[test]
22137 fn route_architecture_keywords_to_opus() {
22138 for kw in &[
22139 "design the API",
22140 "architecture review",
22141 "plan the migration",
22142 "analyze the system",
22143 "evaluate trade-offs",
22144 ] {
22145 let (tier, _) = classify_task(kw);
22146 assert_eq!(tier, "opus", "expected opus for {:?}", kw);
22147 }
22148 }
22149
22150 #[test]
22151 fn route_architecture_beats_edit() {
22152 let (tier, _) = classify_task("design and implement the new auth service");
22154 assert_eq!(tier, "opus");
22155 }
22156
22157 #[test]
22158 fn cli_accepts_global_compact_flag() {
22159 let cli = parse_cli(["tsift", "--compact", "status"]);
22160 assert!(cli.compact);
22161 assert!(matches!(cli.command, Some(Commands::Status { .. })));
22162 }
22163
22164 #[test]
22165 fn summarize_diff_scope_matches_relative_directory() {
22166 let root = Path::new("/repo");
22167 let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
22168
22169 assert!(summarize_diff_matches_scope(
22170 Path::new("/repo/src/feature/main.rs"),
22171 &extract_scope
22172 ));
22173 assert!(!summarize_diff_matches_scope(
22174 Path::new("/repo/src/other/main.rs"),
22175 &extract_scope
22176 ));
22177 }
22178
22179 #[test]
22180 fn summarize_diff_scope_matches_relative_file() {
22181 let root = Path::new("/repo");
22182 let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
22183
22184 assert!(summarize_diff_matches_scope(
22185 Path::new("/repo/src/feature/main.rs"),
22186 &extract_scope
22187 ));
22188 assert!(!summarize_diff_matches_scope(
22189 Path::new("/repo/src/feature/lib.rs"),
22190 &extract_scope
22191 ));
22192 }
22193
22194 #[test]
22195 fn summarize_extract_scope_walks_relative_paths_from_root() {
22196 let dir = tempfile::tempdir().unwrap();
22197 let source_dir = dir.path().join("src");
22198 std::fs::create_dir_all(&source_dir).unwrap();
22199 let main_rs = source_dir.join("main.rs");
22200 std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
22201
22202 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
22203 let files = collect_source_files(&extract_scope).unwrap();
22204
22205 assert_eq!(files, vec![main_rs]);
22206 }
22207
22208 #[test]
22209 fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
22210 let dir = tempfile::tempdir().unwrap();
22211 let nested = dir.path().join("src/nested");
22212 std::fs::create_dir_all(&nested).unwrap();
22213 std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
22214 let nested_file = nested.join("main.rs");
22215 std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
22216
22217 let extract_base = resolve_extract_base(&nested).unwrap();
22218 let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
22219 let files = collect_source_files(&extract_scope).unwrap();
22220
22221 assert_eq!(extract_scope, nested);
22222 assert_eq!(files, vec![nested_file]);
22223 }
22224
22225 #[test]
22226 fn summarize_extract_base_uses_parent_of_file_path() {
22227 let dir = tempfile::tempdir().unwrap();
22228 let nested = dir.path().join("src/nested");
22229 std::fs::create_dir_all(&nested).unwrap();
22230 let file_path = nested.join("main.rs");
22231 std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
22232
22233 let extract_base = resolve_extract_base(&file_path).unwrap();
22234
22235 assert_eq!(extract_base, nested);
22236 }
22237
22238 #[test]
22239 fn summarize_extract_scope_normalizes_dotdot_segments() {
22240 let dir = tempfile::tempdir().unwrap();
22241 let source_dir = dir.path().join("src");
22242 std::fs::create_dir_all(&source_dir).unwrap();
22243
22244 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
22245
22246 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22247 assert!(summarize_diff_matches_scope(
22248 &source_dir.join("main.rs"),
22249 &extract_scope
22250 ));
22251 }
22252
22253 #[cfg(unix)]
22254 #[test]
22255 fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
22256 use std::os::unix::fs::symlink;
22257
22258 let dir = tempfile::tempdir().unwrap();
22259 let real_root = dir.path().join("real");
22260 let source_dir = real_root.join("src");
22261 std::fs::create_dir_all(&source_dir).unwrap();
22262 let symlink_scope = dir.path().join("scope-link");
22263 symlink(&source_dir, &symlink_scope).unwrap();
22264
22265 let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
22266
22267 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22268 assert!(summarize_diff_matches_scope(
22269 &source_dir.join("lib.rs"),
22270 &extract_scope
22271 ));
22272 }
22273
22274 #[test]
22275 fn summarize_diff_extract_includes_untracked_files() {
22276 let dir = tempfile::tempdir().unwrap();
22277 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22278 init_git_repo(dir.path());
22279
22280 let source_dir = dir.path().join("src");
22281 std::fs::create_dir_all(&source_dir).unwrap();
22282 let new_file = source_dir.join("new.rs");
22283 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22284
22285 let files = summarize::git_changed_files(dir.path()).unwrap();
22286
22287 assert_eq!(files.existing, vec![new_file]);
22288 assert!(files.deleted.is_empty());
22289 }
22290
22291 #[test]
22292 fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
22293 let dir = tempfile::tempdir().unwrap();
22294 let status = std::process::Command::new("git")
22295 .args(["init"])
22296 .current_dir(dir.path())
22297 .status()
22298 .unwrap();
22299 assert!(status.success(), "git init failed");
22300
22301 let source_dir = dir.path().join("src");
22302 std::fs::create_dir_all(&source_dir).unwrap();
22303 let new_file = source_dir.join("new.rs");
22304 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22305
22306 let files = summarize::git_changed_files(dir.path()).unwrap();
22307
22308 assert_eq!(files.existing, vec![new_file]);
22309 assert!(files.deleted.is_empty());
22310 }
22311
22312 #[test]
22313 fn summarize_diff_extract_tracks_deleted_files() {
22314 let dir = tempfile::tempdir().unwrap();
22315 let source_dir = dir.path().join("src");
22316 std::fs::create_dir_all(&source_dir).unwrap();
22317 let deleted_file = source_dir.join("gone.rs");
22318 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22319 init_git_repo(dir.path());
22320
22321 std::fs::remove_file(&deleted_file).unwrap();
22322
22323 let files = summarize::git_changed_files(dir.path()).unwrap();
22324
22325 assert!(files.existing.is_empty());
22326 assert_eq!(files.deleted, vec![deleted_file]);
22327 }
22328
22329 #[test]
22330 fn summarize_diff_extract_tracks_git_renames() {
22331 let dir = tempfile::tempdir().unwrap();
22332 let source_dir = dir.path().join("src");
22333 std::fs::create_dir_all(&source_dir).unwrap();
22334 let old_file = source_dir.join("old.rs");
22335 let new_file = source_dir.join("new.rs");
22336 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22337 init_git_repo(dir.path());
22338
22339 let status = std::process::Command::new("git")
22340 .args(["mv", "src/old.rs", "src/new.rs"])
22341 .current_dir(dir.path())
22342 .status()
22343 .unwrap();
22344 assert!(status.success(), "git mv failed");
22345
22346 let files = summarize::git_changed_files(dir.path()).unwrap();
22347
22348 assert_eq!(files.existing, vec![new_file]);
22349 assert_eq!(files.deleted, vec![old_file]);
22350 }
22351
22352 #[test]
22353 fn summarize_diff_extract_deletes_removed_summary_rows() {
22354 let dir = tempfile::tempdir().unwrap();
22355 let source_dir = dir.path().join("src");
22356 std::fs::create_dir_all(&source_dir).unwrap();
22357 let deleted_file = source_dir.join("gone.rs");
22358 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22359 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22360 init_git_repo(dir.path());
22361
22362 let summary_db =
22363 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22364 summary_db
22365 .insert(&summarize::Summary {
22366 id: 0,
22367 symbol_name: "stale".to_string(),
22368 file_path: "src/gone.rs".to_string(),
22369 content_hash: "hash1".to_string(),
22370 summary: "stale summary".to_string(),
22371 entities: None,
22372 relationships: None,
22373 concept_labels: None,
22374 extracted_at: "1700000000".to_string(),
22375 model: "test".to_string(),
22376 tokens_input: Some(100),
22377 tokens_output: Some(50),
22378 })
22379 .unwrap();
22380
22381 std::fs::remove_file(&deleted_file).unwrap();
22382
22383 cmd_summarize(
22384 None,
22385 None,
22386 Some(PathBuf::from("src")),
22387 true,
22388 false,
22389 dir.path(),
22390 false,
22391 true,
22392 false,
22393 false,
22394 false,
22395 None,
22396 )
22397 .unwrap();
22398
22399 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22400 }
22401
22402 #[test]
22403 fn summarize_diff_extract_deletes_renamed_summary_rows() {
22404 let dir = tempfile::tempdir().unwrap();
22405 let source_dir = dir.path().join("src");
22406 std::fs::create_dir_all(&source_dir).unwrap();
22407 let old_file = source_dir.join("old.rs");
22408 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22409 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22410 init_git_repo(dir.path());
22411
22412 let summary_db =
22413 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22414 summary_db
22415 .insert(&summarize::Summary {
22416 id: 0,
22417 symbol_name: "stale".to_string(),
22418 file_path: "src/old.rs".to_string(),
22419 content_hash: "hash1".to_string(),
22420 summary: "stale summary".to_string(),
22421 entities: None,
22422 relationships: None,
22423 concept_labels: None,
22424 extracted_at: "1700000000".to_string(),
22425 model: "test".to_string(),
22426 tokens_input: Some(100),
22427 tokens_output: Some(50),
22428 })
22429 .unwrap();
22430
22431 let status = std::process::Command::new("git")
22432 .args(["mv", "src/old.rs", "src/new.rs"])
22433 .current_dir(dir.path())
22434 .status()
22435 .unwrap();
22436 assert!(status.success(), "git mv failed");
22437
22438 cmd_summarize(
22439 None,
22440 None,
22441 Some(PathBuf::from("src")),
22442 true,
22443 false,
22444 dir.path(),
22445 false,
22446 true,
22447 false,
22448 false,
22449 false,
22450 None,
22451 )
22452 .unwrap();
22453
22454 assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
22455 }
22456
22457 #[test]
22458 fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
22459 let dir = tempfile::tempdir().unwrap();
22460 let source_dir = dir.path().join("src");
22461 std::fs::create_dir_all(&source_dir).unwrap();
22462 let deleted_file = source_dir.join("gone.rs");
22463 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22464
22465 let summary_db =
22466 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22467 summary_db
22468 .insert(&summarize::Summary {
22469 id: 0,
22470 symbol_name: "stale".to_string(),
22471 file_path: "src/gone.rs".to_string(),
22472 content_hash: "hash1".to_string(),
22473 summary: "stale summary".to_string(),
22474 entities: None,
22475 relationships: None,
22476 concept_labels: None,
22477 extracted_at: "1700000000".to_string(),
22478 model: "test".to_string(),
22479 tokens_input: Some(100),
22480 tokens_output: Some(50),
22481 })
22482 .unwrap();
22483
22484 std::fs::remove_file(&deleted_file).unwrap();
22485
22486 cmd_summarize(
22487 None,
22488 None,
22489 Some(PathBuf::from("src")),
22490 false,
22491 false,
22492 dir.path(),
22493 false,
22494 true,
22495 false,
22496 false,
22497 false,
22498 None,
22499 )
22500 .unwrap();
22501
22502 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22503 }
22504
22505 #[test]
22506 fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
22507 let dir = tempfile::tempdir().unwrap();
22508 let source_dir = dir.path().join("src");
22509 std::fs::create_dir_all(&source_dir).unwrap();
22510 let file = source_dir.join("lib.rs");
22511 std::fs::write(&file, "fn helper() {}\n").unwrap();
22512
22513 let content = std::fs::read(&file).unwrap();
22514 let summary_db =
22515 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22516 summary_db
22517 .insert(&summarize::Summary {
22518 id: 0,
22519 symbol_name: "lib.rs".to_string(),
22520 file_path: "src/lib.rs".to_string(),
22521 content_hash: summarize::content_hash(&content),
22522 summary: "cached summary".to_string(),
22523 entities: None,
22524 relationships: None,
22525 concept_labels: None,
22526 extracted_at: "1700000000".to_string(),
22527 model: "test".to_string(),
22528 tokens_input: Some(100),
22529 tokens_output: Some(50),
22530 })
22531 .unwrap();
22532 drop(summary_db);
22533
22534 let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
22535 let _lock = hold_writer_lock(&lock_path);
22536
22537 let err = cmd_summarize(
22538 None,
22539 None,
22540 Some(PathBuf::from("src")),
22541 false,
22542 false,
22543 dir.path(),
22544 false,
22545 true,
22546 false,
22547 false,
22548 false,
22549 None,
22550 )
22551 .unwrap_err();
22552 let message = err.to_string();
22553
22554 assert!(message.contains("another tsift summarize extractor is already active"));
22555 assert!(message.contains("tsift summarize --extract"));
22556 }
22557
22558 #[test]
22559 fn summarize_stats_fails_closed_when_cache_missing() {
22560 let dir = tempfile::tempdir().unwrap();
22561 let err = cmd_summarize(
22562 None,
22563 None,
22564 None,
22565 false,
22566 true,
22567 dir.path(),
22568 false,
22569 false,
22570 false,
22571 false,
22572 false,
22573 None,
22574 )
22575 .unwrap_err();
22576
22577 assert!(
22578 err.to_string().contains("no summaries.db found"),
22579 "got: {err}"
22580 );
22581 assert!(!dir.path().join(".tsift/summaries.db").exists());
22582 }
22583
22584 #[test]
22585 fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22586 let dir = tempfile::tempdir().unwrap();
22587 let summary_db =
22588 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22589 summary_db
22590 .insert(&summarize::Summary {
22591 id: 0,
22592 symbol_name: "alpha_helper".to_string(),
22593 file_path: "src/lib.rs".to_string(),
22594 content_hash: "hash1".to_string(),
22595 summary: "cached summary".to_string(),
22596 entities: None,
22597 relationships: None,
22598 concept_labels: None,
22599 extracted_at: "1700000000".to_string(),
22600 model: "claude-haiku-4-5-20251001".to_string(),
22601 tokens_input: Some(100),
22602 tokens_output: Some(40),
22603 })
22604 .unwrap();
22605 drop(summary_db);
22606 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22607
22608 let result = cmd_summarize(
22609 None,
22610 None,
22611 None,
22612 false,
22613 true,
22614 dir.path(),
22615 false,
22616 false,
22617 false,
22618 false,
22619 false,
22620 None,
22621 );
22622
22623 assert!(result.is_ok());
22624 }
22625
22626 #[test]
22627 fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22628 let dir = tempfile::tempdir().unwrap();
22629 let summary_db =
22630 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22631 summary_db
22632 .insert(&summarize::Summary {
22633 id: 0,
22634 symbol_name: "alpha_helper".to_string(),
22635 file_path: "src/lib.rs".to_string(),
22636 content_hash: "hash1".to_string(),
22637 summary: "cached summary".to_string(),
22638 entities: None,
22639 relationships: None,
22640 concept_labels: None,
22641 extracted_at: "1700000000".to_string(),
22642 model: "claude-haiku-4-5-20251001".to_string(),
22643 tokens_input: Some(100),
22644 tokens_output: Some(40),
22645 })
22646 .unwrap();
22647 drop(summary_db);
22648 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22649
22650 let result = cmd_summarize(
22651 Some("alpha_helper".to_string()),
22652 None,
22653 None,
22654 false,
22655 false,
22656 dir.path(),
22657 false,
22658 true,
22659 false,
22660 false,
22661 false,
22662 None,
22663 );
22664
22665 assert!(result.is_ok());
22666 }
22667
22668 #[test]
22669 fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
22670 let dir = tempfile::tempdir().unwrap();
22671 let nested = dir.path().join("src/nested");
22672 std::fs::create_dir_all(&nested).unwrap();
22673
22674 let summary_db =
22675 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22676 summary_db
22677 .insert(&summarize::Summary {
22678 id: 0,
22679 symbol_name: "alpha_helper".to_string(),
22680 file_path: "src/lib.rs".to_string(),
22681 content_hash: "hash1".to_string(),
22682 summary: "cached summary".to_string(),
22683 entities: None,
22684 relationships: None,
22685 concept_labels: None,
22686 extracted_at: "1700000000".to_string(),
22687 model: "claude-haiku-4-5-20251001".to_string(),
22688 tokens_input: Some(100),
22689 tokens_output: Some(40),
22690 })
22691 .unwrap();
22692
22693 let result = cmd_summarize(
22694 Some("alpha_helper".to_string()),
22695 None,
22696 None,
22697 false,
22698 false,
22699 &nested,
22700 false,
22701 true,
22702 false,
22703 false,
22704 false,
22705 None,
22706 );
22707
22708 assert!(result.is_ok());
22709 assert!(!nested.join(".tsift/summaries.db").exists());
22710 }
22711
22712 #[test]
22713 fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
22714 let dir = tempfile::tempdir().unwrap();
22715 std::fs::write(
22716 dir.path().join(".gitmodules"),
22717 r#"[submodule "src/alpha"]
22718 path = src/alpha
22719 url = https://example.com/alpha
22720[submodule "src/beta"]
22721 path = src/beta
22722 url = https://example.com/beta
22723"#,
22724 )
22725 .unwrap();
22726
22727 let alpha_root = dir.path().join("src/alpha");
22728 let beta_root = dir.path().join("src/beta");
22729 std::fs::create_dir_all(alpha_root.join("src")).unwrap();
22730 std::fs::create_dir_all(beta_root.join("src")).unwrap();
22731 std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
22732 std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
22733 std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
22734 let beta_file = beta_root.join("src/lib.rs");
22735 std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
22736 std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
22737 std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
22738
22739 let context = find_symbols_db_for_file(dir.path(), &beta_file)
22740 .unwrap()
22741 .expect("expected matching scoped index");
22742
22743 assert_eq!(
22744 context.db_path,
22745 dir.path().join(".tsift/indexes/beta/index.db")
22746 );
22747 assert_eq!(context.source_root, beta_root);
22748 }
22749
22750 fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
22753 EditOp {
22754 file: PathBuf::from("dummy.txt"),
22755 old: old.to_string(),
22756 new: new.to_string(),
22757 replace_all,
22758 }
22759 }
22760
22761 #[test]
22762 fn edit_replaces_single_occurrence() {
22763 let content = "hello world";
22764 let op = make_op("world", "rust", false);
22765 let (result, count) = apply_edit_op(content, &op).unwrap();
22766 assert_eq!(result, "hello rust");
22767 assert_eq!(count, 1);
22768 }
22769
22770 #[test]
22771 fn edit_replace_all_replaces_every_occurrence() {
22772 let content = "foo foo foo";
22773 let op = make_op("foo", "bar", true);
22774 let (result, count) = apply_edit_op(content, &op).unwrap();
22775 assert_eq!(result, "bar bar bar");
22776 assert_eq!(count, 3);
22777 }
22778
22779 #[test]
22780 fn edit_fails_when_old_not_found() {
22781 let content = "hello world";
22782 let op = make_op("missing", "x", false);
22783 assert!(apply_edit_op(content, &op).is_err());
22784 }
22785
22786 #[test]
22787 fn edit_fails_when_ambiguous_without_replace_all() {
22788 let content = "foo foo";
22789 let op = make_op("foo", "bar", false);
22790 let err = apply_edit_op(content, &op).unwrap_err();
22791 assert!(err.to_string().contains("2 times"), "got: {}", err);
22792 }
22793
22794 #[test]
22795 fn edit_fails_when_old_equals_new() {
22796 let content = "hello";
22797 let op = make_op("hello", "hello", false);
22798 assert!(apply_edit_op(content, &op).is_err());
22799 }
22800
22801 #[test]
22802 fn edit_batch_rolls_back_when_later_swap_fails() {
22803 let dir = tempfile::tempdir().unwrap();
22804 let alpha = dir.path().join("alpha.txt");
22805 let beta = dir.path().join("beta.txt");
22806 fs::write(&alpha, "alpha old\n").unwrap();
22807 fs::write(&beta, "beta old\n").unwrap();
22808
22809 let batch = EditBatch {
22810 edits: vec![
22811 EditOp {
22812 file: alpha.clone(),
22813 old: "old".to_string(),
22814 new: "new".to_string(),
22815 replace_all: false,
22816 },
22817 EditOp {
22818 file: beta.clone(),
22819 old: "old".to_string(),
22820 new: "new".to_string(),
22821 replace_all: false,
22822 },
22823 ],
22824 };
22825
22826 let plan = build_edit_plan(&batch).unwrap();
22827 let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
22828 if commit_index == 1 {
22829 bail!("simulated swap failure");
22830 }
22831 Ok(())
22832 }) {
22833 Ok(_) => panic!("expected simulated swap failure"),
22834 Err(err) => err,
22835 };
22836
22837 assert!(err.to_string().contains("simulated swap failure"));
22838 assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22839 assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22840 }
22841
22842 fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22845 let tmp = tempfile::NamedTempFile::new().unwrap();
22846 let conn = Connection::open(tmp.path()).unwrap();
22847 conn.execute_batch(
22848 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22849 INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22850 INSERT INTO users VALUES (2, 'Bob', NULL);
22851 CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22852 FOREIGN KEY(user_id) REFERENCES users(id));
22853 INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22854 INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22855 INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22856 ).unwrap();
22857 (tmp, conn)
22858 }
22859
22860 #[test]
22863 fn rewrite_rg_simple_pattern() {
22864 let result = rewrite_command("rg authenticate");
22865 assert_eq!(
22866 result,
22867 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22868 );
22869 }
22870
22871 #[test]
22872 fn rewrite_rg_with_path() {
22873 let result = rewrite_command("rg authenticate src/");
22874 assert_eq!(
22875 result,
22876 Some(
22877 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22878 .to_string()
22879 )
22880 );
22881 }
22882
22883 #[test]
22884 fn rewrite_rg_with_flags_ignored() {
22885 let result = rewrite_command("rg -i authenticate src/");
22886 assert_eq!(
22887 result,
22888 Some(
22889 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22890 .to_string()
22891 )
22892 );
22893 }
22894
22895 #[test]
22896 fn rewrite_rg_with_type_flag() {
22897 let result = rewrite_command("rg -t rs authenticate");
22899 assert_eq!(
22900 result,
22901 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
22902 );
22903 }
22904
22905 #[test]
22906 fn rewrite_rg_pipe_passthrough() {
22907 let result = rewrite_command("rg authenticate | head -5");
22909 assert_eq!(result, None);
22910 }
22911
22912 #[test]
22913 fn rewrite_rg_files_passthrough() {
22914 let result = rewrite_command("rg --files src/tsift .agent-doc logs");
22915 assert_eq!(result, None);
22916 }
22917
22918 #[test]
22919 fn rewrite_find_passthrough() {
22920 let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
22921 assert_eq!(result, None);
22922 }
22923
22924 #[test]
22925 fn rewrite_grep_recursive() {
22926 let result = rewrite_command("grep -r authenticate src/");
22927 assert_eq!(
22928 result,
22929 Some(
22930 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22931 .to_string()
22932 )
22933 );
22934 }
22935
22936 #[test]
22937 fn rewrite_grep_non_recursive_passthrough() {
22938 let result = rewrite_command("grep authenticate file.txt");
22939 assert_eq!(result, None);
22940 }
22941
22942 #[test]
22943 fn rewrite_tsift_passthrough() {
22944 let result = rewrite_command("tsift search \"foo\"");
22945 assert_eq!(result, Some("tsift search \"foo\"".to_string()));
22946 }
22947
22948 #[test]
22949 fn rewrite_run_tsift_search_disables_timeout_by_default() {
22950 let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
22951 assert_eq!(
22952 result,
22953 "tsift search hookcaps --exact --path /tmp/x --timeout 0"
22954 );
22955 }
22956
22957 #[test]
22958 fn rewrite_run_preserves_explicit_search_timeout() {
22959 let result = effective_rewrite_run_command(
22960 "tsift search hookcaps --exact --path /tmp/x --timeout 5",
22961 );
22962 assert_eq!(
22963 result,
22964 "tsift search hookcaps --exact --path /tmp/x --timeout 5"
22965 );
22966 }
22967
22968 #[test]
22969 fn rewrite_unrelated_passthrough() {
22970 let result = rewrite_command("echo cargo build");
22971 assert_eq!(result, None);
22972 }
22973
22974 #[test]
22975 fn rewrite_rg_quoted_pattern() {
22976 let result = rewrite_command("rg \"fn main\"");
22977 assert_eq!(
22978 result,
22979 Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
22980 );
22981 }
22982
22983 #[test]
22984 fn rewrite_git_diff_to_diff_digest() {
22985 let result = rewrite_command("git diff");
22986 assert_eq!(result, Some("tsift diff-digest .".to_string()));
22987 }
22988
22989 #[test]
22990 fn rewrite_git_diff_cached_to_diff_digest() {
22991 let result = rewrite_command("git diff --cached");
22992 assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
22993 }
22994
22995 #[test]
22996 fn rewrite_git_diff_with_path_to_diff_digest() {
22997 let result = rewrite_command("git diff -- src/");
22998 assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
22999 }
23000
23001 #[test]
23002 fn rewrite_git_diff_with_revision_passthrough() {
23003 let result = rewrite_command("git diff HEAD~1");
23004 assert_eq!(result, None);
23005 }
23006
23007 #[test]
23008 fn rewrite_git_show_to_revision_diff_digest() {
23009 let result = rewrite_command("git show HEAD~1");
23010 assert_eq!(
23011 result,
23012 Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
23013 );
23014 }
23015
23016 #[test]
23017 fn rewrite_git_log_patch_history_to_revision_diff_digest() {
23018 let result = rewrite_command("git log -p -1 HEAD~2");
23019 assert_eq!(
23020 result,
23021 Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
23022 );
23023 }
23024
23025 #[test]
23026 fn rewrite_cat_long_agent_doc_session_to_session_digest() {
23027 let dir = tempfile::tempdir().unwrap();
23028 let session = dir.path().join("tsift.md");
23029 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23030 for index in 0..90 {
23031 body.push_str(&format!("❯ prompt {index}?\n"));
23032 }
23033 fs::write(&session, body).unwrap();
23034
23035 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23036 assert_eq!(
23037 result,
23038 Some(format!(
23039 "tsift session-digest --path {} --input {} --source markdown",
23040 shell_quote(&resolve_digest_context_path(&session)),
23041 shell_quote(session.to_str().unwrap())
23042 ))
23043 );
23044 }
23045
23046 #[test]
23047 fn rewrite_head_long_claude_jsonl_to_session_digest() {
23048 let dir = tempfile::tempdir().unwrap();
23049 let session = dir.path().join("session.jsonl");
23050 let line =
23051 r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
23052 let body = std::iter::repeat_n(line, 120)
23053 .collect::<Vec<_>>()
23054 .join("\n");
23055 fs::write(&session, format!("{body}\n")).unwrap();
23056
23057 let result = rewrite_command(&format!(
23058 "head -n 120 {}",
23059 shell_quote(session.to_str().unwrap())
23060 ));
23061 assert_eq!(
23062 result,
23063 Some(format!(
23064 "tsift session-digest --path {} --input {} --source claude-jsonl",
23065 shell_quote(&resolve_digest_context_path(&session)),
23066 shell_quote(session.to_str().unwrap())
23067 ))
23068 );
23069 }
23070
23071 #[test]
23072 fn rewrite_head_long_codex_jsonl_to_session_digest() {
23073 let dir = tempfile::tempdir().unwrap();
23074 let session = dir.path().join("codex.jsonl");
23075 let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
23076 let body = std::iter::repeat_n(line, 120)
23077 .collect::<Vec<_>>()
23078 .join("\n");
23079 fs::write(&session, format!("{body}\n")).unwrap();
23080
23081 let result = rewrite_command(&format!(
23082 "head -n 120 {}",
23083 shell_quote(session.to_str().unwrap())
23084 ));
23085 assert_eq!(
23086 result,
23087 Some(format!(
23088 "tsift session-digest --path {} --input {} --source codex-jsonl",
23089 shell_quote(&resolve_digest_context_path(&session)),
23090 shell_quote(session.to_str().unwrap())
23091 ))
23092 );
23093 }
23094
23095 #[test]
23096 fn rewrite_small_transcript_window_passthrough() {
23097 let dir = tempfile::tempdir().unwrap();
23098 let session = dir.path().join("session.jsonl");
23099 let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
23100 let body = std::iter::repeat_n(line, 120)
23101 .collect::<Vec<_>>()
23102 .join("\n");
23103 fs::write(&session, format!("{body}\n")).unwrap();
23104
23105 let result = rewrite_command(&format!(
23106 "tail -n 20 {}",
23107 shell_quote(session.to_str().unwrap())
23108 ));
23109 assert_eq!(result, None);
23110 }
23111
23112 #[test]
23113 fn rewrite_sed_large_agent_doc_range_to_session_digest() {
23114 let dir = tempfile::tempdir().unwrap();
23115 let session = dir.path().join("tsift.md");
23116 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23117 for index in 0..120 {
23118 body.push_str(&format!("### Re: topic {index}\n"));
23119 }
23120 fs::write(&session, body).unwrap();
23121
23122 let result = rewrite_command(&format!(
23123 "sed -n '1,120p' {}",
23124 shell_quote(session.to_str().unwrap())
23125 ));
23126 assert_eq!(
23127 result,
23128 Some(format!(
23129 "tsift session-digest --path {} --input {} --source markdown",
23130 shell_quote(&resolve_digest_context_path(&session)),
23131 shell_quote(session.to_str().unwrap())
23132 ))
23133 );
23134 }
23135
23136 #[test]
23137 fn rewrite_cat_large_agent_doc_log_to_session_digest() {
23138 let dir = tempfile::tempdir().unwrap();
23139 let session = dir.path().join("tsift.log");
23140 let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
23141 let body = std::iter::repeat_n(line, 120)
23142 .collect::<Vec<_>>()
23143 .join("\n");
23144 fs::write(&session, format!("{body}\n")).unwrap();
23145
23146 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23147 assert_eq!(
23148 result,
23149 Some(format!(
23150 "tsift session-digest --path {} --input {} --source agent-doc-log",
23151 shell_quote(&resolve_digest_context_path(&session)),
23152 shell_quote(session.to_str().unwrap())
23153 ))
23154 );
23155 }
23156
23157 #[test]
23158 fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
23159 let dir = tempfile::tempdir().unwrap();
23160 fs::write(
23161 dir.path().join(".gitmodules"),
23162 r#"[submodule "src/tsift"]
23163 path = src/tsift
23164 url = https://example.com/tsift
23165"#,
23166 )
23167 .unwrap();
23168 let submodule = dir.path().join("src/tsift");
23169 fs::create_dir_all(submodule.join("tasks")).unwrap();
23170 fs::write(
23171 submodule.join(".git"),
23172 "gitdir: ../../.git/modules/src/tsift\n",
23173 )
23174 .unwrap();
23175 let session = submodule.join("tasks/plan.md");
23176 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23177 for index in 0..90 {
23178 body.push_str(&format!("❯ prompt {index}?\n"));
23179 }
23180 fs::write(&session, body).unwrap();
23181
23182 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23183
23184 assert_eq!(
23185 result,
23186 Some(format!(
23187 "tsift session-digest --path {} --input {} --source markdown",
23188 shell_quote(submodule.to_str().unwrap()),
23189 shell_quote(session.to_str().unwrap())
23190 ))
23191 );
23192 }
23193
23194 #[test]
23195 fn rewrite_regular_markdown_read_passthrough() {
23196 let dir = tempfile::tempdir().unwrap();
23197 let readme = dir.path().join("README.md");
23198 let body = std::iter::repeat_n("plain markdown", 120)
23199 .collect::<Vec<_>>()
23200 .join("\n");
23201 fs::write(&readme, format!("{body}\n")).unwrap();
23202
23203 let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
23204 assert_eq!(result, None);
23205 }
23206
23207 #[test]
23208 fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
23209 let dir = tempfile::tempdir().unwrap();
23210 write_empty_root_index(dir.path());
23211 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23212
23213 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23214
23215 assert_eq!(
23216 result,
23217 Some(format!(
23218 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
23219 shell_quote(&dir.path().to_string_lossy())
23220 ))
23221 );
23222 }
23223
23224 #[test]
23225 fn rewrite_head_small_source_window_passthrough() {
23226 let dir = tempfile::tempdir().unwrap();
23227 write_empty_root_index(dir.path());
23228 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23229
23230 let result = rewrite_command(&format!(
23231 "head -n 20 {}",
23232 shell_quote(source.to_str().unwrap())
23233 ));
23234
23235 assert_eq!(result, None);
23236 }
23237
23238 #[test]
23239 fn rewrite_sed_large_source_range_to_source_read() {
23240 let dir = tempfile::tempdir().unwrap();
23241 write_empty_root_index(dir.path());
23242 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23243
23244 let result = rewrite_command(&format!(
23245 "sed -n '40,160p' {}",
23246 shell_quote(source.to_str().unwrap())
23247 ));
23248
23249 assert_eq!(
23250 result,
23251 Some(format!(
23252 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
23253 shell_quote(&dir.path().to_string_lossy())
23254 ))
23255 );
23256 }
23257
23258 #[test]
23259 fn rewrite_tail_large_source_window_preserves_tail_anchor() {
23260 let dir = tempfile::tempdir().unwrap();
23261 write_empty_root_index(dir.path());
23262 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23263
23264 let result = rewrite_command(&format!(
23265 "tail -n 120 {}",
23266 shell_quote(source.to_str().unwrap())
23267 ));
23268
23269 assert_eq!(
23270 result,
23271 Some(format!(
23272 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
23273 shell_quote(&dir.path().to_string_lossy())
23274 ))
23275 );
23276 }
23277
23278 #[test]
23279 fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
23280 let dir = tempfile::tempdir().unwrap();
23281 write_empty_root_index(dir.path());
23282 let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
23283
23284 let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
23285
23286 assert_eq!(result, None);
23287 }
23288
23289 #[test]
23290 fn rewrite_large_source_read_passthrough_without_index() {
23291 let dir = tempfile::tempdir().unwrap();
23292 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23293
23294 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23295
23296 assert_eq!(result, None);
23297 }
23298
23299 #[test]
23300 fn rewrite_cargo_test_to_digest_runner() {
23301 let result = rewrite_command("cargo test --lib");
23302 assert_eq!(
23303 result,
23304 Some(
23305 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
23306 )
23307 );
23308 }
23309
23310 #[test]
23311 fn rewrite_pytest_to_digest_runner() {
23312 let result = rewrite_command("pytest -q tests/test_cli.py");
23313 assert_eq!(
23314 result,
23315 Some(
23316 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
23317 )
23318 );
23319 }
23320
23321 #[test]
23322 fn rewrite_python_m_pytest_to_digest_runner() {
23323 let result = rewrite_command("python -m pytest tests/test_cli.py");
23324 assert_eq!(
23325 result,
23326 Some(
23327 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
23328 )
23329 );
23330 }
23331
23332 #[test]
23333 fn rewrite_cargo_build_to_log_digest_runner() {
23334 let result = rewrite_command("cargo build --release");
23335 assert_eq!(
23336 result,
23337 Some(
23338 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
23339 )
23340 );
23341 }
23342
23343 #[test]
23344 fn rewrite_cargo_install_to_log_digest_runner() {
23345 let result = rewrite_command("cargo install --path . --force");
23346 assert_eq!(
23347 result,
23348 Some(
23349 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
23350 )
23351 );
23352 }
23353
23354 #[test]
23355 fn rewrite_metacharacter_command_passthrough() {
23356 let result = rewrite_command("cargo test | head");
23357 assert_eq!(result, None);
23358 }
23359
23360 #[test]
23361 fn rewrite_output_cap_detects_search_even_with_global_flag() {
23362 let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
23363 assert_eq!(cap.max_lines, 50);
23364 assert_eq!(cap.strip_prefix, Some("Strategy:"));
23365 }
23366
23367 #[test]
23368 fn rewrite_output_cap_skips_structured_output() {
23369 assert!(rewrite_output_cap("tsift search foo --json").is_none());
23370 assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
23371 assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
23372 }
23373
23374 #[test]
23375 fn rewrite_output_format_forwards_envelope_to_digest_runner() {
23376 let command = rewrite_command("cargo test --lib").expect("rewrite");
23377 let forwarded = apply_rewrite_output_format(
23378 &command,
23379 OutputFormat {
23380 json_output: true,
23381 compact: false,
23382 pretty: false,
23383 terse: false,
23384 ultra_terse: false,
23385 schema: false,
23386 envelope: true,
23387 },
23388 );
23389 assert_eq!(
23390 forwarded,
23391 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
23392 );
23393 }
23394
23395 #[test]
23396 fn rewrite_output_format_forwards_json_when_requested() {
23397 let command = rewrite_command("cargo build --release").expect("rewrite");
23398 let forwarded = apply_rewrite_output_format(
23399 &command,
23400 OutputFormat {
23401 json_output: true,
23402 compact: false,
23403 pretty: true,
23404 terse: false,
23405 ultra_terse: false,
23406 schema: false,
23407 envelope: false,
23408 },
23409 );
23410 assert_eq!(
23411 forwarded,
23412 "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
23413 );
23414 }
23415
23416 #[test]
23417 fn output_cap_strips_search_header_and_truncates() {
23418 let capped = apply_output_cap(
23419 b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
23420 OutputCap {
23421 max_lines: 2,
23422 strip_prefix: Some("Strategy:"),
23423 },
23424 );
23425 assert_eq!(
23426 capped,
23427 "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
23428 );
23429 }
23430
23431 #[test]
23432 fn sql_schema_overview_lists_tables() {
23433 let (_tmp, conn) = setup_test_db();
23434 let tables = schema_overview(&conn).unwrap();
23435 let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
23436 assert_eq!(names, &["posts", "users"]);
23437 }
23438
23439 #[test]
23440 fn sql_schema_overview_row_counts() {
23441 let (_tmp, conn) = setup_test_db();
23442 let tables = schema_overview(&conn).unwrap();
23443 let users = tables.iter().find(|t| t.name == "users").unwrap();
23444 let posts = tables.iter().find(|t| t.name == "posts").unwrap();
23445 assert_eq!(users.row_count, 2);
23446 assert_eq!(posts.row_count, 3);
23447 }
23448
23449 #[test]
23450 fn sql_table_columns_metadata() {
23451 let (_tmp, conn) = setup_test_db();
23452 let cols = table_columns(&conn, "users").unwrap();
23453 assert_eq!(cols.len(), 3);
23454 assert_eq!(cols[0].name, "id");
23455 assert!(cols[0].pk);
23456 assert_eq!(cols[1].name, "name");
23457 assert!(cols[1].notnull);
23458 assert_eq!(cols[2].name, "email");
23459 assert!(!cols[2].notnull);
23460 }
23461
23462 #[test]
23463 fn sql_execute_query_returns_rows() {
23464 let (_tmp, conn) = setup_test_db();
23465 let (columns, rows) =
23466 execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
23467 assert_eq!(columns, &["name", "email"]);
23468 assert_eq!(rows.len(), 2);
23469 assert_eq!(rows[0][0], serde_json::json!("Alice"));
23470 assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
23471 assert_eq!(rows[1][1], serde_json::Value::Null);
23472 }
23473
23474 #[test]
23475 fn sql_execute_query_aggregate() {
23476 let (_tmp, conn) = setup_test_db();
23477 let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
23478 assert_eq!(columns, &["cnt"]);
23479 assert_eq!(rows[0][0], serde_json::json!(3));
23480 }
23481
23482 #[test]
23483 fn sql_execute_query_join() {
23484 let (_tmp, conn) = setup_test_db();
23485 let (_cols, rows) = execute_query(
23486 &conn,
23487 "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
23488 )
23489 .unwrap();
23490 assert_eq!(rows.len(), 3);
23491 assert_eq!(rows[0][0], serde_json::json!("Alice"));
23492 assert_eq!(rows[2][0], serde_json::json!("Bob"));
23493 }
23494
23495 #[test]
23496 fn sql_open_db_read_only() {
23497 let (tmp, _conn) = setup_test_db();
23498 drop(_conn);
23499 let ro_conn = open_db(tmp.path()).unwrap();
23500 let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
23501 assert!(result.is_err(), "read-only connection should reject writes");
23502 }
23503
23504 #[test]
23505 fn sql_empty_table_schema() {
23506 let tmp = tempfile::NamedTempFile::new().unwrap();
23507 let conn = Connection::open(tmp.path()).unwrap();
23508 conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
23509 .unwrap();
23510 let tables = schema_overview(&conn).unwrap();
23511 assert_eq!(tables[0].row_count, 0);
23512 assert_eq!(tables[0].columns.len(), 2);
23513 }
23514
23515 fn setup_graph_index() -> tempfile::TempDir {
23518 let dir = tempfile::tempdir().unwrap();
23519 std::fs::write(
23520 dir.path().join("main.rs"),
23521 "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
23522 )
23523 .unwrap();
23524 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23525 db.apply_changes(dir.path()).unwrap();
23526 dir
23527 }
23528
23529 fn setup_traversal_project() -> tempfile::TempDir {
23530 let dir = setup_graph_index();
23531 let task_dir = dir.path().join("tasks/software");
23532 std::fs::create_dir_all(&task_dir).unwrap();
23533 std::fs::write(
23534 task_dir.join("tsift.md"),
23535 r#"---
23536agent_doc_session: tsift-v0.1
23537agent_doc_format: template
23538---
23539
23540## Exchange
23541
23542<!-- agent:exchange patch=append -->
23543❯ do [#kgnv]
23544Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
23545<!-- /agent:exchange -->
23546
23547<!-- agent:queue -->
23548dispatch #spec-test-build-install-commit-push
23549- do [#kgnv]
23550<!-- /agent:queue -->
23551
23552## Backlog
23553
23554<!-- agent:backlog -->
23555- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
23556<!-- /agent:backlog -->
23557"#,
23558 )
23559 .unwrap();
23560 dir
23561 }
23562
23563 fn resolve_ast_span_node<'a>(
23564 graph: &'a TraversalGraphBuild,
23565 label: &str,
23566 symbol_kind: &str,
23567 ) -> &'a TraversalNode {
23568 graph
23569 .nodes
23570 .values()
23571 .find(|node| {
23572 node.kind == "ast_span"
23573 && node.label == label
23574 && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
23575 })
23576 .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
23577 }
23578
23579 fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
23580 let dir = tempfile::tempdir().unwrap();
23581 std::fs::write(
23582 dir.path().join("rust.rs"),
23583 r#"mod fixture_nav_rust_mod {
23584 pub fn fixture_nav_rust_helper() {}
23585 pub fn fixture_nav_rust_entry() {
23586 fixture_nav_rust_helper();
23587 }
23588}
23589"#,
23590 )
23591 .unwrap();
23592 std::fs::write(
23593 dir.path().join("python.py"),
23594 r#"def fixture_nav_python_helper():
23595 return 1
23596
23597def fixture_nav_python_entry():
23598 return fixture_nav_python_helper()
23599"#,
23600 )
23601 .unwrap();
23602 std::fs::write(
23603 dir.path().join("typescript.ts"),
23604 r#"export function fixture_nav_typescript_entry(): number {
23605 return fixtureNavTsHelper();
23606}
23607
23608function fixtureNavTsHelper(): number {
23609 return 1;
23610}
23611"#,
23612 )
23613 .unwrap();
23614 std::fs::write(
23615 dir.path().join("javascript.js"),
23616 r#"function fixture_nav_javascript_entry() {
23617 return fixtureNavJsHelper();
23618}
23619
23620function fixtureNavJsHelper() {
23621 return 1;
23622}
23623"#,
23624 )
23625 .unwrap();
23626 std::fs::write(
23627 dir.path().join("kotlin.kt"),
23628 r#"fun fixture_nav_kotlin_entry(): Int {
23629 return fixtureNavKotlinHelper()
23630}
23631
23632fun fixtureNavKotlinHelper(): Int = 1
23633"#,
23634 )
23635 .unwrap();
23636 std::fs::write(
23637 dir.path().join("zig.zig"),
23638 r#"pub fn fixture_nav_zig_entry() i32 {
23639 return fixtureNavZigHelper();
23640}
23641
23642fn fixtureNavZigHelper() i32 {
23643 return 1;
23644}
23645"#,
23646 )
23647 .unwrap();
23648 std::fs::write(
23649 dir.path().join("bash.sh"),
23650 r#"#!/usr/bin/env bash
23651fixture_nav_bash_entry() {
23652 fixture_nav_bash_helper
23653}
23654
23655fixture_nav_bash_helper() {
23656 echo ok
23657}
23658
23659alias fixture_nav_bash_alias='echo alias'
23660"#,
23661 )
23662 .unwrap();
23663 std::fs::write(
23664 dir.path().join("README.md"),
23665 r#"# Fixture Guide
23666
23667## Fixture Section
23668
23669- Fixture step
23670 - Nested fixture step
23671
23672```python
23673def fixture_nav_markdown_embedded():
23674 return 1
23675```
23676"#,
23677 )
23678 .unwrap();
23679
23680 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23681 db.apply_changes(dir.path()).unwrap();
23682 dir
23683 }
23684
23685 fn assert_cli_expand_command_parses(command: &str) {
23686 let args = shell_split(command)
23687 .into_iter()
23688 .map(str::to_string)
23689 .collect::<Vec<_>>();
23690 assert!(
23691 try_parse_cli(args).is_ok(),
23692 "expand command should parse as a tsift CLI command: {command}"
23693 );
23694 }
23695
23696 fn setup_multiplicity_project() -> tempfile::TempDir {
23697 let dir = tempfile::tempdir().unwrap();
23698 std::fs::write(
23699 dir.path().join("Cargo.toml"),
23700 r#"[workspace]
23701members = ["crates/core-lib", "crates/cli-app"]
23702"#,
23703 )
23704 .unwrap();
23705 std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
23706 std::fs::write(
23707 dir.path().join("crates/core-lib/Cargo.toml"),
23708 r#"[package]
23709name = "core-lib"
23710
23711[lib]
23712name = "core_lib"
23713
23714[features]
23715default = []
23716"#,
23717 )
23718 .unwrap();
23719 std::fs::write(
23720 dir.path().join("crates/core-lib/src/lib.rs"),
23721 "pub fn run() {}\n",
23722 )
23723 .unwrap();
23724 std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
23725 std::fs::write(
23726 dir.path().join("crates/cli-app/Cargo.toml"),
23727 r#"[package]
23728name = "cli-app"
23729
23730[[bin]]
23731name = "cli-app"
23732
23733[dependencies]
23734core-lib = { path = "../core-lib" }
23735"#,
23736 )
23737 .unwrap();
23738 std::fs::write(
23739 dir.path().join("crates/cli-app/src/main.rs"),
23740 "use core_lib::run;\nfn main() { run(); }\n",
23741 )
23742 .unwrap();
23743 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23744 db.apply_changes(dir.path()).unwrap();
23745
23746 let task_dir = dir.path().join("tasks/software");
23747 std::fs::create_dir_all(&task_dir).unwrap();
23748 std::fs::write(
23749 task_dir.join("tsift.md"),
23750 r#"---
23751agent_doc_session: tsift-multiplicity
23752agent_doc_format: template
23753---
23754
23755## Backlog
23756
23757<!-- agent:backlog -->
23758- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
23759<!-- /agent:backlog -->
23760"#,
23761 )
23762 .unwrap();
23763 init_git_repo(dir.path());
23764 dir
23765 }
23766
23767 fn setup_dependency_dag_project() -> tempfile::TempDir {
23768 let dir = tempfile::tempdir().unwrap();
23769 std::fs::write(
23770 dir.path().join("main.rs"),
23771 "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
23772 )
23773 .unwrap();
23774 std::fs::write(
23775 dir.path().join("Cargo.toml"),
23776 "[package]\nname = \"dag-fixture\"\n",
23777 )
23778 .unwrap();
23779 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23780 db.apply_changes(dir.path()).unwrap();
23781
23782 let task_dir = dir.path().join("tasks/software");
23783 std::fs::create_dir_all(&task_dir).unwrap();
23784 std::fs::write(
23785 task_dir.join("tsift.md"),
23786 r#"---
23787agent_doc_session: tsift-dag
23788agent_doc_format: template
23789---
23790
23791## Exchange
23792
23793<!-- agent:exchange patch=append -->
23794Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
23795<!-- /agent:exchange -->
23796
23797## Backlog
23798
23799<!-- agent:backlog -->
23800- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
23801- [ ] [#alpha] Update shared_helper in main.rs after #prep.
23802- [ ] [#beta] Refactor shared_helper tests in main.rs.
23803- [ ] [#gamma] Follow-up review for graph navigation.
23804<!-- /agent:backlog -->
23805"#,
23806 )
23807 .unwrap();
23808 dir
23809 }
23810
23811 fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
23812 let dir = setup_graph_index();
23813 let task_dir = dir.path().join("tasks/software");
23814 std::fs::create_dir_all(&task_dir).unwrap();
23815 std::fs::write(
23816 task_dir.join("tsift.md"),
23817 r#"---
23818agent_doc_session: tsift-dag-cycle
23819agent_doc_format: template
23820---
23821
23822## Backlog
23823
23824<!-- agent:backlog -->
23825- [ ] [#left] Left side depends on #right.
23826- [ ] [#right] Right side depends on #left.
23827<!-- /agent:backlog -->
23828"#,
23829 )
23830 .unwrap();
23831 dir
23832 }
23833
23834 fn seed_traversal_semantic_summaries(dir: &Path) {
23835 let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23836 summary_db
23837 .insert(&summarize::Summary {
23838 id: 0,
23839 symbol_name: "helper".to_string(),
23840 file_path: "main.rs".to_string(),
23841 content_hash: "hash-main".to_string(),
23842 summary: "helper builds graph navigation handles for traversal.".to_string(),
23843 entities: Some(vec![
23844 summarize::Entity {
23845 name: "helper".to_string(),
23846 kind: "function".to_string(),
23847 description: "Builds graph navigation handles.".to_string(),
23848 },
23849 summarize::Entity {
23850 name: "TraversalGraph".to_string(),
23851 kind: "type".to_string(),
23852 description: "Carries GraphStore-backed traversal rows.".to_string(),
23853 },
23854 ]),
23855 relationships: Some(vec![summarize::Relationship {
23856 from: "helper".to_string(),
23857 to: "TraversalGraph".to_string(),
23858 kind: "uses".to_string(),
23859 }]),
23860 concept_labels: Some(vec![
23861 "graph navigation".to_string(),
23862 "semantic extraction".to_string(),
23863 ]),
23864 extracted_at: "1700000000".to_string(),
23865 model: "test-model".to_string(),
23866 tokens_input: Some(10),
23867 tokens_output: Some(5),
23868 })
23869 .unwrap();
23870 }
23871
23872 fn seed_tsift_memory_graph_db(dir: &Path) {
23873 let db = dir.join(".tsift").join("memory.db");
23874 let store = MemoryStore::open_or_create(&db).unwrap();
23875 let project = dir.to_string_lossy().to_string();
23876 let observation = MemoryEvent::new(
23877 MemoryEventKind::ImportedObservation,
23878 "claude-mem:observations:1",
23879 [
23880 "Graph memory adapter",
23881 "read-only projection",
23882 "graph-db should retrieve tsift memory observations",
23883 "Project memory is queried from .tsift/memory.db",
23884 "graph memory, tsift memory, semantic query",
23885 ]
23886 .join("\n\n"),
23887 )
23888 .with_session_id("claude-session-a")
23889 .with_observed_at_unix(1_700_000_000)
23890 .with_import("claude-mem", "observations:1")
23891 .with_metadata("project", project.clone())
23892 .with_metadata("observation_type", "fact")
23893 .with_metadata("prompt_number", "7")
23894 .with_metadata("discovery_tokens", "42")
23895 .with_metadata("content_hash", "hash-observation-1");
23896 store.insert_event(&observation).unwrap();
23897
23898 let summary = MemoryEvent::new(
23899 MemoryEventKind::ImportedSessionSummary,
23900 "claude-mem:session_summaries:2",
23901 [
23902 "Query old memory from graph-db",
23903 "Read-only tsift memory SQLite projection",
23904 "Semantic graph rows can point at existing memory",
23905 "Projected source and session nodes",
23906 "Keep capture ownership inside tsift-memory",
23907 "summary note",
23908 ]
23909 .join("\n\n"),
23910 )
23911 .with_session_id("claude-session-a")
23912 .with_observed_at_unix(1_700_000_010)
23913 .with_import("claude-mem", "session_summaries:2")
23914 .with_metadata("project", project)
23915 .with_metadata("prompt_number", "8")
23916 .with_metadata("discovery_tokens", "36");
23917 store.insert_event(&summary).unwrap();
23918
23919 let prompt = MemoryEvent::new(
23920 MemoryEventKind::ImportedUserPrompt,
23921 "claude-mem:user_prompts:3",
23922 "How can graph-db query tsift memory semantic history?",
23923 )
23924 .with_session_id("claude-session-a")
23925 .with_observed_at_unix(1_700_000_020)
23926 .with_import("claude-mem", "user_prompts:3")
23927 .with_metadata("prompt_number", "9");
23928 store.insert_event(&prompt).unwrap();
23929 }
23930
23931 #[test]
23932 fn graph_callers_query() {
23933 let dir = setup_graph_index();
23934 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23935 let callers = db.callers_of("helper").unwrap();
23936 assert_eq!(callers.len(), 1);
23937 assert_eq!(callers[0].caller_name, "main");
23938 }
23939
23940 #[test]
23941 fn graph_callees_query() {
23942 let dir = setup_graph_index();
23943 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23944 let callees = db.callees_of("main").unwrap();
23945 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
23946 assert!(names.contains(&"helper"));
23947 assert!(names.contains(&"new"));
23948 }
23949
23950 #[test]
23951 fn graph_no_callers_returns_empty() {
23952 let dir = setup_graph_index();
23953 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23954 let callers = db.callers_of("nonexistent").unwrap();
23955 assert!(callers.is_empty());
23956 }
23957
23958 #[test]
23959 fn graph_cmd_autoindexes_missing_index_by_default() {
23960 let dir = tempfile::tempdir().unwrap();
23961 std::fs::write(
23962 dir.path().join("main.rs"),
23963 "fn helper() {}\nfn main() { helper(); }\n",
23964 )
23965 .unwrap();
23966 let result = cmd_graph(
23967 "helper",
23968 dir.path(),
23969 true,
23970 false,
23971 None,
23972 20,
23973 false,
23974 true,
23975 false,
23976 false,
23977 false,
23978 false,
23979 false,
23980 TagpathSearchOpts::default(),
23981 );
23982
23983 assert!(result.is_ok());
23984 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
23985 let summary = db.compute_changes(dir.path()).unwrap();
23986 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
23987 }
23988
23989 #[test]
23990 fn traversal_graph_has_stable_typed_handles() {
23991 let dir = setup_traversal_project();
23992 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23993 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23994
23995 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
23996 let symbol = resolve_traversal_node(&graph, "helper").unwrap();
23997 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23998 let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
23999
24000 assert!(file.handle.starts_with("gfil-"));
24001 assert!(symbol.handle.starts_with("gsym-"));
24002 assert!(backlog.handle.starts_with("gbak-"));
24003 assert!(session.handle.starts_with("gses-"));
24004
24005 assert_eq!(
24006 symbol.handle,
24007 resolve_traversal_node(&graph_again, "helper")
24008 .unwrap()
24009 .handle
24010 );
24011 assert_eq!(
24012 backlog.handle,
24013 resolve_traversal_node(&graph_again, "#kgnv")
24014 .unwrap()
24015 .handle
24016 );
24017 }
24018
24019 #[test]
24020 fn traversal_graph_links_backlog_items_to_code_tokens() {
24021 let dir = setup_traversal_project();
24022 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24023 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24024 let helper = resolve_traversal_node(&graph, "helper").unwrap();
24025
24026 assert!(graph.edges.iter().any(|edge| {
24027 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24028 }));
24029 }
24030
24031 #[test]
24032 fn session_hinted_traversal_skips_global_call_edges() {
24033 let dir = setup_traversal_project();
24034 let session = dir.path().join("tasks/software/tsift.md");
24035 let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
24036 let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
24037 let helper = resolve_traversal_node(&bounded, "helper").unwrap();
24038
24039 assert!(bounded.edges.iter().any(|edge| {
24040 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24041 }));
24042 assert!(
24043 !bounded.edges.iter().any(|edge| edge.relation == "calls"),
24044 "session-hinted graph-db projections should not materialize unrelated global call edges"
24045 );
24046
24047 let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24048 assert!(
24049 full.edges.iter().any(|edge| edge.relation == "calls"),
24050 "root/full projections still carry the complete indexed call graph"
24051 );
24052 }
24053
24054 #[test]
24055 fn agent_doc_task_path_infers_matching_workspace_scope() {
24056 let dir = tempfile::tempdir().unwrap();
24057 std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
24058 std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
24059 std::fs::write(
24060 dir.path().join(".gitmodules"),
24061 "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
24062 )
24063 .unwrap();
24064 let task = dir.path().join("tasks/software/tsift.md");
24065 std::fs::write(&task, "# tsift\n").unwrap();
24066
24067 let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
24068 let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
24069 let cfg = config::Config::load(dir.path()).unwrap();
24070
24071 assert_eq!(targets.len(), 1);
24072 assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
24073 assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
24074 assert!(
24075 targets[0]
24076 .db_path
24077 .ends_with(".tsift/indexes/tsift/index.db")
24078 );
24079 assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
24080 }
24081
24082 #[test]
24083 fn cargo_package_scope_selector_indexes_package_db() {
24084 let dir = setup_multiplicity_project();
24085 let targets =
24086 resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
24087
24088 assert_eq!(targets.len(), 1);
24089 assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
24090 assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
24091 assert!(
24092 targets[0]
24093 .db_path
24094 .ends_with(".tsift/indexes/cargo/core-lib/index.db")
24095 );
24096
24097 cmd_index(
24098 dir.path(),
24099 false,
24100 false,
24101 false,
24102 false,
24103 true,
24104 false,
24105 Some("core_lib"),
24106 false,
24107 true,
24108 false,
24109 false,
24110 false,
24111 false,
24112 )
24113 .unwrap();
24114 assert!(targets[0].db_path.exists());
24115 }
24116
24117 #[test]
24118 fn source_read_symbols_build_cargo_package_index_on_demand() {
24119 let dir = setup_multiplicity_project();
24125 let cargo_index = dir.path().join(".tsift/indexes/cargo/core-lib/index.db");
24126 assert!(
24127 !cargo_index.exists(),
24128 "core-lib cargo index should not exist before the first source-read"
24129 );
24130
24131 let file_abs = dir.path().join("crates/core-lib/src/lib.rs");
24132 let source = std::fs::read(&file_abs).unwrap();
24133 let mut warnings = Vec::new();
24134 let symbols = load_source_symbols(
24135 dir.path(),
24136 &file_abs,
24137 "crates/core-lib/src/lib.rs",
24138 &source,
24139 None,
24140 1,
24141 usize::MAX,
24142 10,
24143 4096,
24144 &mut warnings,
24145 );
24146
24147 assert!(
24148 warnings.is_empty(),
24149 "source-read must build the index on demand instead of warning: {warnings:?}"
24150 );
24151 let symbol_names = symbols
24152 .iter()
24153 .map(|symbol| symbol.name.as_str())
24154 .collect::<Vec<_>>();
24155 assert!(
24156 symbol_names.contains(&"run"),
24157 "source-read should resolve `run` from the on-demand-built cargo index: {symbol_names:?}"
24158 );
24159 assert!(
24160 cargo_index.exists(),
24161 "source-read should have built the core-lib cargo index on demand"
24162 );
24163 }
24164
24165 #[test]
24166 fn path_inference_prefers_nested_cargo_package_without_submodule() {
24167 let dir = setup_multiplicity_project();
24168 let source = dir.path().join("crates/cli-app/src/main.rs");
24169 let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
24170
24171 assert_eq!(targets.len(), 1);
24172 assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
24173 assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
24174 }
24175
24176 #[test]
24177 fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
24178 let dir = setup_multiplicity_project();
24179 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24180 let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
24181 let core = resolve_traversal_node(&graph, "core-lib").unwrap();
24182 let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
24183 let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
24184
24185 assert_eq!(workspace.kind, "cargo_workspace");
24186 assert_eq!(core.kind, "cargo_package");
24187 assert_eq!(
24188 core.properties.get("features"),
24189 Some(&"default".to_string())
24190 );
24191 assert!(graph.edges.iter().any(|edge| {
24192 edge.from == workspace.handle
24193 && edge.to == core.handle
24194 && edge.relation == "contains_package"
24195 }));
24196 assert!(graph.edges.iter().any(|edge| {
24197 edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
24198 }));
24199 assert!(graph.edges.iter().any(|edge| {
24200 edge.from == cli.handle
24201 && edge.to == core.handle
24202 && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
24203 }));
24204 }
24205
24206 #[test]
24207 fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
24208 let dir = setup_multiplicity_project();
24209 let session = dir.path().join("tasks/software/tsift.md");
24210 let report =
24211 build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
24212 .unwrap();
24213
24214 assert!(report.per_target_fail_closed.is_empty());
24215 let candidate = report
24216 .candidates
24217 .iter()
24218 .find(|candidate| candidate.target == "corepkg")
24219 .unwrap();
24220 assert!(
24221 candidate
24222 .owned_files
24223 .iter()
24224 .any(|file| file == "crates/core-lib/Cargo.toml"),
24225 "{:?}",
24226 candidate.owned_files
24227 );
24228 }
24229
24230 #[test]
24231 fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
24232 let dir = setup_traversal_project();
24233 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24234 let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
24235 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24236
24237 assert_eq!(job.kind, "job_packet");
24238 assert!(job.handle.starts_with("gjob-"));
24239 assert!(graph.edges.iter().any(|edge| {
24240 edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
24241 }));
24242
24243 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24244 let jobs = store.nodes_by_kind("job_packet").unwrap();
24245 assert!(
24246 jobs.iter()
24247 .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24248 "expected queued job packet in graph store, got {jobs:?}"
24249 );
24250 }
24251
24252 #[test]
24253 fn traversal_graph_includes_routes_and_handler_edges() {
24254 let dir = tempfile::tempdir().unwrap();
24255 std::fs::write(
24256 dir.path().join("api.py"),
24257 r#"@router.get("/items")
24258def list_items():
24259 return []
24260"#,
24261 )
24262 .unwrap();
24263 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24264 db.apply_changes(dir.path()).unwrap();
24265
24266 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24267 let route = resolve_traversal_node(&graph, "/items").unwrap();
24268 let handler = resolve_traversal_node(&graph, "list_items").unwrap();
24269
24270 assert_eq!(route.kind, "route");
24271 assert!(graph.edges.iter().any(|edge| {
24272 edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
24273 }));
24274 }
24275
24276 #[test]
24277 fn traversal_graph_projects_rust_ast_navigation_edges() {
24278 let dir = tempfile::tempdir().unwrap();
24279 std::fs::write(
24280 dir.path().join("main.rs"),
24281 r#"mod api {
24282 pub fn helper() {}
24283 pub fn handler() { helper(); }
24284}
24285
24286fn main() { api::handler(); }
24287"#,
24288 )
24289 .unwrap();
24290 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24291 db.apply_changes(dir.path()).unwrap();
24292
24293 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24294 let api = resolve_ast_span_node(&graph, "api", "mod");
24295 let helper = resolve_ast_span_node(&graph, "helper", "function");
24296 let handler = resolve_ast_span_node(&graph, "handler", "function");
24297
24298 assert_eq!(helper.kind, "ast_span");
24299 assert!(helper.handle.starts_with("span-"));
24300 assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
24301 assert!(graph.edges.iter().any(|edge| {
24302 edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
24303 }));
24304 assert!(graph.edges.iter().any(|edge| {
24305 edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
24306 }));
24307 assert!(graph.edges.iter().any(|edge| {
24308 edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
24309 }));
24310 assert!(graph.edges.iter().any(|edge| {
24311 edge.from == helper.handle
24312 && edge.to == handler.handle
24313 && edge.relation == "next_sibling"
24314 }));
24315 assert!(graph.edges.iter().any(|edge| {
24316 edge.from == handler.handle
24317 && edge.to == helper.handle
24318 && edge.relation == "previous_sibling"
24319 }));
24320 assert!(graph.edges.iter().any(|edge| {
24321 edge.from == helper.handle
24322 && edge.to == api.handle
24323 && edge.relation == "enclosing_module"
24324 }));
24325 assert!(graph.edges.iter().any(|edge| {
24326 edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
24327 }));
24328
24329 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24330 let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
24331 assert!(
24332 ast_nodes.iter().any(|node| node.id == helper.handle
24333 && node.properties.get("symbol_kind") == Some(&"function".to_string())),
24334 "expected helper AST span in graph store, got {ast_nodes:?}"
24335 );
24336 assert!(
24337 store
24338 .outgoing_edges(&helper.handle, Some("parent"))
24339 .unwrap()
24340 .iter()
24341 .any(|edge| edge.to_id == api.handle),
24342 "expected persisted AST parent edge"
24343 );
24344 }
24345
24346 #[test]
24347 fn traversal_graph_projects_markdown_section_block_edges() {
24348 let dir = tempfile::tempdir().unwrap();
24349 std::fs::write(
24350 dir.path().join("README.md"),
24351 "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
24352 )
24353 .unwrap();
24354 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24355 db.apply_changes(dir.path()).unwrap();
24356
24357 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24358 let guide = resolve_ast_span_node(&graph, "Guide", "heading");
24359 let code = resolve_ast_span_node(&graph, "rust", "code_block");
24360 let embedded = resolve_ast_span_node(&graph, "demo", "function");
24361 let list_item = graph
24362 .nodes
24363 .values()
24364 .find(|node| {
24365 node.kind == "ast_span"
24366 && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
24367 && node.properties.get("section_handle") == Some(&guide.handle)
24368 })
24369 .expect("missing Markdown list item AST span");
24370
24371 assert_eq!(
24372 code.properties.get("markdown_block_kind"),
24373 Some(&"fenced_code_block".to_string())
24374 );
24375 assert_eq!(
24376 guide.properties.get("heading_level"),
24377 Some(&"1".to_string())
24378 );
24379 assert_eq!(
24380 embedded.properties.get("embedded"),
24381 Some(&"true".to_string())
24382 );
24383 assert_eq!(
24384 embedded.properties.get("language"),
24385 Some(&"rust".to_string())
24386 );
24387 assert_eq!(
24388 embedded.properties.get("markdown_block_handle"),
24389 Some(&code.handle)
24390 );
24391 assert!(graph.edges.iter().any(|edge| {
24392 edge.from == guide.handle
24393 && edge.to == code.handle
24394 && edge.relation == "contains_markdown_block"
24395 }));
24396 assert!(graph.edges.iter().any(|edge| {
24397 edge.from == code.handle
24398 && edge.to == guide.handle
24399 && edge.relation == "enclosing_section"
24400 }));
24401 assert!(graph.edges.iter().any(|edge| {
24402 edge.from == guide.handle
24403 && edge.to == list_item.handle
24404 && edge.relation == "contains_markdown_block"
24405 }));
24406 assert!(graph.edges.iter().any(|edge| {
24407 edge.from == code.handle
24408 && edge.to == embedded.handle
24409 && edge.relation == "contains_embedded_symbol"
24410 }));
24411 assert!(graph.edges.iter().any(|edge| {
24412 edge.from == embedded.handle
24413 && edge.to == code.handle
24414 && edge.relation == "embedded_in_fence"
24415 }));
24416 assert!(graph.edges.iter().any(|edge| {
24417 edge.from == guide.handle
24418 && edge.to == embedded.handle
24419 && edge.relation == "contains_embedded_code"
24420 }));
24421
24422 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24423 assert!(
24424 store
24425 .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
24426 .unwrap()
24427 .iter()
24428 .any(|edge| edge.to_id == code.handle),
24429 "expected persisted Markdown section/block edge"
24430 );
24431 assert!(
24432 store
24433 .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
24434 .unwrap()
24435 .iter()
24436 .any(|edge| edge.to_id == embedded.handle),
24437 "expected persisted Markdown fence/embedded symbol edge"
24438 );
24439 }
24440
24441 #[test]
24442 fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
24443 let dir = setup_multilingual_ast_navigation_project();
24444 let db =
24445 index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
24446 let symbols = db.all_symbols().unwrap();
24447 let expected_symbols = [
24448 ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
24449 (
24450 "python",
24451 "fixture_nav_python_entry",
24452 "function",
24453 "python.py",
24454 ),
24455 (
24456 "typescript",
24457 "fixture_nav_typescript_entry",
24458 "function",
24459 "typescript.ts",
24460 ),
24461 (
24462 "javascript",
24463 "fixture_nav_javascript_entry",
24464 "function",
24465 "javascript.js",
24466 ),
24467 (
24468 "kotlin",
24469 "fixture_nav_kotlin_entry",
24470 "function",
24471 "kotlin.kt",
24472 ),
24473 ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
24474 ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
24475 ("markdown", "Fixture Section", "heading", "README.md"),
24476 ("markdown", "Fixture step", "list_item", "README.md"),
24477 ("markdown", "python", "code_block", "README.md"),
24478 ];
24479
24480 for (language, name, kind, file) in expected_symbols {
24481 let symbol = symbols
24482 .iter()
24483 .find(|symbol| {
24484 symbol.language == language
24485 && symbol.name == name
24486 && symbol.kind == kind
24487 && symbol.file.ends_with(file)
24488 })
24489 .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
24490 assert!(
24491 symbol.start_byte.is_some() && symbol.end_byte.is_some(),
24492 "{language} {name} should carry AST byte spans"
24493 );
24494 }
24495
24496 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24497 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24498 let expected_ast_nodes = [
24499 ("fixture_nav_rust_entry", "function", "rust"),
24500 ("fixture_nav_python_entry", "function", "python"),
24501 ("fixture_nav_typescript_entry", "function", "typescript"),
24502 ("fixture_nav_javascript_entry", "function", "javascript"),
24503 ("fixture_nav_kotlin_entry", "function", "kotlin"),
24504 ("fixture_nav_zig_entry", "function", "zig"),
24505 ("fixture_nav_bash_entry", "function", "bash"),
24506 ("Fixture Section", "heading", "markdown"),
24507 ("Fixture step", "list_item", "markdown"),
24508 ("python", "code_block", "markdown"),
24509 ("fixture_nav_markdown_embedded", "function", "python"),
24510 ];
24511
24512 for (name, kind, language) in expected_ast_nodes {
24513 let node = resolve_ast_span_node(&graph, name, kind);
24514 let repeated = resolve_ast_span_node(&graph_again, name, kind);
24515 assert!(
24516 node.handle.starts_with("span-"),
24517 "{name} handle: {}",
24518 node.handle
24519 );
24520 assert_eq!(
24521 node.handle, repeated.handle,
24522 "{language} {name} handle drifted"
24523 );
24524 assert_eq!(
24525 node.properties.get("language"),
24526 Some(&language.to_string()),
24527 "{name} should keep its language label"
24528 );
24529 }
24530
24531 let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
24532 let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
24533 let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
24534 assert!(graph.edges.iter().any(|edge| {
24535 edge.from == markdown_section.handle
24536 && edge.to == markdown_code.handle
24537 && edge.relation == "contains_markdown_block"
24538 }));
24539 assert!(graph.edges.iter().any(|edge| {
24540 edge.from == markdown_code.handle
24541 && edge.to == embedded.handle
24542 && edge.relation == "contains_embedded_symbol"
24543 }));
24544 assert!(
24545 graph.nodes.len() <= 80,
24546 "multilingual AST fixture should stay bounded, got {} nodes",
24547 graph.nodes.len()
24548 );
24549 assert!(
24550 graph.edges.len() <= 180,
24551 "multilingual AST fixture should stay bounded, got {} edges",
24552 graph.edges.len()
24553 );
24554
24555 let response = empty_search_response(dir.path(), "lexical");
24556 let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
24557 let report = build_relative_search_budget_report(
24558 "fixture_nav_python_entry",
24559 "lexical",
24560 dir.path(),
24561 &response,
24562 &symbol_hits,
24563 ResponseBudget::new(Some(8), Some(120)),
24564 &SearchFacetFilters::default(),
24565 );
24566 let report_again = build_relative_search_budget_report(
24567 "fixture_nav_python_entry",
24568 "lexical",
24569 dir.path(),
24570 &response,
24571 &symbol_hits,
24572 ResponseBudget::new(Some(8), Some(120)),
24573 &SearchFacetFilters::default(),
24574 );
24575
24576 let top = report
24577 .ranked
24578 .first()
24579 .expect("ranked preview should not be empty");
24580 assert_eq!(top.source, "symbol_span");
24581 assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
24582 assert!(top.handle.starts_with("srnk-"));
24583 assert_eq!(top.handle, report_again.ranked[0].handle);
24584 assert!(
24585 top.reasons.iter().any(|reason| reason == "ast_span"),
24586 "expected AST span ranking reason, got {:?}",
24587 top.reasons
24588 );
24589 assert!(report.ranked.len() <= 8);
24590 assert!(report.symbols.len() <= 8);
24591
24592 let symbol = report
24593 .symbols
24594 .iter()
24595 .find(|symbol| symbol.name == "fixture_nav_python_entry")
24596 .expect("missing search preview symbol");
24597 assert_cli_expand_command_parses(&symbol.expand);
24598 let ast = symbol
24599 .ast
24600 .as_ref()
24601 .expect("search symbol should expose AST");
24602 assert_cli_expand_command_parses(&ast.expand.source_window);
24603 assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
24604 assert_cli_expand_command_parses(&ast.expand.symbol_read);
24605
24606 let markdown_hits = db.symbol_search("python", 20).unwrap();
24607 let markdown_report = build_relative_search_budget_report(
24608 "python",
24609 "lexical",
24610 dir.path(),
24611 &response,
24612 &markdown_hits,
24613 ResponseBudget::new(Some(8), Some(120)),
24614 &SearchFacetFilters::default(),
24615 );
24616 let markdown_symbol = markdown_report
24617 .symbols
24618 .iter()
24619 .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
24620 .expect("missing Markdown code-block symbol");
24621 let markdown_ast = markdown_symbol
24622 .ast
24623 .as_ref()
24624 .expect("Markdown code block should expose AST");
24625 assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
24626 assert_eq!(
24627 markdown_ast
24628 .span
24629 .markdown
24630 .as_ref()
24631 .unwrap()
24632 .embedded_symbols[0]
24633 .name,
24634 "fixture_nav_markdown_embedded"
24635 );
24636 }
24637
24638 #[test]
24639 fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
24640 let edges = vec![
24641 TraversalEdge {
24642 from: "origin".to_string(),
24643 to: "aaa_low".to_string(),
24644 relation: "unknown".to_string(),
24645 label: None,
24646 weight: 1,
24647 },
24648 TraversalEdge {
24649 from: "origin".to_string(),
24650 to: "zzz_high".to_string(),
24651 relation: "mentions".to_string(),
24652 label: None,
24653 weight: 1,
24654 },
24655 ];
24656
24657 let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
24658
24659 assert!(handles.contains("origin"));
24660 assert!(handles.contains("zzz_high"), "{handles:?}");
24661 assert!(!handles.contains("aaa_low"), "{handles:?}");
24662 }
24663
24664 #[test]
24665 fn traversal_materializes_provider_neutral_sqlite_graph() {
24666 let dir = setup_traversal_project();
24667 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24668 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24669
24670 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24671 let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
24672 assert!(
24673 backlog_nodes.iter().any(|node| node.id == backlog.handle
24674 && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24675 "expected materialized backlog node, got {backlog_nodes:?}"
24676 );
24677 assert!(
24678 store
24679 .all_nodes()
24680 .unwrap()
24681 .iter()
24682 .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
24683 && node.properties.get("projection_version")
24684 == Some(&GRAPH_PROJECTION_VERSION.to_string())),
24685 "expected projection metadata node"
24686 );
24687 let source_handles = store.nodes_by_kind("source_handle").unwrap();
24688 assert!(
24689 source_handles
24690 .iter()
24691 .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
24692 "expected bounded source_handle rows, got {source_handles:?}"
24693 );
24694 let worker_context = store.nodes_by_kind("worker_context").unwrap();
24695 assert!(
24696 worker_context
24697 .iter()
24698 .any(|node| node.properties.get("target")
24699 == Some(&"tasks/software/tsift.md".to_string())),
24700 "expected bounded worker_context rows, got {worker_context:?}"
24701 );
24702 let worker_results = store.nodes_by_kind("worker_result").unwrap();
24703 assert!(
24704 worker_results.iter().any(|node| {
24705 node.properties.get("ref_id") == Some(&"kgnv".to_string())
24706 && node.properties.get("status") == Some(&"completed".to_string())
24707 && node.properties.get("touched_files") == Some(&"main.rs".to_string())
24708 && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
24709 }),
24710 "expected worker_result rows, got {worker_results:?}"
24711 );
24712 }
24713
24714 #[test]
24715 fn traversal_projection_materializes_cached_semantic_rows() {
24716 let dir = setup_traversal_project();
24717 seed_traversal_semantic_summaries(dir.path());
24718 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24719 let helper = resolve_traversal_node(&graph, "helper").unwrap();
24720 let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
24721 let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
24722
24723 assert_eq!(concept.kind, "semantic_concept");
24724 assert_eq!(entity.kind, "semantic_entity");
24725 assert!(concept.handle.starts_with("gcon-"));
24726 assert!(entity.handle.starts_with("gent-"));
24727
24728 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24729 assert!(
24730 store
24731 .nodes_by_kind("semantic_concept")
24732 .unwrap()
24733 .iter()
24734 .any(|node| node.label == "semantic extraction"
24735 && node.properties.contains_key("embedding")),
24736 "expected persisted concept embeddings"
24737 );
24738 assert!(
24739 store
24740 .outgoing_edges(&helper.handle, Some("mentions_concept"))
24741 .unwrap()
24742 .iter()
24743 .any(|edge| edge.to_id == concept.handle),
24744 "expected helper symbol to link to cached summary concept"
24745 );
24746 assert!(
24747 store
24748 .outgoing_edges(
24749 &semantic_entity_handle("helper", "function"),
24750 Some("semantic_relation")
24751 )
24752 .unwrap()
24753 .iter()
24754 .any(|edge| edge.to_id == entity.handle
24755 && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
24756 "expected LLM relationship rows projected into GraphStore"
24757 );
24758 }
24759
24760 #[test]
24761 fn traversal_projection_materializes_tsift_memory_rows() {
24762 let dir = setup_traversal_project();
24763 seed_tsift_memory_graph_db(dir.path());
24764 let memory_db = dir.path().join(".tsift").join("memory.db");
24765 let store = MemoryStore::open_or_create(&memory_db).unwrap();
24766 for summary in ["first closeout", "second closeout"] {
24767 let event = MemoryEvent::new(
24768 MemoryEventKind::ResponseSummary,
24769 "tasks/software/tsift.md",
24770 summary,
24771 )
24772 .with_session_id("tasks/software/tsift.md")
24773 .with_observed_at_unix(1_700_000_100);
24774 store.insert_event(&event).unwrap();
24775 }
24776 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24777 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24778
24779 let native_sources = store
24780 .nodes_by_kind("source_handle")
24781 .unwrap()
24782 .into_iter()
24783 .filter(|node| {
24784 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24785 && node.properties.get("source_ref")
24786 == Some(&"tasks/software/tsift.md".to_string())
24787 })
24788 .collect::<Vec<_>>();
24789 assert_eq!(
24790 native_sources.len(),
24791 2,
24792 "same-source native memory events must get distinct source handles"
24793 );
24794
24795 let source = store
24796 .nodes_by_kind("source_handle")
24797 .unwrap()
24798 .into_iter()
24799 .find(|node| {
24800 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24801 })
24802 .expect("expected tsift-memory source handle");
24803 let session = store
24804 .nodes_by_kind("memory_session")
24805 .unwrap()
24806 .into_iter()
24807 .find(|node| {
24808 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24809 && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
24810 })
24811 .expect("expected tsift-memory session node");
24812 let event = store
24813 .nodes_by_kind("memory_event")
24814 .unwrap()
24815 .into_iter()
24816 .find(|node| {
24817 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24818 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24819 && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
24820 })
24821 .expect("expected tsift-memory event node");
24822 let concept = store
24823 .nodes_by_kind("semantic_concept")
24824 .unwrap()
24825 .into_iter()
24826 .find(|node| {
24827 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24828 && node.label.contains("Graph memory adapter")
24829 && node.properties.contains_key("embedding")
24830 })
24831 .expect("expected tsift-memory semantic concept");
24832
24833 assert!(
24834 store
24835 .outgoing_edges(&session.id, Some("records_memory_source"))
24836 .unwrap()
24837 .iter()
24838 .any(|edge| edge.to_id == source.id),
24839 "expected session to link to source handle"
24840 );
24841 assert!(
24842 store
24843 .outgoing_edges(&session.id, Some("records_memory_event"))
24844 .unwrap()
24845 .iter()
24846 .any(|edge| edge.to_id == event.id),
24847 "expected session to link to memory event"
24848 );
24849 assert!(
24850 store
24851 .outgoing_edges(&event.id, Some("projects_source"))
24852 .unwrap()
24853 .iter()
24854 .any(|edge| edge.to_id == source.id),
24855 "expected memory event to project source handle"
24856 );
24857 assert!(
24858 store
24859 .outgoing_edges(&source.id, Some("mentions_concept"))
24860 .unwrap()
24861 .iter()
24862 .any(|edge| edge.to_id == concept.id),
24863 "expected source handle to seed semantic concept"
24864 );
24865
24866 let related = semantic_related_report_from_store(
24867 dir.path(),
24868 None,
24869 "tsift memory graph adapter",
24870 5,
24871 SemanticRelatedKind::Concept,
24872 &store,
24873 )
24874 .unwrap();
24875 assert!(
24876 related
24877 .items
24878 .iter()
24879 .any(|item| item.handle == concept.id && item.score > 0.0),
24880 "expected semantic query to retrieve tsift-memory concept, got {:?}",
24881 related.items
24882 );
24883
24884 let graph_related = graph_db_report_from_store(
24885 dir.path(),
24886 None,
24887 "sqlite",
24888 GraphDbQuery::Related {
24889 query: "tsift memory graph adapter".to_string(),
24890 kind: SemanticRelatedKind::Concept,
24891 depth: 1,
24892 seed_limit: 5,
24893 limit: 20,
24894 },
24895 &store,
24896 sqlite_graph_freshness(&store, "root").unwrap(),
24897 Vec::new(),
24898 )
24899 .unwrap();
24900 assert_eq!(
24901 graph_related
24902 .readiness
24903 .as_ref()
24904 .map(|readiness| readiness.status.as_str()),
24905 Some("ready"),
24906 "tsift-memory semantic rows should satisfy graph-db related readiness"
24907 );
24908 assert!(
24909 graph_related.nodes.iter().any(|node| {
24910 node.kind == "semantic_concept"
24911 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24912 }),
24913 "expected related graph output to include tsift-memory semantic rows"
24914 );
24915 }
24916
24917 #[test]
24918 fn semantic_related_query_uses_persisted_graph_embeddings() {
24919 let dir = setup_traversal_project();
24920 seed_traversal_semantic_summaries(dir.path());
24921 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24922 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24923 let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
24924 .unwrap()
24925 .query_row(
24926 "SELECT COUNT(*) FROM graph_node_semantic_vectors",
24927 [],
24928 |row| row_usize(row, 0),
24929 )
24930 .unwrap();
24931 assert!(semantic_vector_rows > 0);
24932
24933 let report = semantic_related_report_from_store(
24934 dir.path(),
24935 None,
24936 "graph navigation",
24937 5,
24938 SemanticRelatedKind::Concept,
24939 &store,
24940 )
24941 .unwrap();
24942
24943 assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
24944 assert!(
24945 report
24946 .items
24947 .iter()
24948 .any(|item| item.label == "graph navigation"
24949 && item.kind == "semantic_concept"
24950 && item.score > 0.9),
24951 "expected nearest concept match from graph embeddings, got {:?}",
24952 report.items
24953 );
24954 }
24955
24956 #[test]
24957 fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
24958 let dir = setup_traversal_project();
24959 seed_traversal_semantic_summaries(dir.path());
24960 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24961 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24962
24963 let report = graph_db_report_from_store(
24964 dir.path(),
24965 None,
24966 "sqlite",
24967 GraphDbQuery::Related {
24968 query: "graph navigation".to_string(),
24969 kind: SemanticRelatedKind::All,
24970 depth: 1,
24971 seed_limit: 2,
24972 limit: 20,
24973 },
24974 &store,
24975 sqlite_graph_freshness(&store, "root").unwrap(),
24976 Vec::new(),
24977 )
24978 .unwrap();
24979
24980 let knowledge = report.knowledge_retrieval.as_ref().unwrap();
24981 assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
24982 assert_eq!(knowledge.seed_kind, "all");
24983 assert_eq!(knowledge.depth, 1);
24984 assert_eq!(
24985 report
24986 .readiness
24987 .as_ref()
24988 .map(|readiness| readiness.status.as_str()),
24989 Some("ready")
24990 );
24991 assert!(
24992 knowledge
24993 .diagnostics
24994 .iter()
24995 .any(|diagnostic| diagnostic.contains("incident"))
24996 );
24997 assert!(
24998 report
24999 .semantic_related
25000 .iter()
25001 .any(|item| item.label == "graph navigation"
25002 && item.kind == "semantic_concept"
25003 && item.score > 0.9),
25004 "expected natural-language query to seed the graph navigation concept, got {:?}",
25005 report.semantic_related
25006 );
25007 assert!(
25008 report
25009 .nodes
25010 .iter()
25011 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
25012 );
25013 assert!(
25014 report
25015 .nodes
25016 .iter()
25017 .any(|node| node.kind == "symbol" && node.label == "helper"),
25018 "incident expansion from semantic seed should recover source symbols, got {:?}",
25019 report
25020 .nodes
25021 .iter()
25022 .map(|node| (&node.kind, &node.label))
25023 .collect::<Vec<_>>()
25024 );
25025 assert!(
25026 report
25027 .edges
25028 .iter()
25029 .any(|edge| edge.kind == "mentions_concept")
25030 );
25031 assert!(
25032 report.output_budget.as_ref().is_some_and(|budget| budget
25033 .diagnostics
25034 .iter()
25035 .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
25036 "expected related output budget diagnostics, got {:?}",
25037 report.output_budget
25038 );
25039 }
25040
25041 #[test]
25042 fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
25043 let dir = setup_graph_index();
25044 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25045 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25046
25047 let report = graph_db_report_from_store(
25048 dir.path(),
25049 None,
25050 "sqlite",
25051 GraphDbQuery::Related {
25052 query: "graph navigation".to_string(),
25053 kind: SemanticRelatedKind::All,
25054 depth: 1,
25055 seed_limit: 2,
25056 limit: 20,
25057 },
25058 &store,
25059 sqlite_graph_freshness(&store, "root").unwrap(),
25060 Vec::new(),
25061 )
25062 .unwrap();
25063
25064 let readiness = report.readiness.as_ref().unwrap();
25065 assert_eq!(readiness.status, "blocked");
25066 assert_eq!(readiness.reason, "summary_cache_empty");
25067 assert!(readiness.fail_closed);
25068 assert_eq!(
25069 readiness.next_commands,
25070 vec![
25071 "tsift summarize --extract .".to_string(),
25072 graph_db_refresh_command(dir.path(), None)
25073 ]
25074 );
25075 assert!(
25076 report
25077 .knowledge_retrieval
25078 .as_ref()
25079 .unwrap()
25080 .diagnostics
25081 .iter()
25082 .any(|diagnostic| diagnostic.contains("summary cache empty")
25083 && diagnostic.contains("graph-db materialized code/session rows")),
25084 "expected related diagnostics to carry readiness gate, got {:?}",
25085 report.knowledge_retrieval.as_ref().unwrap().diagnostics
25086 );
25087 }
25088
25089 #[test]
25090 fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
25091 let mut nodes = vec![
25092 SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
25093 SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
25094 ];
25095 let mut edges = vec![SubstrateGraphEdge::new(
25096 "zzz_high",
25097 "seed",
25098 "mentions_concept",
25099 )];
25100 for idx in 0..24 {
25101 let id = format!("aaa_low_{idx:02}");
25102 nodes.push(SubstrateGraphNode::new(
25103 id.clone(),
25104 "note",
25105 format!("low {idx}"),
25106 ));
25107 edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
25108 }
25109 let mut store = SqliteGraphStore::in_memory().unwrap();
25110 store
25111 .replace_projection(&GraphProjection { nodes, edges })
25112 .unwrap();
25113
25114 let subgraph =
25115 graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
25116
25117 assert_eq!(subgraph.nodes.len(), 3);
25118 assert_eq!(subgraph.nodes[0].id, "seed");
25119 assert_eq!(
25120 subgraph.nodes[1].id, "zzz_high",
25121 "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
25122 subgraph.nodes
25123 );
25124 assert!(subgraph.truncated);
25125 assert!(
25126 subgraph
25127 .diagnostics
25128 .iter()
25129 .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
25130 "{:?}",
25131 subgraph.diagnostics
25132 );
25133 assert!(
25134 subgraph
25135 .diagnostics
25136 .iter()
25137 .any(|diagnostic| diagnostic.contains("skipped")),
25138 "{:?}",
25139 subgraph.diagnostics
25140 );
25141 }
25142
25143 #[test]
25144 fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
25145 let dir = setup_traversal_project();
25146 seed_traversal_semantic_summaries(dir.path());
25147 init_git_repo(dir.path());
25148 let session = dir.path().join("tasks/software/tsift.md");
25149 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25150 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25151 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25152 let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25153 root: dir.path(),
25154 scope: None,
25155 backend: "sqlite",
25156 target: "kgnv",
25157 preferred_path: None,
25158 depth: 4,
25159 limit: 8,
25160 cursor: None,
25161 store: &store,
25162 freshness,
25163 warnings: Vec::new(),
25164 })
25165 .unwrap();
25166 assert!(
25167 evidence
25168 .semantic_related
25169 .iter()
25170 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
25171 "expected semantic evidence rows, got {:?}",
25172 evidence
25173 .semantic_related
25174 .iter()
25175 .map(|node| (&node.kind, &node.label))
25176 .collect::<Vec<_>>()
25177 );
25178 assert!(
25179 evidence
25180 .output_budget
25181 .as_ref()
25182 .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
25183 diagnostic.contains("semantic_match")
25184 && diagnostic.contains("source_handle_coverage")
25185 })),
25186 "expected evidence output budget diagnostics, got {:?}",
25187 evidence.output_budget
25188 );
25189
25190 let cached_diff = diff_digest::compute(
25191 dir.path(),
25192 diff_digest::DiffDigestOptions {
25193 cached: true,
25194 revision: None,
25195 max_parsed_files: None,
25196 },
25197 )
25198 .unwrap();
25199 let impact_report = impact::compute(
25200 dir.path(),
25201 impact::ImpactOptions {
25202 cached: true,
25203 revision: None,
25204 scope: None,
25205 limit: 10,
25206 },
25207 )
25208 .unwrap();
25209 let graph_nodes = store.all_nodes().unwrap();
25210 let graph_index = conflict_matrix_graph_index(&graph_nodes);
25211 let semantic_candidate = conflict_matrix_candidate_from_evidence(
25212 dir.path(),
25213 &evidence,
25214 &graph_index,
25215 &cached_diff,
25216 &impact_report,
25217 );
25218 assert!(semantic_candidate.semantic_dispatch_score > 0);
25219 assert!(
25220 semantic_candidate
25221 .semantic_dispatch_reasons
25222 .iter()
25223 .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
25224 "expected semantic ranking explanations, got {:?}",
25225 semantic_candidate.semantic_dispatch_reasons
25226 );
25227 assert!(
25228 semantic_candidate
25229 .semantic_related
25230 .iter()
25231 .any(|item| item.label == "graph navigation")
25232 );
25233
25234 let mut plain_candidate = semantic_candidate.clone();
25235 plain_candidate.target = "plain".to_string();
25236 plain_candidate.semantic_related.clear();
25237 plain_candidate.semantic_dispatch_score = 0;
25238 plain_candidate.semantic_dispatch_reasons.clear();
25239 let mut ranked = [plain_candidate, semantic_candidate];
25240 ranked.sort_by(|left, right| {
25241 left.risk
25242 .cmp(&right.risk)
25243 .then_with(|| left.risk_score.cmp(&right.risk_score))
25244 .then_with(|| {
25245 right
25246 .semantic_dispatch_score
25247 .cmp(&left.semantic_dispatch_score)
25248 })
25249 .then_with(|| left.target.cmp(&right.target))
25250 });
25251 assert_eq!(ranked[0].target, "kgnv");
25252 }
25253
25254 #[test]
25255 fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
25256 let dir = setup_dependency_dag_project();
25257 let session = dir.path().join("tasks/software/tsift.md");
25258 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25259
25260 assert_eq!(report.contract_version, "dependency-dag-v1");
25261 assert_eq!(
25262 report.targets,
25263 vec![
25264 "prep".to_string(),
25265 "alpha".to_string(),
25266 "beta".to_string(),
25267 "gamma".to_string()
25268 ]
25269 );
25270 assert!(report.edges.iter().any(|edge| {
25271 edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
25272 }));
25273 assert!(report.edges.iter().any(|edge| {
25274 edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
25275 }));
25276 assert!(report.edges.iter().any(|edge| {
25277 edge.from == "alpha"
25278 && edge.to == "beta"
25279 && edge.kind == "shared_resource"
25280 && edge.shared_files.contains(&"main.rs".to_string())
25281 && edge.shared_symbols.contains(&"shared_helper".to_string())
25282 }));
25283 assert!(
25284 !report.cycle_diagnostics.has_cycles,
25285 "{:?}",
25286 report.cycle_diagnostics
25287 );
25288 assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
25289 assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
25290 assert!(
25291 report.replay_commands[0].contains("dependency-dag"),
25292 "{:?}",
25293 report.replay_commands
25294 );
25295
25296 cmd_dependency_dag(
25297 &session,
25298 None,
25299 &["alpha".to_string(), "beta".to_string()],
25300 4,
25301 12,
25302 OutputFormat {
25303 json_output: true,
25304 compact: false,
25305 pretty: false,
25306 terse: false,
25307 ultra_terse: false,
25308 schema: false,
25309 envelope: false,
25310 },
25311 )
25312 .unwrap();
25313 }
25314
25315 #[test]
25316 fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
25317 let dir = setup_dependency_dag_cycle_project();
25318 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25319
25320 assert!(report.cycle_diagnostics.has_cycles);
25321 assert_eq!(
25322 report.cycle_diagnostics.blocked_nodes,
25323 vec!["left".to_string(), "right".to_string()]
25324 );
25325 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25326 edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
25327 }));
25328 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25329 edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
25330 }));
25331 }
25332
25333 #[test]
25334 fn traversal_projection_queries_match_sqlite_and_convex_stores() {
25335 let dir = setup_traversal_project();
25336 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
25337 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
25338
25339 let mut sqlite = SqliteGraphStore::in_memory().unwrap();
25340 sqlite.replace_projection(&projection).unwrap();
25341 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25342 projection.upsert_into(&convex).unwrap();
25343
25344 let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
25345 let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
25346 assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
25347 assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
25348
25349 let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
25350 let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
25351 assert!(convex_graph.edges.iter().any(|edge| {
25352 edge.from == sqlite_backlog.handle
25353 && edge.to == convex_helper.handle
25354 && edge.relation == "mentions"
25355 }));
25356 }
25357
25358 #[test]
25359 fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
25360 let dir = setup_traversal_project();
25361 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25362 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25363 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25364 assert_eq!(freshness.status, "current");
25365
25366 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25367 let report = graph_db_report_from_store(
25368 dir.path(),
25369 None,
25370 "sqlite",
25371 GraphDbQuery::Neighborhood {
25372 id: backlog.handle.clone(),
25373 depth: 1,
25374 edge_kind: Some("mentions".to_string()),
25375 cursor: None,
25376 limit: None,
25377 property_filters: Vec::new(),
25378 },
25379 &store,
25380 freshness,
25381 Vec::new(),
25382 )
25383 .unwrap();
25384 assert!(
25385 report
25386 .edges
25387 .iter()
25388 .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
25389 "expected backlog mention edge, got {:?}",
25390 report.edges
25391 );
25392 assert!(
25393 report.ranked_neighbors.iter().any(|neighbor| {
25394 neighbor.depth == Some(1)
25395 && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
25396 && neighbor.node_id != backlog.handle
25397 && neighbor.handle_coverage_pct >= 95.0
25398 && neighbor.duplicate_name_precision >= 0.99
25399 }),
25400 "expected ranked neighborhood neighbors with quality scores, got {:?}",
25401 report.ranked_neighbors
25402 );
25403 assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
25404 let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
25405 assert!(!ranking_gate.ranked_output_default);
25406 assert_eq!(ranking_gate.default_order, "stable_node_id");
25407 assert!(
25408 ranking_gate
25409 .diagnostics
25410 .iter()
25411 .any(|diagnostic| diagnostic.contains("score-capped")),
25412 "{ranking_gate:?}"
25413 );
25414 assert!(
25415 ranking_gate
25416 .required_metrics
25417 .iter()
25418 .any(|metric| metric == "handle_coverage_pct")
25419 );
25420 assert!(
25421 ranking_gate
25422 .required_metrics
25423 .iter()
25424 .any(|metric| metric == "duplicate_name_precision")
25425 );
25426 assert!(
25427 report
25428 .page
25429 .as_ref()
25430 .unwrap()
25431 .diagnostics
25432 .iter()
25433 .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
25434 "expected SQLite neighborhood query plan diagnostics, got {:?}",
25435 report.page.as_ref().unwrap().diagnostics
25436 );
25437 let edges_report = graph_db_report_from_store(
25438 dir.path(),
25439 None,
25440 "sqlite",
25441 GraphDbQuery::Edges {
25442 edge_kind: Some("mentions".to_string()),
25443 cursor: None,
25444 limit: Some(2),
25445 property_filters: Vec::new(),
25446 },
25447 &store,
25448 sqlite_graph_freshness(&store, "root").unwrap(),
25449 Vec::new(),
25450 )
25451 .unwrap();
25452 let edge_id = edges_report
25453 .edges
25454 .first()
25455 .map(|edge| edge.id.clone())
25456 .expect("expected at least one paged mentions edge");
25457 assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
25458 assert_eq!(
25459 edges_report.page.as_ref().unwrap().returned_edges,
25460 edges_report.edges.len()
25461 );
25462
25463 let edge_report = graph_db_report_from_store(
25464 dir.path(),
25465 None,
25466 "sqlite",
25467 GraphDbQuery::Edge {
25468 id: edge_id.clone(),
25469 },
25470 &store,
25471 sqlite_graph_freshness(&store, "root").unwrap(),
25472 Vec::new(),
25473 )
25474 .unwrap();
25475 assert_eq!(
25476 edge_report
25477 .edge
25478 .as_ref()
25479 .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
25480 Some(edge_id.clone())
25481 );
25482
25483 let incident_report = graph_db_report_from_store(
25484 dir.path(),
25485 None,
25486 "sqlite",
25487 GraphDbQuery::Incident {
25488 id: backlog.handle.clone(),
25489 edge_kind: Some("mentions".to_string()),
25490 cursor: None,
25491 limit: Some(1),
25492 property_filters: Vec::new(),
25493 },
25494 &store,
25495 sqlite_graph_freshness(&store, "root").unwrap(),
25496 Vec::new(),
25497 )
25498 .unwrap();
25499 assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
25500 assert!(
25501 incident_report
25502 .edges
25503 .iter()
25504 .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
25505 "{:?}",
25506 incident_report.edges
25507 );
25508
25509 let schema_report = graph_db_report_from_store(
25510 dir.path(),
25511 None,
25512 "sqlite",
25513 GraphDbQuery::Schema,
25514 &store,
25515 sqlite_graph_freshness(&store, "root").unwrap(),
25516 Vec::new(),
25517 )
25518 .unwrap();
25519 assert!(
25520 schema_report
25521 .schema
25522 .unwrap()
25523 .operations
25524 .iter()
25525 .any(|operation| operation.command.starts_with("neighborhood"))
25526 );
25527 }
25528
25529 #[test]
25530 fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
25531 let mut nodes = vec![SubstrateGraphNode::new(
25532 "origin",
25533 "backlog",
25534 "#budgeted-neighborhood",
25535 )];
25536 let mut edges = Vec::new();
25537 for idx in 0..32 {
25538 let id = format!("src-{idx:02}");
25539 nodes.push(
25540 SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
25541 .with_property("source_ref", format!("fixture:{idx}"))
25542 .with_property("detail", "x".repeat(600)),
25543 );
25544 edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
25545 }
25546 let store = SqliteGraphStore::in_memory().unwrap();
25547 GraphProjection { nodes, edges }
25548 .upsert_into(&store)
25549 .unwrap();
25550
25551 let report = graph_db_report_from_store(
25552 Path::new("."),
25553 None,
25554 "fixture",
25555 GraphDbQuery::Neighborhood {
25556 id: "origin".to_string(),
25557 depth: 1,
25558 edge_kind: None,
25559 cursor: None,
25560 limit: None,
25561 property_filters: Vec::new(),
25562 },
25563 &store,
25564 current_graph_db_freshness(),
25565 Vec::new(),
25566 )
25567 .unwrap();
25568 let budget = report.output_budget.as_ref().unwrap();
25569 assert!(budget.selected_nodes < budget.candidate_nodes);
25570 assert!(
25571 budget.dropped_by_budget.iter().any(|drop| {
25572 drop.item == "node"
25573 && drop.kind == "source_handle"
25574 && drop.reason == "per_kind_quota"
25575 }),
25576 "expected source_handle budget drops, got {:?}",
25577 budget.dropped_by_budget
25578 );
25579 assert!(report.page.as_ref().unwrap().truncated);
25580 assert!(
25581 report
25582 .page
25583 .as_ref()
25584 .unwrap()
25585 .diagnostics
25586 .iter()
25587 .any(|diagnostic| diagnostic.contains("budget ranking signals")),
25588 "{:?}",
25589 report.page
25590 );
25591 }
25592
25593 #[test]
25594 fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
25595 let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
25596 let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
25597 for idx in 0..8 {
25598 let id = format!("far-{idx:02}");
25599 nodes.push(SubstrateGraphNode::new(
25600 id.clone(),
25601 "note",
25602 format!("aaa deeper row {idx}"),
25603 ));
25604 depth_by_id.insert(id, 6);
25605 }
25606
25607 let origin_ids = vec!["target".to_string()];
25608 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
25609 &origin_ids,
25610 &BTreeMap::new(),
25611 nodes,
25612 Vec::new(),
25613 Some(3),
25614 Some(&depth_by_id),
25615 None,
25616 );
25617
25618 assert!(
25619 budgeted.nodes.iter().any(|node| node.id == "near"),
25620 "expected the shallow evidence row to outrank deeper rows, got {:?}",
25621 budgeted
25622 .nodes
25623 .iter()
25624 .map(|node| (&node.id, &node.label))
25625 .collect::<Vec<_>>()
25626 );
25627 assert!(
25628 budgeted.report.dropped_by_budget.iter().any(|drop| {
25629 drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
25630 }),
25631 "expected node quota drops, got {:?}",
25632 budgeted.report.dropped_by_budget
25633 );
25634 assert!(
25635 budgeted
25636 .report
25637 .diagnostics
25638 .iter()
25639 .any(|diagnostic| diagnostic.contains("depth")),
25640 "{:?}",
25641 budgeted.report.diagnostics
25642 );
25643 }
25644
25645 #[test]
25646 fn evidence_pagination_returns_next_cursor_when_truncated() {
25647 let mut nodes = vec![SubstrateGraphNode::new(
25648 "target".to_string(),
25649 "backlog_item",
25650 "target item".to_string(),
25651 )];
25652 let mut depth_by_id = BTreeMap::new();
25653 depth_by_id.insert("target".to_string(), 0);
25654 for idx in 0..20 {
25655 let id = format!("ev-{idx}");
25656 nodes.push(
25657 SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
25658 .with_property("detail", "x".repeat(400)),
25659 );
25660 depth_by_id.insert(id, 1);
25661 }
25662 let origin_ids = vec!["target".to_string()];
25663 let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
25664 &origin_ids,
25665 &BTreeMap::new(),
25666 nodes.clone(),
25667 Vec::new(),
25668 Some(3),
25669 Some(&depth_by_id),
25670 None,
25671 );
25672 assert!(
25673 first_page.truncated,
25674 "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
25675 first_page.nodes.len(),
25676 first_page.report.candidate_nodes
25677 );
25678 assert!(
25679 first_page.next_cursor.is_some(),
25680 "expected next_cursor when truncated"
25681 );
25682 let cursor = first_page.next_cursor.unwrap();
25683 assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
25684 let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
25685 let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
25686 &origin_ids,
25687 &BTreeMap::new(),
25688 nodes.clone(),
25689 Vec::new(),
25690 Some(3),
25691 Some(&depth_by_id),
25692 Some(&cursor),
25693 );
25694 let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
25695 let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
25696 assert!(
25697 overlap.is_empty(),
25698 "pages should not overlap, but found shared ids: {overlap:?}"
25699 );
25700 assert!(
25701 second_page
25702 .report
25703 .diagnostics
25704 .iter()
25705 .any(|d| d.contains("cursor skipped")),
25706 "expected cursor skip diagnostic, got {:?}",
25707 second_page.report.diagnostics
25708 );
25709 }
25710
25711 #[test]
25712 fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
25713 let mut nodes = vec![SubstrateGraphNode::new(
25714 "target".to_string(),
25715 "backlog_item",
25716 "target item".to_string(),
25717 )];
25718 let mut depth_by_id = BTreeMap::new();
25719 depth_by_id.insert("target".to_string(), 0);
25720 for idx in 0..3 {
25721 let id = format!("ev-{idx}");
25722 nodes.push(SubstrateGraphNode::new(
25723 id.clone(),
25724 "source_handle",
25725 format!("evidence row {idx}"),
25726 ));
25727 depth_by_id.insert(id, 1);
25728 }
25729 let origin_ids = vec!["target".to_string()];
25730 let result = graph_db_apply_output_budget_with_depths_and_cursor(
25731 &origin_ids,
25732 &BTreeMap::new(),
25733 nodes,
25734 Vec::new(),
25735 None,
25736 Some(&depth_by_id),
25737 None,
25738 );
25739 assert!(
25740 !result.truncated,
25741 "expected no truncation with small candidate set and default budget"
25742 );
25743 assert!(
25744 result.next_cursor.is_none(),
25745 "expected no next_cursor when not truncated"
25746 );
25747 }
25748
25749 #[test]
25750 fn evidence_pagination_invalid_cursor_returns_first_page() {
25751 let mut nodes = vec![SubstrateGraphNode::new(
25752 "target".to_string(),
25753 "backlog_item",
25754 "target item".to_string(),
25755 )];
25756 let mut depth_by_id = BTreeMap::new();
25757 depth_by_id.insert("target".to_string(), 0);
25758 for idx in 0..5 {
25759 let id = format!("ev-{idx}");
25760 nodes.push(SubstrateGraphNode::new(
25761 id.clone(),
25762 "source_handle",
25763 format!("evidence row {idx}"),
25764 ));
25765 depth_by_id.insert(id, 1);
25766 }
25767 let origin_ids = vec!["target".to_string()];
25768 let result = graph_db_apply_output_budget_with_depths_and_cursor(
25769 &origin_ids,
25770 &BTreeMap::new(),
25771 nodes.clone(),
25772 Vec::new(),
25773 None,
25774 Some(&depth_by_id),
25775 Some("nonexistent-id"),
25776 );
25777 assert!(
25778 result
25779 .report
25780 .diagnostics
25781 .iter()
25782 .any(|d| d.contains("cursor skipped 0")),
25783 "invalid cursor should skip 0 candidates, got {:?}",
25784 result.report.diagnostics
25785 );
25786 }
25787
25788 #[test]
25789 fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
25790 let dir = setup_traversal_project();
25791 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25792 let graph_db = dir.path().join(".tsift/graph.db");
25793 let _lock = hold_rollback_journal_lock(&graph_db);
25794
25795 let report =
25796 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25797 .unwrap();
25798
25799 assert_eq!(report.status, "current");
25800 assert_eq!(
25801 report.recovery,
25802 Some(index::ReadOnlyRecovery::SnapshotFallback)
25803 );
25804 assert!(
25805 report
25806 .warnings
25807 .iter()
25808 .any(|warning| warning.contains("rollback-journal lock")),
25809 "expected rollback-journal recovery warning, got {:?}",
25810 report.warnings
25811 );
25812 }
25813
25814 #[test]
25815 fn graph_db_status_copies_wal_sidecars_when_locked() {
25816 let dir = setup_traversal_project();
25817 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25818 let graph_db = dir.path().join(".tsift/graph.db");
25819 let _lock = hold_wal_database_lock(&graph_db);
25820
25821 let report =
25822 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25823 .unwrap();
25824
25825 assert_eq!(report.status, "current");
25826 assert_eq!(
25827 report.recovery,
25828 Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
25829 );
25830 assert!(
25831 report
25832 .warnings
25833 .iter()
25834 .any(|warning| warning.contains("WAL-aware snapshot fallback")),
25835 "expected WAL recovery warning, got {:?}",
25836 report.warnings
25837 );
25838 }
25839
25840 #[test]
25841 fn graph_db_doctor_reports_snapshot_fallback_when_rollback_journal_is_locked() {
25842 let dir = setup_traversal_project();
25843 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25844 let graph_db = dir.path().join(".tsift/graph.db");
25845 let _lock = hold_rollback_journal_lock(&graph_db);
25846
25847 let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25848 append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25849 report.finalize();
25850
25851 assert_eq!(report.status, "ok");
25852 assert!(!report.fail_closed);
25853 let recovery_check = report
25854 .checks
25855 .iter()
25856 .find(|check| check.name == "sqlite_graph_db_read_recovery")
25857 .expect("doctor should include read recovery diagnostic");
25858 assert_eq!(recovery_check.status, "recovered");
25859 assert!(
25860 recovery_check
25861 .diagnostics
25862 .iter()
25863 .any(|diagnostic| diagnostic.contains("rollback-journal lock")),
25864 "expected rollback-journal recovery diagnostic, got {:?}",
25865 recovery_check.diagnostics
25866 );
25867 }
25868
25869 #[test]
25870 fn graph_db_doctor_reports_wal_snapshot_fallback_when_locked() {
25871 let dir = setup_traversal_project();
25872 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25873 let graph_db = dir.path().join(".tsift/graph.db");
25874 let _lock = hold_wal_database_lock(&graph_db);
25875
25876 let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25877 append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25878 report.finalize();
25879
25880 assert_eq!(report.status, "ok");
25881 assert!(!report.fail_closed);
25882 let recovery_check = report
25883 .checks
25884 .iter()
25885 .find(|check| check.name == "sqlite_graph_db_read_recovery")
25886 .expect("doctor should include WAL read recovery diagnostic");
25887 assert_eq!(recovery_check.status, "recovered");
25888 assert!(
25889 recovery_check
25890 .diagnostics
25891 .iter()
25892 .any(|diagnostic| diagnostic.contains("WAL-aware snapshot fallback")),
25893 "expected WAL recovery diagnostic, got {:?}",
25894 recovery_check.diagnostics
25895 );
25896 }
25897
25898 #[test]
25899 fn graph_db_snapshot_export_import_round_trip_preserves_projection_metadata() {
25900 let dir = setup_traversal_project();
25901 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25902 let artifact = dir.path().join("graph.db.gz");
25903
25904 let exported =
25905 commands::infra::graph_db_snapshot_export_report(dir.path(), None, &artifact, false)
25906 .unwrap();
25907 let exported_projection_version = exported.freshness.projection_version.clone();
25908 let exported_content_hash = exported.freshness.content_hash.clone();
25909 let exported_source_watermark = exported.freshness.source_watermark.clone();
25910 let exported_nodes = exported.counts.nodes;
25911 let exported_edges = exported.counts.edges;
25912 assert_eq!(exported.operation, "snapshot-export");
25913 assert!(exported.status.starts_with("exported"));
25914 assert!(artifact.exists());
25915 assert!(exported.artifact_bytes > 0);
25916 assert_eq!(exported.compression, "gzip");
25917
25918 fs::remove_file(dir.path().join(".tsift/graph.db")).unwrap();
25919
25920 let imported =
25921 commands::infra::graph_db_snapshot_import_report(dir.path(), None, &artifact, false)
25922 .unwrap();
25923 assert_eq!(imported.operation, "snapshot-import");
25924 assert!(imported.status.starts_with("imported"));
25925 assert_eq!(
25926 imported.freshness.projection_version,
25927 exported_projection_version
25928 );
25929 assert_eq!(imported.freshness.content_hash, exported_content_hash);
25930 assert_eq!(
25931 imported.freshness.source_watermark,
25932 exported_source_watermark
25933 );
25934 assert_eq!(imported.counts.nodes, exported_nodes);
25935 assert_eq!(imported.counts.edges, exported_edges);
25936 assert!(dir.path().join(".tsift/graph.db").exists());
25937 }
25938
25939 #[test]
25940 fn graph_db_snapshot_export_fails_closed_when_wal_lock_requires_recovery() {
25941 let dir = setup_traversal_project();
25942 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25943 let graph_db = dir.path().join(".tsift/graph.db");
25944 let _lock = hold_wal_database_lock(&graph_db);
25945
25946 let err = match commands::infra::graph_db_snapshot_export_report(
25947 dir.path(),
25948 None,
25949 &dir.path().join("graph.db.gz"),
25950 false,
25951 ) {
25952 Ok(report) => panic!("expected snapshot export to fail, got {}", report.status),
25953 Err(err) => err,
25954 };
25955
25956 let message = err.to_string();
25962 assert!(
25963 message.contains("recovered path")
25964 && message.contains("database is locked")
25965 && message.contains("wait for it to finish before retrying the export"),
25966 "expected unified live-lock recovery diagnostic, got {err:#}"
25967 );
25968 }
25969
25970 #[test]
25971 fn graph_db_snapshot_clean_export_maps_database_locked_to_live_lock_diagnostic() {
25972 let dir = setup_traversal_project();
25973 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25974 let graph_db = dir.path().join(".tsift/graph.db");
25975
25976 let blocker = Connection::open(&graph_db).unwrap();
25980 blocker
25981 .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
25982 .unwrap();
25983 assert!(!substrate::rollback_journal_path(&graph_db).exists());
25984
25985 let clean_path = dir.path().join("graph-clean-export.db");
25986 let err = match commands::infra::graph_db_snapshot_clean_export_copy(&graph_db, &clean_path)
25987 {
25988 Ok(bytes) => panic!("expected export to fail under live lock, got {bytes} bytes"),
25989 Err(err) => err,
25990 };
25991
25992 let message = err.to_string();
25993 assert!(
25994 message.contains("concurrent graph-db refresh or snapshot-import is in progress"),
25995 "expected actionable live-lock diagnostic, got {err:#}"
25996 );
25997 assert!(
25998 message.contains("wait for it to finish before retrying"),
25999 "expected retry guidance, got {err:#}"
26000 );
26001 assert!(
26003 !message.contains("creating clean graph-db export copy"),
26004 "live-lock case must not surface the generic VACUUM context, got {err:#}"
26005 );
26006
26007 drop(blocker);
26008 }
26009
26010 #[test]
26011 fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
26012 let dir = setup_traversal_project();
26013 let session = dir.path().join("tasks/software/tsift.md");
26014 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
26015 let graph_db = dir.path().join(".tsift/graph.db");
26016 let _lock = hold_rollback_journal_lock(&graph_db);
26017
26018 let result = cmd_graph_db(
26019 &session,
26020 None,
26021 GraphDbBackend::Sqlite,
26022 None,
26023 GraphDbQuery::Evidence {
26024 target: "kgnv".to_string(),
26025 depth: 3,
26026 limit: 8,
26027 cursor: None,
26028 },
26029 OutputFormat {
26030 json_output: false,
26031 compact: true,
26032 pretty: false,
26033 terse: false,
26034 ultra_terse: false,
26035 schema: false,
26036 envelope: false,
26037 },
26038 );
26039
26040 assert!(result.is_ok());
26041 }
26042
26043 fn current_graph_db_freshness() -> GraphDbFreshnessReport {
26044 GraphDbFreshnessReport {
26045 status: "current".to_string(),
26046 fail_closed: false,
26047 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
26048 content_hash: Some("fixture".to_string()),
26049 source_watermark: None,
26050 diagnostics: Vec::new(),
26051 }
26052 }
26053
26054 #[test]
26055 fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
26056 let dir = setup_traversal_project();
26057 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26058 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
26059 let stale = GraphDbFreshnessReport {
26060 status: "stale".to_string(),
26061 fail_closed: true,
26062 projection_version: Some("old-v0".to_string()),
26063 content_hash: None,
26064 source_watermark: None,
26065 diagnostics: vec!["projection content hash is missing".to_string()],
26066 };
26067
26068 let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
26069 root: dir.path(),
26070 scope: None,
26071 backend: "sqlite",
26072 target: "kgnv",
26073 preferred_path: None,
26074 depth: 3,
26075 limit: 8,
26076 cursor: None,
26077 store: &store,
26078 freshness: stale,
26079 warnings: Vec::new(),
26080 }) {
26081 Ok(_) => panic!("stale graph freshness should fail closed"),
26082 Err(err) => err,
26083 };
26084 let message = err.to_string();
26085 assert!(message.contains("failed closed"), "{message}");
26086 assert!(message.contains("graph-db --path"), "{message}");
26087 assert!(message.contains("refresh --json"), "{message}");
26088 }
26089
26090 fn paged_graph_ids(
26091 store: &impl GraphStore,
26092 cursor: Option<&str>,
26093 ) -> (Vec<String>, GraphDbPageReport) {
26094 let report = graph_db_report_from_store(
26095 Path::new("."),
26096 None,
26097 "fixture",
26098 GraphDbQuery::Kind {
26099 kind: "backlog".to_string(),
26100 cursor: cursor.map(str::to_string),
26101 limit: Some(2),
26102 property_filters: vec!["phase=open".to_string()],
26103 },
26104 store,
26105 current_graph_db_freshness(),
26106 Vec::new(),
26107 )
26108 .unwrap();
26109 (
26110 report.nodes.iter().map(|node| node.id.clone()).collect(),
26111 report.page.unwrap(),
26112 )
26113 }
26114
26115 #[test]
26116 fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
26117 let nodes = (0..5)
26118 .map(|idx| {
26119 let phase = if idx == 1 { "closed" } else { "open" };
26120 SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
26121 .with_property("phase", phase)
26122 })
26123 .collect::<Vec<_>>();
26124 let projection = GraphProjection {
26125 nodes,
26126 edges: Vec::new(),
26127 };
26128 let sqlite = SqliteGraphStore::in_memory().unwrap();
26129 projection.upsert_into(&sqlite).unwrap();
26130 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
26131 projection.upsert_into(&convex).unwrap();
26132
26133 let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
26134 let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
26135 assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
26136 assert_eq!(sqlite_first_ids, convex_first_ids);
26137 assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
26138 assert!(sqlite_first_page.truncated);
26139 assert_eq!(
26140 sqlite_first_page.returned_nodes,
26141 convex_first_page.returned_nodes
26142 );
26143 assert_eq!(
26144 sqlite_first_page.property_filters,
26145 convex_first_page.property_filters
26146 );
26147 assert!(
26148 sqlite_first_page
26149 .diagnostics
26150 .iter()
26151 .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
26152 "expected SQLite kind query plan diagnostics, got {:?}",
26153 sqlite_first_page.diagnostics
26154 );
26155
26156 let cursor = sqlite_first_page.next_cursor.as_deref();
26157 let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
26158 let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
26159 assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
26160 assert_eq!(sqlite_next_ids, convex_next_ids);
26161 assert_eq!(sqlite_next_page.next_cursor, None);
26162 assert!(!sqlite_next_page.truncated);
26163 assert_eq!(
26164 sqlite_next_page.returned_nodes,
26165 convex_next_page.returned_nodes
26166 );
26167 assert_eq!(
26168 sqlite_next_page.property_filters,
26169 convex_next_page.property_filters
26170 );
26171 }
26172
26173 #[test]
26174 fn traversal_shortest_path_crosses_artifacts_and_symbols() {
26175 let dir = setup_traversal_project();
26176 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26177 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
26178 let main = resolve_traversal_node(&graph, "main").unwrap();
26179
26180 let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
26181 assert_eq!(path.first(), Some(&backlog.handle));
26182 assert_eq!(path.last(), Some(&main.handle));
26183 assert!(
26184 path.len() >= 3,
26185 "expected backlog -> symbol -> main, got {path:?}"
26186 );
26187 }
26188
26189 #[test]
26190 fn traversal_report_recommends_next_bugfix_nodes() {
26191 let dir = setup_traversal_project();
26192 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26193 let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
26194
26195 assert_eq!(report.mode, "neighborhood");
26196 assert!(
26197 report
26198 .recommendations
26199 .iter()
26200 .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
26201 "expected helper recommendation, got {:?}",
26202 report.recommendations
26203 );
26204 assert!(
26205 !report.exploration.source_windows.is_empty(),
26206 "expected exploration source windows"
26207 );
26208 assert!(
26209 report
26210 .exploration
26211 .no_reread_guidance
26212 .contains("avoid whole-file reads")
26213 );
26214 }
26215
26216 #[test]
26217 fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
26218 let dir = setup_traversal_project();
26219 std::thread::sleep(std::time::Duration::from_millis(50));
26220 std::fs::write(
26221 dir.path().join("main.rs"),
26222 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26223 )
26224 .unwrap();
26225
26226 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26227
26228 assert!(
26229 graph
26230 .warnings
26231 .iter()
26232 .any(|warning| warning.contains("index refreshed")
26233 && warning.contains("graph traversal packet")),
26234 "expected refresh diagnostic, got {:?}",
26235 graph.warnings
26236 );
26237 assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
26238
26239 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26240 let summary = db.compute_changes(dir.path()).unwrap();
26241 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26242 }
26243
26244 #[test]
26245 fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
26246 let dir = setup_traversal_project();
26247 let db_path = dir.path().join(".tsift/index.db");
26248 let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
26249 std::thread::sleep(std::time::Duration::from_millis(50));
26250 std::fs::write(
26251 dir.path().join("main.rs"),
26252 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26253 )
26254 .unwrap();
26255
26256 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26257 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
26258
26259 assert!(
26260 graph
26261 .warnings
26262 .iter()
26263 .any(|warning| warning.contains("falling back to raw source file nodes")),
26264 "expected raw-source fallback diagnostic, got {:?}",
26265 graph.warnings
26266 );
26267 assert!(
26268 file.detail
26269 .as_deref()
26270 .is_some_and(|detail| detail.contains("raw source fallback")),
26271 "expected raw-source detail, got {:?}",
26272 file.detail
26273 );
26274 assert!(
26275 file.expand.contains("source-read"),
26276 "expected source-read fallback command, got {}",
26277 file.expand
26278 );
26279 assert!(
26280 resolve_traversal_node(&graph, "helper").is_none(),
26281 "stale symbol evidence should be skipped when refresh is blocked"
26282 );
26283 }
26284
26285 #[test]
26286 fn traversal_cmd_supports_json_and_html_outputs() {
26287 let dir = setup_traversal_project();
26288 cmd_traverse(
26289 Some("#kgnv"),
26290 Some("main"),
26291 dir.path(),
26292 None,
26293 1,
26294 50,
26295 TraverseFormat::Json,
26296 false,
26297 false,
26298 false,
26299 None,
26300 )
26301 .unwrap();
26302 cmd_traverse(
26303 None,
26304 None,
26305 dir.path(),
26306 None,
26307 1,
26308 50,
26309 TraverseFormat::Html,
26310 false,
26311 false,
26312 false,
26313 None,
26314 )
26315 .unwrap();
26316 }
26317
26318 #[test]
26319 fn traversal_html_renders_inline_graph_visualization() {
26320 let dir = setup_traversal_project();
26321 seed_traversal_semantic_summaries(dir.path());
26322 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26323 let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
26324 let html = traversal_report_html(&report).unwrap();
26325
26326 assert!(html.contains("id=\"graph-canvas\""));
26327 assert!(html.contains("semantic_concept"));
26328 assert!(html.contains("graph navigation"));
26329 assert!(html.contains("JSON.parse"));
26330 }
26331
26332 #[test]
26333 fn compact_helpers_trim_scores_and_snippets() {
26334 assert_eq!(format_score(0.12345, true), "0.12");
26335 assert_eq!(format_score(0.12345, false), "0.1235");
26336 let snippet = compact_snippet(" first line with useful context\nsecond");
26337 assert_eq!(snippet.as_deref(), Some("first line with useful context"));
26338 }
26339
26340 #[test]
26341 fn compact_members_caps_list() {
26342 let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
26343 .iter()
26344 .map(|n| graph::CommunityMember::new(*n))
26345 .collect();
26346 assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
26347 }
26348
26349 #[test]
26350 fn abbreviate_kind_maps_common_kinds() {
26351 assert_eq!(abbreviate_kind("function"), "fn");
26352 assert_eq!(abbreviate_kind("method"), "meth");
26353 assert_eq!(abbreviate_kind("class"), "cls");
26354 assert_eq!(abbreviate_kind("interface"), "iface");
26355 assert_eq!(abbreviate_kind("type_alias"), "type");
26356 assert_eq!(abbreviate_kind("data_class"), "data_cls");
26357 assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
26358 assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
26359 assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
26360 assert_eq!(abbreviate_kind("object"), "obj");
26361 assert_eq!(abbreviate_kind("heading"), "h");
26362 assert_eq!(abbreviate_kind("code_block"), "code");
26363 assert_eq!(abbreviate_kind("struct"), "struct");
26365 assert_eq!(abbreviate_kind("trait"), "trait");
26366 assert_eq!(abbreviate_kind("enum"), "enum");
26367 assert_eq!(abbreviate_kind("const"), "const");
26368 assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
26369 }
26370
26371 #[test]
26372 fn abbreviate_match_type_maps_search_types() {
26373 assert_eq!(abbreviate_match_type("exact_name"), "exact");
26374 assert_eq!(abbreviate_match_type("partial_tags"), "partial");
26375 assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
26376 assert_eq!(abbreviate_match_type("other_type"), "other_type");
26377 }
26378
26379 #[test]
26380 fn explain_compact_groups_edges_by_file() {
26381 let edges = vec![
26382 index::StoredEdge {
26383 caller_file: "src/main.rs".to_string(),
26384 caller_name: "main".to_string(),
26385 caller_line: 1,
26386 callee_name: "helper".to_string(),
26387 call_site_line: 2,
26388 tagpath_handle: None,
26389 },
26390 index::StoredEdge {
26391 caller_file: "src/main.rs".to_string(),
26392 caller_name: "main".to_string(),
26393 caller_line: 1,
26394 callee_name: "render".to_string(),
26395 call_site_line: 3,
26396 tagpath_handle: None,
26397 },
26398 ];
26399 let lines = format_edge_groups(&edges, false);
26400 assert_eq!(lines, vec![" src/main.rs (2): helper, render"]);
26401 }
26402
26403 #[test]
26404 fn search_hit_groups_preserve_file_counts_and_samples() {
26405 let dir = tempfile::tempdir().unwrap();
26406 let root = dir.path();
26407 let main_rs = root.join("src/main.rs");
26408 fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
26409 fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
26410 let freshness = exact_search_file_timestamp(&main_rs);
26411 let hits = vec![
26412 sift::SearchHit {
26413 artifact_id: "a".to_string(),
26414 artifact_kind: sift::ContextArtifactKind::File,
26415 path: main_rs.display().to_string(),
26416 rank: 1,
26417 score: 10.0,
26418 confidence: sift::ScoreConfidence::High,
26419 location: Some("line 3".to_string()),
26420 snippet: "claudescore-3 anchor".to_string(),
26421 provenance: sift::ArtifactProvenance {
26422 adapter: sift::AcquisitionAdapterKind::FileSystem,
26423 source: "ripgrep -F".to_string(),
26424 synthetic: false,
26425 },
26426 freshness: freshness.clone(),
26427 budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
26428 },
26429 sift::SearchHit {
26430 artifact_id: "b".to_string(),
26431 artifact_kind: sift::ContextArtifactKind::File,
26432 path: main_rs.display().to_string(),
26433 rank: 2,
26434 score: 9.0,
26435 confidence: sift::ScoreConfidence::High,
26436 location: Some("line 7".to_string()),
26437 snippet: "claudescore-3 follow-up".to_string(),
26438 provenance: sift::ArtifactProvenance {
26439 adapter: sift::AcquisitionAdapterKind::FileSystem,
26440 source: "ripgrep -F".to_string(),
26441 synthetic: false,
26442 },
26443 freshness: freshness.clone(),
26444 budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
26445 },
26446 sift::SearchHit {
26447 artifact_id: "c".to_string(),
26448 artifact_kind: sift::ContextArtifactKind::File,
26449 path: main_rs.display().to_string(),
26450 rank: 3,
26451 score: 8.0,
26452 confidence: sift::ScoreConfidence::High,
26453 location: Some("line 9".to_string()),
26454 snippet: "claudescore-3 tail".to_string(),
26455 provenance: sift::ArtifactProvenance {
26456 adapter: sift::AcquisitionAdapterKind::FileSystem,
26457 source: "ripgrep -F".to_string(),
26458 synthetic: false,
26459 },
26460 freshness,
26461 budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
26462 },
26463 ];
26464
26465 let groups = group_search_hits(&hits, root, false);
26466 assert_eq!(groups.len(), 1);
26467 assert_eq!(groups[0].path, "src/main.rs");
26468 assert_eq!(groups[0].hits, 3);
26469 assert_eq!(
26470 groups[0].samples,
26471 vec![
26472 "line 3: claudescore-3 anchor".to_string(),
26473 "line 7: claudescore-3 follow-up".to_string()
26474 ]
26475 );
26476 assert!(should_collapse_search_hits(&hits, root, false));
26477 }
26478
26479 #[test]
26480 fn dense_edge_groups_trigger_collapse() {
26481 let edges = vec![
26482 index::StoredEdge {
26483 caller_file: "src/main.rs".to_string(),
26484 caller_name: "main".to_string(),
26485 caller_line: 1,
26486 callee_name: "helper".to_string(),
26487 call_site_line: 2,
26488 tagpath_handle: None,
26489 },
26490 index::StoredEdge {
26491 caller_file: "src/main.rs".to_string(),
26492 caller_name: "beta".to_string(),
26493 caller_line: 5,
26494 callee_name: "helper".to_string(),
26495 call_site_line: 6,
26496 tagpath_handle: None,
26497 },
26498 index::StoredEdge {
26499 caller_file: "src/main.rs".to_string(),
26500 caller_name: "gamma".to_string(),
26501 caller_line: 9,
26502 callee_name: "helper".to_string(),
26503 call_site_line: 10,
26504 tagpath_handle: None,
26505 },
26506 ];
26507 assert!(should_collapse_edge_groups(&edges));
26508 }
26509
26510 fn setup_workspace() -> tempfile::TempDir {
26513 let dir = tempfile::tempdir().unwrap();
26514 let root = dir.path();
26515 std::fs::write(
26516 root.join(".gitmodules"),
26517 r#"[submodule "src/alpha"]
26518 path = src/alpha
26519 url = https://example.com/alpha
26520[submodule "src/beta"]
26521 path = src/beta
26522 url = https://example.com/beta
26523"#,
26524 )
26525 .unwrap();
26526 let alpha = root.join("src/alpha");
26527 let beta = root.join("src/beta");
26528 std::fs::create_dir_all(&alpha).unwrap();
26529 std::fs::create_dir_all(&beta).unwrap();
26530 std::fs::write(
26531 alpha.join("lib.rs"),
26532 "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
26533 )
26534 .unwrap();
26535 std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
26536 dir
26537 }
26538
26539 fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
26540 let dir = tempfile::tempdir().unwrap();
26541 let root = dir.path();
26542 std::fs::write(
26543 root.join(".gitmodules"),
26544 r#"[submodule "pkg/app/foo"]
26545 path = pkg/app/foo
26546 url = https://example.com/pkg-app-foo
26547[submodule "vendor/foo"]
26548 path = vendor/foo
26549 url = https://example.com/vendor-foo
26550"#,
26551 )
26552 .unwrap();
26553 let pkg_foo = root.join("pkg/app/foo");
26554 let vendor_foo = root.join("vendor/foo");
26555 std::fs::create_dir_all(&pkg_foo).unwrap();
26556 std::fs::create_dir_all(&vendor_foo).unwrap();
26557 std::fs::write(
26558 pkg_foo.join("lib.rs"),
26559 "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
26560 )
26561 .unwrap();
26562 std::fs::write(
26563 vendor_foo.join("lib.rs"),
26564 "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
26565 )
26566 .unwrap();
26567 dir
26568 }
26569
26570 #[test]
26571 fn workspace_index_creates_per_submodule_dbs() {
26572 let dir = setup_workspace();
26573 cmd_index(
26574 dir.path(),
26575 false,
26576 false,
26577 false,
26578 false,
26579 false,
26580 true,
26581 None,
26582 false,
26583 false,
26584 false,
26585 false,
26586 false,
26587 false,
26588 )
26589 .unwrap();
26590 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26591 assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
26592 }
26593
26594 #[test]
26595 fn workspace_index_single_submodule() {
26596 let dir = setup_workspace();
26597 cmd_index(
26598 dir.path(),
26599 false,
26600 false,
26601 false,
26602 false,
26603 false,
26604 false,
26605 Some("alpha"),
26606 false,
26607 false,
26608 false,
26609 false,
26610 false,
26611 false,
26612 )
26613 .unwrap();
26614 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26615 assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
26616 }
26617
26618 #[test]
26619 fn workspace_index_single_submodule_errors_on_unknown_scope() {
26620 let dir = setup_workspace();
26621
26622 let err = cmd_index(
26623 dir.path(),
26624 false,
26625 false,
26626 false,
26627 false,
26628 false,
26629 false,
26630 Some("missing"),
26631 false,
26632 false,
26633 false,
26634 false,
26635 false,
26636 false,
26637 )
26638 .unwrap_err();
26639
26640 let msg = err.to_string();
26641 assert!(msg.contains("unknown scope `missing`"));
26642 assert!(msg.contains("Available scopes: alpha, beta"));
26643 assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
26644 }
26645
26646 #[test]
26647 fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
26648 let dir = setup_workspace_with_duplicate_leaf_names();
26649 cmd_index(
26650 dir.path(),
26651 false,
26652 false,
26653 false,
26654 false,
26655 false,
26656 true,
26657 None,
26658 false,
26659 false,
26660 false,
26661 false,
26662 false,
26663 false,
26664 )
26665 .unwrap();
26666
26667 assert!(
26668 dir.path()
26669 .join(".tsift/indexes/pkg/app/foo/index.db")
26670 .exists()
26671 );
26672 assert!(
26673 dir.path()
26674 .join(".tsift/indexes/vendor/foo/index.db")
26675 .exists()
26676 );
26677 }
26678
26679 #[test]
26680 fn federated_search_across_submodules() {
26681 let dir = setup_workspace();
26682 cmd_index(
26683 dir.path(),
26684 false,
26685 false,
26686 false,
26687 false,
26688 false,
26689 true,
26690 None,
26691 false,
26692 false,
26693 false,
26694 false,
26695 false,
26696 false,
26697 )
26698 .unwrap();
26699 let (hits, _diag) = federated_symbol_search(
26700 dir.path(),
26701 "alpha_helper",
26702 10,
26703 &TagpathSearchOpts {
26704 no_tagpath: true,
26705 strict: false,
26706 },
26707 )
26708 .unwrap();
26709 assert!(
26710 !hits.is_empty(),
26711 "should find alpha_helper via federated search"
26712 );
26713 }
26714
26715 #[test]
26716 fn federated_search_respects_isolation() {
26717 let dir = setup_workspace();
26718 let tsift_dir = dir.path().join(".tsift");
26719 std::fs::create_dir_all(&tsift_dir).unwrap();
26720 std::fs::write(
26721 tsift_dir.join("config.toml"),
26722 r#"
26723[overrides.alpha]
26724tier = "isolated"
26725"#,
26726 )
26727 .unwrap();
26728 cmd_index(
26729 dir.path(),
26730 false,
26731 false,
26732 false,
26733 false,
26734 false,
26735 true,
26736 None,
26737 false,
26738 false,
26739 false,
26740 false,
26741 false,
26742 false,
26743 )
26744 .unwrap();
26745 let (hits, _diag) = federated_symbol_search(
26746 dir.path(),
26747 "alpha_helper",
26748 10,
26749 &TagpathSearchOpts {
26750 no_tagpath: true,
26751 strict: false,
26752 },
26753 )
26754 .unwrap();
26755 assert!(
26756 hits.is_empty(),
26757 "isolated submodule should not appear in federated search"
26758 );
26759 }
26760
26761 #[test]
26762 fn federated_lexical_search_respects_isolation() {
26763 let dir = setup_workspace();
26764 let tsift_dir = dir.path().join(".tsift");
26765 std::fs::create_dir_all(&tsift_dir).unwrap();
26766 std::fs::write(
26767 tsift_dir.join("config.toml"),
26768 r#"
26769[overrides.alpha]
26770tier = "isolated"
26771"#,
26772 )
26773 .unwrap();
26774 cmd_index(
26775 dir.path(),
26776 false,
26777 false,
26778 false,
26779 false,
26780 false,
26781 true,
26782 None,
26783 false,
26784 false,
26785 false,
26786 false,
26787 false,
26788 false,
26789 )
26790 .unwrap();
26791
26792 let response = federated_sift_search(
26793 dir.path(),
26794 &dir.path().join(".tsift/search-cache"),
26795 "fn",
26796 10,
26797 0,
26798 "lexical",
26799 None,
26800 )
26801 .unwrap();
26802
26803 assert!(
26804 !response.hits.is_empty(),
26805 "shared scopes should still contribute lexical hits"
26806 );
26807 assert!(
26808 response
26809 .hits
26810 .iter()
26811 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26812 "isolated scope should not leak lexical hits: {:?}",
26813 response.hits
26814 );
26815 }
26816
26817 #[test]
26818 fn federated_lexical_search_respects_private_tier() {
26819 let dir = setup_workspace();
26820 let tsift_dir = dir.path().join(".tsift");
26821 std::fs::create_dir_all(&tsift_dir).unwrap();
26822 std::fs::write(
26823 tsift_dir.join("config.toml"),
26824 r#"
26825[overrides.alpha]
26826tier = "private"
26827"#,
26828 )
26829 .unwrap();
26830 cmd_index(
26831 dir.path(),
26832 false,
26833 false,
26834 false,
26835 false,
26836 false,
26837 true,
26838 None,
26839 false,
26840 false,
26841 false,
26842 false,
26843 false,
26844 false,
26845 )
26846 .unwrap();
26847
26848 let response = federated_sift_search(
26849 dir.path(),
26850 &dir.path().join(".tsift/search-cache"),
26851 "fn",
26852 10,
26853 0,
26854 "lexical",
26855 None,
26856 )
26857 .unwrap();
26858
26859 assert!(
26860 !response.hits.is_empty(),
26861 "shared scopes should still contribute lexical hits"
26862 );
26863 assert!(
26864 response
26865 .hits
26866 .iter()
26867 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26868 "private scope should not leak lexical hits: {:?}",
26869 response.hits
26870 );
26871 }
26872
26873 #[test]
26874 fn scoped_search_finds_submodule_symbols() {
26875 let dir = setup_workspace();
26876 cmd_index(
26877 dir.path(),
26878 false,
26879 false,
26880 false,
26881 false,
26882 false,
26883 true,
26884 None,
26885 false,
26886 false,
26887 false,
26888 false,
26889 false,
26890 false,
26891 )
26892 .unwrap();
26893 let cfg = config::Config::load(dir.path()).unwrap();
26894 let db_path = cfg.db_path_for(dir.path(), "alpha");
26895 let db = index::IndexDb::open(&db_path).unwrap();
26896 let hits = db.symbol_search("alpha_main", 10).unwrap();
26897 assert!(!hits.is_empty());
26898 assert_eq!(hits[0].name, "alpha_main");
26899 }
26900
26901 #[test]
26902 fn scoped_search_cmd_errors_on_unknown_scope() {
26903 let dir = setup_workspace();
26904
26905 let err = cmd_search(
26906 "alpha_main".to_string(),
26907 Some(dir.path().to_path_buf()),
26908 5,
26909 Some("lexical".to_string()),
26910 Some("missing".to_string()),
26911 false,
26912 false,
26913 false,
26914 0,
26915 false,
26916 false,
26917 false,
26918 false,
26919 false,
26920 false,
26921 false,
26922 )
26923 .unwrap_err();
26924
26925 let msg = err.to_string();
26926 assert!(msg.contains("unknown scope `missing`"));
26927 assert!(msg.contains("Available scopes: alpha, beta"));
26928 }
26929
26930 #[test]
26931 fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
26932 let dir = setup_workspace_with_duplicate_leaf_names();
26933 cmd_index(
26934 dir.path(),
26935 false,
26936 false,
26937 false,
26938 false,
26939 false,
26940 true,
26941 None,
26942 false,
26943 false,
26944 false,
26945 false,
26946 false,
26947 false,
26948 )
26949 .unwrap();
26950
26951 let err = cmd_search(
26952 "vendor_only".to_string(),
26953 Some(dir.path().to_path_buf()),
26954 5,
26955 Some("lexical".to_string()),
26956 Some("foo".to_string()),
26957 false,
26958 false,
26959 false,
26960 0,
26961 false,
26962 false,
26963 false,
26964 false,
26965 false,
26966 false,
26967 false,
26968 )
26969 .unwrap_err();
26970
26971 let msg = err.to_string();
26972 assert!(msg.contains("ambiguous scope `foo`"));
26973 assert!(msg.contains("pkg/app/foo"));
26974 assert!(msg.contains("vendor/foo"));
26975 }
26976
26977 #[test]
26978 fn scoped_graph_query() {
26979 let dir = setup_workspace();
26980 cmd_index(
26981 dir.path(),
26982 false,
26983 false,
26984 false,
26985 false,
26986 false,
26987 true,
26988 None,
26989 false,
26990 false,
26991 false,
26992 false,
26993 false,
26994 false,
26995 )
26996 .unwrap();
26997 let cfg = config::Config::load(dir.path()).unwrap();
26998 let db_path = cfg.db_path_for(dir.path(), "alpha");
26999 let db = index::IndexDb::open(&db_path).unwrap();
27000 let callees = db.callees_of("alpha_main").unwrap();
27001 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
27002 assert!(names.contains(&"alpha_helper"));
27003 }
27004
27005 fn assert_workspace_query_requires_scope(err: anyhow::Error) {
27006 let msg = err.to_string();
27007 assert!(msg.contains("require `--scope <scope>`"), "{msg}");
27008 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27009 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27010 assert!(
27011 !msg.contains("no index found at"),
27012 "workspace query should fail with scope guidance, got: {msg}"
27013 );
27014 }
27015
27016 fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
27017 let msg = err.to_string();
27018 assert!(
27019 msg.contains("requires `--scope <scope>` or `--federated`"),
27020 "{msg}"
27021 );
27022 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27023 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27024 assert!(
27025 !msg.contains("autoindexing index"),
27026 "workspace search should fail before creating a shared root index: {msg}"
27027 );
27028 }
27029
27030 #[test]
27031 fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
27032 let dir = setup_workspace();
27033 cmd_index(
27034 dir.path(),
27035 false,
27036 false,
27037 false,
27038 false,
27039 false,
27040 true,
27041 None,
27042 false,
27043 false,
27044 false,
27045 false,
27046 false,
27047 false,
27048 )
27049 .unwrap();
27050
27051 let err = cmd_graph(
27052 "alpha_main",
27053 dir.path(),
27054 false,
27055 false,
27056 None,
27057 20,
27058 false,
27059 false,
27060 false,
27061 false,
27062 false,
27063 false,
27064 false,
27065 TagpathSearchOpts::default(),
27066 )
27067 .unwrap_err();
27068
27069 assert_workspace_query_requires_scope(err);
27070 }
27071
27072 #[test]
27073 fn graph_cmd_infers_scope_from_nested_workspace_path() {
27074 let dir = setup_workspace();
27075 cmd_index(
27076 dir.path(),
27077 false,
27078 false,
27079 false,
27080 false,
27081 false,
27082 true,
27083 None,
27084 false,
27085 false,
27086 false,
27087 false,
27088 false,
27089 false,
27090 )
27091 .unwrap();
27092 let nested = dir.path().join("src/alpha/nested");
27093 std::fs::create_dir_all(&nested).unwrap();
27094
27095 let result = cmd_graph(
27096 "alpha_main",
27097 &nested,
27098 false,
27099 false,
27100 None,
27101 20,
27102 false,
27103 false,
27104 false,
27105 false,
27106 false,
27107 false,
27108 false,
27109 TagpathSearchOpts::default(),
27110 );
27111
27112 assert!(result.is_ok());
27113 }
27114
27115 #[test]
27116 fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
27117 let dir = setup_workspace();
27118 cmd_index(
27119 dir.path(),
27120 false,
27121 false,
27122 false,
27123 false,
27124 false,
27125 true,
27126 None,
27127 false,
27128 false,
27129 false,
27130 false,
27131 false,
27132 false,
27133 )
27134 .unwrap();
27135
27136 let err = cmd_communities(
27137 dir.path(),
27138 None,
27139 1,
27140 10,
27141 false,
27142 false,
27143 false,
27144 false,
27145 false,
27146 false,
27147 TagpathSearchOpts::default(),
27148 )
27149 .unwrap_err();
27150
27151 assert_workspace_query_requires_scope(err);
27152 }
27153
27154 #[test]
27155 fn communities_cmd_infers_scope_from_nested_workspace_path() {
27156 let dir = setup_workspace();
27157 cmd_index(
27158 dir.path(),
27159 false,
27160 false,
27161 false,
27162 false,
27163 false,
27164 true,
27165 None,
27166 false,
27167 false,
27168 false,
27169 false,
27170 false,
27171 false,
27172 )
27173 .unwrap();
27174 let nested = dir.path().join("src/alpha/nested");
27175 std::fs::create_dir_all(&nested).unwrap();
27176
27177 let result = cmd_communities(
27178 &nested,
27179 None,
27180 1,
27181 10,
27182 false,
27183 false,
27184 false,
27185 false,
27186 false,
27187 false,
27188 TagpathSearchOpts::default(),
27189 );
27190
27191 assert!(result.is_ok());
27192 }
27193
27194 #[test]
27195 fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
27196 let dir = setup_workspace();
27197 cmd_index(
27198 dir.path(),
27199 false,
27200 false,
27201 false,
27202 false,
27203 false,
27204 true,
27205 None,
27206 false,
27207 false,
27208 false,
27209 false,
27210 false,
27211 false,
27212 )
27213 .unwrap();
27214
27215 let err = cmd_path(
27216 "alpha_main",
27217 "alpha_helper",
27218 dir.path(),
27219 None,
27220 false,
27221 false,
27222 false,
27223 false,
27224 false,
27225 TagpathSearchOpts::default(),
27226 )
27227 .unwrap_err();
27228
27229 assert_workspace_query_requires_scope(err);
27230 }
27231
27232 #[test]
27233 fn path_cmd_infers_scope_from_nested_workspace_path() {
27234 let dir = setup_workspace();
27235 cmd_index(
27236 dir.path(),
27237 false,
27238 false,
27239 false,
27240 false,
27241 false,
27242 true,
27243 None,
27244 false,
27245 false,
27246 false,
27247 false,
27248 false,
27249 false,
27250 )
27251 .unwrap();
27252 let nested = dir.path().join("src/alpha/nested");
27253 std::fs::create_dir_all(&nested).unwrap();
27254
27255 let result = cmd_path(
27256 "alpha_main",
27257 "alpha_helper",
27258 &nested,
27259 None,
27260 false,
27261 false,
27262 false,
27263 false,
27264 false,
27265 TagpathSearchOpts::default(),
27266 );
27267
27268 assert!(result.is_ok());
27269 }
27270
27271 #[test]
27272 fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27273 let dir = setup_graph_index();
27274 let db_path = dir.path().join(".tsift/index.db");
27275 let _lock = hold_rollback_journal_lock(&db_path);
27276
27277 let result = cmd_path(
27278 "main",
27279 "helper",
27280 dir.path(),
27281 None,
27282 false,
27283 false,
27284 false,
27285 false,
27286 false,
27287 TagpathSearchOpts::default(),
27288 );
27289
27290 assert!(result.is_ok());
27291 }
27292
27293 #[test]
27294 fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
27295 let dir = setup_workspace();
27296 cmd_index(
27297 dir.path(),
27298 false,
27299 false,
27300 false,
27301 false,
27302 false,
27303 true,
27304 None,
27305 false,
27306 false,
27307 false,
27308 false,
27309 false,
27310 false,
27311 )
27312 .unwrap();
27313
27314 let err = cmd_explain(
27315 "alpha_main",
27316 dir.path(),
27317 None,
27318 15,
27319 false,
27320 false,
27321 false,
27322 false,
27323 false,
27324 false,
27325 false,
27326 false,
27327 )
27328 .unwrap_err();
27329
27330 assert_workspace_query_requires_scope(err);
27331 }
27332
27333 #[test]
27334 fn explain_cmd_infers_scope_from_nested_workspace_path() {
27335 let dir = setup_workspace();
27336 cmd_index(
27337 dir.path(),
27338 false,
27339 false,
27340 false,
27341 false,
27342 false,
27343 true,
27344 None,
27345 false,
27346 false,
27347 false,
27348 false,
27349 false,
27350 false,
27351 )
27352 .unwrap();
27353 let nested = dir.path().join("src/alpha/nested");
27354 std::fs::create_dir_all(&nested).unwrap();
27355
27356 let result = cmd_explain(
27357 "alpha_main",
27358 &nested,
27359 None,
27360 15,
27361 false,
27362 false,
27363 false,
27364 false,
27365 false,
27366 false,
27367 false,
27368 false,
27369 );
27370
27371 assert!(result.is_ok());
27372 }
27373
27374 #[test]
27375 fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27376 let dir = setup_graph_index();
27377 let db_path = dir.path().join(".tsift/index.db");
27378 let _lock = hold_rollback_journal_lock(&db_path);
27379
27380 let result = cmd_explain(
27381 "main",
27382 dir.path(),
27383 None,
27384 15,
27385 false,
27386 false,
27387 false,
27388 false,
27389 false,
27390 false,
27391 false,
27392 false,
27393 );
27394
27395 assert!(result.is_ok());
27396 }
27397
27398 #[test]
27401 fn community_detection_groups_related() {
27402 let dir = setup_graph_index();
27403 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27404 let edges = db.all_edges().unwrap();
27405 let result = graph::detect_communities(&edges);
27406 assert!(result.node_count > 0);
27407 assert!(!result.communities.is_empty());
27408 }
27409
27410 #[test]
27411 fn community_cmd_autoindexes_missing_index_by_default() {
27412 let dir = tempfile::tempdir().unwrap();
27413 let result = cmd_communities(
27414 dir.path(),
27415 None,
27416 2,
27417 10,
27418 false,
27419 false,
27420 false,
27421 false,
27422 false,
27423 false,
27424 TagpathSearchOpts::default(),
27425 );
27426
27427 assert!(result.is_ok());
27428 assert!(dir.path().join(".tsift/index.db").exists());
27429 }
27430
27431 #[test]
27434 fn path_finds_connected_symbols() {
27435 let dir = setup_graph_index();
27436 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27437 let edges = db.all_edges().unwrap();
27438 let result = graph::shortest_path(&edges, "main", "helper");
27439 assert!(result.is_some());
27440 let path = result.unwrap();
27441 assert_eq!(path.hops, 1);
27442 }
27443
27444 #[test]
27445 fn path_returns_none_for_unknown() {
27446 let dir = setup_graph_index();
27447 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27448 let edges = db.all_edges().unwrap();
27449 assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
27450 }
27451
27452 #[test]
27453 fn path_cmd_autoindexes_missing_index_by_default() {
27454 let dir = tempfile::tempdir().unwrap();
27455 let result = cmd_path(
27456 "a",
27457 "b",
27458 dir.path(),
27459 None,
27460 false,
27461 false,
27462 false,
27463 false,
27464 false,
27465 TagpathSearchOpts::default(),
27466 );
27467
27468 assert!(result.is_ok());
27469 assert!(dir.path().join(".tsift/index.db").exists());
27470 }
27471
27472 #[test]
27475 fn explain_shows_symbol_info() {
27476 let dir = setup_graph_index();
27477 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27478 let symbols = db.symbol_info("main").unwrap();
27479 assert!(!symbols.is_empty());
27480 assert_eq!(symbols[0].name, "main");
27481 assert_eq!(symbols[0].kind, "function");
27482 }
27483
27484 #[test]
27485 fn explain_cmd_autoindexes_missing_index_by_default() {
27486 let dir = tempfile::tempdir().unwrap();
27487 let result = cmd_explain(
27488 "main",
27489 dir.path(),
27490 None,
27491 15,
27492 false,
27493 false,
27494 false,
27495 false,
27496 false,
27497 false,
27498 false,
27499 false,
27500 );
27501
27502 assert!(result.is_ok());
27503 assert!(dir.path().join(".tsift/index.db").exists());
27504 }
27505
27506 fn hold_write_lock(db_path: &std::path::Path) -> Connection {
27507 let conn = Connection::open(db_path).unwrap();
27508 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
27509 conn
27510 }
27511
27512 fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
27513 use fs4::fs_std::FileExt;
27514 use std::io::Write;
27515
27516 let mut file = std::fs::OpenOptions::new()
27517 .read(true)
27518 .write(true)
27519 .create(true)
27520 .truncate(false)
27521 .open(lock_path)
27522 .unwrap();
27523 assert!(file.try_lock_exclusive().unwrap());
27524 writeln!(file, "{}", std::process::id()).unwrap();
27525 file
27526 }
27527
27528 fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
27529 let conn = Connection::open(db_path).unwrap();
27530 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
27531 .unwrap();
27532 std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
27533 conn
27534 }
27535
27536 fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
27537 let conn = Connection::open(db_path).unwrap();
27538 conn.execute_batch(
27539 "PRAGMA journal_mode=WAL;
27540 PRAGMA wal_autocheckpoint=0;
27541 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
27542 INSERT INTO wal_lock_probe DEFAULT VALUES;
27543 PRAGMA locking_mode=EXCLUSIVE;
27544 BEGIN EXCLUSIVE;",
27545 )
27546 .unwrap();
27547 assert!(substrate::wal_sidecar_path(db_path).exists());
27548 conn
27549 }
27550
27551 #[test]
27552 fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
27553 let dir = setup_graph_index();
27554 let db_path = dir.path().join(".tsift/index.db");
27555 let _lock = hold_wal_database_lock(&db_path);
27556
27557 let err = cmd_index(
27558 dir.path(),
27559 false,
27560 false,
27561 false,
27562 false,
27563 false,
27564 false,
27565 None,
27566 false,
27567 false,
27568 false,
27569 false,
27570 false,
27571 false,
27572 )
27573 .unwrap_err();
27574
27575 let msg = err.to_string();
27576 assert!(msg.contains("indexing"));
27577 assert!(msg.contains("lock diagnostics:"));
27578 assert!(msg.contains("lock: absent"));
27579 assert!(msg.contains("wal: present") || msg.contains("shm: present"));
27580 assert!(msg.contains("wedged writer holding live WAL sidecars"));
27581 assert!(msg.contains("snapshot fallback"));
27582 }
27583
27584 #[test]
27585 fn search_cmd_succeeds_while_writer_lock_is_held() {
27586 let dir = setup_graph_index();
27587 let db_path = dir.path().join(".tsift/index.db");
27588 let _lock = hold_write_lock(&db_path);
27589
27590 let result = cmd_search(
27591 "main".to_string(),
27592 Some(dir.path().to_path_buf()),
27593 5,
27594 Some("lexical".to_string()),
27595 None,
27596 false,
27597 false,
27598 false,
27599 0,
27600 true,
27601 false,
27602 false,
27603 false,
27604 false,
27605 false,
27606 false,
27607 );
27608
27609 assert!(result.is_ok());
27610 }
27611
27612 #[test]
27613 fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
27614 let dir = setup_graph_index();
27615 let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
27616
27617 let result = cmd_search(
27618 "main".to_string(),
27619 Some(dir.path().to_path_buf()),
27620 5,
27621 Some("lexical".to_string()),
27622 None,
27623 false,
27624 false,
27625 false,
27626 0,
27627 true,
27628 false,
27629 false,
27630 false,
27631 false,
27632 false,
27633 false,
27634 );
27635
27636 assert!(result.is_ok());
27637 }
27638
27639 #[test]
27640 fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
27641 let dir = setup_graph_index();
27642 let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
27643
27644 let result = cmd_search(
27645 "main".to_string(),
27646 Some(dir.path().to_path_buf()),
27647 5,
27648 Some("lexical".to_string()),
27649 None,
27650 false,
27651 false,
27652 false,
27653 0,
27654 true,
27655 false,
27656 false,
27657 false,
27658 false,
27659 false,
27660 false,
27661 );
27662
27663 assert!(result.is_ok());
27664 }
27665
27666 #[test]
27667 fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
27668 let dir = setup_graph_index();
27669 std::thread::sleep(std::time::Duration::from_millis(50));
27670 std::fs::write(
27671 dir.path().join("main.rs"),
27672 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27673 )
27674 .unwrap();
27675
27676 let err = cmd_search(
27677 "helper".to_string(),
27678 Some(dir.path().to_path_buf()),
27679 5,
27680 Some("lexical".to_string()),
27681 None,
27682 false,
27683 false,
27684 false,
27685 0,
27686 false,
27687 false,
27688 false,
27689 false,
27690 false,
27691 false,
27692 false,
27693 )
27694 .unwrap_err();
27695
27696 assert!(err.to_string().contains("search aborted"));
27697 assert!(err.to_string().contains("index is stale"));
27698 assert!(err.to_string().contains("--no-autoindex"));
27699 }
27700
27701 #[test]
27702 fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
27703 let dir = setup_graph_index();
27704 std::thread::sleep(std::time::Duration::from_millis(50));
27705 std::fs::write(
27706 dir.path().join("main.rs"),
27707 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27708 )
27709 .unwrap();
27710 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27711
27712 let err = cmd_search(
27713 "helper".to_string(),
27714 Some(dir.path().to_path_buf()),
27715 5,
27716 Some("lexical".to_string()),
27717 None,
27718 false,
27719 false,
27720 false,
27721 0,
27722 false,
27723 false,
27724 false,
27725 false,
27726 false,
27727 false,
27728 false,
27729 )
27730 .unwrap_err();
27731
27732 assert!(err.to_string().contains("search aborted"));
27733 assert!(err.to_string().contains("index is stale"));
27734 assert!(!err.to_string().contains("database is locked"));
27735 }
27736
27737 #[test]
27738 fn search_cmd_autoindexes_stale_index_by_default() {
27739 let dir = setup_graph_index();
27740 std::thread::sleep(std::time::Duration::from_millis(50));
27741 std::fs::write(
27742 dir.path().join("main.rs"),
27743 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27744 )
27745 .unwrap();
27746
27747 let result = cmd_search(
27748 "helper".to_string(),
27749 Some(dir.path().to_path_buf()),
27750 5,
27751 Some("lexical".to_string()),
27752 None,
27753 false,
27754 false,
27755 true,
27756 0,
27757 false,
27758 false,
27759 false,
27760 false,
27761 false,
27762 false,
27763 false,
27764 );
27765
27766 assert!(result.is_ok());
27767
27768 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27769 let summary = db.compute_changes(dir.path()).unwrap();
27770 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27771 }
27772
27773 #[test]
27774 fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
27775 let dir = setup_graph_index();
27776 std::thread::sleep(std::time::Duration::from_millis(50));
27777 std::fs::write(
27778 dir.path().join("main.rs"),
27779 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27780 )
27781 .unwrap();
27782 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27783
27784 let result = cmd_search(
27785 "helper".to_string(),
27786 Some(dir.path().to_path_buf()),
27787 5,
27788 Some("lexical".to_string()),
27789 None,
27790 false,
27791 false,
27792 true,
27793 0,
27794 false,
27795 false,
27796 false,
27797 false,
27798 false,
27799 false,
27800 false,
27801 );
27802
27803 assert!(result.is_ok());
27804
27805 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27806 let summary = db.compute_changes(dir.path()).unwrap();
27807 assert_eq!(summary.modified, 1);
27808 }
27809
27810 #[test]
27811 fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
27812 let dir = setup_graph_index();
27813 std::thread::sleep(std::time::Duration::from_millis(50));
27814 std::fs::write(
27815 dir.path().join("main.rs"),
27816 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27817 )
27818 .unwrap();
27819 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27820
27821 let err = cmd_search(
27822 "helper".to_string(),
27823 Some(dir.path().to_path_buf()),
27824 5,
27825 Some("lexical".to_string()),
27826 None,
27827 false,
27828 false,
27829 true,
27830 0,
27831 false,
27832 false,
27833 false,
27834 false,
27835 false,
27836 false,
27837 false,
27838 )
27839 .unwrap_err();
27840
27841 let msg = err.to_string();
27842 assert!(msg.contains("autoindexing index"));
27843 assert!(msg.contains("lock diagnostics:"));
27844 assert!(msg.contains("journal: present"));
27845 assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
27846 }
27847
27848 #[test]
27849 fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
27850 let dir = setup_graph_index();
27851 let nested = dir.path().join("src/nested");
27852 std::fs::create_dir_all(&nested).unwrap();
27853
27854 let result = cmd_search(
27855 "helper".to_string(),
27856 Some(nested.clone()),
27857 5,
27858 Some("lexical".to_string()),
27859 None,
27860 false,
27861 false,
27862 true,
27863 0,
27864 false,
27865 false,
27866 false,
27867 false,
27868 false,
27869 false,
27870 false,
27871 );
27872
27873 assert!(result.is_ok());
27874 assert!(!nested.join(".tsift/index.db").exists());
27875 }
27876
27877 #[test]
27878 fn exact_search_returns_literal_matches() {
27879 let dir = tempfile::tempdir().unwrap();
27880 std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
27881
27882 let response = run_exact_search_with_timeout(
27883 std::slice::from_ref(&dir.path().to_path_buf()),
27884 "claudescore-3",
27885 5,
27886 0,
27887 )
27888 .unwrap();
27889
27890 assert_eq!(response.strategy, "exact");
27891 assert_eq!(response.hits.len(), 1);
27892 assert!(response.hits[0].path.ends_with("notes.txt"));
27893 assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
27894 assert!(response.hits[0].snippet.contains("claudescore-3"));
27895 }
27896
27897 #[test]
27898 fn exact_search_skips_stale_index_precheck() {
27899 let dir = setup_graph_index();
27900 std::thread::sleep(std::time::Duration::from_millis(50));
27901 std::fs::write(
27902 dir.path().join("main.rs"),
27903 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
27904 )
27905 .unwrap();
27906
27907 let result = cmd_search(
27908 "println!(\"updated\")".to_string(),
27909 Some(dir.path().to_path_buf()),
27910 5,
27911 Some("exact".to_string()),
27912 None,
27913 false,
27914 false,
27915 false,
27916 0,
27917 false,
27918 false,
27919 false,
27920 false,
27921 false,
27922 false,
27923 false,
27924 );
27925
27926 assert!(result.is_ok());
27927 }
27928
27929 #[test]
27930 fn workspace_exact_search_does_not_require_shared_root_index() {
27931 let dir = setup_workspace();
27932 cmd_index(
27933 dir.path(),
27934 false,
27935 false,
27936 false,
27937 false,
27938 false,
27939 true,
27940 None,
27941 false,
27942 false,
27943 false,
27944 false,
27945 false,
27946 false,
27947 )
27948 .unwrap();
27949
27950 let result = cmd_search(
27951 "alpha_helper".to_string(),
27952 Some(dir.path().to_path_buf()),
27953 5,
27954 Some("exact".to_string()),
27955 None,
27956 false,
27957 false,
27958 false,
27959 0,
27960 false,
27961 false,
27962 false,
27963 false,
27964 false,
27965 false,
27966 false,
27967 );
27968
27969 assert!(result.is_ok());
27970 assert!(!dir.path().join(".tsift/index.db").exists());
27971 }
27972
27973 #[test]
27974 fn identifier_like_query_prefers_exact_search() {
27975 assert!(query_prefers_exact_search("claudescore-3"));
27976 assert!(query_prefers_exact_search("alpha_helper"));
27977 assert!(query_prefers_exact_search("src/main.rs"));
27978 assert!(query_prefers_exact_search("crate::module"));
27979 assert!(!query_prefers_exact_search("authenticate"));
27980 assert!(!query_prefers_exact_search("fn main"));
27981 assert!(!query_prefers_exact_search("."));
27982 }
27983
27984 #[test]
27985 fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
27986 assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
27987 assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
27988 assert_eq!(
27989 resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
27990 "hybrid"
27991 );
27992 }
27993
27994 #[test]
27995 fn workspace_identifier_like_search_auto_uses_exact_backend() {
27996 let dir = setup_workspace();
27997 cmd_index(
27998 dir.path(),
27999 false,
28000 false,
28001 false,
28002 false,
28003 false,
28004 true,
28005 None,
28006 false,
28007 false,
28008 false,
28009 false,
28010 false,
28011 false,
28012 )
28013 .unwrap();
28014
28015 let result = cmd_search(
28016 "alpha_helper".to_string(),
28017 Some(dir.path().to_path_buf()),
28018 5,
28019 None,
28020 None,
28021 false,
28022 false,
28023 false,
28024 0,
28025 false,
28026 false,
28027 false,
28028 false,
28029 false,
28030 false,
28031 false,
28032 );
28033
28034 assert!(result.is_ok());
28035 assert!(!dir.path().join(".tsift/index.db").exists());
28036 }
28037
28038 #[test]
28039 fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
28040 let dir = setup_graph_index();
28041 let nested = dir.path().join("src/nested");
28042 std::fs::create_dir_all(&nested).unwrap();
28043 std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
28044
28045 let result = cmd_index(
28046 &nested, false, false, false, false, false, false, None, false, false, false, false,
28047 false, false,
28048 );
28049
28050 assert!(result.is_ok());
28051 assert!(dir.path().join(".tsift/index.db").exists());
28052 assert!(!nested.join(".tsift/index.db").exists());
28053 }
28054
28055 #[test]
28056 fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
28057 let dir = setup_workspace();
28058 let nested = dir.path().join("docs/nested");
28059 std::fs::create_dir_all(&nested).unwrap();
28060
28061 let result = cmd_index(
28062 &nested, false, false, false, false, false, true, None, false, false, false, false,
28063 false, false,
28064 );
28065
28066 let cfg = config::Config::load(dir.path()).unwrap();
28067
28068 assert!(result.is_ok());
28069 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28070 assert!(cfg.db_path_for(dir.path(), "beta").exists());
28071 }
28072
28073 #[test]
28074 fn status_cmd_autoindexes_missing_workspace_scopes() {
28075 let dir = setup_workspace();
28076 let cfg = config::Config::load(dir.path()).unwrap();
28077 let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
28078 let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
28079 let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
28080 alpha_db.apply_changes(&alpha.source_root).unwrap();
28081
28082 let beta_db_path = cfg.db_path_for(dir.path(), "beta");
28083 assert!(!beta_db_path.exists());
28084
28085 cmd_status(
28086 dir.path(),
28087 StatusCommandOptions {
28088 fix: false,
28089 no_fix: false,
28090 json_output: true,
28091 compact: false,
28092 pretty: false,
28093 terse: false,
28094 schema: false,
28095 },
28096 )
28097 .unwrap();
28098
28099 assert!(beta_db_path.exists());
28100 let report = status::check_status(dir.path()).unwrap();
28101 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28102 }
28103
28104 #[test]
28105 fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
28106 let dir = setup_workspace();
28107 let cfg = config::Config::load(dir.path()).unwrap();
28108
28109 cmd_status(
28110 dir.path(),
28111 StatusCommandOptions {
28112 fix: false,
28113 no_fix: false,
28114 json_output: true,
28115 compact: false,
28116 pretty: false,
28117 terse: false,
28118 schema: false,
28119 },
28120 )
28121 .unwrap();
28122
28123 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28124 assert!(cfg.db_path_for(dir.path(), "beta").exists());
28125 let report = status::check_status(dir.path()).unwrap();
28126 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28127 }
28128
28129 #[test]
28130 fn status_cmd_fix_refreshes_stale_index() {
28131 let dir = setup_graph_index();
28132 std::thread::sleep(std::time::Duration::from_millis(50));
28133 std::fs::write(
28134 dir.path().join("main.rs"),
28135 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28136 )
28137 .unwrap();
28138
28139 let report = status::check_status(dir.path()).unwrap();
28140 assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
28141
28142 cmd_status(
28143 dir.path(),
28144 StatusCommandOptions {
28145 fix: false,
28146 no_fix: false,
28147 json_output: true,
28148 compact: false,
28149 pretty: false,
28150 terse: false,
28151 schema: false,
28152 },
28153 )
28154 .unwrap();
28155
28156 let report = status::check_status(dir.path()).unwrap();
28157 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28158 }
28159
28160 #[test]
28161 fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
28162 let dir = setup_graph_index();
28163 let db_path = dir.path().join(".tsift/index.db");
28164 let _lock = hold_wal_database_lock(&db_path);
28165
28166 cmd_status(
28167 dir.path(),
28168 StatusCommandOptions {
28169 fix: false,
28170 no_fix: false,
28171 json_output: true,
28172 compact: false,
28173 pretty: false,
28174 terse: false,
28175 schema: false,
28176 },
28177 )
28178 .unwrap();
28179
28180 let report = status::check_status(dir.path()).unwrap();
28181 assert!(matches!(
28182 report.index,
28183 status::IndexStatus::Fresh {
28184 recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
28185 ..
28186 }
28187 ));
28188 let locks = status::check_locks(dir.path(), None, None).unwrap();
28189 assert!(matches!(
28190 locks.writer_lock,
28191 status::WriterLockStatus::Absent { .. }
28192 ));
28193 assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
28194 assert!(
28195 locks
28196 .recommended_action
28197 .contains("wedged writer holding live WAL sidecars")
28198 );
28199 }
28200
28201 #[test]
28202 fn locks_report_uses_ancestor_project_root_for_nested_paths() {
28203 let dir = setup_graph_index();
28204 let nested = dir.path().join("src/nested");
28205 std::fs::create_dir_all(&nested).unwrap();
28206
28207 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28208 let report = status::check_locks(&root, Some(&nested), None).unwrap();
28209
28210 assert_eq!(report.source_root, dir.path());
28211 assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
28212 }
28213
28214 #[test]
28215 fn workspace_locks_report_infers_scope_from_nested_path() {
28216 let dir = setup_workspace();
28217 cmd_index(
28218 dir.path(),
28219 false,
28220 false,
28221 false,
28222 false,
28223 false,
28224 true,
28225 None,
28226 false,
28227 false,
28228 false,
28229 false,
28230 false,
28231 false,
28232 )
28233 .unwrap();
28234 let nested = dir.path().join("src/alpha/nested");
28235 std::fs::create_dir_all(&nested).unwrap();
28236
28237 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28238 let report = status::check_locks(&root, Some(&nested), None).unwrap();
28239 let cfg = config::Config::load(dir.path()).unwrap();
28240
28241 assert_eq!(report.label, "submodule `alpha` index");
28242 assert_eq!(report.source_root, dir.path().join("src/alpha"));
28243 assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
28244 assert_eq!(
28245 report.reindex_command,
28246 format!("tsift index --submodule alpha {}", dir.path().display())
28247 );
28248 }
28249
28250 #[test]
28251 fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
28252 let dir = setup_workspace();
28253 cmd_index(
28254 dir.path(),
28255 false,
28256 false,
28257 false,
28258 false,
28259 false,
28260 true,
28261 None,
28262 false,
28263 false,
28264 false,
28265 false,
28266 false,
28267 false,
28268 )
28269 .unwrap();
28270
28271 let alpha = dir.path().join("src/alpha/lib.rs");
28272 std::thread::sleep(std::time::Duration::from_millis(50));
28273 std::fs::write(
28274 &alpha,
28275 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28276 )
28277 .unwrap();
28278
28279 let result = cmd_search(
28280 "alpha_helper".to_string(),
28281 Some(dir.path().to_path_buf()),
28282 5,
28283 Some("lexical".to_string()),
28284 Some("alpha".to_string()),
28285 false,
28286 false,
28287 true,
28288 0,
28289 false,
28290 false,
28291 false,
28292 false,
28293 false,
28294 false,
28295 false,
28296 );
28297
28298 assert!(result.is_ok());
28299
28300 let cfg = config::Config::load(dir.path()).unwrap();
28301 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28302 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28303 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28304 }
28305
28306 #[test]
28307 fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28308 let dir = setup_workspace();
28309 cmd_index(
28310 dir.path(),
28311 false,
28312 false,
28313 false,
28314 false,
28315 false,
28316 true,
28317 None,
28318 false,
28319 false,
28320 false,
28321 false,
28322 false,
28323 false,
28324 )
28325 .unwrap();
28326
28327 let alpha = dir.path().join("src/alpha/lib.rs");
28328 std::thread::sleep(std::time::Duration::from_millis(50));
28329 std::fs::write(
28330 &alpha,
28331 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28332 )
28333 .unwrap();
28334
28335 let cfg = config::Config::load(dir.path()).unwrap();
28336 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28337
28338 let err = cmd_search(
28339 "alpha_helper".to_string(),
28340 Some(dir.path().to_path_buf()),
28341 5,
28342 Some("lexical".to_string()),
28343 Some("alpha".to_string()),
28344 false,
28345 false,
28346 false,
28347 0,
28348 false,
28349 false,
28350 false,
28351 false,
28352 false,
28353 false,
28354 false,
28355 )
28356 .unwrap_err();
28357
28358 assert!(err.to_string().contains("search aborted"));
28359 assert!(err.to_string().contains("submodule `alpha` index"));
28360 assert!(!err.to_string().contains("database is locked"));
28361 }
28362
28363 #[test]
28364 fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
28365 let dir = setup_workspace();
28366 cmd_index(
28367 dir.path(),
28368 false,
28369 false,
28370 false,
28371 false,
28372 false,
28373 true,
28374 None,
28375 false,
28376 false,
28377 false,
28378 false,
28379 false,
28380 false,
28381 )
28382 .unwrap();
28383
28384 let alpha = dir.path().join("src/alpha/lib.rs");
28385 std::thread::sleep(std::time::Duration::from_millis(50));
28386 std::fs::write(
28387 &alpha,
28388 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28389 )
28390 .unwrap();
28391
28392 let result = cmd_search(
28393 "alpha_helper".to_string(),
28394 Some(dir.path().to_path_buf()),
28395 5,
28396 Some("lexical".to_string()),
28397 None,
28398 true,
28399 false,
28400 true,
28401 0,
28402 false,
28403 false,
28404 false,
28405 false,
28406 false,
28407 false,
28408 false,
28409 );
28410
28411 assert!(result.is_ok());
28412
28413 let cfg = config::Config::load(dir.path()).unwrap();
28414 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28415 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28416 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28417 }
28418
28419 #[test]
28420 fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28421 let dir = setup_workspace();
28422 cmd_index(
28423 dir.path(),
28424 false,
28425 false,
28426 false,
28427 false,
28428 false,
28429 true,
28430 None,
28431 false,
28432 false,
28433 false,
28434 false,
28435 false,
28436 false,
28437 )
28438 .unwrap();
28439
28440 let alpha = dir.path().join("src/alpha/lib.rs");
28441 std::thread::sleep(std::time::Duration::from_millis(50));
28442 std::fs::write(
28443 &alpha,
28444 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28445 )
28446 .unwrap();
28447
28448 let cfg = config::Config::load(dir.path()).unwrap();
28449 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28450
28451 let err = cmd_search(
28452 "alpha_helper".to_string(),
28453 Some(dir.path().to_path_buf()),
28454 5,
28455 Some("lexical".to_string()),
28456 None,
28457 true,
28458 false,
28459 false,
28460 30,
28461 false,
28462 false,
28463 false,
28464 false,
28465 false,
28466 false,
28467 false,
28468 )
28469 .unwrap_err();
28470
28471 assert!(err.to_string().contains("stale"));
28472 assert!(err.to_string().contains("submodule `alpha` index"));
28473 assert!(!err.to_string().contains("database is locked"));
28474 }
28475
28476 #[test]
28477 fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
28478 let dir = setup_workspace();
28479 cmd_index(
28480 dir.path(),
28481 false,
28482 false,
28483 false,
28484 false,
28485 false,
28486 true,
28487 None,
28488 false,
28489 false,
28490 false,
28491 false,
28492 false,
28493 false,
28494 )
28495 .unwrap();
28496
28497 let err = cmd_search(
28498 "alpha_helper".to_string(),
28499 Some(dir.path().to_path_buf()),
28500 5,
28501 Some("lexical".to_string()),
28502 None,
28503 false,
28504 false,
28505 true,
28506 0,
28507 false,
28508 false,
28509 false,
28510 false,
28511 false,
28512 false,
28513 false,
28514 )
28515 .unwrap_err();
28516
28517 assert_workspace_search_requires_explicit_target(err);
28518 assert!(!dir.path().join(".tsift/index.db").exists());
28519 }
28520
28521 #[test]
28522 fn workspace_search_cmd_infers_scope_from_nested_path() {
28523 let dir = setup_workspace();
28524 cmd_index(
28525 dir.path(),
28526 false,
28527 false,
28528 false,
28529 false,
28530 false,
28531 true,
28532 None,
28533 false,
28534 false,
28535 false,
28536 false,
28537 false,
28538 false,
28539 )
28540 .unwrap();
28541 let nested = dir.path().join("src/alpha/nested");
28542 std::fs::create_dir_all(&nested).unwrap();
28543
28544 let result = cmd_search(
28545 "alpha_helper".to_string(),
28546 Some(nested),
28547 5,
28548 Some("lexical".to_string()),
28549 None,
28550 false,
28551 false,
28552 false,
28553 0,
28554 false,
28555 false,
28556 false,
28557 false,
28558 false,
28559 false,
28560 false,
28561 );
28562
28563 assert!(result.is_ok());
28564 }
28565
28566 #[test]
28567 fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
28568 let dir = setup_workspace_with_duplicate_leaf_names();
28569 cmd_index(
28570 dir.path(),
28571 false,
28572 false,
28573 false,
28574 false,
28575 false,
28576 true,
28577 None,
28578 false,
28579 false,
28580 false,
28581 false,
28582 false,
28583 false,
28584 )
28585 .unwrap();
28586 let nested = dir.path().join("vendor/foo/nested");
28587 std::fs::create_dir_all(&nested).unwrap();
28588
28589 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28590 let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
28591 let cfg = config::Config::load(dir.path()).unwrap();
28592
28593 assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
28594 }
28595
28596 #[test]
28597 fn graph_cmd_succeeds_while_writer_lock_is_held() {
28598 let dir = setup_graph_index();
28599 let db_path = dir.path().join(".tsift/index.db");
28600 let _lock = hold_write_lock(&db_path);
28601
28602 let result = cmd_graph(
28603 "main",
28604 dir.path(),
28605 false,
28606 false,
28607 None,
28608 20,
28609 false,
28610 true,
28611 false,
28612 false,
28613 false,
28614 false,
28615 false,
28616 TagpathSearchOpts::default(),
28617 );
28618
28619 assert!(result.is_ok());
28620 }
28621
28622 #[test]
28623 fn graph_cmd_autoindexes_stale_index_by_default() {
28624 let dir = setup_graph_index();
28625 std::thread::sleep(std::time::Duration::from_millis(50));
28626 std::fs::write(
28627 dir.path().join("main.rs"),
28628 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28629 )
28630 .unwrap();
28631
28632 let result = cmd_graph(
28633 "helper",
28634 dir.path(),
28635 true,
28636 false,
28637 None,
28638 20,
28639 false,
28640 true,
28641 false,
28642 false,
28643 false,
28644 false,
28645 false,
28646 TagpathSearchOpts::default(),
28647 );
28648
28649 assert!(result.is_ok());
28650 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
28651 let summary = db.compute_changes(dir.path()).unwrap();
28652 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28653 }
28654
28655 #[test]
28656 fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28657 let dir = setup_graph_index();
28658 let db_path = dir.path().join(".tsift/index.db");
28659 let _lock = hold_rollback_journal_lock(&db_path);
28660
28661 let result = cmd_graph(
28662 "main",
28663 dir.path(),
28664 false,
28665 false,
28666 None,
28667 20,
28668 false,
28669 true,
28670 false,
28671 false,
28672 false,
28673 false,
28674 false,
28675 TagpathSearchOpts::default(),
28676 );
28677
28678 assert!(result.is_ok());
28679 }
28680
28681 #[test]
28682 fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
28683 let dir = setup_graph_index();
28684 let nested = dir.path().join("src/nested");
28685 std::fs::create_dir_all(&nested).unwrap();
28686
28687 let result = cmd_graph(
28688 "helper",
28689 &nested,
28690 true,
28691 false,
28692 None,
28693 20,
28694 false,
28695 false,
28696 false,
28697 false,
28698 false,
28699 false,
28700 false,
28701 TagpathSearchOpts::default(),
28702 );
28703
28704 assert!(result.is_ok());
28705 }
28706
28707 #[test]
28708 fn communities_cmd_succeeds_while_writer_lock_is_held() {
28709 let dir = setup_graph_index();
28710 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
28711
28712 let result = cmd_communities(
28713 dir.path(),
28714 None,
28715 1,
28716 10,
28717 false,
28718 false,
28719 false,
28720 false,
28721 false,
28722 false,
28723 TagpathSearchOpts::default(),
28724 );
28725
28726 assert!(result.is_ok());
28727 }
28728
28729 #[test]
28730 fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28731 let dir = setup_graph_index();
28732 let db_path = dir.path().join(".tsift/index.db");
28733 let _lock = hold_rollback_journal_lock(&db_path);
28734
28735 let result = cmd_communities(
28736 dir.path(),
28737 None,
28738 1,
28739 10,
28740 false,
28741 false,
28742 false,
28743 false,
28744 false,
28745 false,
28746 TagpathSearchOpts::default(),
28747 );
28748
28749 assert!(result.is_ok());
28750 }
28751
28752 #[test]
28753 fn lint_finds_entities_from_project_root_index_db() {
28754 let dir = tempfile::tempdir().unwrap();
28755 std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
28756 std::fs::write(
28757 dir.path().join("README.md"),
28758 "alpha_helper should be backticked.\n",
28759 )
28760 .unwrap();
28761 cmd_index(
28762 dir.path(),
28763 false,
28764 false,
28765 false,
28766 false,
28767 false,
28768 false,
28769 None,
28770 false,
28771 false,
28772 false,
28773 false,
28774 false,
28775 false,
28776 )
28777 .unwrap();
28778
28779 let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
28780 .unwrap()
28781 .unwrap();
28782 let entities = lint::collect_entities_from_index_path(&root).unwrap();
28783 let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
28784
28785 assert!(
28786 result
28787 .annotations
28788 .iter()
28789 .any(|ann| ann.text == "alpha_helper")
28790 );
28791 }
28792
28793 #[test]
28796 fn search_direct_runs_ok() {
28797 let dir = tempfile::tempdir().unwrap();
28798 let search_dir = dir.path().to_path_buf();
28799 let cache_dir = search_dir.join(".tsift/search-cache");
28800 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28801 let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical", None);
28802 assert!(result.is_ok(), "direct search should succeed");
28803 assert!(
28804 cache_dir.exists(),
28805 "search should create the configured cache dir"
28806 );
28807 }
28808
28809 #[test]
28810 fn search_timeout_zero_disables_timeout() {
28811 let dir = tempfile::tempdir().unwrap();
28812 let search_dir = dir.path().to_path_buf();
28813 let cache_dir = search_dir.join(".tsift/search-cache");
28814 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28815 let result =
28816 run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[], None);
28817 assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
28818 assert!(
28819 cache_dir.exists(),
28820 "timeout=0 should keep using the stable search cache dir"
28821 );
28822 }
28823
28824 #[test]
28825 fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
28826 let dir = tempfile::tempdir().unwrap();
28827 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
28828 cmd_index(
28829 dir.path(),
28830 false,
28831 false,
28832 false,
28833 false,
28834 false,
28835 false,
28836 None,
28837 false,
28838 false,
28839 false,
28840 false,
28841 false,
28842 false,
28843 )
28844 .unwrap();
28845 let db_path = dir.path().join(".tsift/index.db");
28846 std::fs::remove_file(&db_path).unwrap();
28847 let search_target = SearchIndexTarget {
28848 label: "index".to_string(),
28849 db_path,
28850 source_root: dir.path().to_path_buf(),
28851 scope_name: None,
28852 reindex_cmd: format!("tsift index {}", dir.path().display()),
28853 };
28854
28855 let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
28856
28857 assert!(message.contains("timed out after 1s"));
28858 assert!(message.contains("index is missing"));
28859 assert!(message.contains("Run `tsift index"));
28860 assert!(!message.contains("search root looks fresh"));
28861 }
28862
28863 #[test]
28864 fn search_worker_output_path_uses_json_suffix() {
28865 let path = next_search_worker_output_path();
28866 assert!(path.extension().is_some_and(|ext| ext == "json"));
28867 }
28868
28869 #[test]
28870 fn fts_search_flag_value_parses_falsy_escape_hatch() {
28871 for falsy in ["0", "false", "FALSE", " no ", "Off"] {
28873 assert!(
28874 fts_flag_value_disabled(falsy),
28875 "{falsy:?} should force the legacy TokenIndex path"
28876 );
28877 }
28878 for keeps_default in ["", "1", "true", "yes", "on", "maybe"] {
28879 assert!(
28880 !fts_flag_value_disabled(keeps_default),
28881 "{keeps_default:?} should keep the FTS5 default"
28882 );
28883 }
28884 }
28885
28886 #[test]
28887 fn run_sift_search_defaults_to_fts_when_index_db_present() {
28888 let dir = tempfile::tempdir().unwrap();
28891 let root = dir.path();
28892 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28893 index::IndexDb::open(&root.join(".tsift/index.db"))
28894 .unwrap()
28895 .apply_changes(root)
28896 .unwrap();
28897 let cache_dir = root.join(".tsift/search-cache");
28898
28899 if fts_search_forced_off() {
28901 return;
28902 }
28903 let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
28904 assert_eq!(response.strategy, "fts");
28905 assert!(response.hits.iter().any(|h| h.path.ends_with("alpha.rs")));
28906 }
28907
28908 #[test]
28909 fn run_sift_search_falls_back_to_lexical_without_index_db() {
28910 let dir = tempfile::tempdir().unwrap();
28913 let root = dir.path();
28914 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28915 let cache_dir = root.join(".tsift/search-cache");
28916
28917 let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
28918 assert_eq!(response.strategy, "lexical");
28919 }
28920
28921 #[test]
28922 fn run_sift_search_honors_threaded_freshness_verdict() {
28923 let dir = tempfile::tempdir().unwrap();
28927 let root = dir.path();
28928 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28929 index::IndexDb::open(&root.join(".tsift/index.db"))
28930 .unwrap()
28931 .apply_changes(root)
28932 .unwrap();
28933 let cache_dir = root.join(".tsift/search-cache");
28934
28935 if fts_search_forced_off() {
28936 return;
28937 }
28938 let fresh =
28939 run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(true)).unwrap();
28940 assert_eq!(fresh.strategy, "fts");
28941
28942 let stale =
28943 run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(false)).unwrap();
28944 assert_eq!(stale.strategy, "lexical");
28945 }
28946
28947 #[test]
28950 fn index_quiet_suppresses_file_list() {
28951 let dir = setup_graph_index();
28952 let result = cmd_index(
28953 dir.path(),
28954 false,
28955 true,
28956 false,
28957 false,
28958 true,
28959 false,
28960 None,
28961 false,
28962 false,
28963 false,
28964 false,
28965 false,
28966 false,
28967 );
28968 assert!(result.is_ok());
28969 }
28970
28971 #[test]
28972 fn index_exit_code_implies_quiet() {
28973 let dir = setup_graph_index();
28974 let result = cmd_index(
28975 dir.path(),
28976 false,
28977 true,
28978 false,
28979 false,
28980 false,
28981 false,
28982 None,
28983 false,
28984 false,
28985 false,
28986 false,
28987 false,
28988 false,
28989 );
28990 assert!(result.is_ok());
28991 }
28992
28993 #[test]
28994 fn index_quiet_json_omits_changes() {
28995 let dir = setup_graph_index();
28996 let result = cmd_index(
28997 dir.path(),
28998 false,
28999 true,
29000 false,
29001 false,
29002 true,
29003 false,
29004 None,
29005 true,
29006 false,
29007 false,
29008 false,
29009 false,
29010 false,
29011 );
29012 assert!(result.is_ok());
29013 }
29014
29015 #[test]
29016 fn cli_workflow_defaults_to_search_topic() {
29017 let cli = parse_cli(["tsift", "workflow"]);
29018 match cli.command {
29019 Some(Commands::Workflow { topic, json }) => {
29020 assert_eq!(topic, "search");
29021 assert!(!json);
29022 }
29023 _ => panic!("expected Workflow command"),
29024 }
29025 }
29026
29027 #[test]
29028 fn search_workflow_recipe_preserves_handles_across_expansions() {
29029 let recipe = workflow::search_workflow_recipe();
29030 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29031 assert_eq!(
29032 step_names,
29033 vec![
29034 "exact-anchor",
29035 "semantic-search",
29036 "explain-symbol",
29037 "summarize-selection",
29038 "digest-expansion"
29039 ]
29040 );
29041 assert!(
29042 recipe
29043 .handle_contract
29044 .iter()
29045 .any(|item| item.contains("originating command"))
29046 );
29047 assert!(
29048 recipe.steps[1]
29049 .preserves
29050 .iter()
29051 .any(|item| item.contains("sfam-*"))
29052 );
29053 assert!(
29054 recipe.steps[2]
29055 .preserves
29056 .iter()
29057 .any(|item| item.contains("ecall-*"))
29058 );
29059 assert!(
29060 recipe.steps[4]
29061 .preserves
29062 .iter()
29063 .any(|item| item.contains("artifact handles"))
29064 );
29065 }
29066
29067 #[test]
29068 fn kg_workflow_recipe_covers_extract_to_evidence() {
29069 let recipe = workflow::kg_workflow_recipe();
29070 assert_eq!(recipe.topic, "kg");
29071 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29072 assert_eq!(
29073 step_names,
29074 vec!["smoke-check", "extract", "status", "refresh", "evidence"]
29075 );
29076 let evidence = recipe.steps.last().unwrap();
29078 assert!(evidence.command.contains("kg evidence --symbol"));
29079 assert!(!evidence.command.contains("--budget"));
29080 assert!(
29082 recipe
29083 .handle_contract
29084 .iter()
29085 .any(|item| item.contains("Extract once"))
29086 );
29087 }
29088
29089 #[test]
29092 fn to_json_compact_default() {
29093 let val = serde_json::json!({"a": 1, "b": [2, 3]});
29094 let compact = to_json(&val, false, false).unwrap();
29095 assert!(!compact.contains('\n'));
29096 assert!(
29097 compact.contains("\"a\":1")
29098 || compact.contains("\"a\": 1")
29099 || compact.contains("\"a\":")
29100 );
29101 }
29102
29103 #[test]
29104 fn to_json_pretty_indents() {
29105 let val = serde_json::json!({"a": 1, "b": [2, 3]});
29106 let pretty = to_json(&val, true, false).unwrap();
29107 assert!(pretty.contains('\n'));
29108 assert!(pretty.contains(" "));
29109 }
29110
29111 #[test]
29112 fn to_json_compact_is_shorter() {
29113 let val =
29114 serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
29115 let compact = to_json(&val, false, false).unwrap();
29116 let pretty = to_json(&val, true, false).unwrap();
29117 assert!(compact.len() < pretty.len());
29118 }
29119
29120 #[test]
29121 fn terse_renames_keys() {
29122 let val =
29123 serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
29124 let result = to_json(&val, false, true).unwrap();
29125 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29126 assert!(parsed["_s"].is_object());
29127 let d = &parsed["d"];
29128 assert_eq!(d["cf"], "a.rs");
29129 assert_eq!(d["cn"], "main");
29130 assert_eq!(d["csl"], 10);
29131 }
29132
29133 #[test]
29134 fn terse_schema_only_includes_used_keys() {
29135 let val = serde_json::json!({"name": "test", "score": 0.5});
29136 let result = to_json(&val, false, true).unwrap();
29137 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29138 let schema = parsed["_s"].as_object().unwrap();
29139 assert_eq!(schema["n"], "name");
29140 assert_eq!(schema["sc"], "score");
29141 assert!(!schema.contains_key("cf"));
29142 }
29143
29144 #[test]
29145 fn terse_nested_arrays() {
29146 let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
29147 let result = to_json(&val, false, true).unwrap();
29148 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29149 let d = &parsed["d"];
29150 assert_eq!(d["crs"][0]["cn"], "a");
29151 assert_eq!(d["crs"][0]["cf"], "b.rs");
29152 }
29153
29154 #[test]
29155 fn terse_preserves_unknown_keys() {
29156 let val = serde_json::json!({"custom_field": "value", "name": "test"});
29157 let result = to_json(&val, false, true).unwrap();
29158 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29159 let d = &parsed["d"];
29160 assert_eq!(d["custom_field"], "value");
29161 assert_eq!(d["n"], "test");
29162 }
29163
29164 #[test]
29167 fn ultra_terse_strips_properties_from_graph_nodes() {
29168 let val = serde_json::json!({
29169 "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
29170 });
29171 let result = to_json_schema(&val, false, true, true, false).unwrap();
29172 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29173 let node = &parsed["d"]["nodes"][0];
29174 assert_eq!(node["id"], "fn:main");
29175 assert_eq!(node["k"], "fn");
29176 assert_eq!(node["n"], "main");
29177 assert!(node.get("properties").is_none());
29178 }
29179
29180 #[test]
29181 fn ultra_terse_strips_properties_from_graph_edges() {
29182 let val = serde_json::json!({
29183 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
29184 });
29185 let result = to_json_schema(&val, false, true, true, false).unwrap();
29186 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29187 let edge = &parsed["d"]["edges"][0];
29188 assert_eq!(edge["from_id"], "a");
29189 assert_eq!(edge["to_id"], "b");
29190 assert_eq!(edge["k"], "c");
29191 assert!(edge.get("properties").is_none());
29192 }
29193
29194 #[test]
29195 fn ultra_terse_abbreviates_edge_kinds() {
29196 let val = serde_json::json!({
29197 "edges": [
29198 {"from_id": "a", "to_id": "b", "kind": "defines"},
29199 {"from_id": "a", "to_id": "c", "kind": "contains"},
29200 {"from_id": "a", "to_id": "d", "kind": "imports"},
29201 {"from_id": "a", "to_id": "e", "kind": "mentions"},
29202 {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
29203 {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
29204 {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
29205 {"from_id": "a", "to_id": "i", "kind": "uses"},
29206 {"from_id": "a", "to_id": "j", "kind": "parent"},
29207 {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
29208 ]
29209 });
29210 let result = to_json_schema(&val, false, true, true, false).unwrap();
29211 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29212 let edges = &parsed["d"]["edges"].as_array().unwrap();
29213 assert_eq!(edges[0]["k"], "d");
29214 assert_eq!(edges[1]["k"], "ct");
29215 assert_eq!(edges[2]["k"], "i");
29216 assert_eq!(edges[3]["k"], "m");
29217 assert_eq!(edges[4]["k"], "sr");
29218 assert_eq!(edges[5]["k"], "bt");
29219 assert_eq!(edges[6]["k"], "sctx");
29220 assert_eq!(edges[7]["k"], "u");
29221 assert_eq!(edges[8]["k"], "p");
29222 assert_eq!(edges[9]["k"], "unknown_edge");
29223 }
29224
29225 #[test]
29226 fn ultra_terse_strips_provenance_freshness_from_edges() {
29227 let val = serde_json::json!({
29228 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
29229 });
29230 let result = to_json_schema(&val, false, true, true, false).unwrap();
29231 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29232 let edge = &parsed["d"]["edges"][0];
29233 assert!(edge.get("provenance").is_none());
29234 assert!(edge.get("freshness").is_none());
29235 assert_eq!(edge["k"], "c");
29236 }
29237
29238 #[test]
29239 fn ultra_terse_truncates_snippets() {
29240 let long_snippet = "x".repeat(120);
29241 let val = serde_json::json!({"snippet": long_snippet});
29242 let result = to_json_schema(&val, false, true, true, false).unwrap();
29243 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29244 let snipped = parsed["d"]["sn"].as_str().unwrap();
29245 assert_eq!(snipped.len(), 80);
29246 assert!(snipped.ends_with("..."));
29247 }
29248
29249 #[test]
29250 fn ultra_terse_truncates_abbreviated_snippet_key() {
29251 let long_snippet = "y".repeat(100);
29252 let val = serde_json::json!({"snippet": long_snippet});
29253 let result = to_json_schema(&val, false, true, true, false).unwrap();
29254 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29255 let snipped = parsed["d"]["sn"].as_str().unwrap();
29256 assert_eq!(snipped.len(), 80);
29257 assert!(snipped.ends_with("..."));
29258 }
29259
29260 #[test]
29261 fn ultra_terse_compacts_coverage_snapshot() {
29262 let val = serde_json::json!({
29263 "mode": "incremental",
29264 "total_sector_count": 10,
29265 "dirty_sector_count": 2,
29266 "active_rebuild": Some("rebuild-1"),
29267 "completed_dirty_sector_count": 1,
29268 "mounted_sector_count": 8,
29269 "rebuilding_sector_count": 1,
29270 "resumed_sector_count": 3,
29271 "reused_sector_count": 5
29272 });
29273 let result = to_json_schema(&val, false, true, true, false).unwrap();
29274 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29275 let d = &parsed["d"];
29276 assert_eq!(d["mode"], "incremental");
29277 assert_eq!(d["total_sector_count"], 10);
29278 assert_eq!(d["dirty_sector_count"], 2);
29279 assert!(d.get("active_rebuild").is_none());
29280 assert!(d.get("completed_dirty_sector_count").is_none());
29281 assert!(d.get("mounted_sector_count").is_none());
29282 assert!(d.get("rebuilding_sector_count").is_none());
29283 assert!(d.get("resumed_sector_count").is_none());
29284 assert!(d.get("reused_sector_count").is_none());
29285 }
29286
29287 #[test]
29288 fn ultra_terse_short_snippet_unchanged() {
29289 let val = serde_json::json!({"snippet": "short text"});
29290 let result = to_json_schema(&val, false, true, true, false).unwrap();
29291 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29292 assert_eq!(parsed["d"]["sn"], "short text");
29293 }
29294
29295 #[test]
29296 fn ultra_terse_non_graph_object_properties_preserved() {
29297 let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
29298 let result = to_json_schema(&val, false, true, true, false).unwrap();
29299 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29300 assert!(parsed["d"]["config"]["properties"].is_object());
29301 }
29302
29303 #[test]
29306 fn schema_converts_homogeneous_arrays() {
29307 let val = serde_json::json!({"symbols": [
29308 {"name": "foo", "kind": "fn", "line": 10},
29309 {"name": "bar", "kind": "fn", "line": 20}
29310 ]});
29311 let result = to_json_schema(&val, false, false, false, true).unwrap();
29312 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29313 let syms = &parsed["symbols"];
29314 let columns = syms["_c"]
29315 .as_array()
29316 .unwrap()
29317 .iter()
29318 .map(|value| value.as_str().unwrap())
29319 .collect::<Vec<_>>();
29320 let row0 = syms["_r"][0].as_array().unwrap();
29321 let row1 = syms["_r"][1].as_array().unwrap();
29322 let name_index = columns.iter().position(|column| *column == "name").unwrap();
29323 let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
29324 let line_index = columns.iter().position(|column| *column == "line").unwrap();
29325 assert_eq!(row0[name_index], "foo");
29326 assert_eq!(row0[kind_index], "fn");
29327 assert_eq!(row0[line_index], 10);
29328 assert_eq!(row1[name_index], "bar");
29329 assert_eq!(row1[kind_index], "fn");
29330 assert_eq!(row1[line_index], 20);
29331 }
29332
29333 #[test]
29334 fn schema_skips_short_arrays() {
29335 let val = serde_json::json!({"items": [{"name": "only"}]});
29336 let result = to_json_schema(&val, false, false, false, true).unwrap();
29337 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29338 assert!(parsed["items"].is_array());
29339 assert_eq!(parsed["items"][0]["name"], "only");
29340 }
29341
29342 #[test]
29343 fn schema_skips_heterogeneous_arrays() {
29344 let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
29345 let result = to_json_schema(&val, false, false, false, true).unwrap();
29346 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29347 assert!(parsed["items"].is_array());
29348 assert_eq!(parsed["items"][0]["a"], 1);
29349 }
29350
29351 #[test]
29352 fn schema_with_terse_combines() {
29353 let val = serde_json::json!({"callers": [
29354 {"caller_name": "a", "caller_file": "x.rs"},
29355 {"caller_name": "b", "caller_file": "y.rs"}
29356 ]});
29357 let result = to_json_schema(&val, false, true, false, true).unwrap();
29358 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29359 assert!(parsed["_s"].is_object());
29360 let d = &parsed["d"];
29361 let crs = &d["crs"];
29362 assert!(crs["_c"].is_array());
29363 assert!(crs["_r"].is_array());
29364 let columns = crs["_c"]
29365 .as_array()
29366 .unwrap()
29367 .iter()
29368 .map(|value| value.as_str().unwrap())
29369 .collect::<Vec<_>>();
29370 let row = crs["_r"][0].as_array().unwrap();
29371 let name_index = columns.iter().position(|column| *column == "cn").unwrap();
29372 let file_index = columns.iter().position(|column| *column == "cf").unwrap();
29373 assert_eq!(row[name_index], "a");
29374 assert_eq!(row[file_index], "x.rs");
29375 }
29376
29377 #[test]
29378 fn schema_preserves_non_object_arrays() {
29379 let val = serde_json::json!({"tags": ["a", "b", "c"]});
29380 let result = to_json_schema(&val, false, false, false, true).unwrap();
29381 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29382 assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
29383 }
29384
29385 #[test]
29386 fn cli_accepts_global_schema_flag() {
29387 let cli = parse_cli(["tsift", "--schema", "search", "test"]);
29388 assert!(cli.schema);
29389 assert!(matches!(cli.command, Some(Commands::Search { .. })));
29390 }
29391
29392 #[test]
29393 fn cli_accepts_global_envelope_flag() {
29394 let cli = parse_cli([
29395 "tsift",
29396 "--envelope",
29397 "context-pack",
29398 "tasks/software/tsift.md",
29399 ]);
29400 assert!(cli.envelope);
29401 assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
29402 }
29403
29404 #[test]
29405 fn cli_accepts_locks_command() {
29406 let cli = parse_cli(["tsift", "locks"]);
29407 assert!(matches!(cli.command, Some(Commands::Locks { .. })));
29408 }
29409
29410 #[test]
29411 fn cli_parses_memory_budget_guard_command() {
29412 let cli = parse_cli([
29413 "tsift",
29414 "memory",
29415 "budget-guard",
29416 "--file",
29417 "tool.log",
29418 "--budget-tokens",
29419 "1000",
29420 "--json",
29421 ]);
29422 match cli.command {
29423 Some(Commands::Memory {
29424 command:
29425 crate::cli::MemoryCommand::BudgetGuard {
29426 file,
29427 budget_tokens,
29428 json,
29429 ..
29430 },
29431 }) => {
29432 assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
29433 assert_eq!(budget_tokens, 1000);
29434 assert!(json);
29435 }
29436 _ => panic!("expected memory budget-guard command"),
29437 }
29438 }
29439
29440 #[test]
29441 fn cli_parses_memory_capture_agent_doc_closeout_command() {
29442 let cli = parse_cli([
29443 "tsift",
29444 "memory",
29445 "capture-agent-doc-closeout",
29446 ".",
29447 "--session-path",
29448 "tasks/software/tsift.md",
29449 "--prompt-target",
29450 "do [#tsiftmemhooks]",
29451 "--response-summary",
29452 "wired closeout capture",
29453 "--commit-hash",
29454 "abc123",
29455 "--session-check-status",
29456 "clean",
29457 "--json",
29458 ]);
29459 match cli.command {
29460 Some(Commands::Memory {
29461 command:
29462 crate::cli::MemoryCommand::CaptureAgentDocCloseout {
29463 path,
29464 session_path,
29465 prompt_target,
29466 response_summary,
29467 commit_hash,
29468 session_check_status,
29469 json,
29470 },
29471 }) => {
29472 assert_eq!(path, std::path::PathBuf::from("."));
29473 assert_eq!(
29474 session_path,
29475 std::path::PathBuf::from("tasks/software/tsift.md")
29476 );
29477 assert_eq!(prompt_target, "do [#tsiftmemhooks]");
29478 assert_eq!(response_summary, "wired closeout capture");
29479 assert_eq!(commit_hash.as_deref(), Some("abc123"));
29480 assert_eq!(session_check_status, "clean");
29481 assert!(json);
29482 }
29483 _ => panic!("expected memory capture-agent-doc-closeout command"),
29484 }
29485 }
29486
29487 #[test]
29488 fn cli_parses_memory_project_graph_read_policy() {
29489 let cli = parse_cli([
29490 "tsift",
29491 "memory",
29492 "project-graph",
29493 ".",
29494 "--read-policy",
29495 "query-relevant",
29496 "--query",
29497 "semantic memory",
29498 "--limit",
29499 "7",
29500 "--json",
29501 ]);
29502 match cli.command {
29503 Some(Commands::Memory {
29504 command:
29505 crate::cli::MemoryCommand::ProjectGraph {
29506 read_policy,
29507 query,
29508 limit,
29509 json,
29510 ..
29511 },
29512 }) => {
29513 assert_eq!(
29514 read_policy,
29515 crate::cli::MemoryProjectReadPolicy::QueryRelevant
29516 );
29517 assert_eq!(query.as_deref(), Some("semantic memory"));
29518 assert_eq!(limit, 7);
29519 assert!(json);
29520 }
29521 _ => panic!("expected memory project-graph command"),
29522 }
29523 }
29524
29525 #[test]
29526 fn cli_locks_accepts_scope_flag() {
29527 let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
29528 match cli.command {
29529 Some(Commands::Locks { scope, .. }) => {
29530 assert_eq!(scope.as_deref(), Some("alpha"));
29531 }
29532 _ => panic!("expected Locks command"),
29533 }
29534 }
29535
29536 #[test]
29537 fn cli_search_accepts_autoindex_flag() {
29538 let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
29539 match cli.command {
29540 Some(Commands::Search {
29541 autoindex,
29542 no_autoindex,
29543 ..
29544 }) => {
29545 assert!(autoindex);
29546 assert!(!no_autoindex);
29547 }
29548 _ => panic!("expected Search command"),
29549 }
29550 }
29551
29552 #[test]
29553 fn cli_search_accepts_exact_flag() {
29554 let cli = parse_cli(["tsift", "search", "test", "--exact"]);
29555 match cli.command {
29556 Some(Commands::Search {
29557 exact, strategy, ..
29558 }) => {
29559 assert!(exact);
29560 assert!(strategy.is_none());
29561 }
29562 _ => panic!("expected Search command"),
29563 }
29564 }
29565
29566 #[test]
29567 fn cli_parses_diff_digest_command() {
29568 let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
29569 match cli.command {
29570 Some(Commands::DiffDigest {
29571 json,
29572 path,
29573 cached,
29574 revision,
29575 max_parsed_files,
29576 }) => {
29577 assert!(json);
29578 assert_eq!(path, PathBuf::from("."));
29579 assert!(!cached);
29580 assert!(revision.is_none());
29581 assert_eq!(max_parsed_files, 25);
29582 }
29583 _ => panic!("expected DiffDigest command"),
29584 }
29585 }
29586
29587 #[test]
29588 fn cli_rejects_conflicting_diff_digest_modes() {
29589 match try_parse_cli([
29590 "tsift",
29591 "diff-digest",
29592 "--cached",
29593 "--revision",
29594 "HEAD",
29595 ".",
29596 ]) {
29597 Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
29598 Err(err) => {
29599 assert!(err.to_string().contains("--cached"));
29600 assert!(err.to_string().contains("--revision"));
29601 }
29602 }
29603 }
29604
29605 #[test]
29606 fn cli_parses_test_digest_command() {
29607 let cli = parse_cli([
29608 "tsift",
29609 "test-digest",
29610 "--path",
29611 ".",
29612 "--input",
29613 "target/test.log",
29614 "--runner",
29615 "cargo",
29616 "--json",
29617 ]);
29618 match cli.command {
29619 Some(Commands::TestDigest {
29620 json,
29621 path,
29622 input,
29623 runner,
29624 }) => {
29625 assert!(json);
29626 assert_eq!(path, PathBuf::from("."));
29627 assert_eq!(input, Some(PathBuf::from("target/test.log")));
29628 assert_eq!(runner.as_deref(), Some("cargo"));
29629 }
29630 _ => panic!("expected TestDigest command"),
29631 }
29632 }
29633
29634 #[test]
29635 fn cli_parses_log_digest_command() {
29636 let cli = parse_cli([
29637 "tsift",
29638 "log-digest",
29639 "--path",
29640 ".",
29641 "--input",
29642 "target/build.log",
29643 "--json",
29644 ]);
29645 match cli.command {
29646 Some(Commands::LogDigest {
29647 json,
29648 path,
29649 input,
29650 fixture,
29651 fail_under,
29652 }) => {
29653 assert!(json);
29654 assert_eq!(path, PathBuf::from("."));
29655 assert_eq!(input, Some(PathBuf::from("target/build.log")));
29656 assert!(fixture.is_none());
29657 assert!(!fail_under);
29658 }
29659 _ => panic!("expected LogDigest command"),
29660 }
29661 }
29662
29663 #[test]
29664 fn cli_parses_metric_digest_command() {
29665 let cli = parse_cli([
29666 "tsift",
29667 "metric-digest",
29668 "--input",
29669 "target/runs.json",
29670 "--baseline",
29671 "target/prior.json",
29672 "--metric",
29673 "session_mae",
29674 "--lower-is-better",
29675 "session_mae",
29676 "--history",
29677 "4",
29678 "--top",
29679 "2",
29680 "--json",
29681 ]);
29682 match cli.command {
29683 Some(Commands::MetricDigest {
29684 input,
29685 baseline,
29686 metrics,
29687 lower_is_better,
29688 history,
29689 top,
29690 json,
29691 ..
29692 }) => {
29693 assert!(json);
29694 assert_eq!(input, Some(PathBuf::from("target/runs.json")));
29695 assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
29696 assert_eq!(metrics, vec!["session_mae"]);
29697 assert_eq!(lower_is_better, vec!["session_mae"]);
29698 assert_eq!(history, 4);
29699 assert_eq!(top, 2);
29700 }
29701 _ => panic!("expected MetricDigest command"),
29702 }
29703 }
29704
29705 #[test]
29706 fn cli_parses_dci_benchmark_command() {
29707 let cli = parse_cli([
29708 "tsift",
29709 "dci-benchmark",
29710 "--fixture",
29711 "fixtures/dci-search-benchmark.json",
29712 "--json",
29713 ]);
29714 match cli.command {
29715 Some(Commands::DciBenchmark { fixture, json }) => {
29716 assert!(json);
29717 assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
29718 }
29719 _ => panic!("expected DciBenchmark command"),
29720 }
29721 }
29722
29723 #[test]
29724 fn cli_parses_session_digest_command() {
29725 let cli = parse_cli([
29726 "tsift",
29727 "session-digest",
29728 "--path",
29729 ".",
29730 "--input",
29731 "target/session.md",
29732 "--source",
29733 "markdown",
29734 "--json",
29735 ]);
29736 match cli.command {
29737 Some(Commands::SessionDigest {
29738 json,
29739 path,
29740 input,
29741 source,
29742 }) => {
29743 assert!(json);
29744 assert_eq!(path, PathBuf::from("."));
29745 assert_eq!(input, Some(PathBuf::from("target/session.md")));
29746 assert_eq!(source.as_deref(), Some("markdown"));
29747 }
29748 _ => panic!("expected SessionDigest command"),
29749 }
29750 }
29751
29752 #[test]
29753 fn cli_parses_session_cost_command() {
29754 let cli = parse_cli([
29755 "tsift",
29756 "session-cost",
29757 "--input",
29758 "target/session.jsonl",
29759 "--source",
29760 "codex-jsonl",
29761 "--json",
29762 ]);
29763 match cli.command {
29764 Some(Commands::SessionCost {
29765 json,
29766 input,
29767 fixture,
29768 fail_under,
29769 source,
29770 }) => {
29771 assert!(json);
29772 assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
29773 assert_eq!(fixture, None);
29774 assert!(!fail_under);
29775 assert_eq!(source.as_deref(), Some("codex-jsonl"));
29776 }
29777 _ => panic!("expected SessionCost command"),
29778 }
29779
29780 let cli = parse_cli([
29781 "tsift",
29782 "session-cost",
29783 "--fixture",
29784 "fixtures/real-session-prompt-cache-effectiveness.json",
29785 "--fail-under",
29786 "--json",
29787 ]);
29788 match cli.command {
29789 Some(Commands::SessionCost {
29790 json,
29791 input,
29792 fixture,
29793 fail_under,
29794 source,
29795 }) => {
29796 assert!(json);
29797 assert_eq!(input, None);
29798 assert_eq!(
29799 fixture,
29800 Some(PathBuf::from(
29801 "fixtures/real-session-prompt-cache-effectiveness.json"
29802 ))
29803 );
29804 assert!(fail_under);
29805 assert_eq!(source, None);
29806 }
29807 _ => panic!("expected SessionCost command"),
29808 }
29809 }
29810
29811 #[test]
29812 fn cli_parses_session_review_command() {
29813 let cli = parse_cli([
29814 "tsift",
29815 "session-review",
29816 "tasks/software/tsift.md",
29817 "--next-context",
29818 "--json",
29819 ]);
29820 match cli.command {
29821 Some(Commands::SessionReview {
29822 json,
29823 next_context,
29824 path,
29825 ..
29826 }) => {
29827 assert!(json);
29828 assert!(next_context);
29829 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29830 }
29831 _ => panic!("expected SessionReview command"),
29832 }
29833 }
29834
29835 #[test]
29836 fn cli_search_accepts_budget_flags() {
29837 let cli = parse_cli([
29838 "tsift",
29839 "search",
29840 "alpha_helper",
29841 "--max-items",
29842 "3",
29843 "--max-bytes",
29844 "96",
29845 ]);
29846 match cli.command {
29847 Some(Commands::Search {
29848 max_items,
29849 max_bytes,
29850 ..
29851 }) => {
29852 assert_eq!(max_items, Some(3));
29853 assert_eq!(max_bytes, Some(96));
29854 }
29855 _ => panic!("expected Search command"),
29856 }
29857 }
29858
29859 #[test]
29860 fn cli_search_accepts_budget_preset() {
29861 let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
29862 match cli.command {
29863 Some(Commands::Search { budget, .. }) => {
29864 assert_eq!(budget, Some(ResponseBudgetPreset::Small));
29865 }
29866 _ => panic!("expected Search command"),
29867 }
29868 }
29869
29870 #[test]
29871 fn cli_search_accepts_ast_facet_filters() {
29872 let cli = parse_cli([
29873 "tsift",
29874 "search",
29875 "setup",
29876 "--lang",
29877 "markdown",
29878 "--kind",
29879 "list_item",
29880 "--node-kind",
29881 "list_item",
29882 "--section",
29883 "Install",
29884 "--parent",
29885 "Run setup.",
29886 "--child",
29887 "Confirm setup.",
29888 "--fence-language",
29889 "rust",
29890 "--list-depth",
29891 "1",
29892 "--heading-level",
29893 "2",
29894 ]);
29895 match cli.command {
29896 Some(Commands::Search {
29897 lang,
29898 kind,
29899 node_kind,
29900 section,
29901 parent,
29902 child,
29903 fence_language,
29904 list_depth,
29905 heading_level,
29906 ..
29907 }) => {
29908 assert_eq!(lang, vec!["markdown"]);
29909 assert_eq!(kind, vec!["list_item"]);
29910 assert_eq!(node_kind, vec!["list_item"]);
29911 assert_eq!(section, vec!["Install"]);
29912 assert_eq!(parent, vec!["Run setup."]);
29913 assert_eq!(child, vec!["Confirm setup."]);
29914 assert_eq!(fence_language, vec!["rust"]);
29915 assert_eq!(list_depth, vec![1]);
29916 assert_eq!(heading_level, vec![2]);
29917 }
29918 _ => panic!("expected Search command"),
29919 }
29920 }
29921
29922 #[test]
29923 fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
29924 let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
29925 assert_eq!(small.preview_items(), 3);
29926 assert_eq!(small.preview_bytes(), 120);
29927 assert_eq!(small.follow_up_items(), 4);
29928
29929 let overridden =
29930 ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
29931 assert_eq!(overridden.preview_items(), 7);
29932 assert_eq!(overridden.preview_bytes(), 120);
29933 assert_eq!(overridden.follow_up_items(), 7);
29934
29935 let envelope_default = ResponseBudget::from_cli(None, None, None, true);
29936 assert!(envelope_default.is_active());
29937 }
29938
29939 #[test]
29940 fn cli_explain_accepts_budget_flags() {
29941 let cli = parse_cli([
29942 "tsift",
29943 "explain",
29944 "alpha_helper",
29945 "--max-items",
29946 "2",
29947 "--max-bytes",
29948 "80",
29949 ]);
29950 match cli.command {
29951 Some(Commands::Explain {
29952 max_items,
29953 max_bytes,
29954 ..
29955 }) => {
29956 assert_eq!(max_items, Some(2));
29957 assert_eq!(max_bytes, Some(80));
29958 }
29959 _ => panic!("expected Explain command"),
29960 }
29961 }
29962
29963 #[test]
29964 fn cli_session_review_accepts_budget_flags() {
29965 let cli = parse_cli([
29966 "tsift",
29967 "session-review",
29968 "tasks/software/tsift.md",
29969 "--max-items",
29970 "4",
29971 "--max-bytes",
29972 "120",
29973 ]);
29974 match cli.command {
29975 Some(Commands::SessionReview {
29976 max_items,
29977 max_bytes,
29978 ..
29979 }) => {
29980 assert_eq!(max_items, Some(4));
29981 assert_eq!(max_bytes, Some(120));
29982 }
29983 _ => panic!("expected SessionReview command"),
29984 }
29985 }
29986
29987 #[test]
29988 fn cli_parses_context_pack_command() {
29989 let cli = parse_cli([
29990 "tsift",
29991 "context-pack",
29992 "tasks/software/tsift.md",
29993 "--test-input",
29994 "target/test.log",
29995 "--runner",
29996 "cargo",
29997 "--log-input",
29998 "target/build.log",
29999 "--max-items",
30000 "3",
30001 "--max-bytes",
30002 "96",
30003 "--json",
30004 ]);
30005 match cli.command {
30006 Some(Commands::ContextPack {
30007 path,
30008 test_input,
30009 runner,
30010 log_input,
30011 json,
30012 max_items,
30013 max_bytes,
30014 budget,
30015 convex_snapshot,
30016 }) => {
30017 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
30018 assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
30019 assert_eq!(runner.as_deref(), Some("cargo"));
30020 assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
30021 assert!(json);
30022 assert_eq!(max_items, Some(3));
30023 assert_eq!(max_bytes, Some(96));
30024 assert!(budget.is_none());
30025 assert!(convex_snapshot.is_none());
30026 }
30027 _ => panic!("expected ContextPack command"),
30028 }
30029 }
30030
30031 #[test]
30032 fn cli_parses_token_savings_command() {
30033 let cli = parse_cli([
30034 "tsift",
30035 "token-savings",
30036 "--fixture",
30037 "fixtures/tsift-token-savings.json",
30038 "--fail-under",
30039 "--json",
30040 ]);
30041 match cli.command {
30042 Some(Commands::TokenSavings {
30043 fixture,
30044 fail_under,
30045 json,
30046 }) => {
30047 assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
30048 assert!(fail_under);
30049 assert!(json);
30050 }
30051 _ => panic!("expected TokenSavings command"),
30052 }
30053 }
30054
30055 #[test]
30056 fn token_savings_report_records_fixture_thresholds() {
30057 let raw_symbols = [
30058 "validate_user",
30059 "validateUser",
30060 "ValidateUser",
30061 "validate-user",
30062 "VALIDATE_USER",
30063 "Validate_User",
30064 "raw_symbol",
30065 "rawSymbol",
30066 "RawSymbol",
30067 "raw-symbol",
30068 "RAW_SYMBOL",
30069 "Raw_Symbol",
30070 ]
30071 .iter()
30072 .enumerate()
30073 .map(|(idx, identifier)| TokenSavingsRawSymbol {
30074 identifier: (*identifier).to_string(),
30075 file: format!("src/example_{idx}.rs"),
30076 line: (idx + 1) as u64,
30077 context: "function".to_string(),
30078 })
30079 .collect();
30080 let fixture = TokenSavingsFixture {
30081 schema_version: 1,
30082 description: "fixture".to_string(),
30083 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30084 cases: vec![TokenSavingsFixtureCase {
30085 name: "search-preview".to_string(),
30086 surface: "search".to_string(),
30087 minimum_savings_percent: 40.0,
30088 raw_symbols,
30089 tagpath_families: vec![
30090 TokenSavingsFamily {
30091 canonical: "validate_user".to_string(),
30092 count: 6,
30093 aliases: BTreeMap::new(),
30094 },
30095 TokenSavingsFamily {
30096 canonical: "raw_symbol".to_string(),
30097 count: 6,
30098 aliases: BTreeMap::new(),
30099 },
30100 ],
30101 context_pack_inputs: None,
30102 session_review_inputs: None,
30103 source_read_inputs: None,
30104 markdown_projection_inputs: None,
30105 }],
30106 };
30107
30108 let report = build_token_savings_report(&fixture).unwrap();
30109
30110 assert!(report.pass);
30111 assert_eq!(report.cases[0].raw_symbol_count, 12);
30112 assert_eq!(report.cases[0].family_count, 2);
30113 assert_eq!(report.cases[0].status, "pass");
30114 assert!(report.cases[0].byte_delta > 0);
30115 assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
30116 assert!(report.cases[0].savings_percent >= 40.0);
30117 }
30118
30119 #[test]
30120 fn token_savings_source_read_inputs_preserve_required_anchors() {
30121 let fixture = TokenSavingsFixture {
30122 schema_version: 1,
30123 description: "fixture".to_string(),
30124 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30125 cases: vec![TokenSavingsFixtureCase {
30126 name: "source-read".to_string(),
30127 surface: "source-read".to_string(),
30128 minimum_savings_percent: 40.0,
30129 raw_symbols: Vec::new(),
30130 tagpath_families: Vec::new(),
30131 context_pack_inputs: None,
30132 session_review_inputs: None,
30133 source_read_inputs: Some(TokenSavingsSourceReadInputs {
30134 reads: vec![TokenSavingsSourceReadInput {
30135 command: "sed -n '40,160p' src/main.rs".to_string(),
30136 file: "src/main.rs".to_string(),
30137 raw_start: 40,
30138 raw_lines: 121,
30139 raw_excerpt: "line 40\n".repeat(121),
30140 envelope_start: 40,
30141 envelope_lines: 121,
30142 required_line_anchors: vec![40, 120, 160],
30143 }],
30144 }),
30145 markdown_projection_inputs: None,
30146 }],
30147 };
30148
30149 let report = build_token_savings_report(&fixture).unwrap();
30150
30151 assert!(report.pass);
30152 assert_eq!(report.cases[0].surface, "source-read");
30153 assert!(report.cases[0].savings_percent >= 40.0);
30154 }
30155
30156 #[test]
30157 fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
30158 let fixture = TokenSavingsFixture {
30159 schema_version: 1,
30160 description: "fixture".to_string(),
30161 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30162 cases: vec![TokenSavingsFixtureCase {
30163 name: "source-read".to_string(),
30164 surface: "source-read".to_string(),
30165 minimum_savings_percent: 40.0,
30166 raw_symbols: Vec::new(),
30167 tagpath_families: Vec::new(),
30168 context_pack_inputs: None,
30169 session_review_inputs: None,
30170 source_read_inputs: Some(TokenSavingsSourceReadInputs {
30171 reads: vec![TokenSavingsSourceReadInput {
30172 command: "cat src/main.rs".to_string(),
30173 file: "src/main.rs".to_string(),
30174 raw_start: 1,
30175 raw_lines: 200,
30176 raw_excerpt: "line\n".repeat(200),
30177 envelope_start: 1,
30178 envelope_lines: 80,
30179 required_line_anchors: vec![120],
30180 }],
30181 }),
30182 markdown_projection_inputs: None,
30183 }],
30184 };
30185
30186 let err = match build_token_savings_report(&fixture) {
30187 Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
30188 Err(err) => err,
30189 };
30190
30191 assert!(err.to_string().contains("hides required line anchor 120"));
30192 }
30193
30194 #[test]
30195 fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
30196 let fixture = TokenSavingsFixture {
30197 schema_version: 1,
30198 description: "fixture".to_string(),
30199 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30200 cases: vec![TokenSavingsFixtureCase {
30201 name: "markdown-projection".to_string(),
30202 surface: "context-pack".to_string(),
30203 minimum_savings_percent: 40.0,
30204 raw_symbols: Vec::new(),
30205 tagpath_families: Vec::new(),
30206 context_pack_inputs: None,
30207 session_review_inputs: None,
30208 source_read_inputs: None,
30209 markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
30210 documents: vec![TokenSavingsMarkdownProjectionInput {
30211 command: "context-pack markdown body".to_string(),
30212 file: "tasks/software/tsift.md".to_string(),
30213 raw_markdown: "# Heading\n\n".repeat(120),
30214 outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
30215 selected_nodes: vec!["mdast-selected".to_string()],
30216 expand:
30217 "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
30218 .to_string(),
30219 }],
30220 }),
30221 }],
30222 };
30223
30224 let report = build_token_savings_report(&fixture).unwrap();
30225
30226 assert!(report.pass);
30227 assert_eq!(report.cases[0].surface, "context-pack");
30228 assert!(report.cases[0].savings_percent >= 40.0);
30229 }
30230
30231 #[test]
30232 fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
30233 let mut content = String::from("# Cache Root\n\n");
30234 for idx in 0..96 {
30235 content.push_str(&format!(
30236 "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
30237 ));
30238 }
30239
30240 let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30241 assert!(!first.cache_hit);
30242 assert!(first.nodes.len() > 200);
30243
30244 let sections = markdown_section_spans(&content).unwrap();
30245 let list_items = markdown_block_spans(&content, "list_item").unwrap();
30246 let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
30247 let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30248
30249 assert!(second.cache_hit);
30250 assert_eq!(second.nodes.len(), first.nodes.len());
30251 assert_eq!(sections.len(), 97);
30252 assert_eq!(list_items.len(), 96);
30253 assert_eq!(code_blocks.len(), 96);
30254 let first_code = first
30255 .nodes
30256 .iter()
30257 .find(|node| node.kind == "code_block")
30258 .expect("expected a Markdown code block");
30259 let first_code_node = markdown_ast_node(
30260 Path::new("/repo"),
30261 "semantic-edit",
30262 first_code,
30263 content.as_bytes(),
30264 &first.nodes,
30265 8,
30266 );
30267 assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
30268 assert_eq!(
30269 first_code_node.metadata.embedded_symbols[0].name,
30270 "sample_0"
30271 );
30272 assert_eq!(
30273 first_code_node.metadata.embedded_symbols[0].language,
30274 "rust"
30275 );
30276 }
30277
30278 #[test]
30279 fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
30280 let response = empty_search_response(Path::new("/repo"), "lexical");
30281 let symbol_hits = vec![index::SymbolHit {
30282 name: "alpha_helper_with_a_long_name".to_string(),
30283 kind: "function".to_string(),
30284 language: "rust".to_string(),
30285 file: "/repo/src/lib.rs".to_string(),
30286 line: 12,
30287 end_line: None,
30288 node_kind: None,
30289 start_byte: None,
30290 end_byte: None,
30291 body_start_byte: None,
30292 body_end_byte: None,
30293 tags: None,
30294 score: 0.98,
30295 match_type: "exact_name".to_string(),
30296 tagpath_handle: None,
30297 }];
30298
30299 let report = build_relative_search_budget_report(
30300 "alpha_helper_with_a_long_name",
30301 "lexical",
30302 Path::new("/repo"),
30303 &response,
30304 &symbol_hits,
30305 ResponseBudget::new(Some(1), Some(12)),
30306 &SearchFacetFilters::default(),
30307 );
30308
30309 assert_eq!(report.symbols.len(), 1);
30310 assert!(report.symbols[0].handle.starts_with("sfam-"));
30311 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
30312 assert_eq!(report.symbols[0].name, "alpha_hel...");
30313 assert_eq!(report.symbols[0].file, "src/lib.rs");
30314 assert!(report.symbols[0].expand.contains("tsift search"));
30315 }
30316
30317 #[test]
30318 fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
30319 let dir = tempfile::tempdir().unwrap();
30320 let src_dir = dir.path().join("src");
30321 fs::create_dir_all(&src_dir).unwrap();
30322 let source = "fn alpha_helper() {\n beta();\n}\n";
30323 let file = src_dir.join("lib.rs");
30324 fs::write(&file, source).unwrap();
30325 let body_start = source.find("{\n").unwrap() + 1;
30326 let body_end = source.rfind("\n}").unwrap() + 1;
30327
30328 let response = empty_search_response(dir.path(), "lexical");
30329 let symbol_hits = vec![index::SymbolHit {
30330 name: "alpha_helper".to_string(),
30331 kind: "function".to_string(),
30332 language: "rust".to_string(),
30333 file: file.to_string_lossy().to_string(),
30334 line: 0,
30335 end_line: Some(2),
30336 node_kind: Some("function_item".to_string()),
30337 start_byte: Some(0),
30338 end_byte: Some(i64::try_from(source.len()).unwrap()),
30339 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30340 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30341 tags: Some("alpha,helper".to_string()),
30342 score: 0.98,
30343 match_type: "exact_name".to_string(),
30344 tagpath_handle: None,
30345 }];
30346
30347 let report = build_relative_search_budget_report(
30348 "alpha helper",
30349 "lexical",
30350 dir.path(),
30351 &response,
30352 &symbol_hits,
30353 ResponseBudget::new(Some(5), Some(96)),
30354 &SearchFacetFilters::default(),
30355 );
30356
30357 let symbol = &report.symbols[0];
30358 assert_eq!(symbol.language, "rust");
30359 assert_eq!(symbol.end_line, Some(2));
30360 let ast = symbol
30361 .ast
30362 .as_ref()
30363 .expect("search symbol preview should expose an AST span artifact");
30364 assert_eq!(ast.artifact_kind, "ast_span");
30365 assert!(ast.span.handle.starts_with("span-"));
30366 assert_eq!(ast.span.node_kind, "function_item");
30367 assert_eq!(ast.span.start_byte, 0);
30368 assert_eq!(ast.span.end_byte, source.len());
30369 assert_eq!(ast.span.body_start_byte, Some(body_start));
30370 assert_eq!(ast.span.body_end_byte, Some(body_end));
30371 assert!(ast.expand.source_window.contains("source-read"));
30372 assert!(
30373 ast.expand
30374 .source_body
30375 .as_ref()
30376 .unwrap()
30377 .contains("source-read")
30378 );
30379 assert!(ast.expand.symbol_read.contains("symbol-read"));
30380 assert!(ast.expand.markdown_ast.is_none());
30381 }
30382
30383 #[test]
30384 fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
30385 let dir = tempfile::tempdir().unwrap();
30386 let source = "# Guide\n\n## Install\n\n- Run setup.\n";
30387 let file = dir.path().join("README.md");
30388 fs::write(&file, source).unwrap();
30389 let heading_start = source.find("## Install").unwrap();
30390 let heading_end = source.len();
30391
30392 let response = empty_search_response(dir.path(), "lexical");
30393 let symbol_hits = vec![index::SymbolHit {
30394 name: "Install".to_string(),
30395 kind: "heading".to_string(),
30396 language: "markdown".to_string(),
30397 file: file.to_string_lossy().to_string(),
30398 line: 2,
30399 end_line: Some(4),
30400 node_kind: Some("atx_heading".to_string()),
30401 start_byte: Some(i64::try_from(heading_start).unwrap()),
30402 end_byte: Some(i64::try_from(heading_end).unwrap()),
30403 body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
30404 body_end_byte: Some(i64::try_from(heading_end).unwrap()),
30405 tags: Some("install".to_string()),
30406 score: 1.0,
30407 match_type: "exact_name".to_string(),
30408 tagpath_handle: None,
30409 }];
30410
30411 let report = build_relative_search_budget_report(
30412 "Install",
30413 "lexical",
30414 dir.path(),
30415 &response,
30416 &symbol_hits,
30417 ResponseBudget::new(Some(5), Some(96)),
30418 &SearchFacetFilters::default(),
30419 );
30420
30421 let ast = report.symbols[0]
30422 .ast
30423 .as_ref()
30424 .expect("Markdown search symbol should expose an AST span artifact");
30425 assert_eq!(ast.span.node_kind, "atx_heading");
30426 assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
30427 let markdown_ast = ast
30428 .expand
30429 .markdown_ast
30430 .as_ref()
30431 .expect("Markdown symbols should include markdown-ast expansion");
30432 assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
30433 assert!(markdown_ast.contains("--node"), "{markdown_ast}");
30434 assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
30435 assert!(ast.expand.source_window.contains("source-read"));
30436 assert!(ast.expand.symbol_read.contains("symbol-read"));
30437 }
30438
30439 #[test]
30440 fn search_budget_report_exposes_markdown_embedded_code_symbols() {
30441 let dir = tempfile::tempdir().unwrap();
30442 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30443 let file = dir.path().join("README.md");
30444 fs::write(&file, source).unwrap();
30445 let fence_start = source.find("```rust").unwrap();
30446 let body_start = source.find("fn sample").unwrap();
30447 let body_end = body_start + "fn sample() {}\n".len();
30448
30449 let response = empty_search_response(dir.path(), "lexical");
30450 let symbol_hits = vec![index::SymbolHit {
30451 name: "rust".to_string(),
30452 kind: "code_block".to_string(),
30453 language: "markdown".to_string(),
30454 file: file.to_string_lossy().to_string(),
30455 line: 2,
30456 end_line: Some(4),
30457 node_kind: Some("fenced_code_block".to_string()),
30458 start_byte: Some(i64::try_from(fence_start).unwrap()),
30459 end_byte: Some(i64::try_from(source.len()).unwrap()),
30460 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30461 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30462 tags: Some("rust".to_string()),
30463 score: 1.0,
30464 match_type: "exact_name".to_string(),
30465 tagpath_handle: None,
30466 }];
30467
30468 let report = build_relative_search_budget_report(
30469 "rust",
30470 "lexical",
30471 dir.path(),
30472 &response,
30473 &symbol_hits,
30474 ResponseBudget::new(Some(5), Some(96)),
30475 &SearchFacetFilters::default(),
30476 );
30477
30478 let embedded = &report.symbols[0]
30479 .ast
30480 .as_ref()
30481 .unwrap()
30482 .span
30483 .markdown
30484 .as_ref()
30485 .unwrap()
30486 .embedded_symbols;
30487 assert_eq!(embedded.len(), 1);
30488 assert_eq!(embedded[0].name, "sample");
30489 assert_eq!(embedded[0].kind, "function");
30490 assert_eq!(embedded[0].language, "rust");
30491 assert_eq!(embedded[0].node_kind, "function_item");
30492 assert!(embedded[0].handle.starts_with("span-"));
30493 assert_eq!(embedded[0].start_byte, body_start);
30494 assert_eq!(embedded[0].start_line, 4);
30495 }
30496
30497 fn test_lexical_search_hit(
30498 path: &Path,
30499 rank: usize,
30500 score: f64,
30501 snippet: &str,
30502 ) -> sift::SearchHit {
30503 sift::SearchHit {
30504 artifact_id: format!("hit-{rank}"),
30505 artifact_kind: sift::ContextArtifactKind::File,
30506 budget: sift::ArtifactBudget::from_text(snippet, 1),
30507 confidence: sift::ScoreConfidence::High,
30508 freshness: sift::ArtifactFreshness {
30509 modified_unix_secs: None,
30510 observed_unix_secs: 0,
30511 },
30512 location: Some("line 1".to_string()),
30513 path: path.to_string_lossy().to_string(),
30514 provenance: sift::ArtifactProvenance {
30515 adapter: sift::AcquisitionAdapterKind::FileSystem,
30516 source: "test lexical hit".to_string(),
30517 synthetic: false,
30518 },
30519 rank,
30520 score,
30521 snippet: snippet.to_string(),
30522 }
30523 }
30524
30525 fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
30526 summarize::Summary {
30527 id: 0,
30528 symbol_name: symbol_name.to_string(),
30529 file_path: file_path.to_string(),
30530 content_hash: "hash".to_string(),
30531 summary: summary.to_string(),
30532 entities: None,
30533 relationships: None,
30534 concept_labels: None,
30535 extracted_at: "2026-06-02T00:00:00Z".to_string(),
30536 model: "test".to_string(),
30537 tokens_input: None,
30538 tokens_output: None,
30539 }
30540 }
30541
30542 #[test]
30543 fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
30544 let dir = tempfile::tempdir().unwrap();
30545 let src_dir = dir.path().join("src");
30546 fs::create_dir_all(&src_dir).unwrap();
30547 let source = "fn alpha_helper() {}\n";
30548 let file = src_dir.join("lib.rs");
30549 let broad_file = dir.path().join("README.md");
30550 fs::write(&file, source).unwrap();
30551 fs::write(
30552 &broad_file,
30553 "alpha helper alpha helper alpha helper in prose\n",
30554 )
30555 .unwrap();
30556
30557 let mut response = empty_search_response(dir.path(), "lexical");
30558 response.hits.push(test_lexical_search_hit(
30559 &broad_file,
30560 1,
30561 240.0,
30562 "alpha helper alpha helper alpha helper in prose",
30563 ));
30564 let symbol_hits = vec![index::SymbolHit {
30565 name: "alpha_helper".to_string(),
30566 kind: "function".to_string(),
30567 language: "rust".to_string(),
30568 file: file.to_string_lossy().to_string(),
30569 line: 0,
30570 end_line: Some(0),
30571 node_kind: Some("function_item".to_string()),
30572 start_byte: Some(0),
30573 end_byte: Some(i64::try_from(source.len()).unwrap()),
30574 body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30575 body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30576 tags: Some("alpha,helper".to_string()),
30577 score: 0.8,
30578 match_type: "all_tags".to_string(),
30579 tagpath_handle: None,
30580 }];
30581
30582 let report = build_relative_search_budget_report(
30583 "alpha helper",
30584 "lexical",
30585 dir.path(),
30586 &response,
30587 &symbol_hits,
30588 ResponseBudget::new(Some(5), Some(128)),
30589 &SearchFacetFilters::default(),
30590 );
30591
30592 assert_eq!(report.ranked[0].source, "symbol_span");
30593 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30594 assert!(report.ranked[0].score > report.ranked[1].score);
30595 assert_eq!(report.ranked[1].source, "lexical_file");
30596 }
30597
30598 #[test]
30599 fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
30600 let dir = tempfile::tempdir().unwrap();
30601 let src_dir = dir.path().join("src");
30602 fs::create_dir_all(&src_dir).unwrap();
30603 let source = "fn alpha_helper() {\n let needle = \"needle\";\n}\n\nfn other() {}\n";
30604 let file = src_dir.join("lib.rs");
30605 fs::write(&file, source).unwrap();
30606
30607 let mut response = empty_search_response(dir.path(), "exact");
30608 let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
30609 hit.location = Some("line 2".to_string());
30610 response.hits.push(hit);
30611
30612 let symbol_hits = vec![index::SymbolHit {
30613 name: "alpha_helper".to_string(),
30614 kind: "function".to_string(),
30615 language: "rust".to_string(),
30616 file: file.to_string_lossy().to_string(),
30617 line: 0,
30618 end_line: Some(2),
30619 node_kind: Some("function_item".to_string()),
30620 start_byte: Some(0),
30621 end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
30622 body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
30623 body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
30624 tags: Some("alpha,helper".to_string()),
30625 score: 0.9,
30626 match_type: "all_tags".to_string(),
30627 tagpath_handle: None,
30628 }];
30629
30630 let report = build_relative_search_budget_report(
30631 "needle",
30632 "exact",
30633 dir.path(),
30634 &response,
30635 &symbol_hits,
30636 ResponseBudget::new(Some(5), Some(128)),
30637 &SearchFacetFilters::default(),
30638 );
30639
30640 let hit = &report.hits[0];
30641 assert_eq!(hit.line, Some(2));
30642 let source_handle = hit
30643 .source_handle
30644 .as_ref()
30645 .expect("exact hit should expose a bounded source_handle window");
30646 assert!(source_handle.handle.starts_with("xwin-"));
30647 assert_eq!(source_handle.kind, "source_handle");
30648 assert_eq!(source_handle.file, "src/lib.rs");
30649 assert_eq!(source_handle.start_line, 1);
30650 assert_eq!(source_handle.end_line, 3);
30651 assert!(source_handle.expand.contains("source-read"));
30652
30653 let containing_symbol = hit
30654 .containing_symbol
30655 .as_ref()
30656 .expect("exact hit should expose its containing symbol when indexed");
30657 assert_eq!(containing_symbol.name, "alpha_helper");
30658 assert_eq!(containing_symbol.kind, "function");
30659 assert_eq!(containing_symbol.line, 1);
30660 assert_eq!(containing_symbol.end_line, Some(3));
30661 assert!(containing_symbol.expand.contains("symbol-read"));
30662
30663 let lexical_rank = report
30664 .ranked
30665 .iter()
30666 .find(|item| item.source == "lexical_file")
30667 .expect("ranked preview should retain the lexical retrieval handle");
30668 assert!(
30669 lexical_rank
30670 .reasons
30671 .iter()
30672 .any(|reason| reason == "source_handle")
30673 );
30674 assert!(
30675 lexical_rank
30676 .reasons
30677 .iter()
30678 .any(|reason| reason == "containing_symbol")
30679 );
30680 }
30681
30682 #[test]
30683 fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
30684 let dir = tempfile::tempdir().unwrap();
30685 let src_dir = dir.path().join("src");
30686 let tests_dir = dir.path().join("tests");
30687 fs::create_dir_all(&src_dir).unwrap();
30688 fs::create_dir_all(&tests_dir).unwrap();
30689 let source_file = src_dir.join("lib.rs");
30690 let test_file = tests_dir.join("alpha_test.rs");
30691 fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
30692 fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
30693
30694 let response = empty_search_response(dir.path(), "lexical");
30695 let symbol_hits = vec![
30696 index::SymbolHit {
30697 name: "alpha_helper_test".to_string(),
30698 kind: "function".to_string(),
30699 language: "rust".to_string(),
30700 file: test_file.to_string_lossy().to_string(),
30701 line: 1,
30702 end_line: Some(1),
30703 node_kind: Some("function_item".to_string()),
30704 start_byte: Some(8),
30705 end_byte: Some(33),
30706 body_start_byte: Some(31),
30707 body_end_byte: Some(31),
30708 tags: Some("alpha,helper,test".to_string()),
30709 score: 1.0,
30710 match_type: "exact_name".to_string(),
30711 tagpath_handle: None,
30712 },
30713 index::SymbolHit {
30714 name: "alpha_helper".to_string(),
30715 kind: "function".to_string(),
30716 language: "rust".to_string(),
30717 file: source_file.to_string_lossy().to_string(),
30718 line: 0,
30719 end_line: Some(0),
30720 node_kind: Some("function_item".to_string()),
30721 start_byte: Some(0),
30722 end_byte: Some(20),
30723 body_start_byte: Some(18),
30724 body_end_byte: Some(18),
30725 tags: Some("alpha,helper".to_string()),
30726 score: 0.78,
30727 match_type: "all_tags".to_string(),
30728 tagpath_handle: None,
30729 },
30730 ];
30731
30732 let report = build_relative_search_budget_report(
30733 "alpha helper",
30734 "lexical",
30735 dir.path(),
30736 &response,
30737 &symbol_hits,
30738 ResponseBudget::new(Some(5), Some(128)),
30739 &SearchFacetFilters::default(),
30740 );
30741
30742 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30743 assert_eq!(report.ranked[0].path, "src/lib.rs");
30744 assert!(
30745 report.ranked[0]
30746 .reasons
30747 .iter()
30748 .any(|reason| reason == "definition_kind")
30749 );
30750 assert!(
30751 report.ranked[0]
30752 .reasons
30753 .iter()
30754 .any(|reason| reason == "source_path")
30755 );
30756 let test_rank = report
30757 .ranked
30758 .iter()
30759 .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
30760 .expect("test symbol should still be present in the ranked preview");
30761 assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
30762 }
30763
30764 #[test]
30765 fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
30766 let dir = tempfile::tempdir().unwrap();
30767 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30768 let file = dir.path().join("README.md");
30769 fs::write(&file, source).unwrap();
30770 let summary_db =
30771 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
30772 summary_db
30773 .insert(&test_summary(
30774 "rust",
30775 "README.md",
30776 "Rust fence contains a sample function.",
30777 ))
30778 .unwrap();
30779
30780 let fence_start = source.find("```rust").unwrap();
30781 let body_start = source.find("fn sample").unwrap();
30782 let body_end = body_start + "fn sample() {}\n".len();
30783 let response = empty_search_response(dir.path(), "lexical");
30784 let symbol_hits = vec![index::SymbolHit {
30785 name: "rust".to_string(),
30786 kind: "code_block".to_string(),
30787 language: "markdown".to_string(),
30788 file: file.to_string_lossy().to_string(),
30789 line: 2,
30790 end_line: Some(4),
30791 node_kind: Some("fenced_code_block".to_string()),
30792 start_byte: Some(i64::try_from(fence_start).unwrap()),
30793 end_byte: Some(i64::try_from(source.len()).unwrap()),
30794 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30795 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30796 tags: Some("rust".to_string()),
30797 score: 1.0,
30798 match_type: "exact_name".to_string(),
30799 tagpath_handle: None,
30800 }];
30801
30802 let report = build_relative_search_budget_report(
30803 "rust",
30804 "lexical",
30805 dir.path(),
30806 &response,
30807 &symbol_hits,
30808 ResponseBudget::new(Some(5), Some(128)),
30809 &SearchFacetFilters::default(),
30810 );
30811
30812 let symbol = &report.symbols[0];
30813 assert_eq!(symbol.summary_refs, 1);
30814 assert_eq!(symbol.graph_neighbors, 1);
30815 assert!(
30816 report.ranked[0]
30817 .reasons
30818 .iter()
30819 .any(|reason| reason == "summary_refs:1")
30820 );
30821 assert!(
30822 report.ranked[0]
30823 .reasons
30824 .iter()
30825 .any(|reason| reason == "graph_neighbors:1")
30826 );
30827 }
30828
30829 fn markdown_search_facet_fixture() -> tempfile::TempDir {
30830 let dir = tempfile::tempdir().unwrap();
30831 let source = r#"# Guide
30832
30833## Install
30834
30835- Run setup.
30836 - Confirm setup.
30837
30838```rust
30839fn sample() {}
30840```
30841"#;
30842 fs::write(dir.path().join("README.md"), source).unwrap();
30843 let index_dir = dir.path().join(".tsift");
30844 fs::create_dir_all(&index_dir).unwrap();
30845 run_index_update(
30846 &index_dir.join("index.db"),
30847 dir.path(),
30848 "indexing markdown search facet fixture".to_string(),
30849 dir.path(),
30850 None,
30851 false,
30852 false,
30853 )
30854 .unwrap();
30855 dir
30856 }
30857
30858 fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
30859 let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
30860 db.symbol_search(query, 20).unwrap()
30861 }
30862
30863 #[test]
30864 fn search_facet_filters_match_scalar_symbol_fields() {
30865 let dir = tempfile::tempdir().unwrap();
30866 let hits = vec![
30867 index::SymbolHit {
30868 name: "alpha_helper".to_string(),
30869 kind: "function".to_string(),
30870 language: "rust".to_string(),
30871 file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
30872 line: 0,
30873 end_line: None,
30874 node_kind: Some("function_item".to_string()),
30875 start_byte: None,
30876 end_byte: None,
30877 body_start_byte: None,
30878 body_end_byte: None,
30879 tags: None,
30880 score: 1.0,
30881 match_type: "exact_name".to_string(),
30882 tagpath_handle: None,
30883 },
30884 index::SymbolHit {
30885 name: "Install".to_string(),
30886 kind: "heading".to_string(),
30887 language: "markdown".to_string(),
30888 file: dir.path().join("README.md").to_string_lossy().to_string(),
30889 line: 0,
30890 end_line: None,
30891 node_kind: Some("atx_heading".to_string()),
30892 start_byte: None,
30893 end_byte: None,
30894 body_start_byte: None,
30895 body_end_byte: None,
30896 tags: None,
30897 score: 0.9,
30898 match_type: "exact_name".to_string(),
30899 tagpath_handle: None,
30900 },
30901 ];
30902
30903 let filtered = apply_search_facet_filters(
30904 dir.path(),
30905 hits,
30906 &SearchFacetFilters {
30907 languages: vec!["rust".to_string()],
30908 kinds: vec!["function".to_string()],
30909 node_kinds: vec!["function_item".to_string()],
30910 ..SearchFacetFilters::default()
30911 },
30912 );
30913
30914 assert_eq!(filtered.len(), 1);
30915 assert_eq!(filtered[0].name, "alpha_helper");
30916 }
30917
30918 #[test]
30919 fn search_facet_filters_match_markdown_sections_and_block_metadata() {
30920 let dir = markdown_search_facet_fixture();
30921
30922 let nested_list = apply_search_facet_filters(
30923 dir.path(),
30924 markdown_search_facet_hits(dir.path(), "setup"),
30925 &SearchFacetFilters {
30926 sections: vec!["Install".to_string()],
30927 parents: vec!["Run setup.".to_string()],
30928 list_depths: vec![1],
30929 ..SearchFacetFilters::default()
30930 },
30931 );
30932 assert_eq!(nested_list.len(), 1);
30933 assert_eq!(nested_list[0].name, "Confirm setup.");
30934
30935 let parent_list = apply_search_facet_filters(
30936 dir.path(),
30937 markdown_search_facet_hits(dir.path(), "setup"),
30938 &SearchFacetFilters {
30939 children: vec!["Confirm setup.".to_string()],
30940 ..SearchFacetFilters::default()
30941 },
30942 );
30943 assert_eq!(parent_list.len(), 1);
30944 assert_eq!(parent_list[0].name, "Run setup.");
30945
30946 let heading = apply_search_facet_filters(
30947 dir.path(),
30948 markdown_search_facet_hits(dir.path(), "Install"),
30949 &SearchFacetFilters {
30950 heading_levels: vec![2],
30951 node_kinds: vec!["atx_heading".to_string()],
30952 ..SearchFacetFilters::default()
30953 },
30954 );
30955 assert_eq!(heading.len(), 1);
30956 assert_eq!(heading[0].name, "Install");
30957
30958 let fence = apply_search_facet_filters(
30959 dir.path(),
30960 markdown_search_facet_hits(dir.path(), "rust"),
30961 &SearchFacetFilters {
30962 fence_languages: vec!["rust".to_string()],
30963 kinds: vec!["code_block".to_string()],
30964 ..SearchFacetFilters::default()
30965 },
30966 );
30967 assert_eq!(fence.len(), 1);
30968 assert_eq!(fence[0].kind, "code_block");
30969
30970 let embedded_child = apply_search_facet_filters(
30971 dir.path(),
30972 markdown_search_facet_hits(dir.path(), "rust"),
30973 &SearchFacetFilters {
30974 children: vec!["sample".to_string()],
30975 kinds: vec!["code_block".to_string()],
30976 ..SearchFacetFilters::default()
30977 },
30978 );
30979 assert_eq!(embedded_child.len(), 1);
30980 assert_eq!(embedded_child[0].name, "rust");
30981 }
30982
30983 #[test]
30984 fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
30985 let response = empty_search_response(Path::new("/repo"), "lexical");
30986 let symbol_hits = vec![
30987 index::SymbolHit {
30988 name: "alpha_helper".to_string(),
30989 kind: "function".to_string(),
30990 language: "rust".to_string(),
30991 file: "/repo/src/lib.rs".to_string(),
30992 line: 12,
30993 end_line: None,
30994 node_kind: None,
30995 start_byte: None,
30996 end_byte: None,
30997 body_start_byte: None,
30998 body_end_byte: None,
30999 tags: Some("alpha,helper".to_string()),
31000 score: 0.98,
31001 match_type: "exact_name".to_string(),
31002 tagpath_handle: None,
31003 },
31004 index::SymbolHit {
31005 name: "alphaHelper".to_string(),
31006 kind: "method".to_string(),
31007 language: "rust".to_string(),
31008 file: "/repo/src/main.rs".to_string(),
31009 line: 34,
31010 end_line: None,
31011 node_kind: None,
31012 start_byte: None,
31013 end_byte: None,
31014 body_start_byte: None,
31015 body_end_byte: None,
31016 tags: Some("alpha,helper".to_string()),
31017 score: 0.93,
31018 match_type: "tag_overlap".to_string(),
31019 tagpath_handle: None,
31020 },
31021 index::SymbolHit {
31022 name: "alpha_helper".to_string(),
31023 kind: "function".to_string(),
31024 language: "rust".to_string(),
31025 file: "/repo/src/worker.rs".to_string(),
31026 line: 56,
31027 end_line: None,
31028 node_kind: None,
31029 start_byte: None,
31030 end_byte: None,
31031 body_start_byte: None,
31032 body_end_byte: None,
31033 tags: Some("alpha,helper".to_string()),
31034 score: 0.91,
31035 match_type: "tag_overlap".to_string(),
31036 tagpath_handle: None,
31037 },
31038 ];
31039
31040 let report = build_relative_search_budget_report(
31041 "alpha helper",
31042 "lexical",
31043 Path::new("/repo"),
31044 &response,
31045 &symbol_hits,
31046 ResponseBudget::new(Some(5), Some(48)),
31047 &SearchFacetFilters::default(),
31048 );
31049
31050 assert_eq!(report.symbol_total, 1);
31051 assert_eq!(report.raw_symbol_total, 3);
31052 assert_eq!(report.symbols.len(), 1);
31053 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
31054 assert_eq!(report.symbols[0].match_count, 3);
31055 assert_eq!(report.symbols[0].surface_count, 2);
31056 assert_eq!(report.symbols[0].file_count, 3);
31057 assert_eq!(
31058 report.symbols[0].surface_examples,
31059 vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
31060 );
31061 assert!(report.symbols[0].name.contains("(+1 variant)"));
31062 assert!(report.symbols[0].file.contains("(+2 files)"));
31063 assert!(report.symbols[0].expand.contains("tsift search"));
31064 assert!(report.symbols[0].expand.contains("alpha helper"));
31065 }
31066
31067 #[test]
31068 fn search_budget_report_carries_active_filters() {
31069 let response = empty_search_response(Path::new("/repo"), "lexical");
31070 let symbol_hits = vec![index::SymbolHit {
31071 name: "alpha_helper".to_string(),
31072 kind: "function".to_string(),
31073 language: "rust".to_string(),
31074 file: "/repo/src/lib.rs".to_string(),
31075 line: 12,
31076 end_line: None,
31077 node_kind: Some("function_item".to_string()),
31078 start_byte: None,
31079 end_byte: None,
31080 body_start_byte: None,
31081 body_end_byte: None,
31082 tags: Some("alpha,helper".to_string()),
31083 score: 0.98,
31084 match_type: "exact_name".to_string(),
31085 tagpath_handle: None,
31086 }];
31087 let filters = SearchFacetFilters {
31088 languages: vec!["rust".to_string()],
31089 kinds: vec!["function".to_string()],
31090 node_kinds: vec!["function_item".to_string()],
31091 ..SearchFacetFilters::default()
31092 };
31093
31094 let report = build_relative_search_budget_report(
31095 "alpha helper",
31096 "lexical",
31097 Path::new("/repo"),
31098 &response,
31099 &symbol_hits,
31100 ResponseBudget::new(Some(5), Some(48)),
31101 &filters,
31102 );
31103
31104 assert_eq!(report.filters, filters);
31105 assert_eq!(
31106 search_facet_filters_summary(&report.filters),
31107 "lang=rust kind=function node-kind=function_item"
31108 );
31109 }
31110
31111 #[test]
31112 fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
31113 let mut response = empty_search_response(Path::new("/repo"), "lexical");
31114 response.indexed_artifacts = 450;
31115 let symbol_hits = vec![
31116 index::SymbolHit {
31117 name: "alpha_helper".to_string(),
31118 kind: "function".to_string(),
31119 language: "rust".to_string(),
31120 file: "/repo/src/lib.rs".to_string(),
31121 line: 12,
31122 end_line: None,
31123 node_kind: None,
31124 start_byte: None,
31125 end_byte: None,
31126 body_start_byte: None,
31127 body_end_byte: None,
31128 tags: Some("alpha,helper".to_string()),
31129 score: 0.98,
31130 match_type: "exact_name".to_string(),
31131 tagpath_handle: None,
31132 },
31133 index::SymbolHit {
31134 name: "beta_helper".to_string(),
31135 kind: "function".to_string(),
31136 language: "rust".to_string(),
31137 file: "/repo/src/beta.rs".to_string(),
31138 line: 21,
31139 end_line: None,
31140 node_kind: None,
31141 start_byte: None,
31142 end_byte: None,
31143 body_start_byte: None,
31144 body_end_byte: None,
31145 tags: Some("beta,helper".to_string()),
31146 score: 0.92,
31147 match_type: "tag_overlap".to_string(),
31148 tagpath_handle: None,
31149 },
31150 ];
31151
31152 let report = build_relative_search_budget_report(
31153 "helper",
31154 "lexical",
31155 Path::new("/repo"),
31156 &response,
31157 &symbol_hits,
31158 ResponseBudget::new(Some(1), Some(64)),
31159 &SearchFacetFilters::default(),
31160 );
31161
31162 let guard = report
31163 .scale_guard
31164 .as_ref()
31165 .expect("broad previews should emit a scale guard");
31166 assert_eq!(guard.level, "high-hit");
31167 assert_eq!(guard.signals.indexed_artifacts, 450);
31168 assert_eq!(guard.signals.raw_symbol_matches, 2);
31169 assert!(
31170 guard
31171 .narrow_commands
31172 .iter()
31173 .any(|command| command.contains("--exact"))
31174 );
31175 assert!(
31176 guard
31177 .narrow_commands
31178 .iter()
31179 .any(|command| command.contains("alpha helper"))
31180 );
31181 assert!(
31182 guard
31183 .narrow_commands
31184 .last()
31185 .unwrap()
31186 .contains("workflow search")
31187 );
31188 }
31189
31190 #[test]
31191 fn explain_budget_report_limits_edges_and_members() {
31192 let symbols = vec![index::StoredSymbol {
31193 name: "alpha_helper".to_string(),
31194 kind: "function".to_string(),
31195 language: "rust".to_string(),
31196 signature: None,
31197 file: "src/lib.rs".to_string(),
31198 line: 10,
31199 end_line: None,
31200 node_kind: None,
31201 start_byte: None,
31202 end_byte: None,
31203 body_start_byte: None,
31204 body_end_byte: None,
31205 parent_module: None,
31206 visibility: None,
31207 tags: None,
31208 tagpath_handle: None,
31209 }];
31210 let callers = vec![
31211 index::StoredEdge {
31212 caller_file: "src/main.rs".to_string(),
31213 caller_name: "main".to_string(),
31214 caller_line: 1,
31215 callee_name: "alpha_helper".to_string(),
31216 call_site_line: 3,
31217 tagpath_handle: None,
31218 },
31219 index::StoredEdge {
31220 caller_file: "src/worker.rs".to_string(),
31221 caller_name: "worker".to_string(),
31222 caller_line: 5,
31223 callee_name: "alpha_helper".to_string(),
31224 call_site_line: 8,
31225 tagpath_handle: None,
31226 },
31227 ];
31228 let community = graph::Community {
31229 id: 1,
31230 members: vec![
31231 graph::CommunityMember::new("alpha_helper"),
31232 graph::CommunityMember::new("main"),
31233 graph::CommunityMember::new("worker"),
31234 ],
31235 modularity_contribution: 0.5,
31236 };
31237
31238 let report = build_explain_budget_report(
31239 "alpha_helper",
31240 Path::new("/repo"),
31241 &symbols,
31242 &callers,
31243 2,
31244 false,
31245 &[],
31246 0,
31247 false,
31248 Some(&community),
31249 ResponseBudget::new(Some(1), Some(24)),
31250 );
31251
31252 assert_eq!(report.definitions.len(), 1);
31253 assert_eq!(report.callers.len(), 1);
31254 assert!(report.truncated);
31255 assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
31256 assert_eq!(
31257 report.definitions[0].tag_alias.as_deref(),
31258 Some("alpha/helper")
31259 );
31260 assert!(report.callers[0].handle.starts_with("ecall-"));
31261 assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
31262 }
31263
31264 #[test]
31265 fn session_review_next_context_budget_limits_lists() {
31266 let report = session_review::SessionReviewReport {
31267 root: "/repo".to_string(),
31268 target: "tasks/software/tsift.md".to_string(),
31269 target_kind: "file".to_string(),
31270 sessions_considered: 1,
31271 sessions_matched: 1,
31272 claude_sessions: 1,
31273 codex_sessions: 0,
31274 agent_doc_logs: 0,
31275 prompt_target_count: 2,
31276 command_groups: 0,
31277 file_groups: 2,
31278 symbol_groups: 1,
31279 failure_groups: 1,
31280 runtime_event_groups: 0,
31281 restart_churn_groups: 0,
31282 closeout_groups: 0,
31283 usage_samples: 1,
31284 prompt_tokens: 120,
31285 cached_input_tokens: 80,
31286 cache_creation_input_tokens: 0,
31287 output_tokens: 40,
31288 reasoning_output_tokens: 0,
31289 total_tokens: 240,
31290 cached_input_ratio: Some(40.0),
31291 largest_turn_total_tokens: 240,
31292 aggregate_cost: session_review::SessionReviewCostSummary {
31293 scope: "bounded_matched_sessions".to_string(),
31294 sessions: 1,
31295 usage_samples: 1,
31296 prompt_tokens: 120,
31297 cached_input_tokens: 80,
31298 cache_creation_input_tokens: 0,
31299 output_tokens: 40,
31300 reasoning_output_tokens: 0,
31301 total_tokens: 240,
31302 cached_input_ratio: Some(40.0),
31303 largest_turn_total_tokens: 240,
31304 },
31305 latest_session_cost: Some(session_review::SessionReviewCostSummary {
31306 scope: "latest_matched_session".to_string(),
31307 sessions: 1,
31308 usage_samples: 1,
31309 prompt_tokens: 120,
31310 cached_input_tokens: 80,
31311 cache_creation_input_tokens: 0,
31312 output_tokens: 40,
31313 reasoning_output_tokens: 0,
31314 total_tokens: 240,
31315 cached_input_ratio: Some(66.67),
31316 largest_turn_total_tokens: 240,
31317 }),
31318 prompt_cache_cross_run: None,
31319 prompt_cache_roi_scorecard: vec![],
31320 guardrails: vec![
31321 session_cost::SessionCostGuardrail {
31322 kind: "cache_resend".to_string(),
31323 severity: "warn".to_string(),
31324 message: "cached input ratio was high".to_string(),
31325 guidance: "compact or restart the session".to_string(),
31326 },
31327 session_cost::SessionCostGuardrail {
31328 kind: "prompt_budget".to_string(),
31329 severity: "warn".to_string(),
31330 message: "largest prompt turn reached 999999 tokens".to_string(),
31331 guidance: "compact the session before another large turn".to_string(),
31332 },
31333 session_cost::SessionCostGuardrail {
31334 kind: "restart_loop".to_string(),
31335 severity: "warn".to_string(),
31336 message: "restart churn detected".to_string(),
31337 guidance: "restart cleanly".to_string(),
31338 },
31339 session_cost::SessionCostGuardrail {
31340 kind: "noop_closeout".to_string(),
31341 severity: "warn".to_string(),
31342 message: "commit_already_current appeared 8 times".to_string(),
31343 guidance: "avoid reopening without new edits".to_string(),
31344 },
31345 ],
31346 loop_clusters: vec![session_cost::SessionCostLoopCluster {
31347 kind: "command_bundle".to_string(),
31348 label: "cargo test -> cargo build --release".to_string(),
31349 occurrences: 2,
31350 max_consecutive: 2,
31351 }],
31352 file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
31353 path: "src/lib.rs".to_string(),
31354 range: "12-40".to_string(),
31355 occurrences: 3,
31356 estimated_tokens: 1200,
31357 duplicate_estimated_tokens: 800,
31358 follow_up_commands: vec![
31359 "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
31360 .to_string(),
31361 ],
31362 }],
31363 prompt_targets: vec![
31364 session_review::SessionReviewPromptTarget {
31365 text: "do one".to_string(),
31366 occurrences: 1,
31367 },
31368 session_review::SessionReviewPromptTarget {
31369 text: "do two".to_string(),
31370 occurrences: 1,
31371 },
31372 ],
31373 commands: vec![],
31374 touched_files: vec![],
31375 touched_symbols: vec![],
31376 failures: vec![],
31377 runtime_events: vec![],
31378 restart_churn: vec![],
31379 closeout: vec![],
31380 largest_turns: vec![],
31381 sessions: vec![session_review::SessionReviewSession {
31382 source: "claude_jsonl".to_string(),
31383 path: "/tmp/session.jsonl".to_string(),
31384 matched_by: vec!["path".to_string()],
31385 modified_unix_secs: None,
31386 prompt_target_count: 2,
31387 command_groups: 0,
31388 file_groups: 2,
31389 symbol_groups: 1,
31390 failure_groups: 1,
31391 runtime_event_groups: 0,
31392 restart_churn_groups: 0,
31393 closeout_groups: 0,
31394 usage_samples: 1,
31395 prompt_tokens: 120,
31396 cached_input_tokens: 80,
31397 cache_creation_input_tokens: 0,
31398 output_tokens: 40,
31399 reasoning_output_tokens: 0,
31400 total_tokens: 240,
31401 largest_turn_total_tokens: 240,
31402 }],
31403 next_context: session_review::SessionReviewNextContext {
31404 target: "tasks/software/tsift.md".to_string(),
31405 active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
31406 last_verification: session_review::SessionReviewVerificationState {
31407 status: "green".to_string(),
31408 detail: "cargo test".to_string(),
31409 },
31410 touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
31411 touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
31412 unresolved_failures: vec![session_review::SessionReviewFailure {
31413 kind: "timeout".to_string(),
31414 message: "search timed out".to_string(),
31415 occurrences: 1,
31416 command: None,
31417 session_path: None,
31418 }],
31419 agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
31420 active_queue_prompt: Some(
31421 "[#one] do one with enough detail to truncate".to_string(),
31422 ),
31423 live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
31424 backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
31425 review_rows: vec![
31426 "[#review] review one".to_string(),
31427 "[#review2] review two".to_string(),
31428 ],
31429 prompt_presets: vec![
31430 "#spec-test-build-install-commit-push: update spec + tests"
31431 .to_string(),
31432 "#next-steps: collect follow-ups".to_string(),
31433 ],
31434 expansion_handles: vec![
31435 session_review::SessionReviewAgentDocExpansionHandle {
31436 handle: "adq-next-context".to_string(),
31437 label: "refresh next-context".to_string(),
31438 expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
31439 },
31440 session_review::SessionReviewAgentDocExpansionHandle {
31441 handle: "adq-context-pack".to_string(),
31442 label: "refresh context-pack".to_string(),
31443 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
31444 },
31445 ],
31446 }),
31447 prompt_cache_health: None,
31448 next_digest_commands: vec![
31449 "tsift session-review --next-context tasks/software/tsift.md".to_string(),
31450 "tsift diff-digest .".to_string(),
31451 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
31452 "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
31453 ],
31454 },
31455 warnings: vec![],
31456 };
31457
31458 let budget_report = build_session_review_next_context_budget_report(
31459 &report,
31460 ResponseBudget::new(Some(1), Some(12)),
31461 None,
31462 );
31463
31464 assert!(budget_report.truncated);
31465 assert_eq!(budget_report.prompt_targets, vec!["do one"]);
31466 assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
31467 assert!(
31468 budget_report.touched_symbol_refs[0]
31469 .handle
31470 .starts_with("ncsym-")
31471 );
31472 assert_eq!(
31473 budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
31474 Some("alpha/helper")
31475 );
31476 assert!(
31477 budget_report.unresolved_failures[0]
31478 .handle
31479 .starts_with("snf-")
31480 );
31481 assert_eq!(budget_report.next_digest_commands.len(), 4);
31482 assert_eq!(
31483 budget_report.next_digest_commands[2],
31484 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
31485 );
31486 let queue = budget_report
31487 .agent_doc_queue
31488 .as_ref()
31489 .expect("agent-doc queue budget profile should be present");
31490 assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
31491 assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
31492 assert_eq!(queue.review_row_total, 2);
31493 assert_eq!(queue.prompt_presets.len(), 1);
31494 assert_eq!(queue.expansion_handles.len(), 2);
31495 assert!(queue.truncated);
31496 assert_eq!(budget_report.next_token_actions.len(), 1);
31497 assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
31498
31499 let full_action_report = build_session_review_next_context_budget_report(
31500 &report,
31501 ResponseBudget::new(Some(6), Some(120)),
31502 None,
31503 );
31504 assert_eq!(
31505 full_action_report
31506 .next_token_actions
31507 .iter()
31508 .map(|action| action.kind.as_str())
31509 .collect::<Vec<_>>(),
31510 vec![
31511 "prompt_budget",
31512 "cache_resend",
31513 "repeated_raw_read",
31514 "repeated_command_bundle",
31515 "restart_loop",
31516 "noop_closeout"
31517 ]
31518 );
31519 assert_eq!(
31520 full_action_report.next_token_actions[0]
31521 .compact_command
31522 .as_deref(),
31523 Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
31524 );
31525 assert_eq!(
31526 full_action_report.next_token_actions[0]
31527 .restart_command
31528 .as_deref(),
31529 Some("agent-doc start \"tasks/software/tsift.md\"")
31530 );
31531 assert!(
31532 full_action_report.next_token_actions[0]
31533 .digest_commands
31534 .iter()
31535 .any(|command| command
31536 == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
31537 );
31538 let raw_read_action = full_action_report
31539 .next_token_actions
31540 .iter()
31541 .find(|action| action.kind == "repeated_raw_read")
31542 .expect("raw read action");
31543 assert!(
31544 raw_read_action.rewrite_commands.iter().any(
31545 |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
31546 ),
31547 "raw read rewrite commands: {:?}",
31548 raw_read_action.rewrite_commands
31549 );
31550 assert!(raw_read_action.rewrite_commands.iter().any(|command| command
31551 == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
31552 let command_bundle_action = full_action_report
31553 .next_token_actions
31554 .iter()
31555 .find(|action| action.kind == "repeated_command_bundle")
31556 .expect("command bundle action");
31557 assert!(
31558 command_bundle_action
31559 .rewrite_commands
31560 .iter()
31561 .any(|command| command == "tsift rewrite --run \"cargo test\"")
31562 );
31563 assert!(
31564 command_bundle_action
31565 .rewrite_commands
31566 .iter()
31567 .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
31568 );
31569 }
31570
31571 #[test]
31572 fn context_pack_diff_preview_limits_files_and_symbols() {
31573 let report = diff_digest::DiffDigestReport {
31574 root: "/repo".to_string(),
31575 mode: diff_digest::DiffDigestMode::WorkingTree,
31576 revision: None,
31577 files_changed: 2,
31578 files_with_current_summaries: 1,
31579 symbols_touched: 3,
31580 call_edges_added: 1,
31581 call_edges_removed: 0,
31582 files: vec![
31583 diff_digest::DiffDigestFile {
31584 path: "src/lib.rs".to_string(),
31585 status: diff_digest::DiffDigestFileStatus::Modified,
31586 touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
31587 summary_state: diff_digest::DiffDigestSummaryState::Current,
31588 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31589 symbol: "alpha_helper".to_string(),
31590 summary: "alpha helper handles the main alpha workflow".to_string(),
31591 }],
31592 added_call_edges: vec!["alpha->beta".to_string()],
31593 removed_call_edges: vec![],
31594 warnings: vec!["stale parse".to_string()],
31595 },
31596 diff_digest::DiffDigestFile {
31597 path: "src/main.rs".to_string(),
31598 status: diff_digest::DiffDigestFileStatus::Added,
31599 touched_symbols: vec!["main".to_string()],
31600 summary_state: diff_digest::DiffDigestSummaryState::Missing,
31601 current_summaries: vec![],
31602 added_call_edges: vec![],
31603 removed_call_edges: vec![],
31604 warnings: vec![],
31605 },
31606 ],
31607 };
31608
31609 let preview =
31610 build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
31611
31612 assert!(preview.truncated);
31613 assert_eq!(preview.files.len(), 1);
31614 assert_eq!(preview.files[0].path, "src/lib.rs");
31615 assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
31616 assert!(
31617 preview.files[0].touched_symbol_refs[0]
31618 .handle
31619 .starts_with("cdsym-")
31620 );
31621 assert_eq!(
31622 preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
31623 Some("alpha/he...")
31624 );
31625 assert!(
31626 preview.files[0].summary_refs[0]
31627 .handle
31628 .starts_with("cdsum-")
31629 );
31630 assert_eq!(
31631 preview.files[0].summary_refs[0].tag_alias.as_deref(),
31632 Some("alpha/he...")
31633 );
31634 assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
31635 assert_eq!(
31636 preview.files[0].summary_refs[0].expand,
31637 "tsift summarize --file \"src/lib.rs\""
31638 );
31639 assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
31640 }
31641
31642 #[test]
31643 fn context_pack_status_reminders_include_stale_index_state() {
31644 let dir = setup_graph_index();
31645 std::thread::sleep(std::time::Duration::from_millis(50));
31646 std::fs::write(
31647 dir.path().join("main.rs"),
31648 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
31649 )
31650 .unwrap();
31651
31652 let reminders = context_pack_status_reminders(dir.path());
31653
31654 assert_eq!(reminders.len(), 1);
31655 assert!(reminders[0].contains("index stale"));
31656 assert!(reminders[0].contains("tsift index ."));
31657 }
31658
31659 #[test]
31666 fn build_context_pack_reuses_inspect_within_scope() {
31667 let dir = setup_graph_index();
31668 init_git_repo(dir.path());
31669 let _guard = index::InspectScopeGuard::new();
31670 let _ = build_context_pack_report(
31671 dir.path(),
31672 None,
31673 None,
31674 None,
31675 ResponseBudget::new(Some(2), Some(96)),
31676 )
31677 .unwrap();
31678 let (hits, misses) = index::inspect_scope_stats();
31679 assert!(
31680 hits >= 1,
31681 "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
31682 );
31683 assert!(
31684 misses >= 1,
31685 "expected at least one initial inspect miss (hits={hits}, misses={misses})"
31686 );
31687 }
31688
31689 #[test]
31694 fn inspect_read_only_outside_scope_does_not_cache() {
31695 let dir = setup_graph_index();
31696 let db_path = dir.path().join(".tsift/index.db");
31697 let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31698 let (hits, misses) = index::inspect_scope_stats();
31699 assert_eq!(
31700 (hits, misses),
31701 (0, 0),
31702 "no scope guard => no hits/misses recorded"
31703 );
31704 let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31705 let (hits, _) = index::inspect_scope_stats();
31706 assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
31707 }
31708
31709 #[test]
31710 fn context_pack_refreshes_stale_index_before_handoff() {
31711 let dir = setup_graph_index();
31712 init_git_repo(dir.path());
31713 std::thread::sleep(std::time::Duration::from_millis(50));
31714 std::fs::write(
31715 dir.path().join("main.rs"),
31716 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
31717 )
31718 .unwrap();
31719
31720 let report = build_context_pack_report(
31721 dir.path(),
31722 None,
31723 None,
31724 None,
31725 ResponseBudget::new(Some(2), Some(96)),
31726 )
31727 .unwrap();
31728
31729 assert!(
31730 report
31731 .status_reminders
31732 .iter()
31733 .any(|reminder| reminder.contains("index refreshed")
31734 && reminder.contains("context-pack handoff")),
31735 "expected context-pack refresh diagnostic, got {:?}",
31736 report.status_reminders
31737 );
31738 assert!(
31739 !report
31740 .status_reminders
31741 .iter()
31742 .any(|reminder| reminder.contains("index stale")),
31743 "stale reminder should be gone after refresh: {:?}",
31744 report.status_reminders
31745 );
31746
31747 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
31748 let summary = db.compute_changes(dir.path()).unwrap();
31749 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
31750 }
31751
31752 #[test]
31753 fn context_pack_materializes_source_handles_into_graph_store() {
31754 let dir = tempfile::tempdir().unwrap();
31755 let packet = ExplorationPacket {
31756 budget: exploration_budget_for_counts(2, 1),
31757 relationship_map: vec![ExplorationRelation {
31758 from: "file:main.rs".to_string(),
31759 relation: "touches_symbol".to_string(),
31760 to: "symbol:helper".to_string(),
31761 label: Some("modified diff".to_string()),
31762 }],
31763 source_windows: vec![ExplorationSourceWindow {
31764 handle: "xwin-test".to_string(),
31765 file: "main.rs".to_string(),
31766 start: 1,
31767 end: 32,
31768 reason: "changed file".to_string(),
31769 expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
31770 }],
31771 worker_context: vec![ExplorationWorkerContext {
31772 handle: "xwrk-test".to_string(),
31773 target: "tasks/software/tsift.md".to_string(),
31774 summary: "do #kgnv".to_string(),
31775 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
31776 .to_string(),
31777 }],
31778 no_reread_guidance: "use windows".to_string(),
31779 };
31780
31781 let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
31782 assert_eq!(packet.source_windows[0].handle, "xwin-test");
31783
31784 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
31785 let source_handles = store.nodes_by_kind("source_handle").unwrap();
31786 assert_eq!(source_handles.len(), 1);
31787 assert_eq!(
31788 source_handles[0].properties.get("file"),
31789 Some(&"main.rs".to_string())
31790 );
31791 assert_eq!(
31792 store
31793 .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
31794 .unwrap()
31795 .len(),
31796 1
31797 );
31798 let worker_context = store.nodes_by_kind("worker_context").unwrap();
31799 assert_eq!(worker_context.len(), 1);
31800 assert_eq!(
31801 store
31802 .outgoing_edges("xwrk-test", Some("scopes_source"))
31803 .unwrap()
31804 .len(),
31805 1
31806 );
31807 }
31808
31809 #[test]
31810 fn context_pack_records_graph_orchestration_observability() {
31811 let dir = setup_traversal_project();
31812 init_git_repo(dir.path());
31813 let session = dir.path().join("tasks/software/tsift.md");
31814 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
31815
31816 let report = build_context_pack_report(
31817 &session,
31818 None,
31819 None,
31820 None,
31821 ResponseBudget::new(Some(4), Some(160)),
31822 )
31823 .unwrap();
31824
31825 assert_eq!(
31826 report.graph_orchestration.contract_version,
31827 CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
31828 );
31829 assert_eq!(
31830 report
31831 .graph_orchestration
31832 .projection_freshness
31833 .status
31834 .as_str(),
31835 "current"
31836 );
31837 assert!(!report.graph_orchestration.projection_hashes.is_empty());
31838 assert_eq!(report.graph_orchestration.readiness.status, "blocked");
31839 assert_eq!(
31840 report.graph_orchestration.readiness.reason,
31841 "summary_cache_empty"
31842 );
31843 assert!(report.graph_orchestration.readiness.fail_closed);
31844 assert!(
31845 report
31846 .graph_orchestration
31847 .readiness
31848 .next_commands
31849 .iter()
31850 .any(|command| command == "tsift summarize --extract ."),
31851 "{:?}",
31852 report.graph_orchestration.readiness.next_commands
31853 );
31854 assert!(
31855 report
31856 .graph_orchestration
31857 .evidence_packet_ids
31858 .iter()
31859 .all(|id| !id.starts_with("gevd-")),
31860 "evidence packet ids should be empty when readiness is blocked: {:?}",
31861 report.graph_orchestration.evidence_packet_ids
31862 );
31863 assert!(
31864 report
31865 .graph_orchestration
31866 .conflict_matrix_decisions
31867 .iter()
31868 .any(|decision| decision.contains("readiness blocked")),
31869 "conflict-matrix decisions should reference readiness block: {:?}",
31870 report.graph_orchestration.conflict_matrix_decisions
31871 );
31872 assert!(
31873 !report
31874 .graph_orchestration
31875 .follow_up_commands
31876 .iter()
31877 .any(|command| command.contains("conflict-matrix")),
31878 "conflict-matrix command should not appear when readiness is blocked: {:?}",
31879 report.graph_orchestration.follow_up_commands
31880 );
31881 assert!(
31882 report
31883 .graph_orchestration
31884 .follow_up_commands
31885 .iter()
31886 .any(|command| command == "tsift summarize --extract ."),
31887 "{:?}",
31888 report.graph_orchestration.follow_up_commands
31889 );
31890 assert!(
31891 !report
31892 .graph_orchestration
31893 .worker_ownership_blocks
31894 .is_empty()
31895 );
31896 }
31897
31898 #[test]
31899 fn convex_sync_report_chunks_upserts_and_tombstones() {
31900 let dir = setup_traversal_project();
31901 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
31902 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
31903 let mut snapshot = projection.to_convex_rows();
31904 snapshot.nodes.push(ConvexNodeRow {
31905 external_id: "stale-node".to_string(),
31906 kind: "backlog".to_string(),
31907 label: "stale".to_string(),
31908 properties: BTreeMap::new(),
31909 provenance: Vec::new(),
31910 freshness: None,
31911 });
31912 snapshot.edges.clear();
31913 snapshot.edges.push(ConvexEdgeRow {
31914 edge_key: "stale-edge".to_string(),
31915 from_external_id: "stale-node".to_string(),
31916 to_external_id: "stale-node".to_string(),
31917 kind: "mentions".to_string(),
31918 properties: BTreeMap::new(),
31919 provenance: Vec::new(),
31920 freshness: None,
31921 });
31922 let snapshot_path = dir.path().join("convex-snapshot.json");
31923 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
31924
31925 let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
31926
31927 assert_eq!(report.freshness.status, "stale");
31928 assert!(report.freshness.fail_closed);
31929 assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
31930 assert!(
31931 report.edge_upserts.len() > 1,
31932 "snapshot without edges should upsert local edges"
31933 );
31934 assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
31935 assert_eq!(
31936 report.chunks.first().map(|chunk| chunk.operation.as_str()),
31937 Some("delete_edges"),
31938 "edge tombstones should be planned before node tombstones"
31939 );
31940 assert!(
31941 report
31942 .chunks
31943 .iter()
31944 .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
31945 "expected chunked edge upserts, got {:?}",
31946 report.chunks
31947 );
31948 }
31949
31950 #[test]
31951 fn convex_snapshot_validation_fails_closed_when_stale() {
31952 let dir = setup_traversal_project();
31953 build_traversal_graph(dir.path(), dir.path(), None).unwrap();
31954 let snapshot = ConvexProjectionRows::default();
31955 let snapshot_path = dir.path().join("empty-convex-snapshot.json");
31956 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
31957
31958 let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
31959 assert!(
31960 err.to_string()
31961 .contains("Convex graph projection is not current"),
31962 "{err}"
31963 );
31964 }
31965
31966 #[test]
31967 fn convex_sync_report_marks_live_apply_mode_without_network() {
31968 let dir = setup_traversal_project();
31969 let report =
31970 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
31971
31972 assert!(!report.dry_run);
31973 assert!(
31974 !report
31975 .diagnostics
31976 .iter()
31977 .any(|diagnostic| diagnostic.contains("dry-run only")),
31978 "apply-mode report should not claim dry-run diagnostics"
31979 );
31980 assert!(
31981 report
31982 .chunks
31983 .iter()
31984 .any(|chunk| chunk.operation == "upsert_nodes"),
31985 "live apply mode should still expose chunked idempotent operations"
31986 );
31987 }
31988
31989 #[test]
31990 fn convex_sync_apply_round_trips_with_http_backend() {
31991 use std::net::TcpListener;
31992 use std::sync::{Arc, Mutex};
31993
31994 let dir = setup_traversal_project();
31995 let report =
31996 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
31997 let expected_chunks = report.chunks.len();
31998 assert!(expected_chunks > 0);
31999
32000 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
32001 let endpoint = format!("http://{}", listener.local_addr().unwrap());
32002 let operations = Arc::new(Mutex::new(Vec::<String>::new()));
32003 let server_operations = Arc::clone(&operations);
32004 let server = std::thread::spawn(move || {
32005 for _ in 0..expected_chunks {
32006 let (mut stream, _) = listener.accept().unwrap();
32007 let mut reader = BufReader::new(stream.try_clone().unwrap());
32008 let mut request_line = String::new();
32009 reader.read_line(&mut request_line).unwrap();
32010 assert!(request_line.starts_with("POST "));
32011
32012 let mut content_length = 0usize;
32013 loop {
32014 let mut line = String::new();
32015 reader.read_line(&mut line).unwrap();
32016 if line == "\r\n" {
32017 break;
32018 }
32019 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
32020 content_length = value.trim().parse().unwrap();
32021 }
32022 }
32023
32024 let mut body = vec![0u8; content_length];
32025 reader.read_exact(&mut body).unwrap();
32026 let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
32027 server_operations
32028 .lock()
32029 .unwrap()
32030 .push(request["operation"].as_str().unwrap().to_string());
32031
32032 let response = br#"{"status":"ok","message":"accepted"}"#;
32033 write!(
32034 stream,
32035 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
32036 response.len()
32037 )
32038 .unwrap();
32039 stream.write_all(response).unwrap();
32040 }
32041 });
32042
32043 cmd_convex_sync(
32044 ConvexSyncOptions {
32045 path: dir.path(),
32046 scope: None,
32047 snapshot: None,
32048 chunk_size: 100,
32049 remote_snapshot: false,
32050 apply: true,
32051 endpoint: Some(&endpoint),
32052 auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
32053 },
32054 OutputFormat {
32055 json_output: false,
32056 compact: true,
32057 pretty: false,
32058 terse: false,
32059 ultra_terse: false,
32060 schema: false,
32061 envelope: false,
32062 },
32063 )
32064 .unwrap();
32065 server.join().unwrap();
32066
32067 let operations = operations.lock().unwrap().clone();
32068 assert!(operations.contains(&"upsert_nodes".to_string()));
32069 assert!(operations.contains(&"upsert_edges".to_string()));
32070 }
32071
32072 #[test]
32073 fn context_pack_diff_preview_attaches_tag_ontology_refs() {
32074 let root = tempfile::tempdir().unwrap();
32075 fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
32076 fs::write(
32077 root.path().join(".naming/tags/alpha.md"),
32078 "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
32079 )
32080 .unwrap();
32081 let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
32082 let report = diff_digest::DiffDigestReport {
32083 root: root.path().display().to_string(),
32084 mode: diff_digest::DiffDigestMode::WorkingTree,
32085 revision: None,
32086 files_changed: 1,
32087 files_with_current_summaries: 1,
32088 symbols_touched: 1,
32089 call_edges_added: 0,
32090 call_edges_removed: 0,
32091 files: vec![diff_digest::DiffDigestFile {
32092 path: "src/lib.rs".to_string(),
32093 status: diff_digest::DiffDigestFileStatus::Modified,
32094 touched_symbols: vec!["alpha_helper".to_string()],
32095 summary_state: diff_digest::DiffDigestSummaryState::Current,
32096 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
32097 symbol: "alpha_helper".to_string(),
32098 summary: "alpha helper summary".to_string(),
32099 }],
32100 added_call_edges: vec![],
32101 removed_call_edges: vec![],
32102 warnings: vec![],
32103 }],
32104 };
32105
32106 let preview = build_context_pack_diff_preview(
32107 &report,
32108 ResponseBudget::new(Some(1), Some(80)),
32109 Some(&ontology),
32110 );
32111
32112 let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
32113 assert!(symbol_ref.handle.starts_with("tont-"));
32114 assert_eq!(symbol_ref.tag, "alpha");
32115 assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
32116 assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
32117 assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
32118 assert_eq!(
32119 preview.files[0].summary_refs[0].ontology_refs[0].path,
32120 ".naming/tags/alpha.md"
32121 );
32122 }
32123
32124 #[test]
32125 fn context_pack_test_preview_limits_failure_groups() {
32126 let report = test_digest::TestDigestReport {
32127 root: "/repo".to_string(),
32128 runner: "cargo".to_string(),
32129 failures: 2,
32130 grouped_failures: 2,
32131 counts: test_digest::TestDigestCounts {
32132 passed: Some(8),
32133 failed: Some(2),
32134 skipped: Some(1),
32135 },
32136 failure_groups: vec![
32137 test_digest::TestDigestFailure {
32138 tests: vec!["suite::alpha_failure".to_string()],
32139 message: "assertion failed".to_string(),
32140 path: Some("src/lib.rs".to_string()),
32141 line: Some(42),
32142 column: None,
32143 occurrences: 1,
32144 summary_state: test_digest::TestDigestSummaryState::Current,
32145 current_summaries: vec![test_digest::TestDigestSummarySnippet {
32146 symbol: "alpha_failure".to_string(),
32147 summary: "failure summary for alpha test".to_string(),
32148 }],
32149 },
32150 test_digest::TestDigestFailure {
32151 tests: vec!["suite::beta_failure".to_string()],
32152 message: "panic".to_string(),
32153 path: Some("src/main.rs".to_string()),
32154 line: Some(7),
32155 column: None,
32156 occurrences: 1,
32157 summary_state: test_digest::TestDigestSummaryState::Missing,
32158 current_summaries: vec![],
32159 },
32160 ],
32161 warnings: vec!["warning text".to_string()],
32162 };
32163
32164 let preview =
32165 build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32166
32167 assert!(preview.truncated);
32168 assert_eq!(preview.failure_groups.len(), 1);
32169 assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
32170 assert_eq!(preview.failure_groups[0].message, "assertion f...");
32171 assert!(
32172 preview.failure_groups[0].summary_refs[0]
32173 .handle
32174 .starts_with("ctsum-")
32175 );
32176 assert_eq!(
32177 preview.failure_groups[0].summary_refs[0].expand,
32178 "tsift summarize --file \"src/lib.rs\""
32179 );
32180 assert_eq!(preview.warnings, vec!["warning text"]);
32181 }
32182
32183 #[test]
32184 fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
32185 let dir = tempfile::tempdir().unwrap();
32186 let root = dir.path();
32187
32188 let small_input = "Compiling serde v1.0.130\n";
32190 let mut small = log_digest::compute(root, small_input).unwrap();
32191 maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
32192 assert!(small.raw_log_artifact.is_none());
32193 assert!(!root.join(".tsift/artifacts").exists());
32194
32195 let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
32197 let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
32198 maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
32199 let artifact = bulky
32200 .raw_log_artifact
32201 .expect("artifact attached for bulky log");
32202 assert!(artifact.handle.starts_with("logdg-"));
32203 assert_eq!(artifact.bytes, bulky_input.len());
32204 assert!(artifact.expand.contains("tsift log-digest"));
32205 assert!(artifact.expand.contains("--input"));
32206 let persisted = root.join(&artifact.path);
32207 assert!(persisted.exists(), "artifact file written to {persisted:?}");
32208 assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
32209 }
32210
32211 #[test]
32212 fn context_pack_log_preview_limits_signals_and_refs() {
32213 let report = log_digest::LogDigestReport {
32214 root: "/repo".to_string(),
32215 total_lines: 12,
32216 non_empty_lines: 10,
32217 signal_groups: 2,
32218 error_signal_groups: 1,
32219 repeated_line_groups: 2,
32220 repeated_line_occurrences: 3,
32221 line_family_groups: 0,
32222 file_ref_groups: 2,
32223 symbol_ref_groups: 2,
32224 stack_groups: 1,
32225 signals: vec![
32226 log_digest::LogDigestSignal {
32227 severity: "error".to_string(),
32228 message: "src/lib.rs:42 boom".to_string(),
32229 path: Some("src/lib.rs".to_string()),
32230 line: Some(42),
32231 column: None,
32232 occurrences: 2,
32233 summary_state: log_digest::LogDigestSummaryState::Current,
32234 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32235 symbol: "alpha_helper".to_string(),
32236 summary: "alpha helper cached log summary".to_string(),
32237 }],
32238 },
32239 log_digest::LogDigestSignal {
32240 severity: "warn".to_string(),
32241 message: "slow path".to_string(),
32242 path: None,
32243 line: None,
32244 column: None,
32245 occurrences: 1,
32246 summary_state: log_digest::LogDigestSummaryState::Unavailable,
32247 current_summaries: vec![],
32248 },
32249 ],
32250 repeated_lines: vec![
32251 log_digest::LogDigestRepeatedLine {
32252 line: "retrying work item alpha".to_string(),
32253 occurrences: 3,
32254 },
32255 log_digest::LogDigestRepeatedLine {
32256 line: "retrying work item beta".to_string(),
32257 occurrences: 2,
32258 },
32259 ],
32260 line_families: vec![],
32261 file_refs: vec![
32262 log_digest::LogDigestFileRef {
32263 path: "src/lib.rs".to_string(),
32264 line: Some(42),
32265 column: None,
32266 occurrences: 2,
32267 summary_state: log_digest::LogDigestSummaryState::Current,
32268 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32269 symbol: "alpha_helper".to_string(),
32270 summary: "alpha helper cached file summary".to_string(),
32271 }],
32272 },
32273 log_digest::LogDigestFileRef {
32274 path: "src/main.rs".to_string(),
32275 line: Some(7),
32276 column: None,
32277 occurrences: 1,
32278 summary_state: log_digest::LogDigestSummaryState::Missing,
32279 current_summaries: vec![],
32280 },
32281 ],
32282 symbol_refs: vec![
32283 log_digest::LogDigestSymbolRef {
32284 symbol: "alpha_helper".to_string(),
32285 occurrences: 2,
32286 summary_state: log_digest::LogDigestSummaryState::Current,
32287 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32288 symbol: "alpha_helper".to_string(),
32289 summary: "alpha helper cached symbol summary".to_string(),
32290 }],
32291 },
32292 log_digest::LogDigestSymbolRef {
32293 symbol: "beta_helper".to_string(),
32294 occurrences: 1,
32295 summary_state: log_digest::LogDigestSummaryState::Missing,
32296 current_summaries: vec![],
32297 },
32298 ],
32299 stack_traces: vec![log_digest::LogDigestStackGroup {
32300 frames: vec!["frame one".to_string()],
32301 occurrences: 1,
32302 }],
32303 raw_log_artifact: None,
32304 warnings: vec!["warning text".to_string()],
32305 };
32306
32307 let preview =
32308 build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32309
32310 assert!(preview.truncated);
32311 assert_eq!(preview.signals.len(), 1);
32312 assert_eq!(preview.signals[0].message, "src/lib.rs:...");
32313 assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
32314 assert_eq!(preview.file_refs.len(), 1);
32315 assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
32316 assert!(
32317 preview.signals[0].summary_refs[0]
32318 .handle
32319 .starts_with("clsum-")
32320 );
32321 assert!(
32322 preview.file_refs[0].summary_refs[0]
32323 .handle
32324 .starts_with("clfsum-")
32325 );
32326 assert!(
32327 preview.symbol_refs[0].summary_refs[0]
32328 .handle
32329 .starts_with("clssum-")
32330 );
32331 assert_eq!(
32332 preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
32333 Some("alpha/helper")
32334 );
32335 assert_eq!(
32336 preview.symbol_refs[0].summary_refs[0].expand,
32337 "tsift summarize \"alpha_helper\""
32338 );
32339 assert_eq!(preview.warnings, vec!["warning text"]);
32340 }
32341
32342 #[test]
32343 fn cli_search_rejects_exact_with_strategy_flag() {
32344 let cli = try_parse_cli([
32345 "tsift",
32346 "search",
32347 "test",
32348 "--exact",
32349 "--strategy",
32350 "lexical",
32351 ]);
32352 assert!(cli.is_err());
32353 }
32354
32355 #[test]
32356 fn cli_search_autoindexes_by_default() {
32357 let cli = parse_cli(["tsift", "search", "test"]);
32358 match cli.command {
32359 Some(Commands::Search {
32360 autoindex,
32361 no_autoindex,
32362 ..
32363 }) => {
32364 assert!(!autoindex);
32365 assert!(!no_autoindex);
32366 assert!(autoindex || !no_autoindex);
32367 }
32368 _ => panic!("expected Search command"),
32369 }
32370 }
32371
32372 #[test]
32373 fn cli_local_model_status_accepts_json_and_no_probe() {
32374 let cli = parse_cli(["tsift", "local-model", "status", "--json", "--no-probe"]);
32375 match cli.command {
32376 Some(Commands::LocalModel {
32377 command: LocalModelCommand::Status { json, no_probe },
32378 }) => {
32379 assert!(json);
32380 assert!(no_probe);
32381 }
32382 _ => panic!("expected LocalModel status command"),
32383 }
32384 }
32385
32386 #[test]
32387 fn cli_local_model_unload_accepts_probe_and_strict_flags() {
32388 let cli = parse_cli([
32389 "tsift",
32390 "local-model",
32391 "unload",
32392 "--profile",
32393 "qwen3-32b-q4",
32394 "--pre-used-mib",
32395 "200",
32396 "--post-used-mib",
32397 "800",
32398 "--provider-pid",
32399 "42",
32400 "--strict",
32401 "--json",
32402 ]);
32403 match cli.command {
32404 Some(Commands::LocalModel {
32405 command:
32406 LocalModelCommand::Unload {
32407 profile,
32408 provider_pid,
32409 pre_used_mib,
32410 post_used_mib,
32411 strict,
32412 json,
32413 ..
32414 },
32415 }) => {
32416 assert_eq!(profile, "qwen3-32b-q4");
32417 assert_eq!(provider_pid, Some(42));
32418 assert_eq!(pre_used_mib, Some(200));
32419 assert_eq!(post_used_mib, Some(800));
32420 assert!(strict);
32421 assert!(json);
32422 }
32423 _ => panic!("expected LocalModel unload command"),
32424 }
32425 }
32426
32427 #[test]
32428 fn cli_local_model_swap_parses_flags() {
32429 let cli = parse_cli([
32430 "tsift",
32431 "local-model",
32432 "swap",
32433 "--from",
32434 "qwen3-32b-q4",
32435 "--to",
32436 "qwen3-embedding-0.6b",
32437 "--provider-pid",
32438 "42",
32439 "--pre-used-mib",
32440 "200",
32441 "--post-used-mib",
32442 "180",
32443 "--strict",
32444 "--json",
32445 ]);
32446 match cli.command {
32447 Some(Commands::LocalModel {
32448 command:
32449 LocalModelCommand::Swap {
32450 from,
32451 to,
32452 provider_pid,
32453 pre_used_mib,
32454 post_used_mib,
32455 strict,
32456 json,
32457 ..
32458 },
32459 }) => {
32460 assert_eq!(from, "qwen3-32b-q4");
32461 assert_eq!(to, "qwen3-embedding-0.6b");
32462 assert_eq!(provider_pid, Some(42));
32463 assert_eq!(pre_used_mib, Some(200));
32464 assert_eq!(post_used_mib, Some(180));
32465 assert!(strict);
32466 assert!(json);
32467 }
32468 _ => panic!("expected LocalModel swap command"),
32469 }
32470 }
32471
32472 #[test]
32473 fn cli_local_model_resolve_parses_flags() {
32474 use cli::ResolveRole;
32475 let cli = parse_cli([
32476 "tsift",
32477 "local-model",
32478 "resolve",
32479 "--profile",
32480 "hash",
32481 "--role",
32482 "embed",
32483 "--no-probe",
32484 "--json",
32485 ]);
32486 match cli.command {
32487 Some(Commands::LocalModel {
32488 command:
32489 LocalModelCommand::Resolve {
32490 profile,
32491 role,
32492 no_probe,
32493 json,
32494 },
32495 }) => {
32496 assert_eq!(profile.as_deref(), Some("hash"));
32497 assert_eq!(role, ResolveRole::Embed);
32498 assert!(no_probe);
32499 assert!(json);
32500 }
32501 _ => panic!("expected LocalModel resolve command"),
32502 }
32503 }
32504
32505 #[test]
32506 fn cli_semantic_command_accepts_profile_flag() {
32507 let cli = parse_cli([
32508 "tsift",
32509 "semantic",
32510 "auth",
32511 "--profile",
32512 "qwen3-embedding-0.6b",
32513 "--json",
32514 ]);
32515 match cli.command {
32516 Some(Commands::Semantic { profile, query, .. }) => {
32517 assert_eq!(query, "auth");
32518 assert_eq!(profile.as_deref(), Some("qwen3-embedding-0.6b"));
32519 }
32520 _ => panic!("expected Semantic command"),
32521 }
32522 }
32523
32524 #[test]
32525 fn cli_summarize_command_accepts_profile_flag() {
32526 let cli = parse_cli([
32527 "tsift",
32528 "summarize",
32529 "--extract",
32530 "src",
32531 "--profile",
32532 "hash",
32533 "--json",
32534 ]);
32535 match cli.command {
32536 Some(Commands::Summarize {
32537 extract,
32538 profile,
32539 json,
32540 ..
32541 }) => {
32542 assert_eq!(extract.as_deref(), Some(std::path::Path::new("src")));
32543 assert_eq!(profile.as_deref(), Some("hash"));
32544 assert!(json);
32545 }
32546 _ => panic!("expected Summarize command"),
32547 }
32548 }
32549
32550 #[test]
32551 fn cli_local_model_lease_acquire_parses_flags() {
32552 let cli = parse_cli([
32553 "tsift",
32554 "local-model",
32555 "lease",
32556 "acquire",
32557 "--profile",
32558 "qwen3-32b-q4",
32559 "--holder-pid",
32560 "4242",
32561 "--holder-command",
32562 "corky",
32563 "--idle-ttl-seconds",
32564 "120",
32565 "--vram-baseline-mib",
32566 "200",
32567 "--lease-file",
32568 "/tmp/tsift-lease.json",
32569 "--strict",
32570 "--json",
32571 ]);
32572 match cli.command {
32573 Some(Commands::LocalModel {
32574 command:
32575 LocalModelCommand::Lease {
32576 command:
32577 LeaseCommand::Acquire {
32578 profile,
32579 holder_pid,
32580 holder_command,
32581 idle_ttl_seconds,
32582 vram_baseline_mib,
32583 lease_file,
32584 strict,
32585 json,
32586 ..
32587 },
32588 },
32589 }) => {
32590 assert_eq!(profile, "qwen3-32b-q4");
32591 assert_eq!(holder_pid, Some(4242));
32592 assert_eq!(holder_command, "corky");
32593 assert_eq!(idle_ttl_seconds, 120);
32594 assert_eq!(vram_baseline_mib, Some(200));
32595 assert_eq!(lease_file, Some(PathBuf::from("/tmp/tsift-lease.json")));
32596 assert!(strict);
32597 assert!(json);
32598 }
32599 _ => panic!("expected LocalModel lease acquire command"),
32600 }
32601 }
32602
32603 #[test]
32604 fn cli_local_model_lease_release_parses_flags() {
32605 let cli = parse_cli([
32606 "tsift",
32607 "local-model",
32608 "lease",
32609 "release",
32610 "--profile",
32611 "qwen3-embedding-0.6b",
32612 "--holder-pid",
32613 "999",
32614 "--json",
32615 ]);
32616 match cli.command {
32617 Some(Commands::LocalModel {
32618 command:
32619 LocalModelCommand::Lease {
32620 command:
32621 LeaseCommand::Release {
32622 profile,
32623 holder_pid,
32624 json,
32625 ..
32626 },
32627 },
32628 }) => {
32629 assert_eq!(profile, "qwen3-embedding-0.6b");
32630 assert_eq!(holder_pid, Some(999));
32631 assert!(json);
32632 }
32633 _ => panic!("expected LocalModel lease release command"),
32634 }
32635 }
32636
32637 #[test]
32638 fn cli_local_model_lease_show_parses_flags() {
32639 let cli = parse_cli([
32640 "tsift",
32641 "local-model",
32642 "lease",
32643 "show",
32644 "--include-stale",
32645 "--json",
32646 ]);
32647 match cli.command {
32648 Some(Commands::LocalModel {
32649 command:
32650 LocalModelCommand::Lease {
32651 command:
32652 LeaseCommand::Show {
32653 include_stale,
32654 json,
32655 ..
32656 },
32657 },
32658 }) => {
32659 assert!(include_stale);
32660 assert!(json);
32661 }
32662 _ => panic!("expected LocalModel lease show command"),
32663 }
32664 }
32665
32666 #[test]
32667 fn cli_search_accepts_no_autoindex_flag() {
32668 let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
32669 match cli.command {
32670 Some(Commands::Search {
32671 autoindex,
32672 no_autoindex,
32673 ..
32674 }) => {
32675 assert!(!autoindex);
32676 assert!(no_autoindex);
32677 }
32678 _ => panic!("expected Search command"),
32679 }
32680 }
32681
32682 #[test]
32683 fn cli_search_rejects_conflicting_autoindex_flags() {
32684 let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
32685 assert!(cli.is_err());
32686 }
32687
32688 #[test]
32691 fn cli_accepts_global_absolute_flag() {
32692 let cli = parse_cli(["tsift", "--absolute", "status"]);
32693 assert!(cli.absolute);
32694 assert!(matches!(cli.command, Some(Commands::Status { .. })));
32695 }
32696
32697 #[test]
32698 fn cli_accepts_global_tabular_flag() {
32699 let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
32700 assert!(cli.tabular);
32701 assert!(matches!(cli.command, Some(Commands::Search { .. })));
32702 }
32703
32704 #[test]
32705 fn cli_tabular_with_graph() {
32706 let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
32707 assert!(cli.tabular);
32708 assert!(matches!(cli.command, Some(Commands::Graph { .. })));
32709 }
32710
32711 #[test]
32712 fn cli_tabular_with_communities() {
32713 let cli = parse_cli(["tsift", "--tabular", "communities"]);
32714 assert!(cli.tabular);
32715 assert!(matches!(cli.command, Some(Commands::Communities { .. })));
32716 }
32717
32718 #[test]
32719 fn cli_tabular_with_explain() {
32720 let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
32721 assert!(cli.tabular);
32722 assert!(matches!(cli.command, Some(Commands::Explain { .. })));
32723 }
32724
32725 #[test]
32726 fn cli_traverse_accepts_path_target_and_html_format() {
32727 let cli = parse_cli([
32728 "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
32729 ]);
32730 match cli.command {
32731 Some(Commands::Traverse {
32732 node,
32733 to,
32734 path,
32735 format,
32736 ..
32737 }) => {
32738 assert_eq!(node.as_deref(), Some("#kgnv"));
32739 assert_eq!(to.as_deref(), Some("main"));
32740 assert_eq!(path, PathBuf::from("."));
32741 assert_eq!(format, TraverseFormat::Html);
32742 }
32743 _ => panic!("expected Traverse command"),
32744 }
32745 }
32746
32747 #[test]
32748 fn cli_parses_semantic_related_command() {
32749 let cli = parse_cli([
32750 "tsift",
32751 "semantic",
32752 "graph navigation",
32753 "--path",
32754 ".",
32755 "--kind",
32756 "all",
32757 "--limit",
32758 "3",
32759 "--json",
32760 ]);
32761 match cli.command {
32762 Some(Commands::Semantic {
32763 query,
32764 path,
32765 kind,
32766 limit,
32767 json,
32768 ..
32769 }) => {
32770 assert_eq!(query, "graph navigation");
32771 assert_eq!(path, PathBuf::from("."));
32772 assert_eq!(kind, SemanticRelatedKind::All);
32773 assert_eq!(limit, 3);
32774 assert!(json);
32775 }
32776 _ => panic!("expected Semantic command"),
32777 }
32778 }
32779
32780 #[test]
32781 fn cli_parses_convex_sync_command() {
32782 let cli = parse_cli([
32783 "tsift",
32784 "convex-sync",
32785 ".",
32786 "--snapshot",
32787 "rows.json",
32788 "--chunk-size",
32789 "25",
32790 "--json",
32791 ]);
32792 match cli.command {
32793 Some(Commands::ConvexSync {
32794 path,
32795 snapshot,
32796 chunk_size,
32797 json,
32798 ..
32799 }) => {
32800 assert_eq!(path, PathBuf::from("."));
32801 assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
32802 assert_eq!(chunk_size, 25);
32803 assert!(json);
32804 }
32805 _ => panic!("expected ConvexSync command"),
32806 }
32807 }
32808
32809 #[test]
32810 fn cli_parses_convex_sync_live_flags() {
32811 let cli = parse_cli([
32812 "tsift",
32813 "convex-sync",
32814 ".",
32815 "--remote-snapshot",
32816 "--apply",
32817 "--endpoint",
32818 "https://example.test/convex-graph",
32819 "--auth-token-env",
32820 "TSIFT_TEST_TOKEN",
32821 ]);
32822 match cli.command {
32823 Some(Commands::ConvexSync {
32824 remote_snapshot,
32825 apply,
32826 endpoint,
32827 auth_token_env,
32828 ..
32829 }) => {
32830 assert!(remote_snapshot);
32831 assert!(apply);
32832 assert_eq!(
32833 endpoint.as_deref(),
32834 Some("https://example.test/convex-graph")
32835 );
32836 assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
32837 }
32838 _ => panic!("expected ConvexSync command"),
32839 }
32840 }
32841
32842 #[test]
32843 fn cli_parses_graph_db_query() {
32844 let cli = parse_cli([
32845 "tsift",
32846 "graph-db",
32847 "--backend",
32848 "convex-snapshot",
32849 "--convex-snapshot",
32850 "rows.json",
32851 "--json",
32852 "neighborhood",
32853 "gbak-kgnv",
32854 "--depth",
32855 "2",
32856 "--edge-kind",
32857 "mentions",
32858 "--property",
32859 "path=tasks/software/tsift.md",
32860 "--cursor",
32861 "gbak-old",
32862 "--limit",
32863 "10",
32864 ]);
32865 match cli.command {
32866 Some(Commands::GraphDb {
32867 backend,
32868 convex_snapshot,
32869 json,
32870 query,
32871 ..
32872 }) => {
32873 assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
32874 assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
32875 assert!(json);
32876 match query {
32877 GraphDbQuery::Neighborhood {
32878 id,
32879 depth,
32880 edge_kind,
32881 cursor,
32882 limit,
32883 property_filters,
32884 } => {
32885 assert_eq!(id, "gbak-kgnv");
32886 assert_eq!(depth, 2);
32887 assert_eq!(edge_kind.as_deref(), Some("mentions"));
32888 assert_eq!(cursor.as_deref(), Some("gbak-old"));
32889 assert_eq!(limit, Some(10));
32890 assert_eq!(
32891 property_filters,
32892 vec!["path=tasks/software/tsift.md".to_string()]
32893 );
32894 }
32895 _ => panic!("expected graph-db neighborhood query"),
32896 }
32897 }
32898 _ => panic!("expected GraphDb command"),
32899 }
32900 }
32901
32902 #[test]
32903 fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
32904 let cli = parse_cli([
32905 "tsift",
32906 "graph-db",
32907 "--json",
32908 "backend-eval",
32909 "--candidate",
32910 "surrealdb",
32911 "--target",
32912 "gval",
32913 "--full-projection",
32914 ]);
32915 match cli.command {
32916 Some(Commands::GraphDb { json, query, .. }) => {
32917 assert!(json);
32918 match query {
32919 GraphDbQuery::BackendEval {
32920 candidates,
32921 targets,
32922 full_projection,
32923 } => {
32924 assert_eq!(candidates, vec!["surrealdb".to_string()]);
32925 assert_eq!(targets, vec!["gval".to_string()]);
32926 assert!(full_projection);
32927 }
32928 _ => panic!("expected graph-db backend-eval query"),
32929 }
32930 }
32931 _ => panic!("expected GraphDb command"),
32932 }
32933 }
32934
32935 #[test]
32936 fn cli_parses_graph_db_tokensave_backend() {
32937 let cli = parse_cli([
32938 "tsift",
32939 "graph-db",
32940 "--backend",
32941 "tokensave",
32942 "--json",
32943 "node",
32944 "fn:main",
32945 ]);
32946 match cli.command {
32947 Some(Commands::GraphDb {
32948 backend,
32949 json,
32950 query,
32951 ..
32952 }) => {
32953 assert_eq!(backend, GraphDbBackend::Tokensave);
32954 assert!(json);
32955 match query {
32956 GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
32957 _ => panic!("expected graph-db node query"),
32958 }
32959 }
32960 _ => panic!("expected GraphDb command"),
32961 }
32962 }
32963
32964 #[test]
32965 fn cli_parses_analyze_command() {
32966 let cli = parse_cli([
32967 "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
32968 "--limit", "7", "--json",
32969 ]);
32970 match cli.command {
32971 Some(Commands::Analyze {
32972 path,
32973 scope,
32974 entry_points,
32975 limit,
32976 json,
32977 }) => {
32978 assert_eq!(path, PathBuf::from("."));
32979 assert_eq!(scope.as_deref(), Some("core"));
32980 assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
32981 assert_eq!(limit, 7);
32982 assert!(json);
32983 }
32984 _ => panic!("expected Analyze command"),
32985 }
32986 }
32987
32988 #[test]
32989 fn cli_parses_graph_db_related_query() {
32990 let cli = parse_cli([
32991 "tsift",
32992 "graph-db",
32993 "--json",
32994 "related",
32995 "voice avatar memory retrieval",
32996 "--kind",
32997 "all",
32998 "--depth",
32999 "3",
33000 "--seed-limit",
33001 "4",
33002 "--limit",
33003 "12",
33004 ]);
33005 match cli.command {
33006 Some(Commands::GraphDb { json, query, .. }) => {
33007 assert!(json);
33008 match query {
33009 GraphDbQuery::Related {
33010 query,
33011 kind,
33012 depth,
33013 seed_limit,
33014 limit,
33015 } => {
33016 assert_eq!(query, "voice avatar memory retrieval");
33017 assert_eq!(kind, SemanticRelatedKind::All);
33018 assert_eq!(depth, 3);
33019 assert_eq!(seed_limit, 4);
33020 assert_eq!(limit, 12);
33021 }
33022 _ => panic!("expected graph-db related query"),
33023 }
33024 }
33025 _ => panic!("expected GraphDb command"),
33026 }
33027 }
33028
33029 #[test]
33030 fn cli_parses_graph_db_compact_query() {
33031 let cli = parse_cli([
33032 "tsift",
33033 "graph-db",
33034 "--path",
33035 ".",
33036 "compact",
33037 "--apply",
33038 "--prune-tombstones",
33039 "--confirmed-convex-reconciled",
33040 ]);
33041 match cli.command {
33042 Some(Commands::GraphDb { query, .. }) => match query {
33043 GraphDbQuery::Compact {
33044 apply,
33045 prune_tombstones,
33046 confirmed_convex_reconciled,
33047 } => {
33048 assert!(apply);
33049 assert!(prune_tombstones);
33050 assert!(confirmed_convex_reconciled);
33051 }
33052 _ => panic!("expected graph-db compact query"),
33053 },
33054 _ => panic!("expected GraphDb command"),
33055 }
33056 }
33057
33058 #[test]
33059 fn cli_parses_graph_db_snapshot_queries() {
33060 let export_cli = parse_cli([
33061 "tsift",
33062 "graph-db",
33063 "--json",
33064 "snapshot-export",
33065 "graph.db.gz",
33066 "--force",
33067 ]);
33068 match export_cli.command {
33069 Some(Commands::GraphDb { json, query, .. }) => {
33070 assert!(json);
33071 match query {
33072 GraphDbQuery::SnapshotExport { output, force } => {
33073 assert_eq!(output, PathBuf::from("graph.db.gz"));
33074 assert!(force);
33075 }
33076 _ => panic!("expected graph-db snapshot-export query"),
33077 }
33078 }
33079 _ => panic!("expected GraphDb command"),
33080 }
33081
33082 let import_cli = parse_cli([
33083 "tsift",
33084 "graph-db",
33085 "snapshot-import",
33086 "graph.db.gz",
33087 "--replace",
33088 ]);
33089 match import_cli.command {
33090 Some(Commands::GraphDb { query, .. }) => match query {
33091 GraphDbQuery::SnapshotImport { artifact, replace } => {
33092 assert_eq!(artifact, PathBuf::from("graph.db.gz"));
33093 assert!(replace);
33094 }
33095 _ => panic!("expected graph-db snapshot-import query"),
33096 },
33097 _ => panic!("expected GraphDb command"),
33098 }
33099 }
33100
33101 #[test]
33102 fn cli_parses_impact_command() {
33103 let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
33104 match cli.command {
33105 Some(Commands::Impact {
33106 path,
33107 cached,
33108 limit,
33109 ..
33110 }) => {
33111 assert_eq!(path, PathBuf::from("."));
33112 assert!(cached);
33113 assert_eq!(limit, 5);
33114 }
33115 _ => panic!("expected Impact command"),
33116 }
33117 }
33118
33119 #[test]
33120 fn cli_parses_conflict_matrix_command() {
33121 let cli = parse_cli([
33122 "tsift",
33123 "conflict-matrix",
33124 "--path",
33125 "tasks/software/tsift.md",
33126 "--depth",
33127 "4",
33128 "--limit",
33129 "12",
33130 "--impact-limit",
33131 "6",
33132 "--json",
33133 "pwcm",
33134 "#g6kf",
33135 ]);
33136 match cli.command {
33137 Some(Commands::ConflictMatrix {
33138 targets,
33139 path,
33140 depth,
33141 limit,
33142 impact_limit,
33143 json,
33144 ..
33145 }) => {
33146 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33147 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33148 assert_eq!(depth, 4);
33149 assert_eq!(limit, 12);
33150 assert_eq!(impact_limit, 6);
33151 assert!(json);
33152 }
33153 _ => panic!("expected ConflictMatrix command"),
33154 }
33155 }
33156
33157 #[test]
33158 fn cli_parses_dispatch_trace_command() {
33159 let cli = parse_cli([
33160 "tsift",
33161 "dispatch-trace",
33162 "--path",
33163 "tasks/software/tsift.md",
33164 "--format",
33165 "html",
33166 "--depth",
33167 "4",
33168 "pwcm",
33169 "#g6kf",
33170 ]);
33171 match cli.command {
33172 Some(Commands::DispatchTrace {
33173 targets,
33174 path,
33175 format,
33176 depth,
33177 ..
33178 }) => {
33179 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33180 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33181 assert_eq!(format, DispatchTraceFormat::Html);
33182 assert_eq!(depth, 4);
33183 }
33184 _ => panic!("expected DispatchTrace command"),
33185 }
33186 }
33187
33188 #[test]
33189 fn cli_parses_dependency_dag_command() {
33190 let cli = parse_cli([
33191 "tsift",
33192 "dependency-dag",
33193 "--path",
33194 "tasks/software/tsift.md",
33195 "--depth",
33196 "5",
33197 "--limit",
33198 "20",
33199 "--json",
33200 "alpha",
33201 "#beta",
33202 ]);
33203 match cli.command {
33204 Some(Commands::DependencyDag {
33205 targets,
33206 path,
33207 depth,
33208 limit,
33209 json,
33210 ..
33211 }) => {
33212 assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
33213 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33214 assert_eq!(depth, 5);
33215 assert_eq!(limit, 20);
33216 assert!(json);
33217 }
33218 _ => panic!("expected DependencyDag command"),
33219 }
33220 }
33221
33222 #[test]
33223 fn relativize_strips_root_prefix() {
33224 let root = std::path::Path::new("/home/user/project");
33225 assert_eq!(
33226 relativize("/home/user/project/src/main.rs", root),
33227 "src/main.rs"
33228 );
33229 }
33230
33231 #[test]
33232 fn relativize_leaves_non_matching_path() {
33233 let root = std::path::Path::new("/home/user/project");
33234 assert_eq!(
33235 relativize("/other/path/file.rs", root),
33236 "/other/path/file.rs"
33237 );
33238 }
33239
33240 #[test]
33241 fn relativize_leaves_already_relative() {
33242 let root = std::path::Path::new("/home/user/project");
33243 assert_eq!(relativize("src/main.rs", root), "src/main.rs");
33244 }
33245
33246 #[test]
33247 fn relativize_pathbuf_strips_prefix() {
33248 let root = std::path::Path::new("/home/user/project");
33249 let path = std::path::Path::new("/home/user/project/src/lib.rs");
33250 assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
33251 }
33252
33253 #[test]
33254 fn relativize_edges_strips_caller_file() {
33255 let root = std::path::Path::new("/tmp/proj");
33256 let mut edges = vec![index::StoredEdge {
33257 caller_file: "/tmp/proj/src/main.rs".to_string(),
33258 caller_name: "main".to_string(),
33259 caller_line: 1,
33260 callee_name: "helper".to_string(),
33261 call_site_line: 5,
33262 tagpath_handle: None,
33263 }];
33264 relativize_edges(&mut edges, root);
33265 assert_eq!(edges[0].caller_file, "src/main.rs");
33266 }
33267
33268 #[test]
33269 fn relativize_json_paths_strips_known_keys() {
33270 let root = std::path::Path::new("/tmp/proj");
33271 let mut val = serde_json::json!({
33272 "file": "/tmp/proj/src/main.rs",
33273 "path": "/tmp/proj/test.rs",
33274 "name": "/tmp/proj/not-a-path",
33275 "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
33276 });
33277 relativize_json_paths(&mut val, root);
33278 assert_eq!(val["file"], "src/main.rs");
33279 assert_eq!(val["path"], "test.rs");
33280 assert_eq!(val["name"], "/tmp/proj/not-a-path");
33281 assert_eq!(val["hits"][0]["path"], "nested.rs");
33282 }
33283
33284 #[test]
33287 fn cli_graph_accepts_limit_flag() {
33288 let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
33289 match cli.command {
33290 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
33291 _ => panic!("expected Graph command"),
33292 }
33293 }
33294
33295 #[test]
33296 fn cli_graph_default_limit_is_20() {
33297 let cli = parse_cli(["tsift", "graph", "main"]);
33298 match cli.command {
33299 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
33300 _ => panic!("expected Graph command"),
33301 }
33302 }
33303
33304 #[test]
33305 fn cli_communities_accepts_limit_flag() {
33306 let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
33307 match cli.command {
33308 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
33309 _ => panic!("expected Communities command"),
33310 }
33311 }
33312
33313 #[test]
33314 fn cli_communities_default_limit_is_10() {
33315 let cli = parse_cli(["tsift", "communities"]);
33316 match cli.command {
33317 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
33318 _ => panic!("expected Communities command"),
33319 }
33320 }
33321
33322 #[test]
33323 fn cli_explain_accepts_limit_flag() {
33324 let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
33325 match cli.command {
33326 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
33327 _ => panic!("expected Explain command"),
33328 }
33329 }
33330
33331 #[test]
33332 fn cli_explain_default_limit_is_15() {
33333 let cli = parse_cli(["tsift", "explain", "main"]);
33334 match cli.command {
33335 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
33336 _ => panic!("expected Explain command"),
33337 }
33338 }
33339
33340 #[test]
33341 fn cli_limit_zero_means_unlimited() {
33342 let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
33343 match cli.command {
33344 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
33345 _ => panic!("expected Graph command"),
33346 }
33347 }
33348
33349 #[test]
33350 fn graph_cmd_limit_runs_ok() {
33351 let dir = setup_graph_index();
33352 let result = cmd_graph(
33353 "main",
33354 dir.path(),
33355 false,
33356 false,
33357 None,
33358 1,
33359 false,
33360 false,
33361 false,
33362 false,
33363 false,
33364 false,
33365 false,
33366 TagpathSearchOpts::default(),
33367 );
33368 assert!(result.is_ok());
33369 }
33370
33371 #[test]
33372 fn graph_cmd_unlimited_runs_ok() {
33373 let dir = setup_graph_index();
33374 let result = cmd_graph(
33375 "main",
33376 dir.path(),
33377 false,
33378 false,
33379 None,
33380 0,
33381 false,
33382 false,
33383 false,
33384 false,
33385 false,
33386 false,
33387 false,
33388 TagpathSearchOpts::default(),
33389 );
33390 assert!(result.is_ok());
33391 }
33392
33393 #[test]
33394 fn graph_cmd_tabular_runs_ok() {
33395 let dir = setup_graph_index();
33396 let result = cmd_graph(
33397 "main",
33398 dir.path(),
33399 false,
33400 false,
33401 None,
33402 20,
33403 false,
33404 false,
33405 false,
33406 false,
33407 false,
33408 true,
33409 false,
33410 TagpathSearchOpts::default(),
33411 );
33412 assert!(result.is_ok());
33413 }
33414
33415 #[test]
33416 fn communities_cmd_tabular_runs_ok() {
33417 let dir = setup_graph_index();
33418 let result = cmd_communities(
33419 dir.path(),
33420 None,
33421 1,
33422 10,
33423 false,
33424 false,
33425 false,
33426 false,
33427 true,
33428 false,
33429 TagpathSearchOpts::default(),
33430 );
33431 assert!(result.is_ok());
33432 }
33433
33434 #[test]
33435 fn explain_cmd_tabular_runs_ok() {
33436 let dir = setup_graph_index();
33437 let result = cmd_explain(
33438 "main",
33439 dir.path(),
33440 None,
33441 15,
33442 false,
33443 false,
33444 false,
33445 false,
33446 false,
33447 true,
33448 false,
33449 false,
33450 );
33451 assert!(result.is_ok());
33452 }
33453
33454 #[test]
33455 fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
33456 let cases = [
33461 ".agent-doc",
33462 ".agent-doc/snapshots/abc.md",
33463 ".agent-doc/baselines/abc.md",
33464 ".agent-doc/archives/2026.md",
33465 ".agent-doc/runtime/run.jsonl",
33466 "src/foo/.agent-doc",
33467 "src/foo/.agent-doc/snapshots/x.md",
33468 "./.agent-doc/snapshots/x.md",
33469 ];
33470 for path in cases {
33471 assert!(
33472 traversal_relative_path_is_generated_artifact(path),
33473 "expected `{path}` to be excluded from source watermark"
33474 );
33475 }
33476 for path in [
33478 "src/main.rs",
33479 "tests/perf_gate.rs",
33480 "fixtures/x.json",
33481 "agent-doc/src/lib.rs", "src/.agent-doc-helper.rs",
33483 ] {
33484 assert!(
33485 !traversal_relative_path_is_generated_artifact(path),
33486 "expected `{path}` to be included in source watermark"
33487 );
33488 }
33489 }
33490
33491 #[test]
33492 fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
33493 let cases = [
33501 ".tsift",
33502 ".tsift/index.db",
33503 ".tsift/indexes/foo/index.db",
33504 ".tsift/conflict-matrix-cache/inputs/abc.json",
33505 ".tsift/summaries.db",
33506 "src/foo/.tsift",
33507 "src/foo/.tsift/graph.db",
33508 "./.tsift/index.db",
33509 "target",
33510 "target/debug/build/x",
33511 "target/release/tsift",
33512 "src/foo/target/debug/x",
33513 "./target/release/x",
33514 ];
33515 for path in cases {
33516 assert!(
33517 traversal_relative_path_is_generated_artifact(path),
33518 "expected `{path}` to be excluded from source watermark"
33519 );
33520 }
33521 for path in [
33523 "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
33524 "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
33525 "src/tsift-extras/lib.rs",
33526 "tsift/README.md",
33527 "src/targeting.rs",
33528 "src/.tsiftrc",
33529 "src/agent-doc-helper.rs",
33530 ] {
33531 assert!(
33532 !traversal_relative_path_is_generated_artifact(path),
33533 "expected `{path}` to be included in source watermark"
33534 );
33535 }
33536 }
33537
33538 #[test]
33539 fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
33540 let dir = tempfile::tempdir().unwrap();
33549 let root = dir.path();
33550 std::fs::create_dir_all(root.join("src")).unwrap();
33551 std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
33552 let hint = root.join("README.md");
33553 std::fs::write(&hint, "# stable\n").unwrap();
33554 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33556 std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
33557 std::fs::create_dir_all(root.join("target/debug")).unwrap();
33558 std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
33559
33560 let first = traversal_source_watermark(root, &hint, None, true)
33561 .expect("first watermark call must succeed")
33562 .expect("first watermark must produce a hash for hinted markdown");
33563 let second = traversal_source_watermark(root, &hint, None, true)
33564 .expect("second watermark call must succeed")
33565 .expect("second watermark must produce a hash for hinted markdown");
33566 assert_eq!(
33567 first, second,
33568 "watermark must be identical across back-to-back invocations on a quiescent root"
33569 );
33570
33571 std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
33573 std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
33574 let third = traversal_source_watermark(root, &hint, None, true)
33575 .expect("third watermark call must succeed")
33576 .expect("third watermark must produce a hash for hinted markdown");
33577 assert_eq!(
33578 first, third,
33579 "watermark must ignore mutations under .tsift/ and target/"
33580 );
33581
33582 std::thread::sleep(std::time::Duration::from_millis(20));
33587 std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
33588 let fourth = traversal_source_watermark(root, &hint, None, true)
33589 .expect("fourth watermark call must succeed")
33590 .expect("fourth watermark must produce a hash for hinted markdown");
33591 assert_ne!(
33592 first, fourth,
33593 "watermark must invalidate when the hinted markdown file changes"
33594 );
33595 }
33596
33597 #[test]
33598 fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
33599 let dir = tempfile::tempdir().unwrap();
33603 let root = dir.path();
33604 std::fs::write(root.join("README.md"), "# stable\n").unwrap();
33605 let summaries_db_path = root.join(".tsift/summaries.db");
33606 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33607 let mut summary = summarize::Summary {
33608 id: 0,
33609 symbol_name: "main".to_string(),
33610 file_path: "src/main.rs".to_string(),
33611 content_hash: "hash-main".to_string(),
33612 summary: "main wires the CLI".to_string(),
33613 entities: Some(vec![summarize::Entity {
33614 name: "Cli".to_string(),
33615 kind: "type".to_string(),
33616 description: "Command-line interface".to_string(),
33617 }]),
33618 relationships: None,
33619 concept_labels: Some(vec!["cli".to_string()]),
33620 extracted_at: "1700000000".to_string(),
33621 model: "test-model".to_string(),
33622 tokens_input: Some(10),
33623 tokens_output: Some(5),
33624 };
33625 summary_db.insert(&summary).unwrap();
33626 drop(summary_db);
33627
33628 let hint = root.join("README.md");
33629 let first = traversal_source_watermark(root, &hint, None, true)
33630 .expect("first watermark call must succeed")
33631 .expect("first watermark must produce a hash");
33632
33633 std::thread::sleep(std::time::Duration::from_millis(20));
33634 let conn = Connection::open(&summaries_db_path).unwrap();
33635 conn.pragma_update(None, "user_version", 1).unwrap();
33636 conn.pragma_update(None, "user_version", 0).unwrap();
33637 drop(conn);
33638
33639 let second = traversal_source_watermark(root, &hint, None, true)
33640 .expect("second watermark call must succeed")
33641 .expect("second watermark must produce a hash");
33642 assert_eq!(
33643 first, second,
33644 "metadata-only summaries.db churn must not invalidate the source watermark"
33645 );
33646
33647 summary.entities = Some(vec![summarize::Entity {
33648 name: "GraphCache".to_string(),
33649 kind: "type".to_string(),
33650 description: "Stable full-projection cache input".to_string(),
33651 }]);
33652 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33653 summary_db.delete_by_file("src/main.rs").unwrap();
33654 summary_db.insert(&summary).unwrap();
33655 drop(summary_db);
33656
33657 let third = traversal_source_watermark(root, &hint, None, true)
33658 .expect("third watermark call must succeed")
33659 .expect("third watermark must produce a hash");
33660 assert_ne!(
33661 first, third,
33662 "semantic summary row changes must invalidate the source watermark"
33663 );
33664 }
33665
33666 #[test]
33667 fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
33668 let dir = tempfile::tempdir().unwrap();
33672 let root = dir.path();
33673 std::fs::create_dir_all(root.join("src")).unwrap();
33674 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33675 let source = root.join("src/lib.rs");
33676 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33677 std::fs::write(&source, source_body).unwrap();
33678 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33679 db.rebuild(root).unwrap();
33680 drop(db);
33681
33682 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33683 .unwrap()
33684 .value;
33685 std::thread::sleep(std::time::Duration::from_millis(20));
33686 std::fs::write(&source, source_body).unwrap();
33687 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33688 db.apply_changes(root).unwrap();
33689 drop(db);
33690
33691 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33692 .unwrap()
33693 .value;
33694 assert_eq!(
33695 first, second,
33696 "mtime-only source index churn must not invalidate the full-projection cache"
33697 );
33698 }
33699
33700 #[test]
33701 fn full_projection_source_watermark_ignores_session_markdown_churn() {
33702 let dir = tempfile::tempdir().unwrap();
33707 let root = dir.path();
33708 std::fs::create_dir_all(root.join("src")).unwrap();
33709 std::fs::create_dir_all(root.join("tasks/software")).unwrap();
33710 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33711 std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
33712 let task_doc = root.join("tasks/software/tsift.md");
33713 std::fs::write(
33714 &task_doc,
33715 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
33716 )
33717 .unwrap();
33718 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33719 db.rebuild(root).unwrap();
33720 drop(db);
33721
33722 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33723 .unwrap()
33724 .value;
33725 std::fs::write(
33726 &task_doc,
33727 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
33728 )
33729 .unwrap();
33730 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33731 .unwrap()
33732 .value;
33733 assert_eq!(
33734 first, second,
33735 "session markdown churn must not invalidate the full-projection code/summary cache"
33736 );
33737 }
33738
33739 #[test]
33740 fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
33741 let dir = tempfile::tempdir().unwrap();
33745 let root = dir.path();
33746 std::fs::create_dir_all(root.join("src")).unwrap();
33747 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33748 let source = root.join("src/lib.rs");
33749 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33750 std::fs::write(&source, source_body).unwrap();
33751 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33752 db.rebuild(root).unwrap();
33753 drop(db);
33754
33755 let (_projection, _warnings, _phases, first_stats) =
33756 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33757 assert!(
33758 !first_stats.hit,
33759 "the first full-projection run should populate the cache"
33760 );
33761
33762 std::thread::sleep(std::time::Duration::from_millis(20));
33763 std::fs::write(&source, source_body).unwrap();
33764 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33765 db.apply_changes(root).unwrap();
33766 drop(db);
33767
33768 let (_projection, _warnings, phases, second_stats) =
33769 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33770 assert!(second_stats.hit, "mtime-only churn should still cache-hit");
33771 let source_graph_build = phases
33772 .iter()
33773 .find(|phase| phase.name == "full_projection.source_graph_build")
33774 .expect("cache hit must report source_graph_build");
33775 let projection_rows = phases
33776 .iter()
33777 .find(|phase| phase.name == "full_projection.projection_rows")
33778 .expect("cache hit must report projection_rows");
33779 assert_eq!(source_graph_build.duration_micros, 0);
33780 assert_eq!(projection_rows.duration_micros, 0);
33781 }
33782
33783 #[test]
33784 fn build_token_capped_preview_within_cap() {
33785 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 2", "}"];
33786 let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
33787 assert!(!capped.was_capped);
33788 assert_eq!(capped.preview.len(), 3);
33789 assert_eq!(capped.capped_end, 3);
33790 }
33791
33792 #[test]
33793 fn build_token_capped_preview_truncates_long_body() {
33794 let owned: Vec<String> = (0..200)
33795 .map(|i| format!(" let line_{i} = {i};"))
33796 .collect();
33797 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33798 let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
33799 assert!(capped.was_capped);
33800 assert!(capped.preview.len() < 200);
33801 assert!(capped.capped_end < 200);
33802 assert!(!capped.preview.is_empty());
33803 }
33804
33805 #[test]
33806 fn build_token_capped_preview_respects_start_offset() {
33807 let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
33808 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33809 let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
33810 assert!(capped.was_capped);
33811 assert!(capped.capped_end >= 50);
33812 assert!(capped.capped_end < 100);
33813 assert_eq!(capped.preview[0].line, 50);
33814 }
33815
33816 #[test]
33817 fn response_budget_body_token_cap_defaults() {
33818 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
33819 assert_eq!(budget.body_token_cap(), 1500);
33820
33821 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
33822 assert_eq!(budget.body_token_cap(), 500);
33823
33824 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
33825 assert_eq!(budget.body_token_cap(), 3000);
33826 }
33827
33828 #[test]
33829 fn build_token_capped_preview_empty_input() {
33830 let lines: Vec<&str> = vec![];
33831 let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
33832 assert!(!capped.was_capped);
33833 assert!(capped.preview.is_empty());
33834 }
33835
33836 #[test]
33837 fn build_token_capped_preview_single_long_line_fits() {
33838 let lines: Vec<&str> = vec!["short"];
33839 let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
33840 assert!(!capped.was_capped);
33841 assert_eq!(capped.preview.len(), 1);
33842 assert_eq!(capped.capped_end, 1);
33843 }
33844
33845 #[test]
33846 fn edge_index_replaces_from_id_to_id_with_positions() {
33847 let input = serde_json::json!({
33848 "nodes": [
33849 {"id": "symbol:src/lib.rs:foo"},
33850 {"id": "symbol:src/lib.rs:bar"},
33851 {"id": "symbol:src/lib.rs:baz"}
33852 ],
33853 "edges": [
33854 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
33855 {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
33856 ]
33857 });
33858 let result = edge_index_transform(input);
33859 let edges = result.get("edges").unwrap().as_array().unwrap();
33860 assert_eq!(edges.len(), 2);
33861 assert_eq!(edges[0]["from"], 0);
33862 assert_eq!(edges[0]["to"], 1);
33863 assert_eq!(edges[1]["from"], 1);
33864 assert_eq!(edges[1]["to"], 2);
33865 assert!(edges[0].get("from_id").is_none());
33866 assert!(edges[0].get("to_id").is_none());
33867 }
33868
33869 #[test]
33870 fn edge_index_preserves_unresolved_ids_as_strings() {
33871 let input = serde_json::json!({
33872 "nodes": [{"id": "symbol:src/lib.rs:foo"}],
33873 "edges": [
33874 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
33875 ]
33876 });
33877 let result = edge_index_transform(input);
33878 let edge = &result["edges"][0];
33879 assert_eq!(edge["from"], 0);
33880 assert_eq!(edge["to_id"], "symbol:other.rs:missing");
33881 }
33882
33883 #[test]
33884 fn edge_index_noop_without_nodes_and_edges() {
33885 let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
33886 let result = edge_index_transform(input);
33887 assert_eq!(result["report"]["entries"][0]["from_id"], "a");
33888 }
33889}
33890
33891#[derive(Serialize)]
33894struct TableInfo {
33895 name: String,
33896 columns: Vec<ColumnInfo>,
33897 row_count: i64,
33898}
33899
33900#[derive(Serialize)]
33901struct ColumnInfo {
33902 name: String,
33903 #[serde(rename = "type")]
33904 col_type: String,
33905 notnull: bool,
33906 pk: bool,
33907 #[serde(skip_serializing_if = "Option::is_none")]
33908 default_value: Option<String>,
33909}
33910
33911pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
33913 let conn = Connection::open_with_flags(
33914 path,
33915 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
33916 )
33917 .with_context(|| format!("opening database: {}", path.display()))?;
33918 Ok(conn)
33919}
33920
33921pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
33923 let mut stmt = conn.prepare(
33924 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
33925 )?;
33926 let table_names: Vec<String> = stmt
33927 .query_map([], |row| row.get(0))?
33928 .collect::<std::result::Result<Vec<_>, _>>()?;
33929
33930 let mut tables = Vec::new();
33931 for tbl in table_names {
33932 let columns = table_columns(conn, &tbl)?;
33933 let row_count: i64 =
33934 conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
33935 row.get(0)
33936 })?;
33937 tables.push(TableInfo {
33938 name: tbl,
33939 columns,
33940 row_count,
33941 });
33942 }
33943 Ok(tables)
33944}
33945
33946pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
33948 let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
33949 let cols = stmt
33950 .query_map([], |row| {
33951 Ok(ColumnInfo {
33952 name: row.get(1)?,
33953 col_type: row.get::<_, String>(2).unwrap_or_default(),
33954 notnull: row.get::<_, bool>(3).unwrap_or(false),
33955 pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
33956 default_value: row.get(4)?,
33957 })
33958 })?
33959 .collect::<std::result::Result<Vec<_>, _>>()?;
33960 Ok(cols)
33961}
33962
33963pub(crate) fn execute_query(
33965 conn: &Connection,
33966 sql: &str,
33967) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
33968 let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
33969 let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
33970 let col_count = col_names.len();
33971
33972 let mut rows = Vec::new();
33973 let mut query_rows = stmt.query([])?;
33974 while let Some(row) = query_rows.next()? {
33975 let mut vals = Vec::with_capacity(col_count);
33976 for i in 0..col_count {
33977 let val = match row.get_ref(i)? {
33978 rusqlite::types::ValueRef::Null => serde_json::Value::Null,
33979 rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
33980 rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
33981 rusqlite::types::ValueRef::Text(s) => {
33982 serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
33983 }
33984 rusqlite::types::ValueRef::Blob(b) => {
33985 serde_json::Value::String(format!("<blob {} bytes>", b.len()))
33986 }
33987 };
33988 vals.push(val);
33989 }
33990 rows.push(vals);
33991 }
33992 Ok((col_names, rows))
33993}
33994
33995#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33996enum DigestRunnerKind {
33997 Test,
33998 Log,
33999}
34000
34001impl DigestRunnerKind {
34002 fn parse(raw: &str) -> Result<Self> {
34003 match raw.trim().to_ascii_lowercase().as_str() {
34004 "test" => Ok(Self::Test),
34005 "log" => Ok(Self::Log),
34006 other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
34007 }
34008 }
34009
34010 fn as_str(self) -> &'static str {
34011 match self {
34012 Self::Test => "test",
34013 Self::Log => "log",
34014 }
34015 }
34016}
34017
34018pub(crate) fn shell_split(s: &str) -> Vec<&str> {
34020 let mut parts = Vec::new();
34021 let mut i = 0;
34022 let bytes = s.as_bytes();
34023 while i < bytes.len() {
34024 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
34026 i += 1;
34027 }
34028 if i >= bytes.len() {
34029 break;
34030 }
34031 let start = i;
34032 if bytes[i] == b'"' || bytes[i] == b'\'' {
34033 let quote = bytes[i];
34034 i += 1;
34035 while i < bytes.len() && bytes[i] != quote {
34036 i += 1;
34037 }
34038 if i < bytes.len() {
34039 i += 1; }
34041 } else {
34042 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
34043 i += 1;
34044 }
34045 }
34046 parts.push(&s[start..i]);
34047 }
34048 parts
34049}
34050
34051pub(crate) fn shell_quote(s: &str) -> String {
34053 let unquoted =
34055 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
34056 &s[1..s.len() - 1]
34057 } else {
34058 s
34059 };
34060
34061 if unquoted
34062 .chars()
34063 .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
34064 {
34065 format!("\"{}\"", unquoted)
34066 } else {
34067 format!(
34068 "\"{}\"",
34069 unquoted.replace('\\', "\\\\").replace('"', "\\\"")
34070 )
34071 }
34072}
34073
34074fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
34075 sift::SearchCoverageSnapshot {
34076 mode: sift::SearchCoverageMode::Sealed,
34077 total_sector_count: 0,
34078 mounted_sector_count: 0,
34079 reused_sector_count: 0,
34080 dirty_sector_count: 0,
34081 completed_dirty_sector_count: 0,
34082 rebuilding_sector_count: 0,
34083 resumed_sector_count: 0,
34084 active_rebuild: None,
34085 }
34086}
34087
34088fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
34089 let total_sector_count = responses
34090 .iter()
34091 .map(|response| response.coverage.total_sector_count)
34092 .sum();
34093 let mounted_sector_count = responses
34094 .iter()
34095 .map(|response| response.coverage.mounted_sector_count)
34096 .sum();
34097 let reused_sector_count = responses
34098 .iter()
34099 .map(|response| response.coverage.reused_sector_count)
34100 .sum();
34101 let dirty_sector_count = responses
34102 .iter()
34103 .map(|response| response.coverage.dirty_sector_count)
34104 .sum();
34105 let completed_dirty_sector_count = responses
34106 .iter()
34107 .map(|response| response.coverage.completed_dirty_sector_count)
34108 .sum();
34109 let rebuilding_sector_count = responses
34110 .iter()
34111 .map(|response| response.coverage.rebuilding_sector_count)
34112 .sum();
34113 let resumed_sector_count = responses
34114 .iter()
34115 .map(|response| response.coverage.resumed_sector_count)
34116 .sum();
34117
34118 let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
34119 sift::SearchCoverageMode::Sealed
34120 } else if completed_dirty_sector_count > 0
34121 || rebuilding_sector_count > 0
34122 || resumed_sector_count > 0
34123 {
34124 sift::SearchCoverageMode::Converging
34125 } else {
34126 sift::SearchCoverageMode::Frontier
34127 };
34128
34129 sift::SearchCoverageSnapshot {
34130 mode,
34131 total_sector_count,
34132 mounted_sector_count,
34133 reused_sector_count,
34134 dirty_sector_count,
34135 completed_dirty_sector_count,
34136 rebuilding_sector_count,
34137 resumed_sector_count,
34138 active_rebuild: responses
34139 .iter()
34140 .find_map(|response| response.coverage.active_rebuild.clone()),
34141 }
34142}
34143
34144fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
34145 sift::SearchResponse {
34146 strategy: strategy.to_string(),
34147 root: root.display().to_string(),
34148 indexed_artifacts: 0,
34149 skipped_artifacts: 0,
34150 coverage: empty_search_coverage(),
34151 hits: Vec::new(),
34152 }
34153}
34154
34155fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
34156 for hit in &mut response.hits {
34157 let path = Path::new(&hit.path);
34158 if path.is_relative() {
34159 hit.path = search_root.join(path).display().to_string();
34160 }
34161 }
34162}
34163
34164fn merge_search_responses(
34165 root: &Path,
34166 strategy: &str,
34167 limit: usize,
34168 responses: Vec<sift::SearchResponse>,
34169) -> sift::SearchResponse {
34170 let indexed_artifacts = responses
34171 .iter()
34172 .map(|response| response.indexed_artifacts)
34173 .sum();
34174 let skipped_artifacts = responses
34175 .iter()
34176 .map(|response| response.skipped_artifacts)
34177 .sum();
34178 let coverage = if responses.is_empty() {
34179 empty_search_coverage()
34180 } else {
34181 aggregate_search_coverage(&responses)
34182 };
34183 let mut hits: Vec<sift::SearchHit> = responses
34184 .into_iter()
34185 .flat_map(|response| response.hits)
34186 .collect();
34187 hits.sort_by(|left, right| {
34188 right
34189 .score
34190 .partial_cmp(&left.score)
34191 .unwrap_or(Ordering::Equal)
34192 .then_with(|| left.path.cmp(&right.path))
34193 .then_with(|| left.location.cmp(&right.location))
34194 });
34195 hits.truncate(limit);
34196 for (rank, hit) in hits.iter_mut().enumerate() {
34197 hit.rank = rank + 1;
34198 }
34199
34200 sift::SearchResponse {
34201 strategy: strategy.to_string(),
34202 root: root.display().to_string(),
34203 indexed_artifacts,
34204 skipped_artifacts,
34205 coverage,
34206 hits,
34207 }
34208}
34209
34210pub(crate) fn federated_sift_search(
34211 root: &Path,
34212 cache_dir: &Path,
34213 query: &str,
34214 limit: usize,
34215 timeout_secs: u64,
34216 strategy: &str,
34217 fts_index_fresh: Option<bool>,
34218) -> Result<sift::SearchResponse> {
34219 let targets = resolve_search_index_targets(root, root, None, true)?;
34220 if targets.is_empty() {
34221 if config::Config::submodule_dirs(root)?.is_empty() {
34222 return run_search_with_timeout(
34223 root,
34224 cache_dir,
34225 query,
34226 limit,
34227 timeout_secs,
34228 strategy,
34229 &[],
34230 fts_index_fresh,
34231 );
34232 }
34233 return Ok(empty_search_response(root, strategy));
34234 }
34235
34236 let mut responses = Vec::with_capacity(targets.len());
34237 for target in &targets {
34238 let mut response = run_search_with_timeout(
34239 &target.source_root,
34240 cache_dir,
34241 query,
34242 limit,
34243 timeout_secs,
34244 strategy,
34245 std::slice::from_ref(target),
34246 fts_index_fresh,
34247 )?;
34248 absolutize_search_hit_paths(&mut response, &target.source_root);
34249 response.root = root.display().to_string();
34250 responses.push(response);
34251 }
34252
34253 Ok(merge_search_responses(root, strategy, limit, responses))
34254}
34255
34256pub(crate) fn federated_symbol_search(
34264 root: &std::path::Path,
34265 query: &str,
34266 limit: usize,
34267 tagpath_opts: &TagpathSearchOpts,
34268) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
34269 let cfg = config::Config::load(root)?;
34270 let submodules = config::Config::submodule_dirs(root)?;
34271 let mut all_hits: Vec<index::SymbolHit> = Vec::new();
34272 let mut combined = TagpathAnnotationDiagnostic::default();
34273 for scope in &submodules {
34274 if !cfg.federation_for_scope(scope) {
34275 continue;
34276 }
34277 let db_path = cfg.db_path_for(root, &scope.id);
34278 if !db_path.exists() {
34279 continue;
34280 }
34281 let db = index::IndexDb::open_read_only(&db_path)?;
34282 let mut hits = db.symbol_search(query, limit)?;
34283 let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
34284 combined.loaded |= diag.loaded;
34285 if diag.stale && !combined.stale {
34286 combined.stale = true;
34287 combined.reason = diag.reason;
34288 }
34289 all_hits.append(&mut hits);
34290 }
34291 all_hits.sort_by(|a, b| {
34292 b.score
34293 .partial_cmp(&a.score)
34294 .unwrap_or(std::cmp::Ordering::Equal)
34295 });
34296 all_hits.truncate(limit);
34297 Ok((all_hits, combined))
34298}
34299
34300#[derive(Debug, Deserialize)]
34301#[serde(tag = "type", rename_all = "lowercase")]
34302enum RipgrepJsonEvent {
34303 Match {
34304 data: RipgrepMatchData,
34305 },
34306 #[serde(other)]
34307 Other,
34308}
34309
34310#[derive(Debug, Deserialize)]
34311struct RipgrepMatchData {
34312 path: RipgrepTextField,
34313 lines: RipgrepTextField,
34314 line_number: Option<usize>,
34315}
34316
34317#[derive(Debug, Deserialize)]
34318struct RipgrepTextField {
34319 text: Option<String>,
34320}
34321
34322pub(crate) fn federated_exact_search(
34323 root: &Path,
34324 query: &str,
34325 limit: usize,
34326 timeout_secs: u64,
34327) -> Result<sift::SearchResponse> {
34328 let cfg = config::Config::load(root)?;
34329 let mut responses = Vec::new();
34330 for scope in config::Config::submodule_dirs(root)? {
34331 if !cfg.federation_for_scope(&scope) {
34332 continue;
34333 }
34334 let mut response =
34335 run_exact_search_with_timeout(std::slice::from_ref(&scope.source_root), query, limit, timeout_secs)?;
34336 absolutize_search_hit_paths(&mut response, &scope.source_root);
34337 response.root = root.display().to_string();
34338 responses.push(response);
34339 }
34340
34341 Ok(merge_search_responses(root, "exact", limit, responses))
34342}
34343
34344pub(crate) fn run_sift_search(
34345 search_path: &Path,
34346 cache_dir: &Path,
34347 query: &str,
34348 limit: usize,
34349 strategy: &str,
34350 fts_index_fresh: Option<bool>,
34359) -> Result<sift::SearchResponse> {
34360 if !fts_search_forced_off() {
34377 let db_path = search_path.join(".tsift/index.db");
34378 let use_fts = match fts_index_fresh {
34379 Some(fresh) => fresh && db_path.exists(),
34380 None => db_path.exists() && index_db_is_fresh_for_fts(&db_path, search_path),
34381 };
34382 if use_fts {
34383 return sift::fts_search(&db_path, search_path, query, limit)
34384 .context("index.db FTS5 search failed");
34385 }
34386 }
34387
34388 let engine = Sift::builder().with_cache_dir(cache_dir).build();
34389 let options = SearchOptions::default()
34390 .with_limit(limit)
34391 .with_strategy(strategy.to_string());
34392 let input = SearchInput::new(search_path, query).with_options(options);
34393 engine.search(input).context("sift search failed")
34394}
34395
34396fn index_db_is_fresh_for_fts(db_path: &Path, search_path: &Path) -> bool {
34405 match index::IndexDb::inspect_read_only(db_path, search_path, false) {
34406 Ok(inspection) => {
34407 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted == 0
34408 }
34409 Err(_) => false,
34410 }
34411}
34412
34413fn fts_search_forced_off() -> bool {
34418 std::env::var("TSIFT_FTS_SEARCH")
34419 .map(|value| fts_flag_value_disabled(&value))
34420 .unwrap_or(false)
34421}
34422
34423fn fts_flag_value_disabled(value: &str) -> bool {
34426 matches!(
34427 value.trim().to_ascii_lowercase().as_str(),
34428 "0" | "false" | "no" | "off"
34429 )
34430}
34431
34432fn exact_search_timeout_message(timeout_secs: u64) -> String {
34433 format!(
34434 "tsift search timed out after {}s (strategy: exact). \
34435 Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
34436 timeout_secs
34437 )
34438}
34439
34440fn exact_search_command(search_paths: &[PathBuf], query: &str) -> Command {
34441 let mut command = Command::new("rg");
34442 command
34443 .arg("--json")
34444 .arg("--fixed-strings")
34445 .arg("--line-number")
34446 .arg("--hidden")
34447 .arg("--")
34448 .arg(query);
34449 if search_paths.is_empty() {
34450 command.arg(Path::new("."));
34451 } else {
34452 command.args(search_paths);
34453 }
34454 command
34455}
34456
34457fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
34458 let observed_unix_secs = SystemTime::now()
34459 .duration_since(UNIX_EPOCH)
34460 .unwrap_or_default()
34461 .as_secs() as i64;
34462 let modified_unix_secs = fs::metadata(path)
34463 .ok()
34464 .and_then(|metadata| metadata.modified().ok())
34465 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
34466 .map(|duration| duration.as_secs() as i64);
34467 sift::ArtifactFreshness {
34468 observed_unix_secs,
34469 modified_unix_secs,
34470 }
34471}
34472
34473fn parse_exact_search_output(
34474 search_path: &Path,
34475 limit: usize,
34476 raw: &str,
34477) -> Result<sift::SearchResponse> {
34478 if limit == 0 {
34479 return Ok(sift::SearchResponse {
34480 strategy: "exact".to_string(),
34481 root: search_path.display().to_string(),
34482 indexed_artifacts: 0,
34483 skipped_artifacts: 0,
34484 coverage: empty_search_coverage(),
34485 hits: Vec::new(),
34486 });
34487 }
34488
34489 let mut hits = Vec::new();
34490 for line in raw.lines() {
34491 let event: RipgrepJsonEvent =
34492 serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
34493 let RipgrepJsonEvent::Match { data } = event else {
34494 continue;
34495 };
34496 let Some(path_text) = data.path.text else {
34497 continue;
34498 };
34499 let Some(lines_text) = data.lines.text else {
34500 continue;
34501 };
34502 let path = PathBuf::from(path_text);
34503 let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
34504 let rank = hits.len() + 1;
34505 hits.push(sift::SearchHit {
34506 artifact_id: format!(
34507 "exact:{}:{}:{}",
34508 path.display(),
34509 data.line_number.unwrap_or(0),
34510 rank
34511 ),
34512 artifact_kind: sift::ContextArtifactKind::File,
34513 path: path.display().to_string(),
34514 rank,
34515 score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
34516 confidence: sift::ScoreConfidence::High,
34517 location: data.line_number.map(|line| format!("line {}", line)),
34518 snippet: snippet.clone(),
34519 provenance: sift::ArtifactProvenance {
34520 adapter: sift::AcquisitionAdapterKind::FileSystem,
34521 source: "ripgrep -F".to_string(),
34522 synthetic: false,
34523 },
34524 freshness: exact_search_file_timestamp(&path),
34525 budget: sift::ArtifactBudget::from_text(&snippet, 1),
34526 });
34527 if hits.len() >= limit {
34528 break;
34529 }
34530 }
34531
34532 Ok(sift::SearchResponse {
34533 strategy: "exact".to_string(),
34534 root: search_path.display().to_string(),
34535 indexed_artifacts: hits.len(),
34536 skipped_artifacts: 0,
34537 coverage: empty_search_coverage(),
34538 hits,
34539 })
34540}
34541
34542fn exact_search_response_from_process(
34543 search_path: &Path,
34544 limit: usize,
34545 status: std::process::ExitStatus,
34546 stdout: &[u8],
34547 stderr: &[u8],
34548) -> Result<sift::SearchResponse> {
34549 if !status.success() && status.code() != Some(1) {
34550 let message = String::from_utf8_lossy(stderr);
34551 let trimmed = message.trim();
34552 if trimmed.is_empty() {
34553 bail!("ripgrep exact search exited with status {}", status);
34554 }
34555 bail!("{}", trimmed);
34556 }
34557
34558 let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
34559 parse_exact_search_output(search_path, limit, &raw)
34560}
34561
34562fn run_exact_search(search_paths: &[PathBuf], query: &str, limit: usize) -> Result<sift::SearchResponse> {
34563 let output = exact_search_command(search_paths, query)
34564 .output()
34565 .context("running exact search with ripgrep")?;
34566 let root_display = search_paths
34567 .first()
34568 .map(|p| p.as_path())
34569 .unwrap_or_else(|| Path::new("."));
34570 exact_search_response_from_process(
34571 root_display,
34572 limit,
34573 output.status,
34574 &output.stdout,
34575 &output.stderr,
34576 )
34577}
34578
34579pub(crate) fn run_exact_search_with_timeout(
34580 search_paths: &[PathBuf],
34581 query: &str,
34582 limit: usize,
34583 timeout_secs: u64,
34584) -> Result<sift::SearchResponse> {
34585 if timeout_secs == 0 {
34586 return run_exact_search(search_paths, query, limit);
34587 }
34588
34589 let mut child = exact_search_command(search_paths, query)
34590 .stdin(Stdio::null())
34591 .stdout(Stdio::piped())
34592 .stderr(Stdio::piped())
34593 .spawn()
34594 .context("spawning timed exact search worker")?;
34595
34596 let timeout = Duration::from_secs(timeout_secs);
34597 let status = wait_for_child_exit(&mut child, timeout)
34598 .context("waiting for timed exact search worker")?;
34599 if status.is_none() {
34600 let _ = child.kill();
34601 let _ = child.wait();
34602 bail!("{}", exact_search_timeout_message(timeout_secs));
34603 }
34604
34605 let status = status.unwrap();
34606 let stdout = read_child_stdout(&mut child)?;
34607 let stderr = read_child_stderr(&mut child)?;
34608 let root_display = search_paths
34609 .first()
34610 .map(|p| p.as_path())
34611 .unwrap_or_else(|| Path::new("."));
34612 exact_search_response_from_process(
34613 root_display,
34614 limit,
34615 status,
34616 stdout.as_bytes(),
34617 stderr.as_bytes(),
34618 )
34619}
34620
34621#[allow(clippy::too_many_arguments)]
34622pub(crate) fn run_search_with_timeout(
34623 search_path: &Path,
34624 cache_dir: &Path,
34625 query: &str,
34626 limit: usize,
34627 timeout_secs: u64,
34628 strategy: &str,
34629 search_targets: &[SearchIndexTarget],
34630 fts_index_fresh: Option<bool>,
34633) -> Result<sift::SearchResponse> {
34634 if timeout_secs == 0 {
34635 return run_sift_search(search_path, cache_dir, query, limit, strategy, fts_index_fresh);
34636 }
34637
34638 let output_path = next_search_worker_output_path();
34639 let mut command = Command::new(
34640 std::env::current_exe().context("resolving tsift executable for timed search")?,
34641 );
34642 command
34643 .arg("__search-worker")
34644 .arg("--path")
34645 .arg(search_path)
34646 .arg("--cache-dir")
34647 .arg(cache_dir)
34648 .arg("--query")
34649 .arg(query)
34650 .arg("--limit")
34651 .arg(limit.to_string())
34652 .arg("--strategy")
34653 .arg(strategy)
34654 .arg("--output")
34655 .arg(&output_path);
34656 if let Some(fresh) = fts_index_fresh {
34657 command.arg("--fts-index-fresh").arg(fresh.to_string());
34658 }
34659 let mut child = command
34660 .stdin(Stdio::null())
34661 .stdout(Stdio::null())
34662 .stderr(Stdio::piped())
34663 .spawn()
34664 .context("spawning timed sift search worker")?;
34665
34666 let timeout = Duration::from_secs(timeout_secs);
34667 let status =
34668 wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
34669 if status.is_none() {
34670 let _ = child.kill();
34671 let _ = child.wait();
34672 let _ = fs::remove_file(&output_path);
34673 bail!(
34674 "{}",
34675 search_timeout_message(timeout_secs, strategy, search_targets)?
34676 );
34677 }
34678
34679 let status = status.unwrap();
34680 let stderr = read_child_stderr(&mut child)?;
34681 if !status.success() {
34682 let _ = fs::remove_file(&output_path);
34683 let message = stderr.trim();
34684 if message.is_empty() {
34685 bail!("sift search worker exited with status {}", status);
34686 }
34687 bail!("{}", message);
34688 }
34689
34690 let raw = fs::read_to_string(&output_path)
34691 .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
34692 let _ = fs::remove_file(&output_path);
34693 serde_json::from_str(&raw).context("parsing search worker output")
34694}
34695
34696fn next_search_worker_output_path() -> PathBuf {
34697 let stamp = SystemTime::now()
34698 .duration_since(UNIX_EPOCH)
34699 .unwrap_or_default()
34700 .as_nanos();
34701 std::env::temp_dir().join(format!(
34702 "tsift-search-{}-{}.json",
34703 std::process::id(),
34704 stamp
34705 ))
34706}
34707
34708fn wait_for_child_exit(
34709 child: &mut std::process::Child,
34710 timeout: Duration,
34711) -> Result<Option<std::process::ExitStatus>> {
34712 let started = Instant::now();
34713 loop {
34714 if let Some(status) = child.try_wait()? {
34715 return Ok(Some(status));
34716 }
34717 if started.elapsed() >= timeout {
34718 return Ok(None);
34719 }
34720 let remaining = timeout.saturating_sub(started.elapsed());
34721 std::thread::sleep(remaining.min(Duration::from_millis(10)));
34722 }
34723}
34724
34725fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
34726 let mut stderr = String::new();
34727 if let Some(mut pipe) = child.stderr.take() {
34728 pipe.read_to_string(&mut stderr)
34729 .context("reading search worker stderr")?;
34730 }
34731 Ok(stderr)
34732}
34733
34734fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
34735 let mut stdout = String::new();
34736 if let Some(mut pipe) = child.stdout.take() {
34737 pipe.read_to_string(&mut stdout)
34738 .context("reading search worker stdout")?;
34739 }
34740 Ok(stdout)
34741}
34742
34743pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
34744 if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
34745 fs::write(&path, std::process::id().to_string())
34746 .with_context(|| format!("writing search worker pid file: {path}"))?;
34747 }
34748 if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
34749 let delay_ms = ms
34750 .parse::<u64>()
34751 .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
34752 std::thread::sleep(Duration::from_millis(delay_ms));
34753 }
34754 Ok(())
34755}
34756
34757#[cfg(test)]
34758thread_local! {
34759 static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
34760}
34761
34762#[cfg(test)]
34763enum SearchPostPrecheckLockMode {
34764 RollbackJournal,
34765 Wal,
34766}
34767
34768#[cfg(test)]
34769struct SearchPostPrecheckLockHook {
34770 db_path: PathBuf,
34771 mode: SearchPostPrecheckLockMode,
34772}
34773
34774#[cfg(test)]
34775struct SearchPostPrecheckLockGuard;
34776
34777#[cfg(test)]
34778impl Drop for SearchPostPrecheckLockGuard {
34779 fn drop(&mut self) {
34780 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34781 hook.borrow_mut().take();
34782 });
34783 }
34784}
34785
34786#[cfg(test)]
34787fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34788 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
34789}
34790
34791#[cfg(test)]
34792fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34793 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
34794}
34795
34796#[cfg(test)]
34797fn install_search_post_precheck_lock_hook(
34798 db_path: PathBuf,
34799 mode: SearchPostPrecheckLockMode,
34800) -> SearchPostPrecheckLockGuard {
34801 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34802 assert!(
34803 hook.borrow().is_none(),
34804 "search post-precheck lock hook already installed"
34805 );
34806 *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
34807 });
34808 SearchPostPrecheckLockGuard
34809}
34810
34811#[cfg(test)]
34812pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34813 let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
34814 return Ok(());
34815 };
34816 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
34817 std::thread::spawn(move || {
34818 let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
34819 match hook.mode {
34820 SearchPostPrecheckLockMode::RollbackJournal => {
34821 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
34822 .expect("acquiring rollback-journal hook lock");
34823 fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
34824 .expect("writing rollback journal marker");
34825 }
34826 SearchPostPrecheckLockMode::Wal => {
34827 conn.execute_batch(
34828 "PRAGMA journal_mode=WAL;
34829 PRAGMA wal_autocheckpoint=0;
34830 CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
34831 INSERT INTO search_wal_lock_probe DEFAULT VALUES;
34832 PRAGMA locking_mode=EXCLUSIVE;
34833 BEGIN EXCLUSIVE;",
34834 )
34835 .expect("acquiring WAL hook lock");
34836 assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
34837 }
34838 }
34839 ready_tx.send(()).expect("signaling search lock hook");
34840 std::thread::sleep(Duration::from_millis(200));
34841 drop(conn);
34842 let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
34843 });
34844 ready_rx
34845 .recv_timeout(Duration::from_secs(1))
34846 .context("waiting for search post-precheck lock hook")?;
34847 Ok(())
34848}
34849
34850#[cfg(not(test))]
34851pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34852 Ok(())
34853}