1use chrono::Utc;
2use sha2::{Digest, Sha256};
3
4use super::content::{
5 CheckpointPackageContentV1, GotchaExport, GotchasLayer, GraphEdgeExport, GraphLayer,
6 GraphNodeExport, KnowledgeLayer, PackageContent, PatternsLayer, SessionDecision,
7 SessionFinding, SessionLayer,
8};
9use super::manifest::{
10 CompatibilitySpec, PackageIntegrity, PackageLayer, PackageManifest, PackageProvenance,
11 PackageStats,
12};
13
14pub struct PackageBuilder {
15 name: String,
16 version: String,
17 description: String,
18 author: Option<String>,
19 scope: Option<String>,
20 tags: Vec<String>,
21 visibility: Option<String>,
22 compatibility: CompatibilitySpec,
23 content: PackageContent,
24 project_hash: Option<String>,
25 session_id: Option<String>,
26 session_time: Option<chrono::DateTime<Utc>>,
28 level: u32,
29 redistributable: Option<bool>,
30 scope_filter: ContentScope,
31}
32
33pub(crate) type FactFilter = Box<dyn Fn(&crate::core::knowledge::KnowledgeFact) -> bool>;
35
36pub(crate) struct ContentScope {
39 pub facts: Option<FactFilter>,
41 pub knowledge_extras: bool,
43 pub gotchas: bool,
45 pub code: bool,
47}
48
49impl Default for ContentScope {
50 fn default() -> Self {
51 Self {
52 facts: None,
53 knowledge_extras: true,
54 gotchas: true,
55 code: true,
56 }
57 }
58}
59
60impl ContentScope {
61 fn takes(&self, fact: &crate::core::knowledge::KnowledgeFact) -> bool {
62 self.facts.as_ref().is_none_or(|admits| admits(fact))
63 }
64}
65
66impl PackageBuilder {
67 pub fn new(name: &str, version: &str) -> Self {
68 Self {
69 name: name.to_string(),
70 version: version.to_string(),
71 description: String::new(),
72 author: None,
73 scope: None,
74 tags: Vec::new(),
75 visibility: None,
76 compatibility: CompatibilitySpec::default(),
77 content: PackageContent::default(),
78 project_hash: None,
79 session_id: None,
80 session_time: None,
81 level: 1,
82 redistributable: None,
83 scope_filter: ContentScope::default(),
84 }
85 }
86
87 pub(crate) fn redistributable(mut self) -> Self {
89 self.redistributable = Some(true);
90 self
91 }
92
93 pub(crate) fn content_scope(mut self, scope: ContentScope) -> Self {
96 self.scope_filter = scope;
97 self
98 }
99
100 pub fn description(mut self, desc: &str) -> Self {
101 self.description = desc.to_string();
102 self
103 }
104
105 pub(crate) fn author(mut self, author: &str) -> Self {
106 self.author = Some(author.to_string());
107 self
108 }
109
110 pub(crate) fn tags(mut self, tags: Vec<String>) -> Self {
111 self.tags = tags;
112 self
113 }
114
115 pub fn checkpoint(mut self, checkpoint: CheckpointPackageContentV1) -> Self {
116 self.content.checkpoint = Some(checkpoint);
117 self
118 }
119
120 pub(crate) fn scope(mut self, scope: &str) -> Self {
121 self.scope = Some(scope.to_string());
122 self
123 }
124
125 pub(crate) fn private(mut self) -> Self {
127 self.visibility = Some("private".to_string());
128 self
129 }
130
131 pub(crate) fn level(mut self, level: u32) -> Self {
132 self.level = level.clamp(1, 3);
133 self
134 }
135
136 pub(crate) fn add_knowledge_from_project(mut self, project_root: &str) -> Self {
137 let snapshot = crate::core::knowledge::KnowledgeSnapshot::collect(project_root);
141
142 let facts: Vec<_> = snapshot
145 .facts
146 .into_iter()
147 .filter(|fact| self.scope_filter.takes(fact))
148 .map(|mut fact| {
149 fact.status = Some(fact.status());
150 fact
151 })
152 .collect();
153 let (patterns, insights) = if self.scope_filter.knowledge_extras {
154 (snapshot.patterns, snapshot.insights)
155 } else {
156 (Vec::new(), Vec::new())
157 };
158 if facts.is_empty() && patterns.is_empty() && insights.is_empty() {
159 return self;
160 }
161
162 self.content.knowledge = Some(KnowledgeLayer {
163 facts,
164 patterns,
165 insights,
166 exported_at: Utc::now(),
167 });
168
169 self
170 }
171
172 pub(crate) fn add_graph_from_project(mut self, project_root: &str) -> Self {
173 let Ok(graph) = crate::core::property_graph::CodeGraph::open(project_root) else {
174 return self;
175 };
176
177 let nodes = export_graph_nodes(&graph);
178 let edges = export_graph_edges(&graph);
179
180 if nodes.is_empty() && edges.is_empty() {
181 return self;
182 }
183
184 self.content.graph = Some(GraphLayer {
185 nodes,
186 edges,
187 exported_at: Utc::now(),
188 });
189
190 self
191 }
192
193 pub fn add_session(mut self, session: &crate::core::session::SessionState) -> Self {
194 let journal: crate::core::session::AttachSessionJournalV1 =
195 session.attach_session_journal_v1();
196 let has_content = journal.task.is_some()
197 || !journal.findings.is_empty()
198 || !journal.decisions.is_empty()
199 || !journal.next_steps.is_empty()
200 || !journal.files_touched.is_empty();
201
202 if !has_content {
203 return self;
204 }
205
206 let root = session.project_root.as_deref();
209 let layer = SessionLayer {
210 task_description: journal.task.map(|task| task.description),
211 findings: journal
212 .findings
213 .into_iter()
214 .map(|finding| SessionFinding {
215 summary: finding.summary,
216 file: finding.file.and_then(|file| portable_path(&file, root)),
217 line: finding.line,
218 })
219 .collect(),
220 decisions: journal
221 .decisions
222 .into_iter()
223 .map(|decision| SessionDecision {
224 summary: decision.summary,
225 rationale: decision.rationale,
226 })
227 .collect(),
228 next_steps: journal.next_steps,
229 files_touched: journal
230 .files_touched
231 .into_iter()
232 .filter_map(|file| portable_path(&file.path, root))
233 .collect(),
234 exported_at: Utc::now(),
235 };
236
237 self.session_time = Some(session.updated_at);
238 self.content.session = Some(layer);
239 self
240 }
241
242 pub(crate) fn add_patterns_from_project(mut self, project_root: &str) -> Self {
243 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
244
245 if knowledge.patterns.is_empty() {
246 return self;
247 }
248
249 self.content.patterns = Some(PatternsLayer {
250 patterns: knowledge.patterns,
251 exported_at: Utc::now(),
252 });
253
254 self
255 }
256
257 pub(crate) fn build_context_graph(&mut self, project_root: &str) {
258 use super::graph_model::{ContextEdge, ContextGraph, ContextNode};
259
260 let mut graph = ContextGraph::new();
261 let mut node_count: u32 = 0;
262
263 let mut next_id = || -> String {
264 node_count += 1;
265 format!("N{node_count}")
266 };
267
268 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
269 let mut fact_id_map: std::collections::HashMap<String, String> =
270 std::collections::HashMap::new();
271
272 let scope = &self.scope_filter;
273 for fact in knowledge.facts.iter().filter(|fact| scope.takes(fact)) {
274 let id = next_id();
275 let mut node = ContextNode::fact(&id, &fact.value, &fact.category);
276 node.confidence = Some(fact.confidence);
277 node.source = Some(fact.source_session.clone());
278 node.created_at = Some(fact.created_at);
279 if let Some(ref s) = fact.supersedes {
280 node.supersedes = fact_id_map.get(s).cloned();
281 }
282 let map_key = format!("{}/{}", fact.category, fact.key);
283 fact_id_map.insert(map_key, id.clone());
284 graph.add_node(node);
285 }
286
287 let extras = scope.knowledge_extras;
288 for pattern in knowledge.patterns.iter().filter(|_| extras) {
289 let id = next_id();
290 let mut node = ContextNode::fact(&id, &pattern.description, "pattern");
291 node.node_type = "pattern".into();
292 node.created_at = Some(pattern.created_at);
293 graph.add_node(node);
294 }
295
296 for insight in knowledge.history.iter().filter(|_| extras) {
297 let id = next_id();
298 let mut node = ContextNode::fact(&id, &insight.summary, "insight");
299 node.node_type = "insight".into();
300 node.created_at = Some(insight.timestamp);
301 graph.add_node(node);
302 }
303
304 let gotcha_store = crate::core::gotcha_tracker::GotchaStore::load(project_root);
305 let gotchas = scope.gotchas;
306 for g in gotcha_store.gotchas.iter().filter(|_| gotchas) {
307 let id = next_id();
308 let mut node = ContextNode::gotcha(&id, &g.trigger, &g.resolution);
309 node.category = Some(g.category.short_label().to_string());
310 node.confidence = Some(g.confidence);
311 node.created_at = Some(g.first_seen);
312 graph.add_node(node);
313 }
314
315 if self.level >= 2 {
316 if scope.code
317 && let Ok(code_graph) = crate::core::property_graph::CodeGraph::open(project_root)
318 {
319 let v1_nodes = export_graph_nodes(&code_graph);
320 let v1_edges = export_graph_edges(&code_graph);
321
322 let mut code_node_map: std::collections::HashMap<(String, String), String> =
323 std::collections::HashMap::new();
324
325 for n in &v1_nodes {
326 let id = next_id();
327 let node = ContextNode::code_symbol(&id, &n.kind, &n.name, &n.file_path);
328 code_node_map.insert((n.file_path.clone(), n.name.clone()), id.clone());
329 graph.add_node(node);
330 }
331
332 for e in &v1_edges {
333 let src_key = (e.source_path.clone(), e.source_name.clone());
334 let tgt_key = (e.target_path.clone(), e.target_name.clone());
335 if let (Some(from), Some(to)) =
336 (code_node_map.get(&src_key), code_node_map.get(&tgt_key))
337 {
338 graph.add_edge(ContextEdge {
339 from: from.clone(),
340 to: to.clone(),
341 edge_type: e.kind.clone(),
342 weight: 1.0,
343 coactivations: 0,
344 metadata: e.metadata.clone(),
345 });
346 }
347 }
348 }
349
350 let phash = crate::core::project_hash::hash_project_root(project_root);
351 if let Some(rel_graph) =
352 crate::core::knowledge_relations::KnowledgeRelationGraph::load(&phash)
353 {
354 for edge in &rel_graph.edges {
355 let from_key = edge.from.id();
356 let to_key = edge.to.id();
357 if let (Some(from_id), Some(to_id)) =
358 (fact_id_map.get(&from_key), fact_id_map.get(&to_key))
359 {
360 let edge_type = match edge.kind {
361 crate::core::knowledge_relations::KnowledgeEdgeKind::DependsOn => {
362 "depends_on"
363 }
364 crate::core::knowledge_relations::KnowledgeEdgeKind::RelatedTo => {
365 "related_to"
366 }
367 crate::core::knowledge_relations::KnowledgeEdgeKind::Supports => {
368 "supports"
369 }
370 crate::core::knowledge_relations::KnowledgeEdgeKind::Contradicts => {
371 "contradicts"
372 }
373 crate::core::knowledge_relations::KnowledgeEdgeKind::Supersedes => {
374 "supersedes"
375 }
376 };
377 graph.add_edge(ContextEdge {
378 from: from_id.clone(),
379 to: to_id.clone(),
380 edge_type: edge_type.into(),
381 weight: edge.strength,
382 coactivations: edge.count,
383 metadata: None,
384 });
385 }
386 }
387 }
388 }
389
390 if !graph.nodes.is_empty() {
391 self.content.context_graph = Some(graph);
392 }
393 }
394
395 pub(crate) fn add_gotchas_from_project(mut self, project_root: &str) -> Self {
396 let store = crate::core::gotcha_tracker::GotchaStore::load(project_root);
397 if store.gotchas.is_empty() {
398 return self;
399 }
400
401 self.content.gotchas = Some(GotchasLayer {
402 gotchas: store
403 .gotchas
404 .iter()
405 .map(|g| GotchaExport {
406 id: g.id.clone(),
407 category: g.category.short_label().to_string(),
408 severity: match g.severity {
409 crate::core::gotcha_tracker::GotchaSeverity::Critical => "critical".into(),
410 crate::core::gotcha_tracker::GotchaSeverity::Warning => "warning".into(),
411 crate::core::gotcha_tracker::GotchaSeverity::Info => "info".into(),
412 },
413 trigger: g.trigger.clone(),
414 resolution: g.resolution.clone(),
415 file_patterns: g.file_patterns.clone(),
416 confidence: g.confidence,
417 })
418 .collect(),
419 exported_at: Utc::now(),
420 });
421
422 self
423 }
424
425 pub fn build(mut self) -> Result<(PackageManifest, PackageContent), String> {
426 if self.name.is_empty() {
427 return Err("package name is required".into());
428 }
429 if self.version.is_empty() {
430 return Err("package version is required".into());
431 }
432 if self.content.is_empty() {
433 return Err("package has no content — add at least one layer".into());
434 }
435
436 let stamp = build_time(&self.content, self.session_time);
439 let content = &mut self.content;
440 for exported_at in [
441 content.knowledge.as_mut().map(|l| &mut l.exported_at),
442 content.graph.as_mut().map(|l| &mut l.exported_at),
443 content.session.as_mut().map(|l| &mut l.exported_at),
444 content.patterns.as_mut().map(|l| &mut l.exported_at),
445 content.gotchas.as_mut().map(|l| &mut l.exported_at),
446 ]
447 .into_iter()
448 .flatten()
449 {
450 *exported_at = stamp;
451 }
452
453 let is_v2 = self.content.context_graph.is_some() || self.content.checkpoint.is_some();
454
455 let mut layers = Vec::new();
456 if self.content.knowledge.is_some() {
457 layers.push(PackageLayer::Knowledge);
458 }
459 if self.content.graph.is_some() {
460 layers.push(PackageLayer::Graph);
461 }
462 if self.content.session.is_some() {
463 layers.push(PackageLayer::Session);
464 }
465 if self.content.patterns.is_some() {
466 layers.push(PackageLayer::Patterns);
467 }
468 if self.content.gotchas.is_some() {
469 layers.push(PackageLayer::Gotchas);
470 }
471 if self.content.checkpoint.is_some() {
472 layers.push(PackageLayer::Checkpoint);
473 }
474
475 let content_json = serde_json::to_string(&self.content).map_err(|e| e.to_string())?;
476 let content_bytes = content_json.as_bytes();
477
478 let content_hash = sha256_hex(content_bytes);
479 let sha256 =
480 sha256_hex(format!("{}:{}:{}", self.name, self.version, content_hash).as_bytes());
481
482 let stats = compute_stats(&self.content);
483
484 let schema_version = if is_v2 {
485 crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
486 } else {
487 crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION
488 };
489
490 let graph_summary = self
491 .content
492 .context_graph
493 .as_ref()
494 .map(super::graph_model::ContextGraph::summary);
495
496 let manifest = PackageManifest {
497 schema_version,
498 conformance_level: if is_v2 { Some(self.level) } else { None },
499 kind: super::manifest::PackageKind::default(),
500 name: self.name,
501 version: self.version,
502 description: self.description,
503 author: self.author,
504 scope: self.scope,
505 created_at: stamp,
506 updated_at: None,
507 layers,
508 dependencies: Vec::new(),
509 tags: self.tags,
510 visibility: self.visibility,
511 integrity: PackageIntegrity {
512 sha256,
513 content_hash,
514 byte_size: content_bytes.len() as u64,
515 },
516 provenance: PackageProvenance {
517 tool: "lean-ctx".into(),
518 tool_version: env!("CARGO_PKG_VERSION").into(),
519 project_hash: self.project_hash,
520 source_session_id: self.session_id,
521 },
522 compatibility: self.compatibility,
523 stats,
524 signature: None,
525 graph_summary,
526 marketplace: None,
527 redistributable: self.redistributable,
528 };
529
530 manifest.validate().map_err(|errs| errs.join("; "))?;
531
532 Ok((manifest, self.content))
533 }
534}
535
536fn build_time(
542 content: &PackageContent,
543 session_time: Option<chrono::DateTime<Utc>>,
544) -> chrono::DateTime<Utc> {
545 if let Some(epoch) = std::env::var("SOURCE_DATE_EPOCH")
546 .ok()
547 .and_then(|value| value.trim().parse::<i64>().ok())
548 .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0))
549 {
550 return epoch;
551 }
552 let mut times: Vec<chrono::DateTime<Utc>> = session_time.into_iter().collect();
553 if let Some(knowledge) = &content.knowledge {
554 times.extend(
555 knowledge
556 .facts
557 .iter()
558 .flat_map(|f| [f.created_at, f.last_confirmed]),
559 );
560 times.extend(knowledge.insights.iter().map(|i| i.timestamp));
561 times.extend(knowledge.patterns.iter().map(|p| p.created_at));
562 }
563 if let Some(patterns) = &content.patterns {
564 times.extend(patterns.patterns.iter().map(|p| p.created_at));
565 }
566 times
567 .into_iter()
568 .max()
569 .unwrap_or(chrono::DateTime::UNIX_EPOCH)
570}
571
572fn portable_path(path: &str, root: Option<&str>) -> Option<String> {
576 let candidate = std::path::Path::new(path);
577 let anchored = candidate.has_root()
581 || matches!(
582 candidate.components().next(),
583 Some(std::path::Component::Prefix(_))
584 );
585 if !anchored {
586 return Some(path.to_string());
587 }
588 let relative = candidate.strip_prefix(root?).ok()?;
589 let relative = relative.to_string_lossy().replace('\\', "/");
590 (!relative.is_empty()).then_some(relative)
591}
592
593fn export_graph_nodes(graph: &crate::core::property_graph::CodeGraph) -> Vec<GraphNodeExport> {
594 let conn = graph.connection();
595 let Ok(mut stmt) =
596 conn.prepare("SELECT n.kind, n.name, p.path, n.line_start, n.line_end, n.metadata FROM nodes n JOIN paths p ON p.id = n.file_id")
597 else {
598 tracing::warn!("ctxpkg: failed to prepare graph nodes query");
599 return Vec::new();
600 };
601
602 let Ok(rows) = stmt.query_map([], |row| {
603 let line_start: Option<i64> = row.get(3)?;
604 let line_end: Option<i64> = row.get(4)?;
605 Ok(GraphNodeExport {
606 kind: row.get(0)?,
607 name: row.get(1)?,
608 file_path: row.get(2)?,
609 line_start: line_start.map(|v| v as usize),
610 line_end: line_end.map(|v| v as usize),
611 metadata: row.get(5)?,
612 })
613 }) else {
614 tracing::warn!("ctxpkg: failed to query graph nodes");
615 return Vec::new();
616 };
617
618 let mut nodes = Vec::new();
619 for row in rows {
620 match row {
621 Ok(n) => {
624 let served = if n.kind == "symbol" {
625 graph.serves_symbol(&n.file_path, &n.name)
626 } else {
627 graph.serves_file(&n.file_path)
628 };
629 if served {
630 nodes.push(n);
631 }
632 }
633 Err(e) => tracing::warn!("ctxpkg: skipping graph node: {e}"),
634 }
635 }
636 nodes
637}
638
639fn export_graph_edges(graph: &crate::core::property_graph::CodeGraph) -> Vec<GraphEdgeExport> {
640 let conn = graph.connection();
641 let sql = "
642 SELECT p1.path, n1.name, p2.path, n2.name, e.kind, e.metadata
643 FROM edges e
644 JOIN nodes n1 ON e.source_id = n1.id
645 JOIN nodes n2 ON e.target_id = n2.id
646 JOIN paths p1 ON p1.id = n1.file_id
647 JOIN paths p2 ON p2.id = n2.file_id
648 ";
649 let Ok(mut stmt) = conn.prepare(sql) else {
650 tracing::warn!("ctxpkg: failed to prepare graph edges query");
651 return Vec::new();
652 };
653
654 let Ok(rows) = stmt.query_map([], |row| {
655 Ok(GraphEdgeExport {
656 source_path: row.get(0)?,
657 source_name: row.get(1)?,
658 target_path: row.get(2)?,
659 target_name: row.get(3)?,
660 kind: row.get(4)?,
661 metadata: row.get(5)?,
662 })
663 }) else {
664 tracing::warn!("ctxpkg: failed to query graph edges");
665 return Vec::new();
666 };
667
668 let mut edges = Vec::new();
669 for row in rows {
670 match row {
671 Ok(e) => {
673 if graph.serves_file(&e.source_path) && graph.serves_file(&e.target_path) {
674 edges.push(e);
675 }
676 }
677 Err(e) => tracing::warn!("ctxpkg: skipping graph edge: {e}"),
678 }
679 }
680 edges
681}
682
683fn compute_stats(content: &PackageContent) -> PackageStats {
684 let knowledge_facts = content
685 .knowledge
686 .as_ref()
687 .map_or(0, |k| k.facts.len() as u32);
688 let graph_nodes = content.graph.as_ref().map_or(0, |g| g.nodes.len() as u32);
689 let graph_edges = content.graph.as_ref().map_or(0, |g| g.edges.len() as u32);
690 let pattern_count = content
691 .patterns
692 .as_ref()
693 .map_or(0, |p| p.patterns.len() as u32);
694 let gotcha_count = content
695 .gotchas
696 .as_ref()
697 .map_or(0, |g| g.gotchas.len() as u32);
698
699 let raw_json = serde_json::to_string(content).unwrap_or_default();
700 let compression_ratio = {
701 use flate2::Compression;
702 use flate2::write::GzEncoder;
703 use std::io::Write;
704 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
705 let _ = encoder.write_all(raw_json.as_bytes());
706 let compressed = encoder.finish().unwrap_or_default();
707 if raw_json.is_empty() {
708 1.0
709 } else {
710 compressed.len() as f64 / raw_json.len() as f64
711 }
712 };
713
714 PackageStats {
715 knowledge_facts,
716 graph_nodes,
717 graph_edges,
718 pattern_count,
719 gotcha_count,
720 compression_ratio,
721 }
722}
723
724fn sha256_hex(data: &[u8]) -> String {
725 let mut hasher = Sha256::new();
726 hasher.update(data);
727 crate::core::agent_identity::hex_encode(&hasher.finalize())
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use crate::core::context_package::graph_model::{ContextEdge, ContextGraph, ContextNode};
734
735 #[test]
736 fn empty_builder_fails() {
737 let result = PackageBuilder::new("test", "1.0.0").build();
738 assert!(result.is_err());
739 assert!(result.unwrap_err().contains("no content"));
740 }
741
742 #[test]
746 fn a_session_layer_carries_no_private_text_and_no_local_path() {
747 use crate::core::session::{Finding, SessionState, TaskInfo};
748 let root = "/home/someone/work/project";
749 let mut session = SessionState::new();
750 session.project_root = Some(root.to_string());
751 session.shell_cwd = Some("/home/someone/private-cwd".to_string());
752 session.last_semantic_query = Some("CHAT-CANARY what my manager said".to_string());
753 session.task = Some(TaskInfo {
754 description: "ship the parser".to_string(),
755 intent: Some("CHAT-CANARY private intent".to_string()),
756 progress_pct: None,
757 });
758 for file in [
759 format!("{root}/src/lib.rs"),
760 "/etc/private.conf".to_string(),
761 ] {
762 session.findings.push(Finding {
763 file: Some(file.clone()),
764 line: Some(1),
765 summary: "parser entry".to_string(),
766 timestamp: Utc::now(),
767 });
768 session.files_touched.push(
769 serde_json::from_value(serde_json::json!({
770 "path": file, "file_ref": null, "read_count": 1,
771 "modified": true, "last_mode": "full", "tokens": 1
772 }))
773 .expect("file touched"),
774 );
775 }
776
777 let (_, content) = PackageBuilder::new("session-pkg", "1.0.0")
778 .add_session(&session)
779 .build()
780 .expect("build");
781 let text = serde_json::to_string(&content).expect("content");
782 for private in ["CHAT-CANARY", "/home/someone", "/etc/private.conf"] {
783 assert!(!text.contains(private), "package carries {private}");
784 }
785 let layer = content.session.expect("session layer");
786 assert_eq!(layer.files_touched, vec!["src/lib.rs".to_string()]);
787 assert_eq!(layer.findings[0].file.as_deref(), Some("src/lib.rs"));
788 assert_eq!(layer.findings[1].file, None);
789 }
790
791 #[test]
792 fn a_package_build_is_reproducible() {
796 let _data = crate::core::data_dir::isolated_data_dir();
797 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
798 let project = tempfile::tempdir().expect("project");
799 let root = project.path().to_string_lossy().into_owned();
800 crate::core::knowledge::ProjectKnowledge::mutate_locked(&root, |k| {
801 k.remember(
802 "decision",
803 "parser",
804 "the parser is hand-written",
805 "fixture",
806 0.9,
807 &crate::core::memory_policy::MemoryPolicy::default(),
808 );
809 })
810 .expect("fact");
811 let build = || {
812 let (manifest, content) = PackageBuilder::new("repro", "1.0.0")
813 .add_knowledge_from_project(&root)
814 .build()
815 .expect("build");
816 serde_json::to_string(&(manifest, content)).expect("bytes")
817 };
818 let first = build();
819 std::thread::sleep(std::time::Duration::from_millis(1100));
820 assert_eq!(build(), first, "same sources, same bytes");
821
822 let _epoch = crate::test_env::VarGuard::set("SOURCE_DATE_EPOCH", "1700000000");
823 let pinned = build();
824 assert!(
825 pinned.contains("\"created_at\":\"2023-11-14T22:13:20Z\""),
826 "{pinned}"
827 );
828 assert_eq!(build(), pinned);
829 }
830
831 #[test]
832 fn v2_build_with_context_graph() {
833 let mut graph = ContextGraph::new();
834 graph.add_node(ContextNode::fact("n1", "test fact", "architecture"));
835 graph.add_node(ContextNode::gotcha("n2", "trigger", "resolution"));
836 graph.add_edge(ContextEdge {
837 from: "n1".into(),
838 to: "n2".into(),
839 edge_type: "has_gotcha".into(),
840 weight: 0.9,
841 coactivations: 3,
842 metadata: None,
843 });
844
845 let mut builder = PackageBuilder::new("v2-test", "1.0.0")
846 .description("v2 test package")
847 .level(2)
848 .scope("@test");
849
850 builder.content.context_graph = Some(graph);
851
852 let (manifest, content) = builder.build().unwrap();
853
854 assert_eq!(
855 manifest.schema_version,
856 crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
857 );
858 assert_eq!(manifest.conformance_level, Some(2));
859 assert_eq!(manifest.scope.as_deref(), Some("@test"));
860 assert!(content.context_graph.is_some());
861
862 let gs = manifest.graph_summary.unwrap();
863 assert_eq!(gs.node_count, 2);
864 assert_eq!(gs.edge_count, 1);
865 assert!(gs.activation_mean.is_some());
866 assert_eq!(gs.node_types, vec!["fact", "gotcha"]);
867 }
868
869 #[test]
870 fn v1_build_without_context_graph() {
871 let mut builder = PackageBuilder::new("v1-test", "1.0.0").description("v1 test");
872
873 builder.content.knowledge = Some(KnowledgeLayer {
874 facts: vec![],
875 patterns: vec![],
876 insights: vec![],
877 exported_at: chrono::Utc::now(),
878 });
879
880 let (manifest, _content) = builder.build().unwrap();
881 assert_eq!(
882 manifest.schema_version,
883 crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION
884 );
885 assert!(manifest.conformance_level.is_none());
886 assert!(manifest.graph_summary.is_none());
887 }
888
889 #[test]
890 fn level_clamped_to_valid_range() {
891 let b = PackageBuilder::new("t", "1.0.0").level(99);
892 assert_eq!(b.level, 3);
893 let b = PackageBuilder::new("t", "1.0.0").level(0);
894 assert_eq!(b.level, 1);
895 }
896
897 #[test]
898 fn scoped_name_in_v2_build() {
899 let mut builder = PackageBuilder::new("@company/auth", "2.0.0")
900 .level(2)
901 .scope("@company");
902
903 let mut graph = ContextGraph::new();
904 graph.add_node(ContextNode::fact("n1", "jwt auth", "security"));
905 builder.content.context_graph = Some(graph);
906
907 let (manifest, _) = builder.build().unwrap();
908 assert_eq!(manifest.name, "@company/auth");
909 assert_eq!(manifest.scope.as_deref(), Some("@company"));
910 }
911
912 #[test]
913 fn v2_manifest_roundtrip_json() {
914 let mut graph = ContextGraph::new();
915 graph.add_node(ContextNode::fact("n1", "fact", "cat"));
916 graph.add_edge(ContextEdge {
917 from: "n1".into(),
918 to: "n1".into(),
919 edge_type: "self".into(),
920 weight: 1.0,
921 coactivations: 0,
922 metadata: None,
923 });
924
925 let mut builder = PackageBuilder::new("roundtrip-test", "1.0.0")
926 .description("round trip")
927 .level(3)
928 .scope("@local");
929
930 builder.content.context_graph = Some(graph);
931
932 let (manifest, content) = builder.build().unwrap();
933
934 let manifest_json = serde_json::to_string(&manifest).unwrap();
935 let content_json = serde_json::to_string(&content).unwrap();
936
937 let decoded_manifest: crate::core::context_package::manifest::PackageManifest =
938 serde_json::from_str(&manifest_json).unwrap();
939 let decoded_content: PackageContent = serde_json::from_str(&content_json).unwrap();
940
941 assert_eq!(decoded_manifest.schema_version, 2);
942 assert_eq!(decoded_manifest.conformance_level, Some(3));
943 assert!(decoded_content.context_graph.is_some());
944 let dg = decoded_content.context_graph.unwrap();
945 assert_eq!(dg.nodes.len(), 1);
946 assert_eq!(dg.edges.len(), 1);
947 }
948}